137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
/**
|
|
* Serves and replaces file dumps' uploads, a sub-resource of their dump. The
|
|
* players, viewers and download links of the frontend build the URL with
|
|
* `dumpFileUrl`, and its edit page replaces the file.
|
|
*
|
|
* - `GET /api/dumps/:dumpId/file?v=&token=` — public; a private dump's file
|
|
* needs its poster's media `token`. Serves the file, inline when that is
|
|
* safe and as a download otherwise, with byte ranges.
|
|
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
|
|
* `dump:moderate`. Replaces a file dump's upload and comment, and any of its
|
|
* title, slug, categories and visibility (multipart).
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { Router } from "@oak/oak";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import type { APIResponse } from "../../shared/api.ts";
|
|
import type { Dump } from "../../shared/dump.ts";
|
|
import { DUMPS_DIR } from "../config.ts";
|
|
import { resolveMediaDump } from "../lib/dump-media.ts";
|
|
import { can } from "../lib/permissions.ts";
|
|
import { serveFile } from "../lib/serve-file.ts";
|
|
import { authMiddleware } from "../middleware/auth.ts";
|
|
import { parseCategoryIds } from "../validators/dump.ts";
|
|
import { getDump, replaceFileDump } from "../services/dump-service.ts";
|
|
|
|
/**
|
|
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
|
* An `APIException` thrown by a handler becomes the JSON error response there.
|
|
*/
|
|
const router: Router = new Router({ prefix: "/api/dumps" });
|
|
|
|
/**
|
|
* Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
|
|
* throws on any character above U+00FF, so `filename` gets an ASCII copy of the
|
|
* name and `filename*` (RFC 5987) the full one, percent-encoded.
|
|
*/
|
|
function contentDisposition(
|
|
type: "inline" | "attachment",
|
|
name: string,
|
|
): string {
|
|
const asciiFallback = name
|
|
.replace(/["\\]/g, "_")
|
|
.replace(/[^\x20-\x7e]/g, "_");
|
|
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${
|
|
encodeURIComponent(name)
|
|
}`;
|
|
}
|
|
|
|
/**
|
|
* Matches the types served inline. An upload's type is whatever the uploader's
|
|
* browser claimed, and the file comes from the app's own origin, where the
|
|
* session token lives. So only types a browser shows without running code in
|
|
* them are inline, and anything else (HTML, SVG, XML…) downloads. `<img>`,
|
|
* `<audio>` and `fetch()` ignore `Content-Disposition`, so the players and
|
|
* previews still work.
|
|
*/
|
|
const INLINE_MIME_RE =
|
|
/^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/;
|
|
|
|
function isInlineSafe(mime: string): boolean {
|
|
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
|
|
}
|
|
|
|
router.get("/:dumpId/file", async (ctx) => {
|
|
const { dumpId } = ctx.params;
|
|
const { dump, cacheControl } = await resolveMediaDump(ctx, dumpId);
|
|
|
|
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
|
throw new APIException(404, "No file for this dump");
|
|
}
|
|
|
|
const path = `${DUMPS_DIR}/${dumpId}`;
|
|
const inline = isInlineSafe(dump.fileMime);
|
|
ctx.response.headers.set(
|
|
"Content-Disposition",
|
|
contentDisposition(inline ? "inline" : "attachment", dump.fileName),
|
|
);
|
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
|
// Belt and braces for a file opened directly: no scripts, no plugins, no
|
|
// same-origin access. Chrome won't render a PDF in a sandboxed document, and
|
|
// its viewer never runs a PDF's scripts anyway, so PDFs go without.
|
|
if (dump.fileMime !== "application/pdf") {
|
|
ctx.response.headers.set(
|
|
"Content-Security-Policy",
|
|
"sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'",
|
|
);
|
|
}
|
|
const served = await serveFile(ctx, path, {
|
|
mime: dump.fileMime,
|
|
cacheControl,
|
|
ranges: true,
|
|
});
|
|
if (!served) {
|
|
throw new APIException(404, "File not found");
|
|
}
|
|
});
|
|
|
|
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
|
const userId = ctx.state.user.userId;
|
|
const dump = getDump(ctx.params.dumpId, userId);
|
|
const canModerate = can(ctx.state.user, "dump:moderate");
|
|
if (userId !== dump.userId && !canModerate) {
|
|
throw new APIException(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");
|
|
const slug = formData.get("slug");
|
|
|
|
if (!(file instanceof File)) {
|
|
throw new APIException(400, "A file is required");
|
|
}
|
|
|
|
const updatedDump = await replaceFileDump(
|
|
dump.id,
|
|
file,
|
|
typeof comment === "string" && comment ? comment : undefined,
|
|
typeof title === "string" && title ? title : undefined,
|
|
formData.has("categoryIds")
|
|
? parseCategoryIds(formData.get("categoryIds"))
|
|
: undefined,
|
|
formData.has("isPrivate")
|
|
? formData.get("isPrivate") === "true"
|
|
: undefined,
|
|
typeof slug === "string" && slug ? slug : undefined,
|
|
canModerate,
|
|
);
|
|
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
export default router;
|