feat: 20 MB on an upload, and delete one where they are listed
Some checks failed
Tests / backend (push) Failing after 11s
Tests / frontend (push) Failing after 29s
Tests / e2e (push) Failing after 36s

A source PDF is chunked, vectorised and then read by a model a chunk at
a time, so a 500 MB upload is not a big file — it is an hour of work
nobody asked for and a bucket that grows for ever. The cap is 20 MB, in
the config default, in backend/.env which was overriding it at
524288000, and in nginx, which was letting 500M through to be refused
by the application afterwards. A backup of .env is beside it.

And a document is deleted where the documents are listed. The endpoint
has always existed and removes the file, the vector collection and the
row — but reaching it meant opening the document first, which is a page
you go to in order to extract from it, not somewhere you visit to tidy
up. Delete, then Delete it or Keep, in the workbench list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 16:12:29 +02:00
parent 8836993ca1
commit 3d7c619461
6 changed files with 101 additions and 4 deletions

62
backend/.env.bak-160955 Normal file
View file

@ -0,0 +1,62 @@
DATABASE_URL=postgresql://pedquiz:pedquiz_secret@postgres:5432/pedquiz
SECRET_KEY=a787624f6ff8331ba39b0c759fc2ae21dd06898062d81cc9433abd3206cd8f7c
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
REDIS_URL=redis://redis:6379/0
LITELLM_MODEL=openrouter-claude-haiku-4.5
LITELLM_API_KEY=sk-Lu70lYqqWh863YdMJohviA
LITELLM_API_BASE=https://llm.danvics.com
LITELLM_EMBEDDING_MODEL=openrouter-bge-m3
LITELLM_RERANK_MODEL=cohere-rerank-v4.0-pro
#OPENAI_API_KEY=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
#ELEVENLABS_API_KEY=sk_4d43f4cabd67c42850adf8117f8b21b6e24418c1e2dd7491
#GOOGLE_TTS_API_KEY=
LOCAL_SPEECH_GATEWAY_URL=http://local-speech-gateway:8110
AWS_ACCESS_KEY_ID=AKIAZP6UAHW7JS5GC6DK
AWS_SECRET_ACCESS_KEY=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
AWS_REGION=us-east-1
AWS_BEDROCK_REGION=us-east-1
CHROMA_PERSIST_DIR=/app/chroma_data
MAIL_USERNAME=danvics.com
MAIL_PASSWORD=YFZ2L8F06YUYBfBR
MAIL_FROM=noreply@pedshub.com
MAIL_PORT=587
MAIL_SERVER=mail.smtp2go.com
MAIL_STARTTLS=true
MAIL_SSL_TLS=false
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000
APP_URL=https://pedshub.com
# Blank until the hCaptcha secret is issued; the challenge is skipped while it is.
CAP_SECRET_KEY=sk-55R5UwjN96vkTaZMv5pbM5rNrxIGfoJCztKRndFNJtk
CAP_SITE_KEY=863be850d3
CAP_API_URL=http://cap:3000
ADMIN_EMAIL=dan@danvics.com
# Media storage. 'local' keeps the container volume; 's3' uses MinIO.
# Reads fall back to the volume either way, so existing uploads keep working.
STORAGE_BACKEND=s3
S3_ENDPOINT_URL=http://quiz-minio:9000
S3_ACCESS_KEY=pedshub
S3_SECRET_KEY=B4162G4nVvPsHeka4JLBP-GKvXiP2Lmm
S3_BUCKET=pedshub-media
# Clinical library index (Milvus, ped-ai stack). Read-only: articles are written
# from what it retrieves, and nothing here ever writes to it.
CLINICAL_MILVUS_URI=http://milvus:19530
CLINICAL_MILVUS_TOKEN=clinical_query:9950b7d40fd318fd7ecb658a68b40df68f5337ee11c2ac85
CLINICAL_MILVUS_COLLECTION=mcp_bge_m3_1024
OIDC_PROVIDER_URL=https://sso.pedshub.com/application/o/pedshub/
OIDC_CLIENT_ID=YY8v0cH523GCHTfK76d88IlAiuWiQSxHb1QiJXaM
OIDC_CLIENT_SECRET=cYVU3WtW1On6hWWsWeehUlOGvlAV5OWz0Sysk0VwF5EnB6Trm97FCsifKcpe7a0D67BIKqdq8B31nQCbkt4dZvdaZ3tUYZxPR5OlLWizbB51iJfUKSvPpfPb6TeRr4Bx
OIDC_SCOPES=openid email profile
OIDC_PROVIDER_NAME=PedsHub SSO
OIDC_ROLE_CLAIM=groups
OIDC_ADMIN_GROUPS=pedshub-admins
OIDC_MODERATOR_GROUPS=pedshub-moderators

