Files
paperclip/server/src/storage/service.ts
Forgotten fdd2ea6157 feat: add storage system with local disk and S3 providers
Introduces a provider-agnostic storage subsystem for file attachments.
Includes local disk and S3 backends, asset/attachment DB schemas, issue
attachment CRUD routes with multer upload, CLI configure/doctor/env
integration, and enriched issue ancestors with project/goal resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:31:56 -06:00

132 lines
4.1 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import type { StorageService, StorageProvider, PutFileInput, PutFileResult } from "./types.js";
import { badRequest, forbidden, unprocessable } from "../errors.js";
const MAX_SEGMENT_LENGTH = 120;
function sanitizeSegment(value: string): string {
const cleaned = value
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.replace(/_{2,}/g, "_")
.replace(/^_+|_+$/g, "");
if (!cleaned) return "file";
return cleaned.slice(0, MAX_SEGMENT_LENGTH);
}
function normalizeNamespace(namespace: string): string {
const normalized = namespace
.split("/")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => sanitizeSegment(entry));
if (normalized.length === 0) return "misc";
return normalized.join("/");
}
function splitFilename(filename: string | null): { stem: string; ext: string } {
if (!filename) return { stem: "file", ext: "" };
const base = path.basename(filename).trim();
if (!base) return { stem: "file", ext: "" };
const extRaw = path.extname(base);
const stemRaw = extRaw ? base.slice(0, base.length - extRaw.length) : base;
const stem = sanitizeSegment(stemRaw);
const ext = extRaw
.toLowerCase()
.replace(/[^a-z0-9.]/g, "")
.slice(0, 16);
return {
stem,
ext,
};
}
function ensureCompanyPrefix(companyId: string, objectKey: string): void {
const expectedPrefix = `${companyId}/`;
if (!objectKey.startsWith(expectedPrefix)) {
throw forbidden("Object does not belong to company");
}
if (objectKey.includes("..")) {
throw badRequest("Invalid object key");
}
}
function hashBuffer(input: Buffer): string {
return createHash("sha256").update(input).digest("hex");
}
function buildObjectKey(companyId: string, namespace: string, originalFilename: string | null): string {
const ns = normalizeNamespace(namespace);
const now = new Date();
const year = String(now.getUTCFullYear());
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
const day = String(now.getUTCDate()).padStart(2, "0");
const { stem, ext } = splitFilename(originalFilename);
const suffix = randomUUID();
const filename = `${suffix}-${stem}${ext}`;
return `${companyId}/${ns}/${year}/${month}/${day}/${filename}`;
}
function assertPutFileInput(input: PutFileInput): void {
if (!input.companyId || input.companyId.trim().length === 0) {
throw unprocessable("companyId is required");
}
if (!input.namespace || input.namespace.trim().length === 0) {
throw unprocessable("namespace is required");
}
if (!input.contentType || input.contentType.trim().length === 0) {
throw unprocessable("contentType is required");
}
if (!(input.body instanceof Buffer)) {
throw unprocessable("body must be a Buffer");
}
if (input.body.length <= 0) {
throw unprocessable("File is empty");
}
}
export function createStorageService(provider: StorageProvider): StorageService {
return {
provider: provider.id,
async putFile(input: PutFileInput): Promise<PutFileResult> {
assertPutFileInput(input);
const objectKey = buildObjectKey(input.companyId, input.namespace, input.originalFilename);
const byteSize = input.body.length;
const contentType = input.contentType.trim().toLowerCase();
await provider.putObject({
objectKey,
body: input.body,
contentType,
contentLength: byteSize,
});
return {
provider: provider.id,
objectKey,
contentType,
byteSize,
sha256: hashBuffer(input.body),
originalFilename: input.originalFilename,
};
},
async getObject(companyId: string, objectKey: string) {
ensureCompanyPrefix(companyId, objectKey);
return provider.getObject({ objectKey });
},
async headObject(companyId: string, objectKey: string) {
ensureCompanyPrefix(companyId, objectKey);
return provider.headObject({ objectKey });
},
async deleteObject(companyId: string, objectKey: string) {
ensureCompanyPrefix(companyId, objectKey);
await provider.deleteObject({ objectKey });
},
};
}