Fixed 267U.pre2
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* File Server
|
||||
*/ import * as fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import Koa from "koa";
|
||||
import cors from "@koa/cors";
|
||||
import Router from "@koa/router";
|
||||
import sendDriveFile from "./send-drive-file.js";
|
||||
import { serverLogger } from "../index.js";
|
||||
import { isIgnorableConnectionError } from "../is-ignorable-connection-error.js";
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
// Init app
|
||||
const app = new Koa();
|
||||
app.on("error", (err)=>{
|
||||
if (isIgnorableConnectionError(err)) return;
|
||||
serverLogger.error(err);
|
||||
});
|
||||
app.use(cors());
|
||||
app.use(async (ctx, next)=>{
|
||||
ctx.set("Content-Security-Policy", `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`);
|
||||
await next();
|
||||
});
|
||||
// Init router
|
||||
const router = new Router();
|
||||
router.get("/app-default.jpg", (ctx)=>{
|
||||
const file = fs.createReadStream(`${_dirname}/assets/dummy.png`);
|
||||
ctx.body = file;
|
||||
ctx.set("Content-Type", "image/jpeg");
|
||||
ctx.set("Cache-Control", "max-age=31536000, immutable");
|
||||
});
|
||||
router.get("/:key", sendDriveFile);
|
||||
router.get("/:key/{*splat}", sendDriveFile);
|
||||
// Register router
|
||||
app.use(router.routes());
|
||||
export default app;
|
||||
@@ -0,0 +1,207 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user