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>
这个提交包含在:
cryptocommuniums-afk
2026-02-16 18:10:47 +08:00
父节点 cfbe9a0363
当前提交 7dd10bef2d
修改 3 个文件,包含 190 行新增4 行删除

查看文件

@@ -12,7 +12,11 @@
#include "csp/services/learning_note_scoring_service.h"
#include "http_auth.h"
#include <drogon/MultiPart.h>
#include <algorithm>
#include <filesystem>
#include <memory>
#include <exception>
#include <optional>
#include <stdexcept>
@@ -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;
}

查看文件

@@ -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*`,
},
];
},
};

查看文件

@@ -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<string[]>([]);
const [noteUploading, setNoteUploading] = useState(false);
const [noteSaving, setNoteSaving] = useState(false);
const [noteScoring, setNoteScoring] = useState(false);
const [noteScore, setNoteScore] = useState<number | null>(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<File> =>
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<string, unknown>;
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<string, unknown> | 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<WrongBookItem[]>(`/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() {
/>
<div className="mt-2 flex flex-wrap gap-2">
<label className={`mc-btn text-xs ${noteUploading ? "opacity-60" : ""}`}>
<input
type="file"
multiple
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => void handleNoteImageFiles(e.target.files)}
disabled={noteUploading}
/>
{noteUploading ? tx("上传中...", "Uploading...") : tx("拍照/上传图片", "Upload Photos")}
</label>
<button className="mc-btn mc-btn-success text-xs" onClick={() => void saveLearningNote()} disabled={noteSaving}>
{noteSaving ? tx("保存中...", "Saving...") : tx("保存笔记", "Save")}
</button>
@@ -931,6 +1071,29 @@ export default function ProblemDetailPage() {
{noteMsg && <p className="mt-2 text-xs text-[color:var(--mc-stone-dark)]">{noteMsg}</p>}
{noteImages.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
{noteImages.map((fn) => (
<div key={fn} className="rounded border-2 border-black bg-[color:var(--mc-plank-light)] p-1 shadow-[2px_2px_0_rgba(0,0,0,0.25)]">
<a href={`/files/note-images/${fn}`} target="_blank" rel="noreferrer">
<img
src={`/files/note-images/${fn}`}
alt={fn}
className="h-20 w-full object-cover"
/>
</a>
<button
type="button"
className="mt-1 w-full rounded border border-black bg-[color:var(--mc-red)] px-2 py-1 text-[10px] text-white hover:brightness-110"
onClick={() => void deleteNoteImage(fn)}
>
{tx("删除", "Delete")}
</button>
</div>
))}
</div>
)}
{noteFeedback && (
<div className="mt-3 border-t-2 border-dashed border-black pt-3">
<MarkdownRenderer markdown={noteFeedback} className="problem-markdown text-black" />