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>
132 lines
4.1 KiB
TypeScript
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 });
|
|
},
|
|
};
|
|
}
|