A lightweight single-server web app for turning worksheet images or PDFs into two downloadable files:
- a student worksheet PDF without answers
- a complete worksheet PDF with answers and explanations
The project is designed for small VPS deployments. It uses FastAPI, SQLite, local file storage, one worker process, and Nginx by default. It does not require Docker, Redis, or PostgreSQL.
- Upload JPG, PNG, WEBP, or PDF worksheet files.
- Use OpenCV first for photo correction, enhancement, and preprocessing.
- Prefer XeLaTeX for math/formula PDFs.
- Preserve complex diagrams with padded source crops to avoid inaccurate redraws.
- Store task status in SQLite and files under local
data/. - Run a single sequential worker, suitable for 1GB VPS instances.
- MVP limits: 10MB per file, 2 global queued/running jobs, 1 active job per IP, and 5 jobs per IP per hour.
- Optional IPInfo Lite lookup for visitor country and AS name, cached in SQLite.
- Optional shared authorization links so invited users can run tasks without their own API Key.
- Hidden trial-token admin page for fixed-IP or first-use-bound trial tokens.
- Server-side job cleanup after 24 hours, including uploads, work directories, artifacts, and SQLite job rows.
- Browser LocalStorage for recent job references and optional user AI configuration.
- Optional hidden visitor stats page for
page_view,job_created, andartifact_downloadevents. - Full-site English / Chinese UI switching, with English as the default language.
Never commit these files or values to Git:
.env- Cloudflare Origin Certificates and private keys under
cert/ - uploads, generated PDFs, task data, or runtime SQLite data under
data/andvar/ - real VPS IPs, production domains, SSH passwords, API Keys, admin tokens, or trial plaintext tokens
The .gitignore is configured to ignore runtime files. The public repository should contain only code, templates, static assets, and example configuration.
User AI Provider, Base URL, Model, and API Key are configured in the browser. The API Key is sent to the server only when a job is submitted, written temporarily to that job's secrets.json, read by the worker, and deleted immediately. It is not written to SQLite.
Recent tasks are stored in the user's browser LocalStorage. Server-side 24-hour cleanup does not remove browser LocalStorage; if a task has expired, the user will see a missing-task or cleaned-file message when reopening it.
Visitor stats record IP, anonymous browser client_id, User-Agent, path, and job ID references. They do not record API Keys, Base URLs, uploaded worksheet content, generated PDFs, or answer content.
python -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8719Open:
http://127.0.0.1:8719
The site uses a lightweight local i18n layer:
- server-rendered templates use
app/i18n.py - locale files live in
app/locales/en.jsonandapp/locales/zh-CN.json - dynamic browser text uses
static/i18n.js
English is the default language. Users can switch between English and Chinese from the UI. The selected language is saved to LocalStorage and a cookie. ?lang=en or ?lang=zh-CN can override the current language.
To add another language, create a new locale JSON file with the same key structure, add the language code to the supported language list in app/i18n.py and static/i18n.js, then expose it in the language switcher.
Default install directory:
/opt/pdf-exercise-web
Basic packages:
sudo apt install python3-venv python3-pip nginx sqlite3 libgl1 libglib2.0-0 \
texlive-xetex texlive-lang-chinese fonts-noto-cjkCommon environment variables:
APP_HOST=127.0.0.1
APP_PORT=8719
PUBLIC_BASE_URL=https://your-domain.example
DATA_DIR=/opt/pdf-exercise-web/data
DATABASE_PATH=/opt/pdf-exercise-web/var/pdf_exercise.sqlite3
MAX_UPLOAD_MB=10
MAX_ACTIVE_JOBS=2
MAX_ACTIVE_JOBS_PER_IP=1
MAX_JOBS_PER_IP_PER_HOUR=5For Cloudflare Full Strict HTTPS, prepare both the Origin Certificate and its private key on the VPS:
PDF_EXERCISE_DOMAIN=your-domain.example
PDF_EXERCISE_ORIGIN_CERT_FILE=/path/to/origin.pem
PDF_EXERCISE_ORIGIN_KEY_FILE=/path/to/origin.keyThe key file is required. A certificate .pem alone is not enough for Nginx listen 443 ssl.
Hidden Visitor Stats
Set in .env:
VISITOR_STATS_TOKEN=generate-a-long-random-token
VISITOR_EVENT_RETENTION_DAYS=90Open:
https://your-domain.example/internal/visitors?token=change-me
This link is not exposed in the homepage, navigation, or footer. Missing or invalid tokens return 404. The stats page uses the visitor browser timezone for “today”, 7-day trends, and event times.
IPINFO_TOKEN=your-ipinfo-token
IPINFO_CACHE_DAYS=30The public homepage shows only country and AS name, not the full visitor IP. Hidden stats show country and AS name next to IPs. IPInfo failures do not block page loads or job submission.
SHARED_ACCESS_TOKEN=generate-a-long-random-token
SHARED_AI_PROVIDER=openai
SHARED_AI_BASE_URL=https://api.example.com/v1
SHARED_AI_API_KEY=your-private-api-key
SHARED_AI_MODEL=gpt-5.5Authorization links use URL fragments so the token is not sent in web server logs:
https://your-domain.example/#token=generate-a-long-random-token
The browser stores the token in SessionStorage and removes it from the address bar. Shared users are exempt from the hourly 5-job limit, but they are still subject to the 1 active job per IP and 2 global active job limits.
Set in .env:
TOKEN_ADMIN_TOKEN=generate-an-independent-admin-token
TRIAL_RESERVATION_TIMEOUT_HOURS=2
TRIAL_TOKEN_DEFAULT_DAYS=7Hidden admin page:
https://your-domain.example/internal/trial-tokens#token=generate-an-independent-admin-token
The admin token is saved only in the current tab's SessionStorage and removed from the address bar. The control page supports:
- choosing a recent IP, entering an IPv4/IPv6 address manually, or leaving the IP empty to bind on first use
- creating limited-use or unlimited-use trial tokens
- batch-generating up to 100 trial links
- setting a default 7-day expiration or no expiration
- viewing used, reserved, max, and remaining counts
- revoking tokens
Trial links use:
https://your-domain.example/#token=trial_xxx
SQLite stores only SHA-256 token hashes, not recoverable plaintext. Unbound tokens bind to the first IP that presents the token. Job submission first reserves quota atomically; successful PDF generation finalizes usage, while failed jobs release the reservation.
To show a Feishu QR code above the footer, place the image at:
static/feishu-qr.png
If the image is absent, the community section is hidden.
deploy/remote_deploy.py is an optional helper for uploading the app over SSH and running the install process. Pass target VPS details via environment variables. The script excludes .env, cert/, data/, var/, .git/, .venv/, and Python caches.
Each completed job can produce:
student_pdf: worksheet PDF without answersanswer_pdf: answer explanation PDFworksheet_json: structured transcription datatranscript: Markdown transcription drafttoken_usage: AI input/output token usage
sudo systemctl status pdf-exercise-api
sudo systemctl status pdf-exercise-worker
sudo journalctl -u pdf-exercise-api -n 100
sudo journalctl -u pdf-exercise-worker -n 100- The default 1GB VPS setup runs one worker and is not intended for high concurrency.
- Image correction depends on page boundary detection; severe perspective distortion, folds, shadows, and occlusions still need manual review or retaking the photo.
- Math PDFs prefer XeLaTeX; if TeX is unavailable, the app falls back to ReportLab and records a task event.
- LocalStorage API Key storage has XSS risk. The project avoids third-party frontend scripts and sets a basic CSP, but public production deployments should keep tightening security policy.
