import * as fs from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; import send from "koa-send"; import rename from "rename"; import { serverLogger } from "../index.js"; import { contentDisposition } from "../../misc/content-disposition.js"; import { DriveFiles } from "../../models/index.js"; import { InternalStorage } from "../../services/drive/internal-storage.js"; import { createTemp } from "../../misc/create-temp.js"; import { downloadUrl } from "../../misc/download-url.js"; import { detectType } from "../../misc/get-file-info.js"; import { convertToWebp } from "../../services/drive/image-processor.js"; import { GenerateVideoThumbnail } from "../../services/drive/generate-video-thumbnail.js"; import { StatusError } from "../../misc/fetch.js"; import { FILE_TYPE_BROWSERSAFE, MINUTE } from "../../const.js"; import { getIpHash } from "../../misc/get-ip-hash.js"; import { limiter } from "../api/limiter.js"; import authenticate from "../api/authenticate.js"; import { isIgnorableConnectionError } from "../is-ignorable-connection-error.js"; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); const assets = `${_dirname}/../../server/file/assets/`; const commonReadableHandlerGenerator = (ctx)=>(e)=>{ if (isIgnorableConnectionError(e)) return; serverLogger.error(e); ctx.status = 500; ctx.set("Cache-Control", "max-age=300"); }; export default async function(ctx) { const key = ctx.params.key; // Fetch drive file const file = await DriveFiles.createQueryBuilder("file").where("file.accessKey = :accessKey", { accessKey: key }).orWhere("file.thumbnailAccessKey = :thumbnailAccessKey", { thumbnailAccessKey: key }).orWhere("file.webpublicAccessKey = :webpublicAccessKey", { webpublicAccessKey: key }).getOne(); if (file == null) { ctx.status = 404; ctx.set("Cache-Control", "max-age=86400"); await send(ctx, "/dummy.png", { root: assets }); return; } ctx.set("X-Content-Type-Options", "nosniff"); const isThumbnail = file.thumbnailAccessKey === key; const isWebpublic = file.webpublicAccessKey === key; const requestedDownload = ctx.query.download === "1"; const requestedStream = ctx.query.stream === "1"; const isStreamableMedia = file.type.startsWith("video/") || file.type.startsWith("audio/"); // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. const limitActor = getIpHash(ctx.ip); const isMediaPlayback = isStreamableMedia && !requestedDownload; const limit = { key: `drive-file:${key}`, duration: MINUTE * 10, max: isMediaPlayback ? 600 : 10 }; await limiter(limit, limitActor).catch((e)=>{ const remainingTime = e.remainingTime ? `Please try again in ${e.remainingTime}.` : "Please try again later."; ctx.status = 429; ctx.body = "Rate limit exceeded. " + remainingTime; ctx.set("Content-Type", "text/plain; charset=utf-8"); ctx.set("Cache-Control", "no-store"); }); if (ctx.status == 429) return; const requesterId = requestedDownload ? await getRequesterId(ctx) : null; const isOwner = requesterId != null && requesterId === file.userId; const protectedOriginal = !isThumbnail && !isWebpublic && !file.allowDownload && !isOwner; if (requestedDownload && protectedOriginal) { ctx.status = 403; ctx.body = "Download is not allowed for this file."; ctx.set("Cache-Control", "max-age=300"); return; } if (!file.storedInternal) { if (file.isLink && file.uri) { // 期限切れリモートファイル const [path, cleanup] = await createTemp(); try { await downloadUrl(file.uri, path); const { mime, ext } = await detectType(path); const convertFile = async ()=>{ if (isThumbnail) { if ([ "image/jpeg", "image/webp", "image/png", "image/svg+xml", "image/avif" ].includes(mime)) { return await convertToWebp(path, 996, 560); } else if (mime.startsWith("video/")) { return await GenerateVideoThumbnail(path); } } if (isWebpublic) { if ([ "image/svg+xml" ].includes(mime)) { return await convertToWebp(path, 2048, 2048, 100); } } return { data: fs.readFileSync(path), ext, type: mime }; }; const image = await convertFile(); ctx.body = image.data; ctx.set("Content-Type", FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : "application/octet-stream"); ctx.set("Cache-Control", "max-age=31536000, immutable"); } catch (e) { if (isIgnorableConnectionError(e)) return; serverLogger.error(`${e}`); if (e instanceof StatusError && !e.isRetryable) { ctx.status = e.statusCode; ctx.set("Cache-Control", "max-age=86400"); } else { ctx.status = 500; ctx.set("Cache-Control", "max-age=300"); } } finally{ cleanup(); } return; } ctx.status = 204; ctx.set("Cache-Control", "max-age=86400"); return; } if (isThumbnail || isWebpublic) { const { mime, ext } = await detectType(InternalStorage.resolvePath(key)); const filename = rename(file.name, { suffix: isThumbnail ? "-thumb" : "-web", extname: ext ? `.${ext}` : undefined }).toString(); ctx.body = InternalStorage.read(key); ctx.set("Content-Type", FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : "application/octet-stream"); ctx.set("Cache-Control", "max-age=31536000, immutable"); ctx.set("Content-Disposition", contentDisposition("inline", filename)); } else { const storageKey = file.accessKey; const disposition = requestedDownload ? "attachment" : "inline"; ctx.set("Content-Type", isStreamableMedia || FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : "application/octet-stream"); ctx.set("Cache-Control", "max-age=31536000, immutable"); ctx.set("Content-Disposition", contentDisposition(disposition, file.name)); if (!requestedDownload && isStreamableMedia) { const servedRange = await sendRange(ctx, storageKey); if (servedRange) return; } const readable = InternalStorage.read(storageKey); readable.on("error", commonReadableHandlerGenerator(ctx)); ctx.body = readable; } } async function getRequesterId(ctx) { const queryToken = typeof ctx.query.i === "string" ? ctx.query.i : null; try { const [user] = await authenticate(ctx.get("authorization") || null, queryToken, true); return user?.id ?? null; } catch { return null; } } async function sendRange(ctx, key) { const range = ctx.get("range"); if (!range) { ctx.set("Accept-Ranges", "bytes"); return false; } const path = InternalStorage.resolvePath(key); const stat = await fs.promises.stat(path); const match = /^bytes=(\d*)-(\d*)$/.exec(range); if (!match) { ctx.status = 416; ctx.set("Content-Range", `bytes */${stat.size}`); return true; } let start = match[1] === "" ? 0 : Number(match[1]); let end = match[2] === "" ? stat.size - 1 : Number(match[2]); if (match[1] === "" && match[2] !== "") { const suffixLength = Number(match[2]); start = Math.max(stat.size - suffixLength, 0); end = stat.size - 1; } if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start < 0 || end >= stat.size) { ctx.status = 416; ctx.set("Content-Range", `bytes */${stat.size}`); return true; } ctx.status = 206; ctx.set("Accept-Ranges", "bytes"); ctx.set("Content-Range", `bytes ${start}-${end}/${stat.size}`); ctx.set("Content-Length", String(end - start + 1)); const readable = fs.createReadStream(path, { start, end }); readable.on("error", commonReadableHandlerGenerator(ctx)); ctx.body = readable; return true; }