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
10 KiB
PedsHub — Admin Guide
Deployment
# Start everything
docker compose up -d
# Rebuild after code changes (restart alone won't pick up changes)
docker compose build --no-cache backend celery frontend
docker compose up -d backend celery frontend --force-recreate
# View logs
docker compose logs backend --tail=50
docker compose logs celery --tail=50
docker compose logs frontend --tail=50
CLI Management Tool
All commands run inside the backend container:
docker compose exec backend python -m app.cli <command>
User Management
# List all users
docker compose exec backend python -m app.cli list-users
# Reset a user's password
docker compose exec backend python -m app.cli reset-password user@example.com newpassword
# Change user role (admin / moderator / user)
docker compose exec backend python -m app.cli set-role user@example.com moderator
# Force-verify a user's email (skip email confirmation)
docker compose exec backend python -m app.cli verify-email user@example.com
# Delete a user (interactive confirmation)
docker compose exec backend python -m app.cli delete-user user@example.com
# Export users to CSV
docker compose exec backend python -m app.cli export-users > users.csv
Documents
# List all documents with status
docker compose exec backend python -m app.cli list-docs
# Fix documents stuck in 'processing' (>30 min)
docker compose exec backend python -m app.cli fix-stuck-docs
# Requeue a specific document for processing
docker compose exec backend python -m app.cli reprocess-doc 32
Why documents get stuck: If the Celery worker restarts while processing a PDF, the task is lost and the document stays at "processing" forever. fix-stuck-docs resets these to "ready" so they can be reprocessed.
Courses & Quizzes
# List all courses
docker compose exec backend python -m app.cli list-courses
# List quizzes (active only)
docker compose exec backend python -m app.cli list-quizzes
# List all quizzes including deleted
docker compose exec backend python -m app.cli list-quizzes --all
Maintenance
# Platform statistics
docker compose exec backend python -m app.cli stats
# Clean up orphaned Redis keys (quiz progress with no expiry)
docker compose exec backend python -m app.cli cleanup-redis
Database
Direct Access
docker compose exec postgres psql -U pedquiz -d pedquiz
Backups
Automated daily backups run via the db-backup service (prodrigestivill/postgres-backup-local). Retention: 14 daily, 4 weekly, 6 monthly. Stored in ./backups/.
# Check backup status
docker compose logs db-backup --tail=10
# List backups
ls -la backups/
# Manual backup
docker compose exec postgres pg_dump -U pedquiz pedquiz > manual-backup.sql
# Restore from backup
cat backups/daily/pedquiz-YYYYMMDD-HHMMSS.sql.gz | gunzip | \
docker compose exec -T postgres psql -U pedquiz -d pedquiz
User Roles
| Role | Capabilities |
|---|---|
user |
Take quizzes, study flashcards, enroll in courses, create quizzes from question bank |
moderator |
All user abilities + create courses, upload PDFs, manage documents |
admin |
All moderator abilities + admin dashboard, user management, model config, system settings |
Common Issues
Document stuck in "processing"
docker compose exec backend python -m app.cli fix-stuck-docs
Cause: Celery worker restarted mid-task. The fix resets status to "ready".
DDL race condition on startup
One or two backend workers may fail on startup with Application startup failed. This is normal — multiple uvicorn workers race to run ALTER TABLE migrations, and losers get a deadlock error. The surviving workers handle all traffic. Check with:
docker compose logs backend --tail=10 | grep "startup complete"
Celery task not running
# Check Celery is connected
docker compose logs celery --tail=5
# Check if tasks are registered
docker compose exec celery celery -A app.tasks inspect registered
# Check active tasks (don't restart if tasks are running!)
docker compose exec celery celery -A app.tasks inspect active
Password reset without email
docker compose exec backend python -m app.cli reset-password user@example.com newpassword
User can't log in (unverified email)
docker compose exec backend python -m app.cli verify-email user@example.com
Environment
Key settings in backend/.env:
| Variable | Purpose |
|---|---|
APP_URL |
Base URL for email links (e.g. https://pedshub.com) |
SECRET_KEY |
JWT signing key — change in production |
MAIL_FROM |
Sender email for verification/reset emails |
LITELLM_API_BASE |
LiteLLM proxy URL for AI features |
LITELLM_API_KEY |
API key for LiteLLM proxy |
CAP_SITE_KEY / CAP_SECRET_KEY |
Cap bot protection |
BBB_SERVER_URL / BBB_SECRET |
BigBlueButton integration for live sessions |
OIDC_PROVIDER_URL |
OIDC discovery URL (see SSO section below) |
OIDC_CLIENT_ID |
OAuth client ID from your identity provider |
OIDC_CLIENT_SECRET |
OAuth client secret |
OIDC_PROVIDER_NAME |
Display name on login button (default: "SSO") |
SSO / OIDC Setup
PedsHub supports any OpenID Connect provider. When configured, a "Sign in with {provider}" button appears on the login page. Users who sign in via SSO are auto-created with a verified email — no separate registration or email verification needed.
How it works
- User clicks "Sign in with SSO" on the login page
- Browser redirects to your identity provider (Google, Microsoft, etc.)
- After authentication, provider redirects back to
{APP_URL}/api/auth/sso/callback - PedsHub reads the
emailandnameclaims from the OIDC token - If no account exists, one is created automatically (role:
user, email verified) - User gets a JWT and is logged in
Required env vars
Add these to backend/.env:
OIDC_PROVIDER_URL=https://accounts.google.com
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
OIDC_PROVIDER_NAME=Google
OIDC_SCOPES=openid email profile
Then rebuild: docker compose build --no-cache backend celery && docker compose up -d backend celery --force-recreate
SSO-only mode
In Admin > Settings, toggle "SSO-Only Login" to disable password login entirely. Users will only see the SSO button. Admins can still reset passwords via CLI for emergency access.
- Go to Google Cloud Console
- Create a new project (or select existing)
- Go to APIs & Services > Credentials > Create Credentials > OAuth client ID
- Application type: Web application
- Authorized redirect URIs:
https://pedshub.com/api/auth/sso/callback - Copy the Client ID and Client Secret
OIDC_PROVIDER_URL=https://accounts.google.com
OIDC_CLIENT_ID=123456789.apps.googleusercontent.com
OIDC_CLIENT_SECRET=GOCSPX-xxxxxxxx
OIDC_PROVIDER_NAME=Google
You may need to enable the "Google+ API" or configure the OAuth consent screen first.
Microsoft / Azure AD
- Go to Azure Portal > App registrations
- Click New registration
- Name: "PedsHub", Redirect URI:
https://pedshub.com/api/auth/sso/callback(type: Web) - Note the Application (client) ID and Directory (tenant) ID
- Go to Certificates & secrets > New client secret — copy the value
OIDC_PROVIDER_URL=https://login.microsoftonline.com/{tenant-id}/v2.0
OIDC_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
OIDC_CLIENT_SECRET=your-client-secret
OIDC_PROVIDER_NAME=Microsoft
Replace {tenant-id} with your Azure AD tenant ID. For personal Microsoft accounts, use common instead.
Under API permissions, ensure
openid,profileare granted.
Keycloak
- In your Keycloak admin, go to Clients > Create client
- Client ID:
pedshub, Client type: OpenID Connect - Valid redirect URIs:
https://pedshub.com/api/auth/sso/callback - Copy the client secret from the Credentials tab
OIDC_PROVIDER_URL=https://keycloak.example.com/realms/your-realm
OIDC_CLIENT_ID=pedshub
OIDC_CLIENT_SECRET=your-client-secret
OIDC_PROVIDER_NAME=Keycloak
Auth0
- Go to Auth0 Dashboard > Applications
- Create a Regular Web Application
- In Settings, add Allowed Callback URL:
https://pedshub.com/api/auth/sso/callback - Note the Domain, Client ID, and Client Secret
OIDC_PROVIDER_URL=https://your-tenant.auth0.com
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_PROVIDER_NAME=Auth0
Authentik
- In Authentik admin, go to Applications > Providers > Create OAuth2/OpenID Provider
- Redirect URI:
https://pedshub.com/api/auth/sso/callback - Create an Application linked to this provider
OIDC_PROVIDER_URL=https://auth.example.com/application/o/pedshub
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_PROVIDER_NAME=Authentik
Troubleshooting SSO
"SSO login failed" after redirect:
- Check that
APP_URLin.envmatches your actual domain (with https) - Verify the redirect URI in your provider matches exactly:
{APP_URL}/api/auth/sso/callback - Check backend logs:
docker compose logs backend --tail=30 | grep -i "sso\|oidc\|oauth"
User created but wrong name:
- The app reads
namefrom the OIDC token, falling back topreferred_username, then the email prefix - Some providers require the
profilescope to include the name — make sureOIDC_SCOPES=openid email profile
Existing user can't SSO:
- If a user registered with email/password and later tries SSO with the same email, it works — SSO login finds the existing account by email and logs them in (doesn't create a duplicate)
SSO-only mode lockout:
- If SSO breaks while in SSO-only mode, use the CLI to disable it:
docker compose exec backend python3 -c " import redis; r = redis.from_url('redis://redis:6379/0', decode_responses=True) r.set('settings:sso_only', 'false') print('SSO-only mode disabled') "
Security Notes
- Passwords are hashed with bcrypt (one-way, irreversible)
- JWT tokens auto-refresh via sliding expiration (12h age or <1h remaining)
- Rate limiting on login (10 attempts per IP per 15 min) via Redis
- Email verification required for new accounts (SSO users are auto-verified)
- Quiz reminders skip deleted quizzes and course quizzes
- Users can opt out of reminders in Settings > Notifications