Files
outline/plugins/storage/server/api/files.ts
T
dependabot[bot] a20c8e5371 chore(deps): bump zod from 4.3.6 to 4.4.3 (#12563)
* 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>
2026-06-02 22:34:41 -04:00

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;