Proof-of-work rather than a puzzle, and — the reason for it — nothing
about the person signing up is described to a third party in order to let
them in. Turnstile and then hCaptcha were both here; both told Cloudflare
who was at the door.
The `cap` service runs on the compose network with its own Redis
database, kept apart from the app's so a flush of one cannot clear the
other's challenges. The widget talks to /cap/ on this origin, proxied by
the frontend's nginx, so the browser reaches nobody else either. Caddy
passes the whole host through to that container, so it needed no change.
Two things that had to be found rather than read:
Cap's key API is undocumented. The routes are `/auth/login` and
`/server/keys`, and the Bearer value is base64 JSON of `{token, hash}` —
not the session token itself, which is why the obvious call returns
"Malformed session token". The site key and secret were created that way
rather than by hand in a dashboard.
And an nginx proxy_pass whose target is a variable passes the URI through
untouched: the trailing slash that strips a location prefix on a literal
target does nothing. Cap was being asked for /cap/<key>/challenge and
answering NOT_FOUND until the prefix was stripped by an explicit rewrite.
Verified end to end against the running service: a challenge is issued
through the public path, and a token that was never issued is refused
rather than waved through.
Also here: the register modal's Name and Email were bare labels that
neither wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing.
And the knowledge profile paginates ten to a page and expands each row to
its two bars beside the next step. "Correct using hints" is missing from
that bar because /study-tools/recommendations does not carry it per
topic — inferring it from the lifetime figure would be a different set of
answers, so the bar is honestly two-tone until the backend offers it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
27 KiB
PedsHub API Reference
Base URL: /api
All endpoints return JSON unless noted otherwise. Authentication is via Bearer token in the Authorization header. Role hierarchy: admin > moderator > user.
Auth (/api/auth)
POST /api/auth/register
Register a new user account.
- Auth: None
- Rate limit: Cap verification required if configured
- Request body:
{ "email": "string", "password": "string (min 8 chars)", "name": "string", "captcha_token": "string | null" } - Response:
- First user ever:
{"access_token": "...", "token_type": "bearer"}(auto-admin, auto-verified) - Subsequent users:
{"requires_verification": true, "message": "..."}
- First user ever:
- Notes: Registration can be disabled by admin via settings. First registered user is always admin and auto-verified.
POST /api/auth/login
Authenticate and receive a JWT token.
- Auth: None
- Rate limit: 10 attempts per IP per 15 minutes (Redis). No captcha on login.
- Request body:
{ "email": "string", "password": "string" } - Response:
{"access_token": "string", "token_type": "bearer"} - Errors: 401 invalid credentials, 403 email not verified, 429 rate limited
GET /api/auth/verify-email
Verify a user's email address via token link.
- Auth: None
- Query params:
token(string, required) - Response:
{"message": "Email verified successfully! You can now log in."} - Errors: 400 invalid/expired token
POST /api/auth/resend-verification
Resend the email verification link.
- Auth: None
- Request body:
{"email": "string"} - Response:
{"message": "If that email exists, a verification link has been sent."} - Notes: Deliberately vague response to prevent email enumeration.
POST /api/auth/forgot-password
Request a password reset link.
- Auth: None
- Rate limit: 3 reset requests per email per hour
- Request body:
{"email": "string"} - Response:
{"message": "If that email is registered, a reset link has been sent."} - Notes: Deliberately vague response to prevent email enumeration. Sends email in background.
POST /api/auth/reset-password
Reset password using a valid token.
- Auth: None
- Request body:
{ "token": "string", "new_password": "string (min 8 chars)" } - Response:
{"message": "Password reset successfully. You can now log in."} - Errors: 400 invalid/expired/used token
GET /api/auth/me
Get the current authenticated user's profile.
- Auth: User
- Response:
UserResponse—{id, email, name, role, created_at}
PUT /api/auth/me
Update current user's profile (name, password).
- Auth: User
- Request body:
{ "name": "string | null", "current_password": "string | null (required to change password)", "new_password": "string | null (min 8 chars)" } - Response:
UserResponse
GET /api/auth/me/settings
Get user settings stored in Redis (e.g. Nextcloud config).
- Auth: User
- Response:
{}or a JSON object of user settings
PUT /api/auth/me/settings
Save user settings to Redis.
- Auth: User
- Request body: Any JSON object
- Response:
{"saved": true}
Documents (/api/documents)
POST /api/documents/upload
Upload a PDF document for processing.
- Auth: Moderator
- Content-Type:
multipart/form-data - Request:
file(PDF, max size fromMAX_UPLOAD_SIZEsetting) - Response:
DocumentResponse—{id, user_id, filename, original_filename, status, total_pages, error_message, uploaded_at} - Notes: Processing dispatched to Celery (falls back to sync). Status starts as
"processing".
GET /api/documents/
List all documents. Moderators see all; regular users see only their own.
- Auth: User
- Response:
DocumentResponse[]
GET /api/documents/{document_id}
Get a single document by ID.
- Auth: User (own documents only; moderators can access any)
- Response:
DocumentResponse - Errors: 404
GET /api/documents/{document_id}/status
Poll document processing status.
- Auth: User (own documents only; moderators can access any)
- Response:
{id, status, total_pages, error_message}
GET /api/documents/{document_id}/processing-steps
Get PDF processing progress steps from Redis.
- Auth: User (own documents only; moderators can access any)
- Response:
{"steps": [{step object}, ...]} - Notes: Steps are stored in Redis list
pdf:steps:{document_id}.
POST /api/documents/{document_id}/sections
Create a named page range section within a document.
- Auth: Moderator
- Request body:
{ "name": "string", "start_page": "int (>= 1)", "end_page": "int (> start_page, <= total_pages)" } - Response:
SectionResponse—{id, document_id, name, start_page, end_page} - Errors: 400 if document not ready, invalid page range
DELETE /api/documents/{document_id}/sections/{section_id}
Delete a section.
- Auth: Moderator
- Response: 204 No Content
DELETE /api/documents/{document_id}
Delete a document, its file, and its ChromaDB vector collection.
- Auth: Moderator
- Response: 204 No Content
Quizzes (/api/quizzes)
POST /api/quizzes/
Start async quiz extraction from a document section.
- Auth: Moderator
- Request body:
{ "section_id": "int", "title": "string", "mode": "timed | learning", "time_limit_minutes": "int | null", "model_id": "int | null (AIModelConfig.id)", "question_category_id": "int | null", "extraction_mode": "string | null (standard, questions_only, two_step, regex, ai_decide, generate)" } - Response:
{"job_id": "uuid", "status": "pending"} - Notes: Poll
/api/quizzes/job/{job_id}for progress. Falls back to sync if Celery unavailable.
GET /api/quizzes/jobs
List all recent extraction jobs for the current user (any browser session).
- Auth: User
- Response:
[ { "job_id": "uuid", "title": "string", "status": "pending | running | completed | failed | cancelled", "steps_count": "int", "last_step": "string (truncated to 80 chars)", "quiz_id": "int | null" } ]
GET /api/quizzes/job/{job_id}
Poll extraction job progress.
- Auth: Moderator
- Response:
{"job_id", "status", "steps": [...], "quiz_id" (if completed), "error" (if failed)}
POST /api/quizzes/job/{job_id}/cancel
Cancel a running extraction job.
- Auth: User (must own the job)
- Response:
{"status": "cancelled"} - Errors: 403 if not owner, 400 if not running
GET /api/quizzes/search
Hybrid semantic + keyword search across quiz titles and questions.
- Auth: User
- Query params:
q(string, required, 2-200 chars)mode(string:"title"|"questions"|"all", default"all")
- Response: Array of search results with
quiz_id,quiz_title,match_type,matching_questions[] - Notes: Uses pgvector cosine similarity (threshold 0.30) for semantic search, plus ILIKE for keyword matching.
GET /api/quizzes/
List all quizzes. Moderators see all (non-deleted); regular users see only published.
- Auth: User
- Response:
QuizResponse[]—{id, section_id, user_id, title, questions_count, mode, time_limit_minutes, is_published, created_at}
GET /api/quizzes/{quiz_id}
Get a quiz for taking. Learning-mode quizzes include answers/explanations.
- Auth: User
- Query params:
study(bool, default false) — forces answers/explanations to be included - Response:
QuizDetail(timed) orQuizLearningDetail(learning/study) - Notes: Excludes soft-deleted quizzes.
PATCH /api/quizzes/{quiz_id}
Update quiz metadata (title).
- Auth: Moderator
- Request body:
{"title": "string"} - Response:
QuizResponse
POST /api/quizzes/{quiz_id}/shuffle
Return quiz with shuffled question and option order (does not modify DB).
- Auth: User
- Query params:
shuffle_options(bool, default true) - Response:
QuizDetailwith shuffled questions
GET /api/quizzes/{quiz_id}/review
Get quiz with full answers. Requires a completed attempt (or moderator role).
- Auth: User (must have completed attempt) or Moderator
- Response:
QuizReview
GET /api/quizzes/{quiz_id}/questions
Get all questions with answers for editing.
- Auth: Moderator
- Response: Array of
{id, question_text, question_type, options, correct_answer, explanation}
PATCH /api/quizzes/{quiz_id}/questions/{question_id}
Update a question. Changes are shared across all quizzes referencing this question.
- Auth: Moderator
- Request body: Any of
{question_text, options, correct_answer, explanation, question_type} - Response: Updated question object
- Errors: 400 if correct_answer does not match one of the options
DELETE /api/quizzes/{quiz_id}/questions/{question_id}
Remove a question from this quiz. If shared with other quizzes, only the link is removed. If exclusive, the question is deleted from the bank.
- Auth: Moderator
- Response: 204 No Content
PATCH /api/quizzes/{quiz_id}/publish
Show or hide a quiz for regular users.
- Auth: Moderator
- Query params:
published(bool, default true) - Response:
{"quiz_id": int, "is_published": int}
GET /api/quizzes/trash
List soft-deleted quizzes.
- Auth: Moderator
- Response:
QuizResponse[]
DELETE /api/quizzes/{quiz_id}
Soft-delete a quiz (move to trash).
- Auth: Moderator
- Response: 204 No Content
PATCH /api/quizzes/{quiz_id}/restore
Restore a soft-deleted quiz from trash.
- Auth: Moderator
- Response:
QuizResponse
DELETE /api/quizzes/{quiz_id}/permanent
Permanently delete a quiz that is already in trash. Exclusive questions are orphaned to the bank.
- Auth: Moderator
- Response: 204 No Content
- Errors: 400 if quiz is not in trash
Questions (/api/questions)
GET /api/questions/bank
List all questions across all quizzes with filtering, search, and pagination.
- Auth: User
- Query params:
q(string, optional) — keyword/semantic searchquiz_id(int, optional) — filter by source quizcategory_id(int, optional) — filter by question categorycategory_ids(string, optional) — comma-separated category IDs (OR filter)uncategorized(bool, default false)favorites_only(bool, default false)tag_ids(string, optional) — comma-separated tag IDs (AND filter)search_mode(string:"keyword"|"semantic"|"hybrid", default"hybrid")limit(int, default 50, max 200)offset(int, default 0)
- Response:
{ "total": "int", "questions": [ { "id": "int", "quiz_id": "int", "quiz_title": "string", "question_category_id": "int | null", "question_category_name": "string | null", "question_text": "string", "question_type": "string", "options": ["string"], "correct_answer": "string", "explanation": "string", "image_path": "string | null" } ] } - Notes: Hybrid search returns semantic matches first (pgvector cosine similarity >= 0.55), then keyword matches.
GET /api/questions/bank/ids
Return just question IDs for all matching questions (for server-side select-all).
- Auth: User
- Query params: Same filters as
/bank(exceptlimit,offset,search_mode) - Response:
[int]
PATCH /api/questions/{question_id}/category
Assign or remove a question category.
- Auth: Moderator
- Query params:
category_id(int | null) - Response:
{"question_id": int, "question_category_id": int | null}
POST /api/questions/from-bank
Create a new quiz by referencing existing bank questions (no copying -- edits propagate).
- Auth: Moderator
- Request body:
{ "title": "string", "question_ids": [1, 2, 3], "mode": "timed | learning", "time_limit_minutes": "int | null" } - Response:
{"id": int, "title": "string", "questions_count": int}
POST /api/questions/bulk-category
Assign or remove a category from multiple questions at once.
- Auth: Moderator
- Request body:
{ "question_ids": [1, 2, 3], "category_id": "int | null" } - Response:
{"updated": int, "question_category_id": int | null}
Attempts (/api/attempts)
POST /api/attempts/start
Start a new quiz attempt (or resume the most recent incomplete one).
- Auth: User
- Query params:
quiz_id(int, required)fresh(bool, default false) — force a new attempt even if one is in progress
- Response:
AttemptResponse—{id, quiz_id, score, total_questions, percentage, started_at, completed_at}
POST /api/attempts/{attempt_id}/submit
Submit answers for a quiz attempt.
- Auth: User (must own the attempt)
- Request body:
{ "answers": [ {"question_id": "int", "user_answer": "string"} ] } - Response:
AttemptDetail— includesanswers[]with full review (question text, options, user answer, correct answer, is_correct, explanation) - Notes: Updates spaced repetition reminder schedule. Clears saved progress from Redis. Unanswered questions are marked incorrect.
GET /api/attempts/
List all attempts for the current user.
- Auth: User
- Query params:
quiz_id(int, optional) - Response:
AttemptResponse[]
GET /api/attempts/{attempt_id}
Get a specific attempt with full answer details.
- Auth: User (must own the attempt)
- Response:
AttemptDetail
GET /api/attempts/stats/dashboard
Get dashboard statistics for the current user.
- Auth: User
- Response:
{ "total_documents": "int", "total_quizzes": "int (distinct quizzes attempted)", "total_attempts": "int", "average_score": "float", "quiz_stats": [ { "quiz_id": "int", "quiz_title": "string", "attempts_count": "int", "best_score": "float", "latest_score": "float", "average_score": "float" } ] }
GET /api/attempts/history
Get per-quiz attempt history for the performance line graph.
- Auth: User
- Response:
[ { "quiz_id": "int", "title": "string", "attempts": [ {"attempt_id": "int", "date": "ISO string", "percentage": "float", "score": "int", "total": "int"} ] } ]
GET /api/attempts/in-progress
Return all incomplete (not yet submitted) attempts for the current user.
- Auth: User
- Response:
[ { "attempt_id": "int", "quiz_id": "int", "quiz_title": "string", "total_questions": "int", "started_at": "ISO string" } ]
GET /api/attempts/{attempt_id}/in-progress
Return the latest incomplete attempt for a specific quiz.
- Auth: User
- Query params:
quiz_id(int, required) - Response:
AttemptResponse | null
POST /api/attempts/progress
Save in-progress quiz answers to Redis (survives logout/browser change).
- Auth: User
- Headers:
x-quiz-session(optional, for device session lock) - Request body:
{ "quiz_id": "int", "attempt_id": "int", "answers": {"question_id": "answer"}, "current_idx": "int", "mode": "string", "voice": "string | null", "time_left": "int | null", "started_at": "string | null (ISO timestamp)", "total_time": "int | null (seconds)" } - Response:
{"saved": true} - Notes: Progress expires after 7 days. Session lock (30s TTL) prevents concurrent resume on another device.
GET /api/attempts/progress
Retrieve saved progress for a quiz. Auto-submits timed quizzes if timer expired.
- Auth: User
- Headers:
x-quiz-session(optional, for device session lock) - Query params:
quiz_id(int, required) - Response: Saved progress object or
null - Errors: 409 if another device is actively using this attempt
DELETE /api/attempts/progress/{attempt_id}
Clear saved progress.
- Auth: User
- Response: 204 No Content
DELETE /api/attempts/{attempt_id}
Delete an attempt, its answers, and associated reminder schedule.
- Auth: User (must own the attempt)
- Response: 204 No Content
Tags (/api/tags)
GET /api/tags
Get all tags grouped by type (subjects, diseases, keywords), with question counts.
- Auth: User
- Response:
{ "subjects": [{"id": "int", "name": "string", "count": "int"}], "diseases": [{"id": "int", "name": "string", "count": "int"}], "keywords": [{"id": "int", "name": "string", "count": "int"}] }
POST /api/tags/classify
Trigger background AI classification of untagged questions.
- Auth: Moderator
- Response:
{"job_id": "uuid", "status": "started"}
GET /api/tags/status/{job_id}
Check classification job progress.
- Auth: User
- Response:
{"job_id": "string", "status": "string", "steps": [...], "error": "string | null"}
Teach (/api/teach)
GET /api/teach/models
Return available teach AI models.
- Auth: User
- Response:
[{"id": int, "name": "string", "model_id": "string", "is_default": bool}]
POST /api/teach/chat
Send a message to the AI tutor with full question context.
- Auth: User
- Rate limit: 30 messages per user per 10 minutes (admins and unthrottled users exempt)
- Request body:
{ "question_id": "int", "messages": [ {"role": "user | assistant", "content": "string"} ], "model_id": "int | null (AIModelConfig.id)" } - Response:
{"reply": "string (markdown)", "suggestions": ["string", "string", "string"]} - Notes: System prompt includes the question, correct answer, explanation, and up to 4 semantically similar questions for broader context. Follow-up suggestions are parsed from lines starting with
>. - Errors: 503 if no teach model configured, 502 if AI temporarily unavailable
TTS (/api/tts)
GET /api/tts/voices
Return available TTS voices. Excludes Polly voices if Polly is disabled.
- Auth: User
- Response:
[{"id": "string (model_id)", "name": "string", "is_default": bool}]
POST /api/tts/speak
Convert text to speech audio.
- Auth: User
- Rate limit: 60 requests per user per hour (admins and unthrottled users exempt)
- Request body:
{ "text": "string (max 2000 chars)", "voice": "string | null (model_id override)" } - Response: Binary
audio/mpegdata - Notes: Supports LiteLLM-routed local TTS voices, e.g.
local-kokoro-tts:am_adam. The prefix before:is the LiteLLM model route; the suffix is the speaker voice sent to that route.
Favorites (/api/favorites)
POST /api/favorites
Add a question to the current user's favorites.
- Auth: User
- Request body:
{"question_id": "int"} - Response (201):
FavoriteResponse—{id, user_id, question_id, created_at} - Notes: Idempotent -- returns existing favorite if already favorited.
DELETE /api/favorites/{question_id}
Remove a question from favorites.
- Auth: User
- Response: 204 No Content
GET /api/favorites
Get list of favorited question IDs for the current user.
- Auth: User
- Response:
[int]
Categories (/api/categories)
Quiz-level categories for organizing quizzes.
GET /api/categories/
List all quiz categories with quiz counts.
- Auth: User
- Response:
[{"id": int, "name": "string", "quiz_count": int}]
POST /api/categories/
Create a new quiz category.
- Auth: Moderator
- Request body:
{"name": "string"} - Response:
CategoryResponse
DELETE /api/categories/{category_id}
Delete a quiz category. Unassigns all quizzes in the category.
- Auth: Moderator
- Response: 204 No Content
PATCH /api/categories/quizzes/{quiz_id}
Assign or remove a category from a quiz.
- Auth: Moderator
- Query params:
category_id(int | null) - Response:
{"quiz_id": int, "category_id": int | null}
Question Categories (/api/question-categories)
Question-level categories for organizing the question bank.
GET /api/question-categories/
List all question categories with question counts.
- Auth: User
- Response:
[{"id": int, "name": "string", "description": "string | null", "question_count": int}]
POST /api/question-categories/
Create a new question category.
- Auth: Moderator
- Request body:
{"name": "string", "description": "string | null"} - Response:
QCatResponse
PATCH /api/question-categories/{cat_id}
Update a question category.
- Auth: Moderator
- Request body:
{"name": "string", "description": "string | null"} - Response:
QCatResponse
DELETE /api/question-categories/{cat_id}
Delete a question category. Optionally move its questions to another category.
- Auth: Moderator
- Query params:
move_to(int | null) -- target category ID, or null to uncategorize - Response: 204 No Content
POST /api/question-categories/{cat_id}/create-quiz
Create a new quiz from all questions in a question category.
- Auth: Moderator
- Query params:
title(string, required)mode(string:"timed"|"learning", default"timed")time_limit_minutes(int | null)
- Response:
{"id": int, "title": "string", "questions_count": int}
Contact (/api/contact)
POST /api/contact
Submit a contact form (public, no auth).
- Auth: None
- Request body:
{ "name": "string (max 120)", "email": "valid email", "type": "question | moderator", "message": "string (max 2000)", "captcha_token": "string | null" } - Response:
{"success": true} - Notes: Stores submission in DB and emails admin. Cap verification if configured.
GET /api/contact/submissions
List all contact submissions.
- Auth: Admin (enforced in endpoint body, not decorator)
- Response:
[{id, name, email, type, message, read, created_at}]
PUT /api/contact/submissions/{submission_id}/read
Mark a contact submission as read.
- Auth: Admin (enforced in endpoint body, not decorator)
- Response:
{"success": true}
Admin (/api/admin)
User Management
GET /api/admin/users
List all users.
- Auth: Admin
- Response:
UserResponse[]
POST /api/admin/users
Create a user directly (email auto-verified).
- Auth: Admin
- Request body:
{"email": "string", "password": "string", "name": "string"} - Response:
UserResponse
PUT /api/admin/users/{user_id}/role
Change a user's role.
- Auth: Admin
- Request body:
{"role": "admin | moderator | user"} - Response:
UserResponse - Errors: 400 cannot change own role
PUT /api/admin/users/{user_id}/unthrottle
Set or clear the unthrottle flag (exempt from AI/TTS rate limits).
- Auth: Admin
- Request body:
{"unthrottled": true | false} - Response:
UserResponse
DELETE /api/admin/users/{user_id}
Delete a user and all their data.
- Auth: Admin
- Response: 204 No Content
- Errors: 400 cannot delete yourself
AI Model Configuration
GET /api/admin/models/available
List active models for a task (for users when creating/taking quizzes).
- Auth: User
- Query params:
task(string, default"extraction") - Response:
[{"id": int | null, "name": "string", "model_id": "string", "is_default": bool}] - Notes: Falls back to env
LITELLM_MODELif nothing configured.
GET /api/admin/models
List all AI model configurations.
- Auth: Admin
- Response:
AIModelConfigResponse[]--{id, name, model_id, task, is_active, is_default, api_key (masked)}
POST /api/admin/models
Create an AI model configuration.
- Auth: Admin
- Request body:
{ "name": "string", "model_id": "string", "task": "extraction | tts | teach | keyword", "is_active": "bool", "is_default": "bool", "api_key": "string | null" } - Response:
AIModelConfigResponse - Notes: Auto-sets as default if first model for that task. Unique constraint on (model_id, task).
PUT /api/admin/models/{model_id}
Update an AI model configuration.
- Auth: Admin
- Request body: Partial
AIModelConfigUpdate - Response:
AIModelConfigResponse
DELETE /api/admin/models/{model_id}
Delete an AI model configuration.
- Auth: Admin
- Response: 204 No Content
POST /api/admin/models/{model_id}/test
Send a test completion to verify an LLM model is reachable.
- Auth: Admin
- Response:
{"message": "model_id -> 'OK'"} - Errors: 400 for TTS models (use Preview button instead), 502 if unreachable
POST /api/admin/litellm/models
Query available models from LiteLLM proxy or OpenAI-compatible API.
- Auth: Admin
- Request body:
{"api_key": "string | null", "api_base": "string | null"} - Response:
{"models": ["string"], "source": "string"}
TTS Voice Search
POST /api/admin/tts/voices
Discover available TTS voices from a provider.
- Auth: Admin
- Request body:
{ "provider": "elevenlabs | polly | openai", "api_key": "string | null", "region": "string | null" } - Response:
{"voices": [{"model_id": "string", "name": "string", "labels": {}}]} - Notes: OpenAI returns hardcoded list of tts-1 and tts-1-hd voices.
System Settings
GET /api/admin/settings
Get system settings (registration enabled, embedding model, Polly enabled).
- Auth: Admin
- Response:
{"registration_enabled": bool, "embedding_model": "string"}
PUT /api/admin/settings
Update system settings.
- Auth: Admin
- Request body:
{"registration_enabled": bool, "embedding_model": "string"} - Response:
{"success": true, "message": "Settings updated"}
Embedding Management
POST /api/admin/embedding/test
Test the currently configured embedding model.
- Auth: Admin
- Response:
{"model": "string", "dimensions": int, "status": "ok"}
POST /api/admin/embedding/regenerate
Queue a Celery task to regenerate all question embeddings.
- Auth: Admin
- Response:
{"job_id": "uuid", "message": "Regeneration started..."}
Nextcloud (/api/nextcloud)
WebDAV proxy for importing PDFs from Nextcloud (avoids browser CORS issues).
POST /api/nextcloud/test
Test Nextcloud credentials.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/"} - Response:
{"ok": true, "message": "Connected successfully"}
POST /api/nextcloud/files
List PDFs and folders at a given Nextcloud path.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/"} - Response:
{"path": "string", "items": [{"name", "path", "type": "dir | pdf", "size"}]}
POST /api/nextcloud/download
Download a PDF from Nextcloud and stream it back.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/some/file.pdf"} - Response: Streamed
application/pdfbinary - Errors: 400 if not a PDF file