208 lines
8.8 KiB
JavaScript
208 lines
8.8 KiB
JavaScript
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;
|
|
}
|