summaryrefslogtreecommitdiff
path: root/lib/services/fileService.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/services/fileService.ts')
-rw-r--r--lib/services/fileService.ts516
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