diff options
Diffstat (limited to 'lib/services/fileService.ts')
| -rw-r--r-- | lib/services/fileService.ts | 516 |
1 files changed, 516 insertions, 0 deletions
diff --git a/lib/services/fileService.ts b/lib/services/fileService.ts new file mode 100644 index 00000000..56966a86 --- /dev/null +++ b/lib/services/fileService.ts @@ -0,0 +1,516 @@ +// 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<boolean> { + // 내부 사용자는 모든 권한 보유 + 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<boolean>`true`, + canDownload: sql<boolean>`${isInternal}`, + canEdit: sql<boolean>`${isInternal}`, + canDelete: sql<boolean>`${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<boolean>` + CASE + WHEN ${fileItems.category} IN ('public', 'restricted') THEN true + WHEN ${filePermissions.canView} = true THEN true + ELSE false + END + `, + canDownload: sql<boolean>` + CASE + WHEN ${fileItems.category} = 'public' THEN true + WHEN ${filePermissions.canDownload} = true THEN true + ELSE false + END + `, + canEdit: sql<boolean>`COALESCE(${filePermissions.canEdit}, false)`, + canDelete: sql<boolean>`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<FileItem> { + // 내부 사용자만 파일 생성 가능 + 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<FileItem | null> { + 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<string> { + 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<void> { + // 내부 사용자만 권한 부여 가능 + 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<void> { + 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<void> { + 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<void> { + 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<number>`COALESCE(SUM(${fileItems.size}), 0)`, + fileCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'file' THEN 1 END)`, + folderCount: sql<number>`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 }; + } +}
\ No newline at end of file |
