"use server" import { createId } from "@paralleldrive/cuid2" import { requirePermission, checkPermission, PermissionDeniedError, } from "@/shared/lib/auth-guard" import { trackEvent } from "@/shared/lib/track-event" import { logAudit } from "@/shared/lib/audit-logger" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" import { storageProvider } from "@/shared/lib/storage-provider" import { generateStoragePath, isAllowedMimeType, MAX_FILE_SIZE, } from "@/shared/lib/file-storage" import { UploadMetadataSchema, BatchDeleteSchema, FileListQuerySchema } from "./schema" import { createFileAttachment, getFileAttachment, getFileAttachmentsWithFilters, getFileStats, getFileAttachmentsByIds, deleteFileAttachment, deleteFileAttachments, } from "./data-access" import type { FileAttachment, FileUploadResult, FileStats as FileStatsType, FileAttachmentQueryParams, BatchDeleteResult, } from "./types" function handleActionError(e: unknown): ActionState { if (e instanceof PermissionDeniedError) { return { success: false, message: e.message } } if (e instanceof Error) return { success: false, message: e.message } return { success: false, message: "Unexpected error" } } /** * Upload a file: persist to storage + create DB record. * * Requires `FILE_UPLOAD` permission. Performs Zod-validated metadata, * MIME/size checks, and writes to disk via the storageProvider abstraction. * Records `file.uploaded` track event + audit log entry. * * @returns ActionState */ export async function uploadFileAction( file: File, rawMetadata: { targetType?: string | null; targetId?: string | null } ): Promise> { try { const ctx = await requirePermission(Permissions.FILE_UPLOAD) const meta = UploadMetadataSchema.parse(rawMetadata) if (file.size === 0) { return { success: false, message: "File is empty" } } if (file.size > MAX_FILE_SIZE) { return { success: false, message: "File size exceeds 10MB limit" } } const mimeType = file.type || "application/octet-stream" if (!isAllowedMimeType(mimeType)) { return { success: false, message: `File type ${mimeType} is not allowed` } } const originalName = file.name || "unnamed" const storagePath = generateStoragePath(originalName) const bytes = Buffer.from(await file.arrayBuffer()) const url = await storageProvider.save(bytes, storagePath) const id = createId() const filename = storagePath.split("/").pop() ?? id const created = await createFileAttachment({ id, filename, originalName, mimeType, size: file.size, storagePath, url, uploaderId: ctx.userId, targetType: meta.targetType ?? null, targetId: meta.targetId, }) if (!created) { return { success: false, message: "Failed to persist file record" } } await trackEvent({ event: "file.uploaded", userId: ctx.userId, targetId: id, targetType: "file", properties: { filename: originalName, mimeType, size: file.size, targetType: meta.targetType ?? null, }, }) await logAudit({ action: "upload", module: "files", targetId: id, targetType: "file", detail: { filename: originalName, mimeType, size: file.size }, }) const result: FileUploadResult = { id: created.id, url: created.url ?? url, filename: created.filename, originalName: created.originalName, size: created.size, mimeType: created.mimeType, } return { success: true, data: result } } catch (e) { await trackEvent({ event: "file.upload_failed", targetType: "file", properties: { reason: e instanceof Error ? e.message : "unknown" }, }).catch(() => undefined) return handleActionError(e) } } /** * Get a single file by ID. * * Requires `FILE_READ` permission. Non-admin users (those without * `FILE_DELETE`) can only read files they uploaded themselves, * preventing horizontal privilege escalation. * * Records `file.viewed` track event. */ export async function getFileAction( id: string ): Promise> { try { const ctx = await requirePermission(Permissions.FILE_READ) const file = await getFileAttachment(id) if (!file) { return { success: false, message: "File not found" } } // Data-level permission: non-admins can only read their own uploads. const { allowed: canManage } = await checkPermission(Permissions.FILE_DELETE) if (!canManage && file.uploaderId !== ctx.userId) { return { success: false, message: "Permission denied" } } await trackEvent({ event: "file.viewed", userId: ctx.userId, targetId: id, targetType: "file", }) return { success: true, data: file } } catch (e) { return handleActionError(e) } } /** * Delete a single file by ID. * * Requires `FILE_DELETE` permission. Persists removal via storageProvider * abstraction (no direct fs/promises calls). Records `file.deleted` track * event + audit log entry. */ export async function deleteFileAction( id: string ): Promise> { try { const ctx = await requirePermission(Permissions.FILE_DELETE) const file = await getFileAttachment(id) if (!file) { return { success: false, message: "File not found" } } await storageProvider.delete(file.storagePath) const ok = await deleteFileAttachment(id) if (!ok) { return { success: false, message: "Failed to delete file record" } } await trackEvent({ event: "file.deleted", userId: ctx.userId, targetId: id, targetType: "file", properties: { filename: file.originalName, size: file.size }, }) await logAudit({ action: "delete", module: "files", targetId: id, targetType: "file", detail: { filename: file.originalName, mimeType: file.mimeType }, }) return { success: true, data: { id } } } catch (e) { return handleActionError(e) } } /** * Batch delete files by IDs. * * Requires `FILE_DELETE` permission. Input is Zod-validated (max 100 ids * per call). Persists storage removal via storageProvider abstraction. * Records `file.batch_deleted` track event + audit log entry. */ export async function batchDeleteFilesAction( rawIds: unknown ): Promise> { try { const ctx = await requirePermission(Permissions.FILE_DELETE) const { ids } = BatchDeleteSchema.parse({ ids: rawIds }) const files = await getFileAttachmentsByIds(ids) await Promise.all( files.map((f) => storageProvider.delete(f.storagePath).catch(() => undefined) ) ) const result = await deleteFileAttachments(ids) await trackEvent({ event: "file.batch_deleted", userId: ctx.userId, targetType: "file", properties: { requestedCount: ids.length, deletedCount: result.deletedCount, failedCount: result.failedIds.length, }, }) await logAudit({ action: "batch_delete", module: "files", targetType: "file", detail: { requestedCount: ids.length, deletedCount: result.deletedCount, failedIds: result.failedIds, }, }) return { success: true, data: result } } catch (e) { return handleActionError(e) } } /** * Get file list with filters (admin). * * Requires `FILE_READ` permission. Input is Zod-validated to enforce * limit (1..200) and offset (>=0) bounds. */ export async function getFileListAction( params?: Partial ): Promise> { try { await requirePermission(Permissions.FILE_READ) const query = FileListQuerySchema.parse(params ?? {}) const files = await getFileAttachmentsWithFilters(query) return { success: true, data: { files } } } catch (e) { return handleActionError(e) } } /** * Get file statistics (admin dashboard). * * Requires `FILE_READ` permission. */ export async function getFileStatsAction(): Promise> { try { await requirePermission(Permissions.FILE_READ) const stats = await getFileStats() return { success: true, data: stats } } catch (e) { return handleActionError(e) } }