import { createHash, randomUUID } from 'node:crypto'; import { mkdirSync, statfsSync, existsSync } from 'node:fs'; import { createWriteStream, createReadStream } from 'node:fs'; import { rename, unlink, readdir, stat, open } from 'node:fs/promises'; import { resolve } from 'node:path'; import { Transform, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { and, eq, gt, isNull, inArray, sql, or } from 'drizzle-orm'; import sharp from 'sharp'; import type { FastifyRequest } from 'fastify'; import type { Guest, Service } from './service.js'; import * as s from './db/schema.js'; import { ApiError, fail } from './security.js'; export type StorageSubject = { id: string; kind?: 'user'; sessionId?: string }; export class Storage { private active = new Map< string, { guestId: string; controller: AbortController; fileId: string; sessionId?: string } >(); private cleaning = false; private cleanupPromise: Promise | undefined; readonly temp: string; readonly originals: string; constructor(readonly service: Service) { this.temp = resolve(service.cfg.uploadDir, 'temporary'); this.originals = resolve(service.cfg.uploadDir, 'originals'); mkdirSync(this.temp, { recursive: true, mode: 0o700 }); mkdirSync(this.originals, { recursive: true, mode: 0o700 }); } private request(g: StorageSubject, id: string) { return g.kind === 'user' ? this.service.accounts.request(g.id, id) : this.service.request(g as Guest, id); } private access(g: StorageSubject, id: string) { return g.kind === 'user' ? this.service.accounts.access(g.id, id) : this.service.accessDetail(g as Guest, id); } private blocked(g: StorageSubject, row: ReturnType) { return g.kind === 'user' ? !this.service.accounts.canTransfer(row) : this.service.isBlocked(g.id, row.recipientGuestId!); } private isSender(g: StorageSubject, row: ReturnType) { return g.kind === 'user' ? row.senderUserId === g.id : row.senderGuestId === g.id; } fileRows(id: string) { return this.service.db.select().from(s.files).where(eq(s.files.requestId, id)).all(); } createManifest( g: StorageSubject, requestId: string, manifest: { name: string; size: number; type: string }[], ) { for (const [position, file] of manifest.entries()) this.service.db .insert(s.files) .values({ id: randomUUID(), requestId, ownerGuestId: g.kind === 'user' ? null : g.id, ownerUserId: g.kind === 'user' ? g.id : null, position, name: file.name, size: file.size, type: file.type, storageKey: randomUUID(), status: 'pending', updatedAt: Date.now(), }) .run(); } private used(guestId?: string) { return this.service.db .select({ bytes: sql`coalesce(sum(${s.files.size}),0)` }) .from(s.files) .where( and( inArray(s.files.status, ['stored', 'cleaning']), sql`${s.files.ownerGuestId} IS NOT NULL`, guestId ? eq(s.files.ownerGuestId, guestId) : undefined, ), ) .get()!.bytes; } private reserved(guestId?: string) { return this.service.db .select({ bytes: sql`coalesce(sum(${s.reservations.bytes}),0)` }) .from(s.reservations) .where( and( gt(s.reservations.expiresAt, Date.now()), sql`${s.reservations.guestId} IS NOT NULL`, guestId ? eq(s.reservations.guestId, guestId) : undefined, ), ) .get()!.bytes; } reserve(g: StorageSubject, requestId: string) { const { db, cfg } = this.service; const files = this.fileRows(requestId).filter((f) => f.status !== 'stored'); const missing = files.filter( (file) => !db .select() .from(s.reservations) .where(and(eq(s.reservations.fileId, file.id), gt(s.reservations.expiresAt, Date.now()))) .get(), ); const total = missing.reduce((sum, file) => sum + file.size, 0); if (g.kind === 'user') { const reserved = db .select({ bytes: sql`coalesce(sum(${s.reservations.bytes}),0)` }) .from(s.reservations) .where(and(eq(s.reservations.userId, g.id), gt(s.reservations.expiresAt, Date.now()))) .get()!.bytes; this.service.accounts.ensureQuota(g.id, reserved + total); } if (g.kind !== 'user' && this.used(g.id) + this.reserved(g.id) + total > cfg.guestQuota) fail(413, 'GUEST_QUOTA', '이번 기기의 저장 용량을 초과했습니다.'); if (g.kind !== 'user' && this.used() + this.reserved() + total > cfg.totalGuestQuota) fail(507, 'TOTAL_QUOTA', '현재 서버의 익명 저장 공간이 부족합니다.'); const allReserved = db .select({ bytes: sql`coalesce(sum(${s.reservations.bytes}),0)` }) .from(s.reservations) .where(gt(s.reservations.expiresAt, Date.now())) .get()!.bytes; const disk = statfsSync(this.originals, { bigint: true }); if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize) fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.'); for (const file of missing) db.insert(s.reservations) .values({ id: randomUUID(), fileId: file.id, guestId: g.kind === 'user' ? null : g.id, userId: g.kind === 'user' ? g.id : null, bytes: file.size, expiresAt: Date.now() + cfg.orphanHours * 3600000, }) .onConflictDoUpdate({ target: s.reservations.fileId, set: { expiresAt: Date.now() + cfg.orphanHours * 3600000 }, }) .run(); } release(requestId: string) { this.service.resumable?.abort(requestId); const db = this.service.db; for (const file of this.fileRows(requestId)) { for (const [uploadId, upload] of this.active) if (upload.fileId === file.id) { upload.controller.abort(); this.active.delete(uploadId); } db.delete(s.reservations).where(eq(s.reservations.fileId, file.id)).run(); if (file.status !== 'stored') db.update(s.files) .set({ status: 'failed', updatedAt: Date.now() }) .where(eq(s.files.id, file.id)) .run(); } } uploadManifest(g: StorageSubject, requestId: string, position: number) { if (g.kind === 'user') { const session = g.sessionId ? this.service.db .select() .from(s.accountSessions) .where(eq(s.accountSessions.id, g.sessionId)) .get() : null; if ( !session || session.userId !== g.id || session.revokedAt || session.expiresAt <= Date.now() ) fail(401, 'SESSION_REVOKED', '계정 세션이 종료되었습니다.'); } else { const guest = this.service.db.select().from(s.guests).where(eq(s.guests.id, g.id)).get(); if (!guest || guest.revokedAt || guest.expiresAt <= Date.now()) fail(401, 'SESSION_EXPIRED', '기기 세션이 종료되었습니다.'); } const row = this.request(g, requestId); if (row.kind !== 'file' || !this.isSender(g, row)) fail(403, 'UPLOAD_FORBIDDEN', '발신자만 업로드할 수 있습니다.'); if (this.service.vault.source(requestId)) fail(409, 'VAULT_TRANSFER_MANAGED', '이미 저장된 파일을 전달합니다.'); if ( row.status !== 'accepted' || !row.startBy || row.startBy <= Date.now() || this.blocked(g, row) ) fail(409, 'ACCEPTANCE_REQUIRED', '수락된 유효한 요청이 필요합니다.'); const file = this.fileRows(requestId).find((f) => f.position === position); if (!file) fail(404, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.'); if (file!.status === 'uploading') fail(409, 'UPLOAD_ACTIVE', '파일이 처리 중입니다.'); return file!; } async upload( g: StorageSubject, requestId: string, position: number, request: FastifyRequest, key: string, stagedPath?: string, ) { const { db, cfg } = this.service; let file: typeof s.files.$inferSelect; let uploadId: string; const existing = this.fileRows(requestId).find((f) => f.position === position); if (existing?.status === 'stored') { const row = this.request(g, requestId); if (!this.isSender(g, row)) fail(403, 'UPLOAD_FORBIDDEN', '발신자만 업로드할 수 있습니다.'); if (existing.uploadKey !== key) fail(409, 'FILE_STORED', '이미 저장된 파일입니다.'); const checksum = createHash('sha256'); let bytes = 0, count = 0; for await (const part of request.parts({ limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 }, })) { if (part.type !== 'file') throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.'); count++; if (part.filename !== existing.name) fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.'); for await (const chunk of part.file) { bytes += chunk.length; if (bytes > existing.size) fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.'); checksum.update(chunk); } } if (count !== 1 || bytes !== existing.size || checksum.digest('hex') !== existing.checksum) fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.'); return { uploadId: existing.uploadId!, fileId: existing.id, status: 'stored' }; } db.transaction(() => { const row = this.request(g, requestId); if (row.kind !== 'file' || !this.isSender(g, row)) fail(403, 'UPLOAD_FORBIDDEN', '이 요청에 파일을 올릴 수 없습니다.'); if ( row.status !== 'accepted' || !row.startBy || row.startBy <= Date.now() || this.blocked(g, row) ) fail(409, 'ACCEPTANCE_REQUIRED', '수락된 유효한 요청이 필요합니다.'); file = this.fileRows(requestId).find((f) => f.position === position)!; if (!file) fail(400, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.'); if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.'); if ( file.status === 'uploading' || [...this.active.values()].filter((v) => v.guestId === `${g.kind ?? 'guest'}:${g.id}`) .length >= (g.kind === 'user' ? 2 : 1) ) fail(409, 'UPLOAD_ACTIVE', '진행 중인 업로드를 먼저 끝내 주세요.'); this.reserve(g, requestId); uploadId = randomUUID(); db.update(s.files) .set({ status: 'uploading', uploadId, uploadKey: key, updatedAt: Date.now() }) .where(eq(s.files.id, file.id)) .run(); db.update(s.batches) .set({ status: 'uploading' }) .where(eq(s.batches.requestId, requestId)) .run(); }); const controller = new AbortController(), tempPath = stagedPath ?? resolve(this.temp, uploadId!); this.active.set(uploadId!, { guestId: `${g.kind ?? 'guest'}:${g.id}`, controller, sessionId: g.sessionId, fileId: file!.id, }); request.raw.once('aborted', () => controller.abort()); this.service.notifyTransfer(requestId); try { let parts = 0; let bytes = 0; const checksum = createHash('sha256'); for await (const part of request.parts({ limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 }, })) { if (part.type !== 'file') throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개만 업로드해 주세요.'); parts++; if (part.filename !== file!.name) fail(409, 'METADATA_MISMATCH', '요청한 파일 이름과 다릅니다.'); const counter = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; if (bytes > file!.size || bytes > cfg.maxFileBytes) return callback( new ApiError(413, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'), ); checksum.update(chunk); callback(null, chunk); }, }); await pipeline( part.file, counter, stagedPath ? new Writable({ write(_chunk, _encoding, callback) { callback(); }, }) : createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }), { signal: controller.signal }, ); if (part.file.truncated || bytes !== file!.size) fail(409, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'); } if (parts !== 1) fail(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.'); const originalPath = resolve(this.originals, file!.storageKey); await rename(tempPath, originalPath); try { db.transaction(() => { if (g.kind === 'user') { const session = g.sessionId ? db .select() .from(s.accountSessions) .where(eq(s.accountSessions.id, g.sessionId)) .get() : null; if ( !session || session.userId !== g.id || session.revokedAt || session.expiresAt <= Date.now() ) fail(401, 'SESSION_REVOKED', '계정 세션이 종료되었습니다.'); } const row = this.request(g, requestId), current = db.select().from(s.files).where(eq(s.files.id, file!.id)).get(); if ( row.status !== 'accepted' || this.blocked(g, row) || current?.uploadId !== uploadId || current.status !== 'uploading' ) fail(409, 'UPLOAD_CANCELLED', '전송이 취소되었거나 권한이 변경되었습니다.'); const batch = db .select() .from(s.batches) .where(eq(s.batches.requestId, requestId)) .get()!, now = Date.now(), itemId = randomUUID(); db.update(s.files) .set({ status: 'stored', checksum: checksum.digest('hex'), updatedAt: now }) .where(eq(s.files.id, file!.id)) .run(); db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run(); db.insert(s.items) .values({ id: itemId, batchId: batch.id, kind: 'file', fileId: file!.id, createdAt: now, expiresAt: now + cfg.fileRetentionDays * 86400000, }) .run(); this.service.accounts.createAccess(row, itemId); const all = this.fileRows(requestId); db.update(s.batches) .set({ status: all.every((f) => f.status === 'stored') ? 'stored' : 'waiting', storedAt: now, }) .where(eq(s.batches.id, batch.id)) .run(); }); } catch (error) { await unlink(originalPath).catch(() => {}); throw error; } return { uploadId: uploadId!, fileId: file!.id, status: 'stored' }; } catch (error) { await unlink(tempPath).catch(() => {}); db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run(); db.update(s.files) .set({ status: 'failed', updatedAt: Date.now() }) .where(and(eq(s.files.id, file!.id), eq(s.files.uploadId, uploadId!))) .run(); db.update(s.batches) .set({ status: 'waiting' }) .where(and(eq(s.batches.requestId, requestId), eq(s.batches.status, 'uploading'))) .run(); if (error instanceof ApiError) throw error; if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOSPC') fail(507, 'DISK_FULL', '서버 저장 공간이 부족합니다.'); fail(400, 'UPLOAD_FAILED', '업로드가 중단되었습니다. 파일을 다시 선택해 재시도해 주세요.'); } finally { this.active.delete(uploadId!); this.service.notifyTransfer(requestId); } } cancel(g: StorageSubject, id: string) { const file = this.service.db .select() .from(s.files) .where( and( eq(s.files.uploadId, id), g.kind === 'user' ? eq(s.files.ownerUserId, g.id) : eq(s.files.ownerGuestId, g.id), ), ) .get(); if (!file) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.'); if (file!.status !== 'uploading') fail(409, 'UPLOAD_NOT_ACTIVE', '진행 중인 업로드만 취소할 수 있습니다.'); this.active.get(id)?.controller.abort(); this.service.db .update(s.files) .set({ status: 'failed', updatedAt: Date.now() }) .where(eq(s.files.id, file!.id)) .run(); } download(g: StorageSubject, accessId: string) { const row = this.access(g, accessId); if (row.item.kind !== 'file' || !row.item.fileId) fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.'); const file = this.service.db .select() .from(s.files) .where(and(eq(s.files.id, row.item.fileId!), eq(s.files.status, 'stored'))) .get(); if (!file || !existsSync(resolve(this.originals, file.storageKey))) fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.'); return { file: file!, path: resolve(this.originals, file!.storageKey) }; } async preview(g: StorageSubject, id: string) { const { path } = this.download(g, id); try { const handle = await open(path, 'r'); const magic = Buffer.alloc(16); try { await handle.read(magic, 0, 16, 0); } finally { await handle.close(); } const raster = magic.subarray(0, 3).equals(Buffer.from([255, 216, 255])) || magic.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || /^GIF8[79]a/.test(magic.toString('ascii')) || (magic.toString('ascii', 0, 4) === 'RIFF' && magic.toString('ascii', 8, 12) === 'WEBP') || (magic.toString('ascii', 4, 8) === 'ftyp' && ['avif', 'avis', 'heic', 'heix', 'mif1'].includes(magic.toString('ascii', 8, 12))); if (!raster) throw Error(); const image = sharp(path, { limitInputPixels: 40000000, animated: false }); const metadata = await image.metadata(); if (!['jpeg', 'png', 'webp', 'gif', 'avif', 'heif'].includes(metadata.format ?? '')) throw Error(); return await image .resize(720, 720, { fit: 'inside', withoutEnlargement: true }) .rotate() .webp({ quality: 78 }) .toBuffer(); } catch { fail(415, 'PREVIEW_UNSUPPORTED', '이 파일은 이미지 미리보기를 지원하지 않습니다.'); } } deleteAccess(g: StorageSubject, ids: string[]) { const { db, cfg } = this.service; return db.transaction(() => { const unique = [...new Set(ids)], rows = unique.map((id) => this.access(g, id)); const id = randomUUID(), now = Date.now(); db.insert(s.deletions) .values({ id, guestId: g.kind === 'user' ? null : g.id, userId: g.kind === 'user' ? g.id : null, createdAt: now, undoUntil: now + cfg.undoSeconds * 1000, }) .run(); for (const row of rows) { db.update(s.access).set({ deletedAt: now }).where(eq(s.access.id, row.access.id)).run(); db.insert(s.deletionTargets) .values({ id: randomUUID(), operationId: id, accessId: row.access.id }) .run(); } return { operationId: id, undoUntil: now + cfg.undoSeconds * 1000 }; }); } undo(g: StorageSubject, id: string) { const { db } = this.service; return db.transaction(() => { const operation = db .select() .from(s.deletions) .where( and( eq(s.deletions.id, id), g.kind === 'user' ? eq(s.deletions.userId, g.id) : eq(s.deletions.guestId, g.id), ), ) .get(); if (!operation) fail(404, 'DELETE_NOT_FOUND', '삭제 작업을 찾을 수 없습니다.'); if (operation!.restoredAt) return { restored: true }; if (operation!.undoUntil <= Date.now()) fail(409, 'UNDO_EXPIRED', '실행 취소 시간이 지났습니다.'); const targets = db .select() .from(s.deletionTargets) .where(eq(s.deletionTargets.operationId, id)) .all(); let count = 0; for (const target of targets) { const row = db .select({ access: s.access, item: s.items }) .from(s.access) .innerJoin(s.items, eq(s.access.itemId, s.items.id)) .where( and( eq(s.access.id, target.accessId), g.kind === 'user' ? eq(s.access.userId, g.id) : eq(s.access.guestId, g.id), eq(s.access.deletedAt, operation!.createdAt), gt(s.items.expiresAt, Date.now()), ), ) .get(); if (row) { if (g.kind === 'user' && row.item.fileId) { const file = db.select().from(s.files).where(eq(s.files.id, row.item.fileId)).get(); if (file && !this.service.accounts.canStoreFile(g.id, file)) fail(413, 'ACCOUNT_QUOTA', '보관함 용량이 부족해 복원하지 못했습니다.'); } db.update(s.access).set({ deletedAt: null }).where(eq(s.access.id, row.access.id)).run(); count++; } } db.update(s.deletions).set({ restoredAt: Date.now() }).where(eq(s.deletions.id, id)).run(); return { restored: count > 0 }; }); } cleanup(): Promise { if (this.cleanupPromise) return this.cleanupPromise; this.cleanupPromise = this.performCleanup().finally(() => { this.cleanupPromise = undefined; }); return this.cleanupPromise; } private async performCleanup() { if (this.cleaning) return; this.cleaning = true; try { this.service.resumable?.cleanup(); const { db, cfg } = this.service, now = Date.now(); const expired = db .select() .from(s.reservations) .where(sql`${s.reservations.expiresAt} <= ${now}`) .all(); for (const r of expired) { const file = db.select().from(s.files).where(eq(s.files.id, r.fileId)).get(); if (file) this.release(file.requestId); } for (const file of db.select().from(s.files).where(eq(s.files.status, 'uploading')).all()) { if (!file.uploadId || !this.active.has(file.uploadId)) { db.update(s.files) .set({ status: 'failed', updatedAt: now }) .where(eq(s.files.id, file.id)) .run(); } } const all = db .select() .from(s.files) .where(inArray(s.files.status, ['stored', 'cleaning'])) .all(); for (const file of all) { const refs = db .select({ id: s.items.id }) .from(s.items) .innerJoin(s.access, eq(s.access.itemId, s.items.id)) .where( and( eq(s.items.fileId, file.id), gt(s.items.expiresAt, now), orLive(now, cfg.undoSeconds), ), ) .all(); if (refs.length) continue; db.update(s.files).set({ status: 'cleaning' }).where(eq(s.files.id, file.id)).run(); try { await unlink(resolve(this.originals, file.storageKey)); } catch (error) { if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') continue; } db.update(s.files) .set({ status: 'failed', checksum: null, updatedAt: now }) .where(eq(s.files.id, file.id)) .run(); } const validKeys = new Set( db .select() .from(s.files) .where(inArray(s.files.status, ['stored', 'cleaning'])) .all() .map((f) => f.storageKey), ); for (const name of await readdir(this.originals)) { if (validKeys.has(name)) continue; const path = resolve(this.originals, name), metadata = await stat(path); if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {}); } for (const name of await readdir(this.temp)) { if (this.active.has(name)) continue; const path = resolve(this.temp, name); const metadata = await stat(path); if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {}); } } finally { this.cleaning = false; } } abortSession(id: string) { this.service.resumable?.abortSession(id); for (const upload of this.active.values()) if (upload.sessionId === id) upload.controller.abort(); } abortUser(id: string) { this.service.resumable?.abortUser(id); for (const upload of this.active.values()) if (upload.guestId === `user:${id}`) upload.controller.abort(); } close() { for (const upload of this.active.values()) upload.controller.abort(); } } function orLive(now: number, undoSeconds: number) { return sql`(${s.access.deletedAt} IS NULL OR ${s.access.deletedAt} > ${now - undoSeconds * 1000})`; }