Files
gerbeur/api/routes/dumps.ts
khannurien fae25f3e6c
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
v3: added site-wide categories, added admin category management, various visual fixes
2026-06-28 16:23:44 +00:00

307 lines
8.8 KiB
TypeScript

import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type Dump,
isCreateUrlDumpRequest,
isUpdateDumpRequest,
type PaginatedData,
} from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { validateImageUpload } from "../lib/upload.ts";
import { THUMBNAILS_DIR } from "../config.ts";
import {
createFileDump,
createUrlDump,
deleteDump,
getDump,
listDumps,
refreshDumpMetadata,
removeDumpThumbnail,
replaceFileDump,
setDumpThumbnail,
updateDump,
} from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts";
const router = new Router({ prefix: "/api/dumps" });
// Categories arrive on the multipart create path as a JSON-encoded string field.
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
if (typeof value !== "string" || !value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((v): v is string => typeof v === "string")
: [];
} catch {
return [];
}
}
router.post(
"/",
authMiddleware,
async (ctx) => {
const userId = ctx.state.user.userId;
const contentType = ctx.request.headers.get("content-type") ?? "";
let dump: Dump;
if (contentType.includes("multipart/form-data")) {
const formData = await ctx.request.body.formData();
const file = formData.get("file");
const comment = formData.get("comment");
const title = formData.get("title");
const isPrivate = formData.get("isPrivate") === "true";
if (!(file instanceof File)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"A file is required",
);
}
dump = await createFileDump(
file,
typeof comment === "string" && comment ? comment : undefined,
userId,
isPrivate,
typeof title === "string" && title ? title : undefined,
parseCategoryIds(formData.get("categoryIds")),
);
} else {
const body = await ctx.request.body.json();
if (!isCreateUrlDumpRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid dump data",
);
}
dump = await createUrlDump(body, userId);
}
const responseBody: APIResponse<Dump> = { success: true, data: dump };
ctx.response.status = 201;
ctx.response.body = responseBody;
},
);
router.get("/:dumpId", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);
const responseBody: APIResponse<Dump> = { success: true, data: dump };
ctx.response.body = responseBody;
});
router.get("/:dumpId/voters", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getDumpVoters(dump.id, page, limit);
ctx.response.body = {
success: true,
data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
total,
hasMore: page * limit < total,
},
};
});
router.get("/:dumpId/related", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);
const related = getRelatedDumps(dump.id, requestingUserId);
const responseBody: APIResponse<Dump[]> = { success: true, data: related };
ctx.response.body = responseBody;
});
router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = listDumps(page, limit, requestingUserId);
const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, total, hasMore: page * limit < total },
};
ctx.response.body = responseBody;
});
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const formData = await ctx.request.body.formData();
const file = formData.get("file");
const comment = formData.get("comment");
const title = formData.get("title");
if (!(file instanceof File)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"A file is required",
);
}
const updatedDump = await replaceFileDump(
dumpId,
file,
typeof comment === "string" && comment ? comment : undefined,
typeof title === "string" && title ? title : undefined,
formData.has("categoryIds")
? parseCategoryIds(formData.get("categoryIds"))
: undefined,
);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.put("/:dumpId", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const body = await ctx.request.body.json();
if (!isUpdateDumpRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
422,
"Erroneous user input",
);
}
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const updatedDump = await updateDump(dumpId, body);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const formData = await ctx.request.body.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Missing file field",
);
}
const data = new Uint8Array(await file.arrayBuffer());
const mime = validateImageUpload(data);
// DB update first (resolves slug → id), then file write.
const updatedDump = setDumpThumbnail(dump.id, mime);
const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`;
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
try {
await Deno.writeFile(filePath, data);
} catch (err) {
await Deno.remove(filePath).catch(() => {});
throw err;
}
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const updatedDump = await removeDumpThumbnail(dump.id);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const updatedDump = await refreshDumpMetadata(dumpId);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.delete("/:dumpId", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to delete dump",
);
}
await deleteDump(dumpId);
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});
export default router;