From 7dd10bef2d37f4ad3f5ec09c51c8b37cf249e0ad Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Mon, 16 Feb 2026 18:10:47 +0800 Subject: [PATCH] fix: image upload proxy, compression, and JSON parse safety - Add /api/v1/ and /files/ rewrite rules in next.config.ts so frontend can call backend without /admin139 prefix - Fix upload using MultiPartParser instead of req->getUploadedFiles() - Add client-side image compression (canvas resize to 1920px, quality 0.8) for photos >500KB before upload - Safe JSON parsing: catch HTML error responses instead of throwing SyntaxError on non-JSON backend responses - Fix backslash escape in delete filename validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/src/controllers/me_controller.cc | 21 ++- frontend/next.config.ts | 10 ++ frontend/src/app/problems/[id]/page.tsx | 163 +++++++++++++++++++++++ 3 files changed, 190 insertions(+), 4 deletions(-) diff --git a/backend/src/controllers/me_controller.cc b/backend/src/controllers/me_controller.cc index 56d2d41..e3df3e4 100644 --- a/backend/src/controllers/me_controller.cc +++ b/backend/src/controllers/me_controller.cc @@ -12,7 +12,11 @@ #include "csp/services/learning_note_scoring_service.h" #include "http_auth.h" +#include + #include +#include +#include #include #include #include @@ -363,7 +367,12 @@ void MeController::uploadWrongBookNoteImages( const auto user_id = RequireAuth(req, cb); if (!user_id.has_value()) return; - const auto files = req->getUploadedFiles(); + drogon::MultiPartParser parser; + if (parser.parse(req) != 0) { + cb(JsonError(drogon::k400BadRequest, "bad multipart")); + return; + } + const auto& files = parser.getFiles(); if (files.empty()) { cb(JsonError(drogon::k400BadRequest, "no files")); return; @@ -398,8 +407,12 @@ void MeController::uploadWrongBookNoteImages( for (const auto &f : files) { if ((int)arr.size() >= kMaxImages) break; - const auto ct_hdr = f.getFileType(); - if (ct_hdr.rfind("image/", 0) != 0) { + // Allow common image extensions only (frontend also restricts accept=image/*) + std::string name_for_ext = f.getFileName(); + auto dot = name_for_ext.find_last_of('.'); + std::string ext_check = (dot == std::string::npos) ? std::string("") : name_for_ext.substr(dot); + for (auto &c : ext_check) c = (char)std::tolower((unsigned char)c); + if (!(ext_check==".png" || ext_check==".jpg" || ext_check==".jpeg" || ext_check==".gif" || ext_check==".webp")) { continue; } if (f.fileLength() > 5 * 1024 * 1024) { @@ -456,7 +469,7 @@ void MeController::deleteWrongBookNoteImage( } const std::string filename = (*json).get("filename", "").asString(); - if (filename.empty() || filename.find("..") != std::string::npos || filename.find('/') != std::string::npos || filename.find('\\\\') != std::string::npos) { + if (filename.empty() || filename.find("..") != std::string::npos || filename.find('/') != std::string::npos || filename.find('\\') != std::string::npos) { cb(JsonError(drogon::k400BadRequest, "bad filename")); return; } diff --git a/frontend/next.config.ts b/frontend/next.config.ts index a8b2a63..943f2e9 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -18,6 +18,16 @@ const nextConfig: NextConfig = { source: "/admin139/:path+", destination: `${backendInternal}/:path*`, }, + { + // Proxy backend API calls made without /admin139 prefix + source: "/api/v1/:path*", + destination: `${backendInternal}/api/v1/:path*`, + }, + { + // Proxy backend static files (note images etc.) + source: "/files/:path*", + destination: `${backendInternal}/files/:path*`, + }, ]; }, }; diff --git a/frontend/src/app/problems/[id]/page.tsx b/frontend/src/app/problems/[id]/page.tsx index 103c526..1f0558e 100644 --- a/frontend/src/app/problems/[id]/page.tsx +++ b/frontend/src/app/problems/[id]/page.tsx @@ -156,6 +156,15 @@ type RunResult = { compile_log: string; }; +type WrongBookItem = { + problem_id: number; + note: string; + note_score: number; + note_rating: number; + note_feedback_md: string; + note_images?: string[]; +}; + type DraftResp = { language: string; code: string; @@ -302,6 +311,8 @@ export default function ProblemDetailPage() { const [policyMsg, setPolicyMsg] = useState(""); const [noteText, setNoteText] = useState("" ); + const [noteImages, setNoteImages] = useState([]); + const [noteUploading, setNoteUploading] = useState(false); const [noteSaving, setNoteSaving] = useState(false); const [noteScoring, setNoteScoring] = useState(false); const [noteScore, setNoteScore] = useState(null); @@ -340,6 +351,99 @@ export default function ProblemDetailPage() { if (!problem) return ""; return sanitizeStatementMarkdown(problem); }, [problem]); + + + const compressImage = (file: File, maxWidth = 1920, quality = 0.8): Promise => + new Promise((resolve) => { + if (file.size <= 500 * 1024 || !file.type.startsWith("image/")) { + resolve(file); + return; + } + const img = new Image(); + const url = URL.createObjectURL(file); + img.onload = () => { + URL.revokeObjectURL(url); + let { width, height } = img; + if (width > maxWidth) { + height = Math.round((height * maxWidth) / width); + width = maxWidth; + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + canvas.getContext("2d")!.drawImage(img, 0, 0, width, height); + canvas.toBlob( + (blob) => { + if (blob && blob.size < file.size) { + const ext = file.type === "image/png" ? ".png" : ".jpg"; + resolve(new File([blob], file.name.replace(/\.[^.]+$/, ext), { type: blob.type })); + } else { + resolve(file); + } + }, + file.type === "image/png" ? "image/png" : "image/jpeg", + quality, + ); + }; + img.onerror = () => { URL.revokeObjectURL(url); resolve(file); }; + img.src = url; + }); + + const handleNoteImageFiles = async (files: FileList | null) => { + const token = readToken(); + if (!token) { + setNoteMsg(tx("请先登录后再上传图片。", "Please login to upload images.")); + return; + } + if (!problemId) return; + if (!files || files.length === 0) return; + + setNoteUploading(true); + setNoteMsg(""); + try { + const compressed = await Promise.all(Array.from(files).map((f) => compressImage(f))); + const form = new FormData(); + compressed.forEach((f) => form.append("files", f)); + const resp = await fetch(`/api/v1/me/wrong-book/${problemId}/note-images`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form, + }); + const text = await resp.text(); + let json: Record; + try { json = JSON.parse(text); } catch { throw new Error(`Server error: ${resp.status}`); } + if (!resp.ok || json?.ok === false) { + throw new Error((json?.error as string) || `HTTP ${resp.status}`); + } + const d = json?.data as Record | undefined; + const arr = Array.isArray(d?.note_images) ? (d.note_images as string[]) : []; + setNoteImages(arr); + setNoteMsg(tx("图片已上传。", "Images uploaded.")); + } catch (e: unknown) { + setNoteMsg(String(e)); + } finally { + setNoteUploading(false); + } + }; + + const deleteNoteImage = async (filename: string) => { + const token = readToken(); + if (!token) { + setNoteMsg(tx("请先登录后再删除图片。", "Please login to delete images.")); + return; + } + if (!problemId) return; + try { + const resp = await apiFetch<{ note_images: string[] }>(`/api/v1/me/wrong-book/${problemId}/note-images`, { + method: "DELETE", + body: JSON.stringify({ filename }), + }, token); + if (Array.isArray((resp as any).note_images)) setNoteImages((resp as any).note_images); + setNoteMsg(tx("图片已删除。", "Image deleted.")); + } catch (e: unknown) { + setNoteMsg(String(e)); + } + }; const saveLearningNote = async () => { const token = readToken(); if (!token) { @@ -391,8 +495,32 @@ export default function ProblemDetailPage() { } }; + + const sampleInput = problem?.sample_input ?? ""; const problemId = problem?.id ?? 0; + const loadExistingWrongBookNote = async () => { + const token = readToken(); + if (!token) return; + if (!problemId) return; + try { + const resp = await apiFetch(`/api/v1/me/wrong-book`, { method: "GET" }, token); + const item = resp.find((x) => x.problem_id === problemId); + if (!item) return; + if (item.note) setNoteText(item.note); + if (Number.isFinite(item.note_score) && item.note_score > 0) setNoteScore(item.note_score); + if (Number.isFinite(item.note_rating) && item.note_rating > 0) setNoteRating(item.note_rating); + if (item.note_feedback_md) setNoteFeedback(item.note_feedback_md); + if (Array.isArray(item.note_images)) setNoteImages(item.note_images); + } catch { + // ignore + } + }; + + useEffect(() => { + void loadExistingWrongBookNote(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [problemId]); const printableAnswerMarkdown = useMemo( () => buildPrintableAnswerMarkdown(solutionData, tx), [solutionData, tx] @@ -916,6 +1044,18 @@ export default function ProblemDetailPage() { />
+ @@ -931,6 +1071,29 @@ export default function ProblemDetailPage() { {noteMsg &&

{noteMsg}

} + {noteImages.length > 0 && ( +
+ {noteImages.map((fn) => ( +
+ + {fn} + + +
+ ))} +
+ )} + {noteFeedback && (