- FileRepository (CRUD, soft-delete, pagination, project isolation) + FileStorageService (local FS save data/uploads/<projectId>/<uuid>.<ext>, MIME allowlist, filename sanitize, unique uuid name, member permission, file_shared notification to other members, file_uploaded activity log, SSE file.uploaded, uploader/admin delete + FS unlink) - APIs: files list/upload(multipart), download (membership), delete - Screens: files page + Uploader(multipart) + FileList(image Lightbox, PDF link, delete), ProjectNav ファイル link - Unit tests (FileRepository, FileStorageService) + e2e file-sharing
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { FileRepository } from '@/repositories/FileRepository';
|
|
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
|
import { NotificationService } from '@/services/NotificationService';
|
|
import { ActivityLogService } from '@/services/ActivityLogService';
|
|
import { SseHub } from '@/lib/sse/hub';
|
|
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
|
import type { FileAsset } from '@/lib/types';
|
|
|
|
const ALLOWED_MIME_PREFIXES = [
|
|
'image/',
|
|
'text/',
|
|
'application/pdf',
|
|
'application/json',
|
|
'application/zip',
|
|
'application/gzip',
|
|
'application/vnd.openxmlformats-officedocument',
|
|
'application/vnd.ms-excel',
|
|
'application/msword',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/octet-stream',
|
|
];
|
|
|
|
const EXT_BY_MIME: Record<string, string> = {
|
|
'image/png': 'png',
|
|
'image/jpeg': 'jpg',
|
|
'image/gif': 'gif',
|
|
'image/svg+xml': 'svg',
|
|
'image/webp': 'webp',
|
|
'application/pdf': 'pdf',
|
|
'text/plain': 'txt',
|
|
'text/csv': 'csv',
|
|
'application/json': 'json',
|
|
'application/zip': 'zip',
|
|
};
|
|
|
|
export interface UploadFileInput {
|
|
originalName: string;
|
|
mimeType: string;
|
|
data: Buffer;
|
|
}
|
|
|
|
/**
|
|
* ファイル共有の業務ロジックを担うService。
|
|
* ローカルFS保存・MIMEチェック・ファイル名サニタイズ・保存名一意化・
|
|
* 権限チェック・ファイル共有通知・アクティビティログ・SSE配信を行う。
|
|
*/
|
|
export class FileStorageService {
|
|
constructor(
|
|
private readonly fileRepository: FileRepository,
|
|
private readonly projectMemberRepository: ProjectMemberRepository,
|
|
private readonly notificationService: NotificationService,
|
|
private readonly activityLogService: ActivityLogService,
|
|
private readonly sseHub: SseHub,
|
|
private readonly uploadsDir: string
|
|
) {}
|
|
|
|
upload(
|
|
actorId: number,
|
|
projectId: number,
|
|
input: UploadFileInput
|
|
): FileAsset {
|
|
this.requireMember(projectId, actorId);
|
|
if (!input.data || input.data.length === 0) {
|
|
throw new ValidationError('ファイルが空です', 'file');
|
|
}
|
|
if (!this.isAllowedMime(input.mimeType)) {
|
|
throw new ValidationError('許可されていないファイル形式です', 'mimeType');
|
|
}
|
|
|
|
const ext =
|
|
this.sanitizeExt(input.originalName) ??
|
|
EXT_BY_MIME[input.mimeType] ??
|
|
'bin';
|
|
const filename = `${crypto.randomUUID()}.${ext}`;
|
|
const dir = path.join(this.uploadsDir, String(projectId));
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const filePath = path.join(dir, filename);
|
|
fs.writeFileSync(filePath, input.data);
|
|
|
|
const fileAsset = this.fileRepository.create({
|
|
projectId,
|
|
uploaderId: actorId,
|
|
filename,
|
|
originalName: this.sanitizeName(input.originalName) || 'file',
|
|
mimeType: input.mimeType,
|
|
size: input.data.length,
|
|
path: filePath,
|
|
});
|
|
|
|
const memberIds = this.projectMemberRepository
|
|
.findByProject(projectId)
|
|
.map((m) => m.userId)
|
|
.filter((uid) => uid !== actorId);
|
|
if (memberIds.length > 0) {
|
|
this.notificationService.notifyOnEvent({
|
|
type: 'file_shared',
|
|
projectId,
|
|
title: `ファイル「${fileAsset.originalName}」が共有されました`,
|
|
body: null,
|
|
projectMemberIds: memberIds,
|
|
});
|
|
}
|
|
this.activityLogService.logActivity({
|
|
projectId,
|
|
actorId,
|
|
action: 'file_uploaded',
|
|
targetType: 'file',
|
|
targetId: fileAsset.id,
|
|
});
|
|
this.sseHub.broadcast(projectId, {
|
|
type: 'file.uploaded',
|
|
data: { projectId },
|
|
});
|
|
return fileAsset;
|
|
}
|
|
|
|
listFiles(actorId: number, projectId: number, page: number = 1) {
|
|
this.requireMember(projectId, actorId);
|
|
return this.fileRepository.findFilesByProject(projectId, page);
|
|
}
|
|
|
|
getFileInfo(actorId: number, fileId: number): FileAsset {
|
|
const file = this.fileRepository.findFileById(fileId);
|
|
if (!file) throw new NotFoundError('FileAsset', fileId);
|
|
this.requireMember(file.projectId, actorId);
|
|
return file;
|
|
}
|
|
|
|
delete(actorId: number, fileId: number): void {
|
|
const file = this.fileRepository.findFileById(fileId);
|
|
if (!file) throw new NotFoundError('FileAsset', fileId);
|
|
this.requireMember(file.projectId, actorId);
|
|
const role = this.projectMemberRepository.getRole(file.projectId, actorId);
|
|
if (file.uploaderId !== actorId && role !== 'admin') {
|
|
throw new ForbiddenError('アップロード者または管理者のみ削除できます');
|
|
}
|
|
this.fileRepository.delete(fileId);
|
|
try {
|
|
fs.unlinkSync(file.path);
|
|
} catch {
|
|
// FSファイルが無くてもDB論理削除は成功させる
|
|
}
|
|
}
|
|
|
|
private requireMember(projectId: number, actorId: number): void {
|
|
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
|
throw new ForbiddenError('プロジェクトに参加していません');
|
|
}
|
|
}
|
|
|
|
private isAllowedMime(mimeType: string): boolean {
|
|
if (!mimeType) return false;
|
|
return ALLOWED_MIME_PREFIXES.some((prefix) =>
|
|
prefix.endsWith('/')
|
|
? mimeType.startsWith(prefix)
|
|
: mimeType === prefix || mimeType.startsWith(prefix)
|
|
);
|
|
}
|
|
|
|
private sanitizeExt(originalName: string): string | null {
|
|
const base = path.basename(originalName);
|
|
const match = base.match(/\.([a-zA-Z0-9]+)$/);
|
|
return match ? match[1].toLowerCase() : null;
|
|
}
|
|
|
|
private sanitizeName(originalName: string): string {
|
|
return path
|
|
.basename(originalName)
|
|
.replace(/[^\w.-]+/g, '_')
|
|
.slice(0, 200);
|
|
}
|
|
}
|