harden database interactions and improve error handling
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { createReadStream } from "fs";
|
||||
import { resolve, normalize } from "path";
|
||||
import { access } from "fs/promises";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const pathParam = getRouterParam(event, "path") as string | string[];
|
||||
const pathStr = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
|
||||
|
||||
// prevent path traversal
|
||||
const baseDir = resolve(process.cwd(), "private/audio");
|
||||
const filePath = normalize(resolve(baseDir, pathStr));
|
||||
|
||||
if (!filePath.startsWith(baseDir)) {
|
||||
throw createError({ statusCode: 403, message: "Forbidden" });
|
||||
}
|
||||
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
throw createError({ statusCode: 404, message: "Audio not found" });
|
||||
}
|
||||
|
||||
const ext = filePath.endsWith(".mp3") ? "audio/mpeg" : "audio/mpeg";
|
||||
setHeader(event, "Content-Type", ext);
|
||||
setHeader(event, "Cache-Control", "public, max-age=86400");
|
||||
|
||||
return sendStream(event, createReadStream(filePath));
|
||||
});
|
||||
@@ -15,7 +15,10 @@ export default defineEventHandler(async (event) => {
|
||||
if (!body.title.trim()) throw createError({ statusCode: 400, message: "Title cannot be empty" });
|
||||
updates.title = body.title.trim();
|
||||
}
|
||||
if (body.subject !== undefined) updates.subject = body.subject;
|
||||
if (body.subject !== undefined) {
|
||||
if (!body.subject.trim()) throw createError({ statusCode: 400, message: "Subject cannot be empty" });
|
||||
updates.subject = body.subject.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw createError({ statusCode: 400, message: "Nothing to update" });
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { db } from "../../../db/index";
|
||||
import { courses, topics, lessons } from "../../../db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, "id")!;
|
||||
|
||||
const course = await db.query.courses.findFirst({ where: eq(courses.id, id) });
|
||||
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
|
||||
|
||||
const topicRows = await db.query.topics.findMany({
|
||||
where: eq(topics.courseId, id),
|
||||
orderBy: (t, { asc }) => asc(t.order),
|
||||
});
|
||||
|
||||
let lessonTopicIds: Set<string> = new Set();
|
||||
|
||||
if (topicRows.length > 0) {
|
||||
const topicIds = topicRows.map((t) => t.id);
|
||||
const lessonRows = await db.query.lessons.findMany({
|
||||
where: inArray(lessons.topicId, topicIds),
|
||||
});
|
||||
lessonTopicIds = new Set(lessonRows.map((l) => l.topicId));
|
||||
}
|
||||
|
||||
return {
|
||||
status: course.status,
|
||||
stage: course.stage,
|
||||
topics: topicRows.map((t) => ({
|
||||
id: t.id,
|
||||
status: t.status,
|
||||
hasLesson: lessonTopicIds.has(t.id),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -7,9 +7,15 @@ import { resolve } from "path";
|
||||
import { parsePdf } from "../../../utils/parsePdf";
|
||||
import { detectUploadType } from "../../../utils/detectUploadType";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, "id")!;
|
||||
|
||||
if (!UUID_RE.test(id)) {
|
||||
throw createError({ statusCode: 400, message: "Invalid course id" });
|
||||
}
|
||||
|
||||
const course = await db.query.courses.findFirst({ where: eq(courses.id, id) });
|
||||
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
|
||||
|
||||
@@ -20,21 +26,40 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, message: "file is required" });
|
||||
}
|
||||
|
||||
// 50mb limit
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
throw createError({ statusCode: 400, message: "File exceeds 50MB limit" });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// check pdf magic bytes: %PDF = 0x25 0x50 0x44 0x46
|
||||
if (buffer[0] !== 0x25 || buffer[1] !== 0x50 || buffer[2] !== 0x44 || buffer[3] !== 0x46) {
|
||||
throw createError({ statusCode: 400, message: "File does not appear to be a valid PDF" });
|
||||
}
|
||||
|
||||
const uploadDir = resolve(process.cwd(), "uploads", id);
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
const uploadId = randomUUID();
|
||||
const safeFilename = `${uploadId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
|
||||
const storedPath = resolve(uploadDir, safeFilename);
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// safeFilename shouldnt be just dots/underscores
|
||||
if (/^[._]+$/.test(safeFilename.replace(uploadId + "-", ""))) {
|
||||
throw createError({ statusCode: 400, message: "Invalid filename" });
|
||||
}
|
||||
|
||||
const storedPath = resolve(uploadDir, safeFilename);
|
||||
await writeFile(storedPath, buffer);
|
||||
|
||||
let extractedText: string | null = null;
|
||||
let pdfWarning: string | undefined;
|
||||
|
||||
try {
|
||||
extractedText = await parsePdf(buffer);
|
||||
} catch {
|
||||
// non-fatal
|
||||
} catch (err: any) {
|
||||
console.error(`[upload] PDF text extraction failed for file size ${buffer.length}: ${err?.message ?? err}`);
|
||||
pdfWarning = "PDF text extraction failed";
|
||||
}
|
||||
|
||||
const detectedType = await detectUploadType(file.name, extractedText ?? "");
|
||||
@@ -48,5 +73,9 @@ export default defineEventHandler(async (event) => {
|
||||
extractedText,
|
||||
});
|
||||
|
||||
if (pdfWarning) {
|
||||
return { uploadId, filename: file.name, type: detectedType, warning: pdfWarning };
|
||||
}
|
||||
|
||||
return { uploadId, filename: file.name, type: detectedType };
|
||||
});
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
import { db } from "../../db/index";
|
||||
import { courses, topics, userProgress } from "../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const allCourses = await db.query.courses.findMany({
|
||||
orderBy: (c, { desc }) => desc(c.createdAt),
|
||||
});
|
||||
|
||||
const result = [];
|
||||
if (allCourses.length === 0) return [];
|
||||
|
||||
for (const course of allCourses) {
|
||||
const topicRows = await db.query.topics.findMany({
|
||||
where: eq(topics.courseId, course.id),
|
||||
});
|
||||
const courseIds = allCourses.map((c) => c.id);
|
||||
|
||||
const topicCount = topicRows.length;
|
||||
const [allTopics, allProgress] = await Promise.all([
|
||||
db.query.topics.findMany({ where: inArray(topics.courseId, courseIds) }),
|
||||
db.query.userProgress.findMany({ where: inArray(userProgress.courseId, courseIds) }),
|
||||
]);
|
||||
|
||||
let completedCount = 0;
|
||||
if (topicCount > 0) {
|
||||
const progressRows = await db.query.userProgress.findMany({
|
||||
where: eq(userProgress.courseId, course.id),
|
||||
});
|
||||
completedCount = progressRows.filter((p) => p.lessonComplete).length;
|
||||
}
|
||||
|
||||
result.push({
|
||||
...course,
|
||||
topicCount,
|
||||
completedCount,
|
||||
});
|
||||
// group in memory
|
||||
const topicsByCourse = new Map<string, number>();
|
||||
for (const t of allTopics) {
|
||||
topicsByCourse.set(t.courseId, (topicsByCourse.get(t.courseId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
const completedByCourse = new Map<string, number>();
|
||||
for (const p of allProgress) {
|
||||
if (p.lessonComplete) {
|
||||
completedByCourse.set(p.courseId, (completedByCourse.get(p.courseId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return allCourses.map((course) => ({
|
||||
...course,
|
||||
topicCount: topicsByCourse.get(course.id) ?? 0,
|
||||
completedCount: completedByCourse.get(course.id) ?? 0,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { db } from "../../db/index";
|
||||
import { courses, topics } from "../../db/schema";
|
||||
import { inArray, eq } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const allCourses = await db.query.courses.findMany();
|
||||
|
||||
if (allCourses.length === 0) return [];
|
||||
|
||||
const courseIds = allCourses.map((c) => c.id);
|
||||
const allTopics = await db.query.topics.findMany({
|
||||
where: inArray(topics.courseId, courseIds),
|
||||
});
|
||||
|
||||
const topicCountByCourse = new Map<string, number>();
|
||||
const readyCountByCourse = new Map<string, number>();
|
||||
|
||||
for (const t of allTopics) {
|
||||
topicCountByCourse.set(t.courseId, (topicCountByCourse.get(t.courseId) ?? 0) + 1);
|
||||
if (t.status === "ready") {
|
||||
readyCountByCourse.set(t.courseId, (readyCountByCourse.get(t.courseId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return allCourses.map((c) => ({
|
||||
id: c.id,
|
||||
status: c.status,
|
||||
stage: c.stage,
|
||||
topicCount: topicCountByCourse.get(c.id) ?? 0,
|
||||
readyTopicCount: readyCountByCourse.get(c.id) ?? 0,
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { db } from "../db/index";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
let dbOk = false;
|
||||
try {
|
||||
await db.run(sql`SELECT 1`);
|
||||
dbOk = true;
|
||||
} catch {
|
||||
// db is down
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const envOk = !!(config.openrouterApiKey);
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
db: dbOk,
|
||||
env: envOk,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { db } from "../../../db/index";
|
||||
import { topics, lessons } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, "id")!;
|
||||
|
||||
const topic = await db.query.topics.findFirst({ where: eq(topics.id, id) });
|
||||
if (!topic) throw createError({ statusCode: 404, message: "Topic not found" });
|
||||
|
||||
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, id) });
|
||||
if (!lesson) throw createError({ statusCode: 404, message: "No lesson for this topic" });
|
||||
|
||||
return { branchStatus: lesson.branchStatus };
|
||||
});
|
||||
@@ -29,7 +29,7 @@ export default defineEventHandler(async (event) => {
|
||||
eq(topics.status, "pending")
|
||||
)
|
||||
});
|
||||
if (nextTopic) generateLesson(nextTopic.id);
|
||||
if (nextTopic) generateLesson(nextTopic.id).catch((e) => console.error("[pre-gen]", e));
|
||||
|
||||
return { status: "ready" };
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "../../../db/index";
|
||||
import { topics, userProgress } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -12,26 +12,25 @@ export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { lessonComplete, quizScore, tookBranches, branchCount } = body ?? {};
|
||||
|
||||
const existing = await db.query.userProgress.findFirst({
|
||||
where: and(
|
||||
eq(userProgress.topicId, id),
|
||||
eq(userProgress.courseId, topic.courseId)
|
||||
),
|
||||
});
|
||||
// manual validation — no zod
|
||||
if (lessonComplete !== undefined && typeof lessonComplete !== "boolean") {
|
||||
throw createError({ statusCode: 400, message: "lessonComplete must be a boolean" });
|
||||
}
|
||||
if (quizScore !== undefined && quizScore !== null) {
|
||||
if (!Number.isInteger(quizScore)) throw createError({ statusCode: 400, message: "quizScore must be an integer" });
|
||||
}
|
||||
if (tookBranches !== undefined && typeof tookBranches !== "boolean") {
|
||||
throw createError({ statusCode: 400, message: "tookBranches must be a boolean" });
|
||||
}
|
||||
if (branchCount !== undefined && !Number.isInteger(branchCount)) {
|
||||
throw createError({ statusCode: 400, message: "branchCount must be an integer" });
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(userProgress)
|
||||
.set({
|
||||
lessonComplete: lessonComplete ?? existing.lessonComplete,
|
||||
quizScore: quizScore ?? existing.quizScore,
|
||||
tookBranches: tookBranches ?? existing.tookBranches,
|
||||
branchCount: branchCount ?? existing.branchCount,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(userProgress.id, existing.id));
|
||||
} else {
|
||||
await db.insert(userProgress).values({
|
||||
|
||||
// NOTE: this requires a UNIQUE constraint on (course_id, topic_id) in user_progress — the migration adds this
|
||||
await db
|
||||
.insert(userProgress)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
courseId: topic.courseId,
|
||||
topicId: id,
|
||||
@@ -39,8 +38,18 @@ export default defineEventHandler(async (event) => {
|
||||
quizScore: quizScore ?? null,
|
||||
tookBranches: tookBranches ?? false,
|
||||
branchCount: branchCount ?? 0,
|
||||
updatedAt: sql`datetime('now')`,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userProgress.courseId, userProgress.topicId],
|
||||
set: {
|
||||
lessonComplete: lessonComplete ?? sql`excluded.lesson_complete`,
|
||||
quizScore: quizScore !== undefined ? quizScore : sql`excluded.quiz_score`,
|
||||
tookBranches: tookBranches ?? sql`excluded.took_branches`,
|
||||
branchCount: branchCount ?? sql`excluded.branch_count`,
|
||||
updatedAt: sql`datetime('now')`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user