mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
a20c8e5371
* chore(deps): bump zod from 4.3.6 to 4.4.3 Bumps [zod](https://github.com/colinhacks/zod) from 4.3.6 to 4.4.3. - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.3) --- updated-dependencies: - dependency-name: zod dependency-version: 4.4.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> * fix: Make files.create file param optional in schema for zod 4.4 zod 4.4 changed z.custom() to reject undefined. Since validate runs before multipart injects the file, validation failed with 400 on all files.create requests. Mark the field optional and guard in the handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
215 lines
6.0 KiB
TypeScript
215 lines
6.0 KiB
TypeScript
import JWT from "jsonwebtoken";
|
|
import Router from "koa-router";
|
|
import mime from "mime-types";
|
|
import contentDisposition from "content-disposition";
|
|
import { bytesToHumanReadable } from "@shared/utils/files";
|
|
import env from "@server/env";
|
|
import {
|
|
AuthenticationError,
|
|
AuthorizationError,
|
|
NotFoundError,
|
|
ValidationError,
|
|
} from "@server/errors";
|
|
import auth from "@server/middlewares/authentication";
|
|
import multipart from "@server/middlewares/multipart";
|
|
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
|
import timeout from "@server/middlewares/timeout";
|
|
import validate from "@server/middlewares/validate";
|
|
import { Attachment } from "@server/models";
|
|
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
|
import { authorize } from "@server/policies";
|
|
import FileStorage from "@server/storage/files";
|
|
import type LocalStorage from "@server/storage/files/LocalStorage";
|
|
import type { APIContext } from "@server/types";
|
|
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
|
import { getJWTPayload } from "@server/utils/jwt";
|
|
import * as T from "./schema";
|
|
|
|
const router = new Router();
|
|
|
|
router.post(
|
|
"files.create",
|
|
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
|
|
auth(),
|
|
validate(T.FilesCreateSchema),
|
|
timeout(30 * 60 * 1000), // 30 minutes for large file uploads
|
|
multipart({
|
|
maximumFileSize: Math.max(
|
|
env.FILE_STORAGE_UPLOAD_MAX_SIZE,
|
|
env.FILE_STORAGE_IMPORT_MAX_SIZE
|
|
),
|
|
}),
|
|
async (ctx: APIContext<T.FilesCreateReq>) => {
|
|
const actor = ctx.state.auth.user;
|
|
const { key } = ctx.input.body;
|
|
const file = ctx.input.file;
|
|
|
|
if (!file) {
|
|
throw ValidationError("Request must include a file parameter");
|
|
}
|
|
|
|
const attachment = await Attachment.findOne({
|
|
where: { key },
|
|
rejectOnEmpty: true,
|
|
});
|
|
|
|
if (attachment.userId !== actor.id) {
|
|
throw AuthorizationError("Invalid key");
|
|
}
|
|
|
|
const declaredSize = Number(attachment.size);
|
|
|
|
if (file.size > declaredSize) {
|
|
throw ValidationError(
|
|
`The uploaded file exceeds the declared size of ${bytesToHumanReadable(
|
|
declaredSize
|
|
)}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
await attachment.writeFile(file);
|
|
} catch (err) {
|
|
if (err.message.includes("permission denied")) {
|
|
throw Error(
|
|
`Permission denied writing to "${key}". Check the host machine file system permissions.`
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (declaredSize !== file.size) {
|
|
await attachment.update({ size: file.size }, { silent: true });
|
|
}
|
|
|
|
ctx.body = {
|
|
success: true,
|
|
};
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
"files.get",
|
|
auth({ optional: true }),
|
|
validate(T.FilesGetSchema),
|
|
async (ctx: APIContext<T.FilesGetReq>) => {
|
|
const actor = ctx.state.auth.user;
|
|
const key = getKeyFromContext(ctx);
|
|
const forceDownload = !!ctx.input.query.download;
|
|
const isSignedRequest = !!ctx.input.query.sig;
|
|
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
|
|
const cacheHeader = "max-age=604800, immutable";
|
|
const attachment = await Attachment.findByKey(key);
|
|
|
|
// Skip authorization for public bucket, signed requests, or public-read ACL attachments
|
|
const skipAuthorize =
|
|
isPublicBucket ||
|
|
isSignedRequest ||
|
|
(attachment && !attachment.isPrivate);
|
|
|
|
if (!skipAuthorize) {
|
|
if (!attachment && !!ctx.input.query.key) {
|
|
throw NotFoundError();
|
|
}
|
|
|
|
authorize(actor, "read", attachment);
|
|
}
|
|
|
|
const contentType =
|
|
attachment?.contentType ||
|
|
(fileName ? mime.lookup(fileName) : undefined) ||
|
|
"application/octet-stream";
|
|
|
|
if (contentType === "application/pdf") {
|
|
ctx.remove("X-Frame-Options");
|
|
}
|
|
|
|
ctx.set("Accept-Ranges", "bytes");
|
|
ctx.set("Access-Control-Allow-Origin", "*");
|
|
ctx.set("Cache-Control", cacheHeader);
|
|
ctx.set("Content-Type", contentType);
|
|
ctx.set(
|
|
"Content-Security-Policy",
|
|
// Safari will not render PDFs in an embed if the sandbox directive is used, so we use a
|
|
// tight CSP in that case. For all other file types we use the strict sandbox directive
|
|
// which blocks all content from being loaded and rendered.
|
|
contentType === "application/pdf"
|
|
? "default-src 'self'; object-src 'self'; base-uri 'none';"
|
|
: "sandbox"
|
|
);
|
|
ctx.set(
|
|
"Content-Disposition",
|
|
contentDisposition(fileName, {
|
|
type: forceDownload
|
|
? "attachment"
|
|
: FileStorage.getContentDisposition(contentType),
|
|
})
|
|
);
|
|
|
|
// Handle byte range requests
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
|
|
const stats = await (FileStorage as LocalStorage).stat(key);
|
|
const range = getByteRange(ctx, stats.size);
|
|
|
|
if (range) {
|
|
ctx.status = 206;
|
|
ctx.set("Content-Length", String(range.end - range.start + 1));
|
|
ctx.set(
|
|
"Content-Range",
|
|
`bytes ${range.start}-${range.end}/${stats.size}`
|
|
);
|
|
} else {
|
|
ctx.set("Content-Length", String(stats.size));
|
|
}
|
|
|
|
ctx.body = await FileStorage.getFileStream(key, range);
|
|
}
|
|
);
|
|
|
|
function getByteRange(
|
|
ctx: APIContext<T.FilesGetReq>,
|
|
size: number
|
|
): { start: number; end: number } | undefined {
|
|
const { range } = ctx.headers;
|
|
if (!range) {
|
|
return;
|
|
}
|
|
|
|
const match = range.match(/bytes=(\d+)-(\d+)?/);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
|
|
const start = parseInt(match[1], 10);
|
|
const end = parseInt(match[2], 10) || size - 1;
|
|
|
|
return { start, end };
|
|
}
|
|
|
|
function getKeyFromContext(ctx: APIContext<T.FilesGetReq>): string {
|
|
const { key, sig } = ctx.input.query;
|
|
if (sig) {
|
|
const payload = getJWTPayload(sig);
|
|
|
|
if (payload.type !== "attachment") {
|
|
throw AuthenticationError("Invalid signature");
|
|
}
|
|
|
|
try {
|
|
JWT.verify(sig, env.SECRET_KEY);
|
|
} catch (_err) {
|
|
throw AuthenticationError("Invalid signature");
|
|
}
|
|
|
|
return payload.key as string;
|
|
}
|
|
|
|
if (key) {
|
|
return key;
|
|
}
|
|
|
|
throw ValidationError("Must provide either key or sig parameter");
|
|
}
|
|
|
|
export default router;
|