// lib/services/fileService.ts import db from "@/db/db"; import { fileItems, filePermissions, fileShares, fileActivityLogs, projects, type FileItem, type NewFileItem, type FilePermission, } from "@/db/schema/fileSystem"; import { users } from "@/db/schema/users"; import { eq, and, or, isNull, lte, gte, sql, inArray } from "drizzle-orm"; import crypto from "crypto"; export interface FileAccessContext { userId: number; userDomain: string; userEmail: string; ipAddress?: string; userAgent?: string; } export class FileService { // 사용자가 내부 사용자인지 확인 private isInternalUser(domain: string): boolean { // partners가 아닌 경우 내부 사용자로 간주 return domain !== "partners"; } // 파일 접근 권한 확인 async checkFileAccess( fileId: string, context: FileAccessContext, requiredAction: "view" | "download" | "edit" | "delete" | "share" ): Promise { // 내부 사용자는 모든 권한 보유 if (this.isInternalUser(context.userDomain)) { return true; } // 파일 정보 조회 const file = await db.query.fileItems.findFirst({ where: eq(fileItems.id, fileId), }); if (!file) return false; // 외부 사용자 권한 체크 // 1. 파일 카테고리별 기본 권한 체크 switch (file.category) { case "public": // public 파일은 열람과 다운로드 가능 if (requiredAction === "view" || requiredAction === "download") { return true; } break; case "restricted": // restricted 파일은 열람만 가능 if (requiredAction === "view") { return true; } break; case "confidential": case "internal": // 기본적으로 접근 불가 break; } // 2. 개별 권한 설정 체크 const permission = await db.query.filePermissions.findFirst({ where: and( eq(filePermissions.fileItemId, fileId), or( eq(filePermissions.userId, context.userId), eq(filePermissions.userDomain, context.userDomain) ), or( isNull(filePermissions.validFrom), lte(filePermissions.validFrom, new Date()) ), or( isNull(filePermissions.validUntil), gte(filePermissions.validUntil, new Date()) ) ), }); if (permission) { switch (requiredAction) { case "view": return permission.canView; case "download": return permission.canDownload; case "edit": return permission.canEdit; case "delete": return permission.canDelete; case "share": return permission.canShare; } } return false; } // 파일 목록 조회 (트리 뷰 지원) async getFileList( projectId: string, parentId: string | null, context: FileAccessContext, options?: { includeAll?: boolean; // 전체 파일 가져오기 옵션 } ) { const isInternal = this.isInternalUser(context.userDomain); // 기본 쿼리 빌드 let baseConditions = [eq(fileItems.projectId, projectId)]; // includeAll이 false이거나 명시되지 않은 경우에만 parentId 조건 추가 if (!options?.includeAll) { baseConditions.push( parentId ? eq(fileItems.parentId, parentId) : isNull(fileItems.parentId) ); } let query = db .select({ file: fileItems, canView: sql`true`, canDownload: sql`${isInternal}`, canEdit: sql`${isInternal}`, canDelete: sql`${isInternal}`, }) .from(fileItems) .where(and(...baseConditions)); if (!isInternal) { // 외부 사용자는 접근 가능한 파일만 표시 let externalConditions = [eq(fileItems.projectId, projectId)]; if (!options?.includeAll) { externalConditions.push( parentId ? eq(fileItems.parentId, parentId) : isNull(fileItems.parentId) ); } query = db .select({ file: fileItems, canView: sql` CASE WHEN ${fileItems.category} IN ('public', 'restricted') THEN true WHEN ${filePermissions.canView} = true THEN true ELSE false END `, canDownload: sql` CASE WHEN ${fileItems.category} = 'public' THEN true WHEN ${filePermissions.canDownload} = true THEN true ELSE false END `, canEdit: sql`COALESCE(${filePermissions.canEdit}, false)`, canDelete: sql`COALESCE(${filePermissions.canDelete}, false)`, }) .from(fileItems) .leftJoin( filePermissions, and( eq(filePermissions.fileItemId, fileItems.id), or( eq(filePermissions.userId, context.userId), eq(filePermissions.userDomain, context.userDomain) ) ) ) .where( and( ...externalConditions, or( inArray(fileItems.category, ["public", "restricted"]), eq(filePermissions.canView, true) ) ) ); } const results = await query; // 활동 로그 기록 (전체 목록 조회시에는 로그 생략) if (!options?.includeAll) { for (const result of results) { await this.logActivity(result.file.id, projectId, "view", context); } } return results.map(r => ({ ...r.file, permissions: { canView: r.canView, canDownload: r.canDownload, canEdit: r.canEdit, canDelete: r.canDelete, }, })); } // 파일/폴더 생성 async createFileItem( data: NewFileItem, context: FileAccessContext ): Promise { // 내부 사용자만 파일 생성 가능 if (!this.isInternalUser(context.userDomain)) { throw new Error("권한이 없습니다"); } // 경로 계산 let path = "/"; let depth = 0; if (data.parentId) { const parent = await db.query.fileItems.findFirst({ where: eq(fileItems.id, data.parentId), }); if (parent) { path = `${parent.path}${parent.name}/`; depth = parent.depth + 1; } } const [newFile] = await db .insert(fileItems) .values({ ...data, path, depth, createdBy: context.userId, updatedBy: context.userId, }) .returning(); await this.logActivity(newFile.id, newFile.projectId, "upload", context); return newFile; } // 파일 다운로드 async downloadFile( fileId: string, context: FileAccessContext ): Promise { const hasAccess = await this.checkFileAccess(fileId, context, "download"); if (!hasAccess) { throw new Error("다운로드 권한이 없습니다"); } const file = await db.query.fileItems.findFirst({ where: eq(fileItems.id, fileId), }); if (!file) return null; // 다운로드 카운트 증가 await db .update(fileItems) .set({ downloadCount: sql`${fileItems.downloadCount} + 1`, }) .where(eq(fileItems.id, fileId)); // 활동 로그 기록 await this.logActivity(fileId, file.projectId, "download", context); return file; } // 파일 공유 링크 생성 async createShareLink( fileId: string, options: { accessLevel?: "view_only" | "view_download"; password?: string; expiresAt?: Date; maxDownloads?: number; sharedWithEmail?: string; }, context: FileAccessContext ): Promise { const hasAccess = await this.checkFileAccess(fileId, context, "share"); if (!hasAccess) { throw new Error("공유 권한이 없습니다"); } const shareToken = crypto.randomBytes(32).toString("hex"); const [share] = await db .insert(fileShares) .values({ fileItemId: fileId, shareToken, accessLevel: options.accessLevel || "view_only", password: options.password, expiresAt: options.expiresAt, maxDownloads: options.maxDownloads, sharedWithEmail: options.sharedWithEmail, createdBy: context.userId, }) .returning(); const file = await db.query.fileItems.findFirst({ where: eq(fileItems.id, fileId), }); if (file) { await this.logActivity(fileId, file.projectId, "share", context, { shareId: share.id, sharedWithEmail: options.sharedWithEmail, }); } return shareToken; } // 공유 링크로 파일 접근 async accessFileByShareToken( shareToken: string, password?: string ): Promise<{ file: FileItem; accessLevel: string } | null> { const share = await db.query.fileShares.findFirst({ where: eq(fileShares.shareToken, shareToken), with: { fileItem: true, }, }); if (!share || !share.fileItem) return null; // 유효성 검사 if (share.expiresAt && share.expiresAt < new Date()) { throw new Error("공유 링크가 만료되었습니다"); } if (share.password && share.password !== password) { throw new Error("비밀번호가 일치하지 않습니다"); } if ( share.maxDownloads && share.currentDownloads >= share.maxDownloads ) { throw new Error("최대 다운로드 횟수를 초과했습니다"); } // 접근 기록 업데이트 await db .update(fileShares) .set({ lastAccessedAt: new Date(), }) .where(eq(fileShares.id, share.id)); // 조회수 증가 await db .update(fileItems) .set({ viewCount: sql`${fileItems.viewCount} + 1`, }) .where(eq(fileItems.id, share.fileItemId)); return { file: share.fileItem, accessLevel: share.accessLevel, }; } // 파일 권한 부여 async grantPermission( fileId: string, targetUserId: number | null, targetDomain: string | null, permissions: { canView?: boolean; canDownload?: boolean; canEdit?: boolean; canDelete?: boolean; canShare?: boolean; }, context: FileAccessContext ): Promise { // 내부 사용자만 권한 부여 가능 if (!this.isInternalUser(context.userDomain)) { throw new Error("권한 부여 권한이 없습니다"); } await db .insert(filePermissions) .values({ fileItemId: fileId, userId: targetUserId, userDomain: targetDomain, ...permissions, grantedBy: context.userId, }) .onConflictDoUpdate({ target: [filePermissions.fileItemId, filePermissions.userId], set: { ...permissions, updatedAt: new Date(), }, }); } // 활동 로그 기록 private async logActivity( fileItemId: string, projectId: string, action: string, context: FileAccessContext, details: any = {} ): Promise { await db.insert(fileActivityLogs).values({ fileItemId, projectId, action, actionDetails: details, userId: context.userId, userEmail: context.userEmail, userDomain: context.userDomain, ipAddress: context.ipAddress, userAgent: context.userAgent, }); } // 파일 이동 async moveFile( fileId: string, newParentId: string | null, context: FileAccessContext ): Promise { const hasAccess = await this.checkFileAccess(fileId, context, "edit"); if (!hasAccess) { throw new Error("이동 권한이 없습니다"); } // 새 경로 계산 let newPath = "/"; let newDepth = 0; if (newParentId) { const newParent = await db.query.fileItems.findFirst({ where: eq(fileItems.id, newParentId), }); if (newParent) { newPath = `${newParent.path}${newParent.name}/`; newDepth = newParent.depth + 1; } } await db .update(fileItems) .set({ parentId: newParentId, path: newPath, depth: newDepth, updatedBy: context.userId, updatedAt: new Date(), }) .where(eq(fileItems.id, fileId)); // 하위 항목들의 경로도 재귀적으로 업데이트 필요 (생략) } // 파일 삭제 async deleteFile( fileId: string, context: FileAccessContext ): Promise { const hasAccess = await this.checkFileAccess(fileId, context, "delete"); if (!hasAccess) { throw new Error("삭제 권한이 없습니다"); } const file = await db.query.fileItems.findFirst({ where: eq(fileItems.id, fileId), }); if (file) { await this.logActivity(fileId, file.projectId, "delete", context); } await db.delete(fileItems).where(eq(fileItems.id, fileId)); } // 프로젝트별 스토리지 사용량 계산 async getProjectStorageUsage(projectId: string): Promise<{ totalSize: number; fileCount: number; folderCount: number; }> { const result = await db .select({ totalSize: sql`COALESCE(SUM(${fileItems.size}), 0)`, fileCount: sql`COUNT(CASE WHEN ${fileItems.type} = 'file' THEN 1 END)`, folderCount: sql`COUNT(CASE WHEN ${fileItems.type} = 'folder' THEN 1 END)`, }) .from(fileItems) .where(eq(fileItems.projectId, projectId)); return result[0] || { totalSize: 0, fileCount: 0, folderCount: 0 }; } }