feat(m10): file sharing with upload, lightbox, permission, tests, e2e
- 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
This commit is contained in:
175
services/FileStorageService.ts
Normal file
175
services/FileStorageService.ts
Normal file
@ -0,0 +1,175 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user