feat(storage): complete phase 3 branding foundation

This commit is contained in:
poyrazavsever
2026-07-16 17:05:59 +03:00
parent b155132acf
commit 5d863280bf
31 changed files with 5302 additions and 57 deletions
+16
View File
@@ -0,0 +1,16 @@
export function fileResponse(
metadata: { mimeType: string; originalName: string; sha256: string },
bytes: Uint8Array,
cacheControl: string,
) {
return new Response(bytes as BodyInit, {
headers: {
"Cache-Control": cacheControl,
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(metadata.originalName)}`,
"Content-Length": String(bytes.byteLength),
"Content-Type": metadata.mimeType,
ETag: `"${metadata.sha256}"`,
"X-Content-Type-Options": "nosniff",
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import path from "node:path";
import { DomainError } from "../domain/errors";
export function resolveStoragePath(uploadsDir: string, storagePath: string): string {
if (
!storagePath ||
path.isAbsolute(storagePath) ||
storagePath.includes("\\") ||
storagePath.includes("\0")
) {
throw invalidPath();
}
const segments = storagePath.split("/");
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
throw invalidPath();
}
const root = path.resolve(uploadsDir);
const resolved = path.resolve(root, ...segments);
if (!resolved.startsWith(`${root}${path.sep}`)) {
throw invalidPath();
}
return resolved;
}
export function buildStoragePath(directory: string, id: string, extension: string): string {
if (!/^[a-z-]+$/.test(directory) || !/^[a-zA-Z0-9-]+$/.test(id) || !/^[a-z0-9]+$/.test(extension)) {
throw invalidPath();
}
return `${directory}/${id}.${extension}`;
}
function invalidPath() {
return new DomainError("VALIDATION_ERROR", "Geçersiz dosya yolu.");
}
+93
View File
@@ -0,0 +1,93 @@
import { createHash } from "node:crypto";
import { DomainError } from "../domain/errors";
import type { FileKind } from "../domain/types";
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const allowedImages = {
"image/jpeg": { extension: "jpg", matches: isJpeg },
"image/png": { extension: "png", matches: isPng },
"image/webp": { extension: "webp", matches: isWebp },
"image/gif": { extension: "gif", matches: isGif },
} as const;
export type AllowedMimeType = keyof typeof allowedImages;
export type ValidatedUpload = {
bytes: Uint8Array;
byteSize: number;
mimeType: AllowedMimeType;
extension: string;
originalName: string;
sha256: string;
};
export function validateUpload(input: {
kind: FileKind;
originalName: string;
claimedMimeType: string;
bytes: Uint8Array;
}): ValidatedUpload {
const byteSize = input.bytes.byteLength;
if (byteSize === 0) {
throw new DomainError("VALIDATION_ERROR", "Boş dosya yüklenemez.");
}
if (byteSize > MAX_UPLOAD_BYTES) {
throw new DomainError("VALIDATION_ERROR", "Dosya boyutu 5 MB sınırını aşıyor.", {
maximumBytes: MAX_UPLOAD_BYTES,
});
}
const mimeType = input.claimedMimeType.toLowerCase() as AllowedMimeType;
const policy = allowedImages[mimeType];
if (!policy || (input.kind === "branding_icon" && mimeType !== "image/png")) {
throw new DomainError(
"VALIDATION_ERROR",
"Yalnızca JPEG, PNG, WebP ve desteklenen alanlarda GIF görselleri kabul edilir; uygulama ikonu PNG olmalı ve SVG desteklenmez.",
);
}
if (!policy.matches(input.bytes)) {
throw new DomainError("VALIDATION_ERROR", "Dosya içeriği bildirilen MIME türüyle uyuşmuyor.");
}
return {
bytes: input.bytes,
byteSize,
mimeType,
extension: policy.extension,
originalName: normalizeOriginalName(input.originalName),
sha256: createHash("sha256").update(input.bytes).digest("hex"),
};
}
export function normalizeOriginalName(value: string): string {
const normalized = value
.normalize("NFKC")
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/[\\/]/g, "-")
.trim()
.slice(0, 255);
return normalized || "upload";
}
function isJpeg(bytes: Uint8Array) {
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}
function isPng(bytes: Uint8Array) {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
return bytes.length >= signature.length && signature.every((value, index) => bytes[index] === value);
}
function isWebp(bytes: Uint8Array) {
return bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP";
}
function isGif(bytes: Uint8Array) {
const header = ascii(bytes, 0, 6);
return header === "GIF87a" || header === "GIF89a";
}
function ascii(bytes: Uint8Array, start: number, end: number): string {
return String.fromCharCode(...bytes.slice(start, end));
}
+13
View File
@@ -0,0 +1,13 @@
import "server-only";
import { getServerConfig } from "../config";
import { getSqliteConnection } from "../db/client";
import { FileService } from "./service";
export function getFileService(): FileService {
const config = getServerConfig();
return new FileService(getSqliteConnection().db, {
uploadsDir: config.uploadsDir,
tmpDir: config.tmpDir,
});
}
+241
View File
@@ -0,0 +1,241 @@
import fs from "node:fs";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { clients, projects } from "../db/schema/domain";
import { files } from "../db/schema/storage";
import { user } from "../db/schema/auth";
import { assertEnabledActor, requireClientScope, requireOwnerScope, type DomainActor } from "../domain/actor";
import type { DomainDatabase } from "../domain/database";
import { DomainError, notFound } from "../domain/errors";
import { generateId, type IdGenerator } from "../domain/id";
import type { FileKind, FileVisibility } from "../domain/types";
import { createFileRepository } from "../repositories/files";
import { buildStoragePath, resolveStoragePath } from "./paths";
import { validateUpload } from "./policy";
export type FileStorageConfig = { uploadsDir: string; tmpDir: string };
export type FileUploadInput = {
kind: FileKind;
originalName: string;
claimedMimeType: string;
bytes: Uint8Array;
projectId?: string;
portalVisible?: boolean;
};
export type StoredFile = typeof files.$inferSelect;
export class FileService {
private readonly repository;
constructor(
private readonly db: DomainDatabase,
private readonly config: FileStorageConfig,
private readonly id: IdGenerator = generateId,
) {
this.repository = createFileRepository(db);
}
upload(actor: DomainActor, input: FileUploadInput): StoredFile {
assertEnabledActor(actor);
const upload = validateUpload(input);
const fileId = this.id();
const resource = this.resolveUploadResource(actor, input);
const storagePath = buildStoragePath(directoryFor(input.kind), fileId, upload.extension);
const finalPath = resolveStoragePath(this.config.uploadsDir, storagePath);
const temporaryPath = path.join(this.config.tmpDir, `upload-${fileId}.tmp`);
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
fs.mkdirSync(this.config.tmpDir, { recursive: true });
fs.writeFileSync(temporaryPath, upload.bytes, { flag: "wx", mode: 0o600 });
let finalCreated = false;
try {
fs.linkSync(temporaryPath, finalPath);
finalCreated = true;
fs.unlinkSync(temporaryPath);
return this.db.transaction((tx) => {
const stored = tx.insert(files).values({
id: fileId,
ownerUserId: resource.ownerUserId,
uploadedByUserId: actor.authUserId,
authUserId: resource.authUserId,
projectId: resource.projectId,
kind: input.kind,
visibility: resource.visibility,
storagePath,
originalName: upload.originalName,
mimeType: upload.mimeType,
byteSize: upload.byteSize,
sha256: upload.sha256,
}).returning().get();
if (input.kind === "avatar") {
tx.update(user)
.set({ image: `/api/files/${fileId}`, updatedAt: new Date() })
.where(eq(user.id, actor.authUserId))
.run();
}
return stored;
}, { behavior: "immediate" });
} catch (error) {
safeUnlink(temporaryPath);
if (finalCreated) safeUnlink(finalPath);
throw error;
}
}
read(actor: DomainActor, id: string): { metadata: StoredFile; bytes: Buffer } {
assertEnabledActor(actor);
const metadata = this.repository.get(id) ?? this.throwNotFound();
this.assertCanRead(actor, metadata);
return { metadata, bytes: this.readStoredBytes(metadata) };
}
readPublicBranding(id: string): { metadata: StoredFile; bytes: Buffer } {
const metadata = this.repository.getPublicBrandingAsset(id) ?? this.throwNotFound();
return { metadata, bytes: this.readStoredBytes(metadata) };
}
delete(actor: DomainActor, id: string): StoredFile {
assertEnabledActor(actor);
const metadata = this.repository.get(id) ?? this.throwNotFound();
this.assertCanDelete(actor, metadata);
const finalPath = resolveStoragePath(this.config.uploadsDir, metadata.storagePath);
const trashPath = path.join(this.config.tmpDir, `delete-${metadata.id}.tmp`);
fs.mkdirSync(this.config.tmpDir, { recursive: true });
const exists = fs.existsSync(finalPath);
if (exists) fs.renameSync(finalPath, trashPath);
try {
const removed = this.db.transaction((tx) => {
if (metadata.kind === "avatar" && metadata.authUserId) {
tx.update(user)
.set({ image: null, updatedAt: new Date() })
.where(
and(
eq(user.id, metadata.authUserId),
eq(user.image, `/api/files/${metadata.id}`),
),
)
.run();
}
return tx.delete(files).where(eq(files.id, metadata.id)).returning().get();
}, { behavior: "immediate" });
if (!removed) throw notFound("Dosya");
safeUnlink(trashPath);
return removed;
} catch (error) {
if (exists && fs.existsSync(trashPath)) fs.renameSync(trashPath, finalPath);
throw error;
}
}
private resolveUploadResource(actor: DomainActor, input: FileUploadInput): {
ownerUserId: string;
authUserId: string | null;
projectId: string | null;
visibility: FileVisibility;
} {
if (input.kind === "avatar") {
const ownerUserId = actor.role === "freelancer"
? requireOwnerScope(actor).ownerUserId
: this.getClientOwner(actor);
return { ownerUserId, authUserId: actor.authUserId, projectId: null, visibility: "private" };
}
const scope = requireOwnerScope(actor);
if (input.kind === "branding_logo" || input.kind === "branding_icon") {
return { ownerUserId: scope.ownerUserId, authUserId: null, projectId: null, visibility: "public_branding" };
}
if (!input.projectId) {
throw new DomainError("VALIDATION_ERROR", "Project asset için projectId zorunludur.");
}
const project = this.db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, input.projectId), eq(projects.ownerUserId, scope.ownerUserId)))
.get();
if (!project) throw notFound("Proje");
return {
ownerUserId: scope.ownerUserId,
authUserId: null,
projectId: project.id,
visibility: input.portalVisible ? "portal" : "private",
};
}
private assertCanRead(actor: DomainActor, file: StoredFile): void {
if (actor.role === "freelancer") {
if (file.ownerUserId !== requireOwnerScope(actor).ownerUserId) throw notFound("Dosya");
return;
}
const scope = requireClientScope(actor);
if (file.kind === "avatar" && file.authUserId === scope.authUserId) return;
if (file.kind === "project_asset" && file.visibility === "portal" && file.projectId) {
const project = this.db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, file.projectId), eq(projects.clientId, scope.clientId)))
.get();
if (project) return;
}
throw notFound("Dosya");
}
private assertCanDelete(actor: DomainActor, file: StoredFile): void {
if (actor.role === "freelancer" && file.ownerUserId === actor.authUserId) return;
if (actor.role === "client" && file.kind === "avatar" && file.authUserId === actor.authUserId) return;
throw new DomainError("FORBIDDEN", "Bu dosyayı silme yetkiniz yok.");
}
private getClientOwner(actor: DomainActor): string {
const scope = requireClientScope(actor);
const client = this.db
.select({ ownerUserId: clients.ownerUserId })
.from(clients)
.where(and(eq(clients.id, scope.clientId), eq(clients.authUserId, scope.authUserId)))
.get();
if (!client) throw new DomainError("FORBIDDEN", "Geçerli müşteri bağı bulunamadı.");
return client.ownerUserId;
}
private readStoredBytes(metadata: StoredFile): Buffer {
const absolutePath = resolveStoragePath(this.config.uploadsDir, metadata.storagePath);
let descriptor: number | undefined;
try {
descriptor = fs.openSync(absolutePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
const stat = fs.fstatSync(descriptor);
if (!stat.isFile() || stat.size !== metadata.byteSize) {
throw new DomainError("INVARIANT_VIOLATION", "Dosya metadata ile uyuşmuyor.");
}
return fs.readFileSync(descriptor);
} catch (error) {
if (error instanceof DomainError) throw error;
throw notFound("Dosya içeriği");
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
private throwNotFound(): never {
throw notFound("Dosya");
}
}
function directoryFor(kind: FileKind): string {
if (kind === "avatar") return "avatars";
if (kind === "project_asset") return "project-assets";
return "branding";
}
function safeUnlink(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}