View file

@ -58,7 +58,11 @@ class Settings(BaseSettings):
S3_SECRET_KEY: str = ""
S3_BUCKET: str = "pedshub-media"
S3_REGION: str = "us-east-1"
MAX_UPLOAD_SIZE: int = 524288000 # 500MB
# 20 MB. A source PDF is chunked, vectorised and then read by a model a
# chunk at a time, so a 500 MB upload is not a big file — it is an hour of
# work nobody asked for and a bucket that grows for ever. Anything larger
# is split before it comes in.
MAX_UPLOAD_SIZE: int = 20971520 # 20MB
# hCaptcha. Leave the secret blank to disable the challenge entirely.
# Cap, self-hosted beside us. The secret verifies a solve and never leaves

View file

@ -48,7 +48,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
# Large file uploads
client_max_body_size 500M;
client_max_body_size 24M; # 20 MB of PDF plus the multipart wrapper
proxy_request_buffering off;
proxy_read_timeout 600s;
}

View file

@ -66,3 +66,6 @@
font-size: .85rem; font-weight: 600; color: var(--primary); text-decoration: none;
}
.tools-jobs-link:hover { text-decoration: underline; }
/* Delete, and the two buttons it turns into. */
.tools-doc-confirm { display: inline-flex; gap: 6px; }

View file

@ -31,6 +31,19 @@ export default function ToolsPage() {
const [open, setOpen] = useState(null)
const [showDone, setShowDone] = useState(false)
const [labsOpen, setLabsOpen] = useState(false)
//: Which document is asking to be confirmed. A PDF is the heaviest thing
//: anybody uploads and the one most often uploaded twice by mistake.
const [removing, setRemoving] = useState(null)
const remove = async (id) => {
setRemoving(null)
try {
await api.delete(`/documents/${id}`)
setDocuments(rows => rows.filter(row => row.id !== id))
} catch {
setError('Could not delete that document')
}
}
const load = useCallback(() => {
Promise.all([
@ -99,6 +112,21 @@ export default function ToolsPage() {
</small>
</Link>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
{/* Where the documents are listed is where one is thrown away.
Deleting meant opening the document first, which is a page
you go to in order to extract from it not somewhere you
visit to tidy up. */}
{removing === doc.id ? (
<span className="tools-doc-confirm">
<button type="button" className="btn btn-danger btn-sm"
onClick={() => remove(doc.id)}>Delete it</button>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setRemoving(null)}>Keep</button>
</span>
) : (
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setRemoving(doc.id)}>Delete</button>
)}
</li>
))}
</ul>

View file

@ -54,7 +54,7 @@ export default function UploadPage() {
<div className="card">
<h2>Upload PDF Document</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: 20, fontSize: '0.9rem' }}>
Upload a PDF file (up to 500MB) to generate interactive quizzes.
Upload a PDF (up to 20 MB) to extract questions from. Larger books are split before they come in a source is read a chunk at a time, so size is work rather than storage.
</p>
{error && <div className="alert alert-error">{error}</div>}
@ -76,7 +76,7 @@ export default function UploadPage() {
<div>
<div style={{ fontSize: '2rem', marginBottom: 8 }}>PDF</div>
<div>Click or drag a PDF file here</div>
<div style={{ color: 'var(--text-subtle)', fontSize: '0.85rem', marginTop: 4 }}>Supports files up to 500MB</div>
<div style={{ color: 'var(--text-subtle)', fontSize: '0.85rem', marginTop: 4 }}>Up to 20 MB</div>
</div>
)}
</div>