diff --git a/deploy/nginx-vps.conf.example b/deploy/nginx-vps.conf.example new file mode 100644 index 00000000..b7260aef --- /dev/null +++ b/deploy/nginx-vps.conf.example @@ -0,0 +1,85 @@ +# EnCoach — example VPS reverse-proxy config +# +# Sits in front of the docker-compose stack (frontend:80 + backend:8069). +# Copy to /etc/nginx/sites-available/encoach, edit server_name / TLS paths, +# symlink to sites-enabled, then `nginx -t && systemctl reload nginx`. +# +# The inner frontend container already raises its own nginx limits +# (see frontend/Dockerfile). If THIS outer proxy isn't raised too, the +# outer layer returns 413/504 before traffic ever reaches Odoo, and users +# see "Submit failed 413" or "Upload failed" with no server-side log entry. + +# ───────────────────────────────────────────────────────────────── +# HTTP → HTTPS redirect (remove if you're not terminating TLS here) +server { + listen 80; + listen [::]:80; + server_name encoach.example.com; + return 301 https://$host$request_uri; +} + +# ───────────────────────────────────────────────────────────────── +# Main TLS vhost +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name encoach.example.com; + + ssl_certificate /etc/letsencrypt/live/encoach.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/encoach.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # ── CRITICAL ──────────────────────────────────────────────── + # Default nginx caps request bodies at 1 MB, which triggers + # HTTP 413 on: + # * /api/resources (PDF / audio / image uploads) + # * /api/exam/generation/submit (full module payload — can + # easily be 2–10 MB when all tasks are filled) + # * /api/approval-requests (attachments on approval flow) + # Match this value to Odoo's `limit_request` (128 MB). + client_max_body_size 128m; + client_body_buffer_size 1m; + client_body_timeout 900s; + large_client_header_buffers 4 16k; + + # ── Static / SPA served by the frontend container ─────────── + location / { + proxy_pass http://127.0.0.1:8080; # docker-compose: frontend:80 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Odoo API: AI calls can take minutes, needs long timeouts ─ + # Must exceed Odoo's limit_time_real (900s in odoo-docker.conf). + # proxy_request_buffering off streams the body to Odoo instead of + # buffering it entirely on disk → less chance of hitting the 413 + # when uploading big resources. + location /api/ { + proxy_pass http://127.0.0.1:8069/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_read_timeout 900s; + proxy_send_timeout 900s; + proxy_connect_timeout 60s; + proxy_request_buffering off; + proxy_buffering off; # stream AI responses to the client + } + + # ── Odoo web client + longpolling (optional, for /web/* URLs) ─ + location /longpolling/ { + proxy_pass http://127.0.0.1:8069/longpolling/; + proxy_http_version 1.1; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # Access / error logs — keep them separate so 413s are easy to spot + access_log /var/log/nginx/encoach.access.log; + error_log /var/log/nginx/encoach.error.log warn; +} diff --git a/docs/DEPLOY_413_504_FIX.md b/docs/DEPLOY_413_504_FIX.md new file mode 100644 index 00000000..7f6b7a8a --- /dev/null +++ b/docs/DEPLOY_413_504_FIX.md @@ -0,0 +1,105 @@ +# Why "Submit failed 413" and "Upload failed" still happen in production + +QA keeps reporting that the following two actions fail on the deployed VPS +while working locally: + +- **Resource Manager → Upload**: toast shows "Upload failed" (no details). +- **Generation → Submit module as exam for approval**: toast shows + "Submit failed — 413". + +Both are the same root cause: the **outer nginx reverse proxy** on the VPS +(the one terminating TLS in front of the docker stack) is still using its +default `client_max_body_size 1m` and short proxy timeouts. We already +raised the inner limits in: + +- `odoo.conf` / `odoo-docker.conf` → `limit_request 134217728` (128 MB) + + `limit_time_real 900s` +- `frontend/Dockerfile` → `client_max_body_size 128m` + + `proxy_read_timeout 900s` + `proxy_request_buffering off` + +But the **outer** nginx wasn't changed, so it still rejects the request +(413) or times the proxied connection (504) before traffic ever reaches +Odoo. Because the refusal happens at the proxy, nothing shows up in the +Odoo logs — only in `/var/log/nginx/*.error.log`. + +## Fix on the VPS (one-time, 2 minutes) + +1. SSH to the VPS and back up the existing config: + + ```bash + sudo cp /etc/nginx/sites-available/encoach /etc/nginx/sites-available/encoach.bak + ``` + +2. Copy the template from the repo and edit the `server_name` + TLS + cert paths to match the real hostnames: + + ```bash + sudo cp deploy/nginx-vps.conf.example /etc/nginx/sites-available/encoach + sudo ln -sf /etc/nginx/sites-available/encoach /etc/nginx/sites-enabled/encoach + sudo nginx -t + sudo systemctl reload nginx + ``` + + The critical lines added are: + + ```nginx + client_max_body_size 128m; # matches Odoo limit_request + client_body_timeout 900s; + proxy_read_timeout 900s; # matches Odoo limit_time_real + proxy_send_timeout 900s; + proxy_request_buffering off; # stream uploads, don't buffer 100% on disk + ``` + +3. Smoke-test: + + ```bash + # Upload a ~30 MB PDF through /api/resources — should return 200, not 413. + # Submit a module from /generation — should return 201, not 413. + tail -f /var/log/nginx/encoach.error.log # watch for "client intended to send too large body" + tail -f /var/log/odoo/odoo-server.log # watch for the actual request hitting Odoo + ``` + +## If the 413 persists after the nginx change + +The body is getting rejected by something upstream. Check in order: + +1. **Cloudflare / WAF** — Cloudflare's default free-plan body cap is 100 MB + for authenticated endpoints and as low as 1 MB on some enterprise + rules. In the dashboard → Rules → Transform / Firewall, look for + `request.body.size` restrictions. +2. **Odoo worker (`limit_request`)** — verify + `grep limit_request /etc/odoo/odoo.conf` shows at least `134217728`. + If the config file was baked into the docker image and never rebuilt, + `docker compose build backend && docker compose up -d backend`. +3. **`ModSecurity` / CRL** — some VPS providers ship ModSecurity enabled + by default (`SecRequestBodyLimit`, default 13 MB). + +## If the 504 persists + +504 = the upstream didn't respond in time. + +- Confirm `proxy_read_timeout 900s` is actually in the running config + (`nginx -T | grep proxy_read_timeout`). +- Confirm `limit_time_real` in `odoo.conf` is at least 900 (default is + 120; a single AI generation with 4 modules can exceed 3 minutes). +- For first-time submissions, Odoo may need to warm up its AI clients. + Retry once after a minute. + +## What the UI now tells the user + +The frontend toast has been upgraded (see `lib/api-client.ts::describeApiError`). +Users will now see, for example: + +- **413**: _"The module is too large for the server to accept (HTTP 413). + Try a smaller file / fewer tasks, or ask an admin to raise the nginx + client_max_body_size and Odoo limit_request."_ +- **504**: _"The server took too long to respond (HTTP 504). The module + may still be processing — wait a minute and reload before retrying."_ +- **502**: _"The backend is unreachable (HTTP 502). Odoo may be + restarting — try again in a moment."_ +- **401**: _"Your session expired — please sign in again."_ + +This applies to: +- Resource upload (admin + teacher library) +- Exam generation submit +- Custom-exam publish / save draft diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index e0f97839..e3b55638 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -40,6 +40,64 @@ export class ApiError extends Error { } } +/** + * Turn an arbitrary thrown value from an API mutation into a human-readable, + * actionable toast description. Deployment QA kept reporting generic + * "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP + * status and, when we recognise the code, a hint about what to do next + * (payload too big → ask ops to raise nginx, timeout → retry, etc.). + * + * @param err the error thrown by an `api.*` call (usually an ApiError) + * @param fallback text to show if we can't classify the error + * @param context short context word used in the 413/504 hints + * ("file", "request", "payload" …) + */ +export function describeApiError( + err: unknown, + fallback = "Something went wrong", + context = "request", +): string { + const e = err as { status?: number; message?: string; data?: unknown } | null; + const status = e?.status; + // Try to extract a server-side error body even if the generic ApiError + // message wasn't populated (nginx 413/504 respond with an HTML page so + // `data.error` is empty and `message` looks like "413 "). + const serverMsg = + e?.data && typeof e.data === "object" && "error" in (e.data as object) + ? String((e.data as { error: unknown }).error || "").trim() + : ""; + + if (status === 413) { + return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`; + } + if (status === 504) { + return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`; + } + if (status === 502) { + return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment."; + } + if (status === 401) { + return "Your session expired — please sign in again."; + } + if (status === 403) { + return serverMsg || "You don't have permission to do that (HTTP 403)."; + } + if (status === 404) { + return serverMsg || "The requested item no longer exists (HTTP 404)."; + } + if (status === 422 || status === 400) { + return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`; + } + if (status && status >= 500) { + return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`; + } + + // Network failure / fetch aborted / CORS — ApiError not thrown at all. + if (!status && e?.message) return e.message; + + return fallback; +} + const ACCESS_KEY = "encoach_token"; const REFRESH_KEY = "encoach_refresh_token"; const EXP_KEY = "encoach_token_exp"; diff --git a/frontend/src/pages/GenerationPage.tsx b/frontend/src/pages/GenerationPage.tsx index 28a260e2..c3b4ece6 100644 --- a/frontend/src/pages/GenerationPage.tsx +++ b/frontend/src/pages/GenerationPage.tsx @@ -61,7 +61,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner"; import { generationService } from "@/services/generation.service"; import { mediaService, type Avatar } from "@/services/media.service"; import { examsService } from "@/services/exams.service"; -import { api } from "@/lib/api-client"; +import { api, describeApiError } from "@/lib/api-client"; import { useToast } from "@/hooks/use-toast"; import type { ExamStructure, ExamStructureConfig } from "@/types"; @@ -807,7 +807,12 @@ export default function GenerationPage() { description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`, duration: 8000, }), - onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }), + onError: (err) => + toast({ + variant: "destructive", + title: "Submit failed", + description: describeApiError(err, "Submit failed", "module"), + }), }); const anyGenerating = generatePassageMut.isPending || generateExercisesMut.isPending || diff --git a/frontend/src/pages/admin/CustomExamCreate.tsx b/frontend/src/pages/admin/CustomExamCreate.tsx index 7828d42d..32455669 100644 --- a/frontend/src/pages/admin/CustomExamCreate.tsx +++ b/frontend/src/pages/admin/CustomExamCreate.tsx @@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useCreateCustomExam, useSaveAsTemplate } from "@/hooks/queries/useExamTemplates"; +import { describeApiError } from "@/lib/api-client"; import { useSubjects } from "@/hooks/queries/useAdaptive"; import type { CustomExamCreateRequest, CustomScoringMethod } from "@/types"; import { toast } from "sonner"; @@ -139,14 +140,11 @@ export default function CustomExamCreate() { return raw?.id ?? raw?.exam_id ?? raw?.data?.id ?? raw?.data?.exam_id; }; - const describeError = (err: unknown, fallback: string): string => { - const e = err as { status?: number; message?: string }; - if (e?.status === 413) return "Payload too large (HTTP 413). Please reduce the number of questions per section and try again."; - if (e?.status === 504) return "Server took too long to respond (HTTP 504). Please retry in a moment."; - if (e?.status === 401) return "Session expired — please sign in again."; - if (e?.message) return `${fallback}: ${e.message}`; - return fallback; - }; + // Thin wrapper around the shared helper so the existing call-sites below + // keep working; the helper lives in api-client so every page gets the same + // actionable 413/504/401 messaging. + const describeError = (err: unknown, fallback: string): string => + describeApiError(err, fallback, "exam"); const onSaveDraft = async () => { // Validate everything before firing the request — previously the Save diff --git a/frontend/src/pages/admin/ResourceManager.tsx b/frontend/src/pages/admin/ResourceManager.tsx index 2e3d4048..4317ab17 100644 --- a/frontend/src/pages/admin/ResourceManager.tsx +++ b/frontend/src/pages/admin/ResourceManager.tsx @@ -17,6 +17,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { resourcesService } from "@/services/resources.service"; import { coursewareService } from "@/services/courseware.service"; import { taxonomyService } from "@/services/taxonomy.service"; +import { describeApiError } from "@/lib/api-client"; import { useToast } from "@/hooks/use-toast"; import { TaxonomyCascade } from "@/components/TaxonomyCascade"; import type { Resource, ResourceTag } from "@/types"; @@ -274,17 +275,16 @@ export default function ResourceManager() { const uploadMutation = useMutation({ mutationFn: (formData: FormData) => resourcesService.upload(formData), onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); }, - onError: (err: unknown) => { - // Surface status / server message so users can tell 413 (file too big) - // from 401 (session expired) from 500 (server error) without opening - // devtools. Default "Upload failed" hid real failures during QA. - const e = err as { status?: number; message?: string }; - const status = e?.status; - let description = e?.message || "Upload failed"; - if (status === 413) description = "File is too large for the server (HTTP 413). Ask an admin to raise nginx/odoo upload limits."; - else if (status === 401) description = "Session expired — please sign in again."; - else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry."; - toast({ title: "Upload failed", description, variant: "destructive" }); + onError: (err) => { + // describeApiError surfaces status code + actionable next step so QA / + // ops can tell 413 (payload limit) from 504 (timeout) from 401 (session + // expired) without opening DevTools. Default "Upload failed" was + // reported as non-actionable during the Apr-2026 deployment. + toast({ + title: "Upload failed", + description: describeApiError(err, "Upload failed", "file"), + variant: "destructive", + }); }, }); diff --git a/frontend/src/pages/teacher/TeacherLibrary.tsx b/frontend/src/pages/teacher/TeacherLibrary.tsx index 78303ff1..11c17483 100644 --- a/frontend/src/pages/teacher/TeacherLibrary.tsx +++ b/frontend/src/pages/teacher/TeacherLibrary.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { resourcesService } from "@/services/resources.service"; +import { describeApiError } from "@/lib/api-client"; import { coursewareService } from "@/services/courseware.service"; import { taxonomyService } from "@/services/taxonomy.service"; import { useToast } from "@/hooks/use-toast"; @@ -116,14 +117,12 @@ export default function TeacherLibrary() { setUploadTopicIds([]); setUploadObjectiveIds([]); }, - onError: (err: unknown) => { - const e = err as { status?: number; message?: string }; - const status = e?.status; - let description = e?.message || "Upload failed"; - if (status === 413) description = "File is too large for the server (HTTP 413). Try a smaller file or ask an admin to raise the upload limit."; - else if (status === 401) description = "Session expired — please sign in again."; - else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry."; - toast({ title: "Upload failed", description, variant: "destructive" }); + onError: (err) => { + toast({ + title: "Upload failed", + description: describeApiError(err, "Upload failed", "file"), + variant: "destructive", + }); }, });