チャット・掲示板(スレッド/コメント)へファイル/画像添付を追加。 - lib/db/migrations/002_attachments.sql: attachments テーブル + file_assets.source 列 - repositories/AttachmentRepository + services/AttachmentService: 添付紐付け(プロジェクト/アップロード者チェック) - FileStorageService.uploadForAttachment: 通知/SSE/アクティビティを行わない添付専用アップロード(source='attachment') - ChatService/BoardService: fileIds 受理・添付紐付け・履歴/詳細表示・削除クリーンアップ(トランザクション) - API: POST /attachments + chat/board の fileIds 受理 - UI: AttachmentList(画像サムネイル+Lightbox/ダウンロード) + AttachmentPicker(複数アップロード・送信中は送信不可) をチャット/スレッド/コメントに統合 - 添付ファイルは既存の /api/files/[fileId]/download(権限チェック済)で配信
202 lines
6.3 KiB
TypeScript
202 lines
6.3 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, FileAssetSource } 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 {
|
|
const fileAsset = this.persistFile(actorId, projectId, input, 'library');
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* チャット/掲示板の添付ファイル用アップロード。
|
|
* file_shared 通知・file.uploaded SSE・file_uploaded アクティビティを行わず、
|
|
* source='attachment' で保存する(Files一覧には出さない)。
|
|
*/
|
|
uploadForAttachment(
|
|
actorId: number,
|
|
projectId: number,
|
|
input: UploadFileInput
|
|
): FileAsset {
|
|
return this.persistFile(actorId, projectId, input, 'attachment');
|
|
}
|
|
|
|
/**
|
|
* 共通のファイル保存処理。権限チェック・MIMEチェック・FS保存・file_assets登録を行う。
|
|
*/
|
|
private persistFile(
|
|
actorId: number,
|
|
projectId: number,
|
|
input: UploadFileInput,
|
|
source: FileAssetSource
|
|
): 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);
|
|
|
|
return this.fileRepository.create({
|
|
projectId,
|
|
uploaderId: actorId,
|
|
filename,
|
|
originalName: this.sanitizeName(input.originalName) || 'file',
|
|
mimeType: input.mimeType,
|
|
size: input.data.length,
|
|
path: filePath,
|
|
source,
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|