Merge branch 'feature/m10-file-sharing' - M10 file sharing & lightbox
This commit is contained in:
32
app/api/files/[fileId]/download/route.ts
Normal file
32
app/api/files/[fileId]/download/route.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'node:fs';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createFileStorageService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ fileId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { fileId } = await params;
|
||||
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
const file = service.getFileInfo(user.id, Number(fileId));
|
||||
const body = fs.readFileSync(file.path);
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Content-Type': file.mimeType,
|
||||
'Content-Disposition': `inline; filename="${file.originalName}"`,
|
||||
'Content-Length': String(file.size),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
24
app/api/files/[fileId]/route.ts
Normal file
24
app/api/files/[fileId]/route.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createFileStorageService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ fileId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { fileId } = await params;
|
||||
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
service.delete(user.id, Number(fileId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
55
app/api/projects/[projectId]/files/route.ts
Normal file
55
app/api/projects/[projectId]/files/route.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createFileStorageService } from '@/lib/api/services';
|
||||
import { UnauthorizedError, ValidationError } from '@/lib/errors';
|
||||
import { handleApiError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
return NextResponse.json(
|
||||
service.listFiles(user.id, Number(projectId), page)
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return handleApiError(
|
||||
new ValidationError('ファイルを指定してください', 'file')
|
||||
);
|
||||
}
|
||||
|
||||
const data = Buffer.from(await file.arrayBuffer());
|
||||
const service = createFileStorageService();
|
||||
try {
|
||||
const fileAsset = service.upload(user.id, Number(projectId), {
|
||||
originalName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
data,
|
||||
});
|
||||
return NextResponse.json({ file: fileAsset }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
52
app/projects/[projectId]/files/page.tsx
Normal file
52
app/projects/[projectId]/files/page.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import {
|
||||
createFileStorageService,
|
||||
createProjectService,
|
||||
} from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { Uploader } from '@/components/files/Uploader';
|
||||
import { FileList } from '@/components/files/FileList';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function FilesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fileService = createFileStorageService();
|
||||
const { items } = fileService.listFiles(user.id, project.id);
|
||||
const canDelete =
|
||||
projectService.getMemberRole(user.id, project.id) === 'admin' ||
|
||||
items.some((f) => f.uploaderId === user.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="files" />
|
||||
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">ファイル</h1>
|
||||
<Uploader projectId={project.id} />
|
||||
<FileList files={items} canDelete={canDelete} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
components/files/FileList.tsx
Normal file
107
components/files/FileList.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { FileAsset } from '@/lib/types';
|
||||
|
||||
export function FileList({
|
||||
files,
|
||||
canDelete,
|
||||
}: {
|
||||
files: FileAsset[];
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [lightbox, setLightbox] = useState<FileAsset | null>(null);
|
||||
|
||||
async function onDelete(fileId: number) {
|
||||
const res = await fetch(`/api/files/${fileId}`, { method: 'DELETE' });
|
||||
if (res.ok) router.refresh();
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return <p className="text-sm text-gray-400">ファイルはありません。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
|
||||
data-testid="file-list"
|
||||
>
|
||||
{files.map((file) => {
|
||||
const isImage = file.mimeType.startsWith('image/');
|
||||
const isPdf = file.mimeType === 'application/pdf';
|
||||
const url = `/api/files/${file.id}/download`;
|
||||
return (
|
||||
<li
|
||||
key={file.id}
|
||||
className="rounded-lg border bg-white p-3 shadow-sm"
|
||||
data-testid={`file-item-${file.id}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(file)}
|
||||
className="block w-full"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={file.originalName}
|
||||
className="h-24 w-full rounded object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : isPdf ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
PDF を開く
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
ダウンロード
|
||||
</a>
|
||||
)}
|
||||
<p
|
||||
className="mt-2 truncate text-xs text-gray-700"
|
||||
title={file.originalName}
|
||||
>
|
||||
{file.originalName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{file.size} bytes</p>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(file.id)}
|
||||
className="mt-1 text-xs text-red-600 hover:underline"
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{lightbox && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={() => setLightbox(null)}
|
||||
data-testid="lightbox"
|
||||
>
|
||||
<img
|
||||
src={`/api/files/${lightbox.id}/download`}
|
||||
alt={lightbox.originalName}
|
||||
className="max-h-full max-w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
components/files/Uploader.tsx
Normal file
58
components/files/Uploader.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function Uploader({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`/api/projects/${projectId}/files`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? 'アップロードに失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<label className="block text-sm font-medium">
|
||||
ファイルをアップロード
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
onChange={onFile}
|
||||
disabled={loading}
|
||||
className="mt-2 block text-sm"
|
||||
data-testid="file-input"
|
||||
/>
|
||||
{loading && (
|
||||
<p className="mt-1 text-sm text-gray-500">アップロード中...</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ const NAV_ITEMS = [
|
||||
{ href: '/notes', label: 'メモ' },
|
||||
{ href: '/chat', label: 'チャット' },
|
||||
{ href: '/todos', label: 'ToDo' },
|
||||
{ href: '/files', label: 'ファイル' },
|
||||
{ href: '/members', label: 'メンバー' },
|
||||
{ href: '/activity', label: 'アクティビティ' },
|
||||
{ href: '/settings', label: '設定' },
|
||||
@ -24,6 +25,7 @@ export function ProjectNav({
|
||||
| 'notes'
|
||||
| 'chat'
|
||||
| 'todos'
|
||||
| 'files'
|
||||
| 'members'
|
||||
| 'activity'
|
||||
| 'settings';
|
||||
@ -34,6 +36,7 @@ export function ProjectNav({
|
||||
notes: active === 'notes',
|
||||
chat: active === 'chat',
|
||||
todos: active === 'todos',
|
||||
files: active === 'files',
|
||||
members: active === 'members',
|
||||
activity: active === 'activity',
|
||||
settings: active === 'settings',
|
||||
|
||||
@ -16,6 +16,8 @@ import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { getSseHub } from '@/lib/sse/hub';
|
||||
import { TodoService } from '@/services/TodoService';
|
||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { FileStorageService } from '@/services/FileStorageService';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
|
||||
/**
|
||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
||||
@ -81,6 +83,19 @@ export function createTodoService(): TodoService {
|
||||
);
|
||||
}
|
||||
|
||||
export function createFileStorageService(): FileStorageService {
|
||||
const db = getDb();
|
||||
const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads';
|
||||
return new FileStorageService(
|
||||
new FileRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
getSseHub(),
|
||||
uploadsDir
|
||||
);
|
||||
}
|
||||
|
||||
export function createUserRepository(): UserRepository {
|
||||
return new UserRepository(getDb());
|
||||
}
|
||||
|
||||
108
repositories/FileRepository.ts
Normal file
108
repositories/FileRepository.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { FileAsset } from '@/lib/types';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export interface Paginated<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface FileAssetRow {
|
||||
id: number;
|
||||
project_id: number;
|
||||
uploader_id: number;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
path: string;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
function mapFile(row: FileAssetRow): FileAsset {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
uploaderId: row.uploader_id,
|
||||
filename: row.filename,
|
||||
originalName: row.original_name,
|
||||
mimeType: row.mime_type,
|
||||
size: row.size,
|
||||
path: row.path,
|
||||
createdAt: row.created_at,
|
||||
deletedAt: row.deleted_at,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateFileInput {
|
||||
projectId: number;
|
||||
uploaderId: number;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export class FileRepository {
|
||||
constructor(private readonly db: SqliteDatabase) {}
|
||||
|
||||
findFilesByProject(
|
||||
projectId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = DEFAULT_PAGE_SIZE
|
||||
): Paginated<FileAsset> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const items = this.db.query<FileAssetRow>(
|
||||
`SELECT * FROM file_assets
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT @pageSize OFFSET @offset`,
|
||||
{ projectId, pageSize, offset }
|
||||
);
|
||||
const total = this.db.get<{ count: number }>(
|
||||
'SELECT COUNT(*) AS count FROM file_assets WHERE project_id = @projectId AND deleted_at IS NULL',
|
||||
{ projectId }
|
||||
);
|
||||
return { items: items.map(mapFile), total: total?.count ?? 0 };
|
||||
}
|
||||
|
||||
findFileById(id: number): FileAsset | null {
|
||||
const row = this.db.get<FileAssetRow>(
|
||||
'SELECT * FROM file_assets WHERE id = @id AND deleted_at IS NULL',
|
||||
{ id }
|
||||
);
|
||||
return row ? mapFile(row) : null;
|
||||
}
|
||||
|
||||
create(input: CreateFileInput): FileAsset {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.execute(
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||
{
|
||||
projectId: input.projectId,
|
||||
uploaderId: input.uploaderId,
|
||||
filename: input.filename,
|
||||
originalName: input.originalName,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
path: input.path,
|
||||
createdAt: now,
|
||||
}
|
||||
);
|
||||
const created = this.findFileById(Number(result.lastInsertRowid));
|
||||
if (!created) throw new Error('Failed to create file asset');
|
||||
return created;
|
||||
}
|
||||
|
||||
delete(id: number): boolean {
|
||||
const result = this.db.execute(
|
||||
'UPDATE file_assets SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
|
||||
{ now: new Date().toISOString(), id }
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
68
tests/e2e/file-sharing.spec.ts
Normal file
68
tests/e2e/file-sharing.spec.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
function unique(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
// 1x1 透明PNG
|
||||
const PNG_BUFFER = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
|
||||
async function setupOwner(page: import('@playwright/test').Page) {
|
||||
const email = unique('owner') + '@example.com';
|
||||
await page.goto('/login');
|
||||
await page.getByRole('button', { name: '新規登録はこちら' }).click();
|
||||
await page.getByLabel('表示名').fill('Owner');
|
||||
await page.getByLabel('メールアドレス').fill(email);
|
||||
await page.getByLabel('パスワード').fill('password123');
|
||||
await page.getByRole('button', { name: '登録する' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
|
||||
await page.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||
await expect(page).toHaveURL(/\/projects\/\d+$/);
|
||||
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
|
||||
}
|
||||
|
||||
test.describe('file sharing', () => {
|
||||
test('upload, view in lightbox, and delete a file', async ({ page }) => {
|
||||
const projectId = await setupOwner(page);
|
||||
|
||||
// アップロード(multipart)
|
||||
const uploadRes = await page.request.post(
|
||||
`/api/projects/${projectId}/files`,
|
||||
{
|
||||
multipart: {
|
||||
file: {
|
||||
name: 'tiny.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: PNG_BUFFER,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(uploadRes.ok()).toBeTruthy();
|
||||
const { file } = (await uploadRes.json()) as { file: { id: number } };
|
||||
|
||||
// ファイル一覧に表示
|
||||
await page.goto(`/projects/${projectId}/files`);
|
||||
await expect(page.getByTestId(`file-item-${file.id}`)).toBeVisible();
|
||||
|
||||
// 画像をクリック → Lightbox
|
||||
await page
|
||||
.getByTestId(`file-item-${file.id}`)
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByTestId('lightbox')).toBeVisible();
|
||||
// Lightboxを閉じる(リロードで確実に解除)
|
||||
await page.reload();
|
||||
|
||||
// 削除(API)
|
||||
const delRes = await page.request.delete(`/api/files/${file.id}`);
|
||||
expect(delRes.ok()).toBeTruthy();
|
||||
await page.reload();
|
||||
await expect(page.getByTestId(`file-item-${file.id}`)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
88
tests/unit/repositories/FileRepository.test.ts
Normal file
88
tests/unit/repositories/FileRepository.test.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
|
||||
describe('FileRepository', () => {
|
||||
let db: SqliteDatabase;
|
||||
let repo: FileRepository;
|
||||
let projectId: number;
|
||||
let userId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
repo = new FileRepository(db);
|
||||
userId = new UserRepository(db).create({
|
||||
name: 'U',
|
||||
email: 'u@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
new ProjectMemberRepository(db).add(projectId, userId, 'admin');
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
function createFile(name: string) {
|
||||
return repo.create({
|
||||
projectId,
|
||||
uploaderId: userId,
|
||||
filename: `${name}.bin`,
|
||||
originalName: name,
|
||||
mimeType: 'application/octet-stream',
|
||||
size: 10,
|
||||
path: `/tmp/${name}.bin`,
|
||||
});
|
||||
}
|
||||
|
||||
it('creates and finds a file', () => {
|
||||
const f = createFile('test');
|
||||
expect(repo.findFileById(f.id)?.originalName).toBe('test');
|
||||
});
|
||||
|
||||
it('lists files newest first with total', () => {
|
||||
createFile('a');
|
||||
createFile('b');
|
||||
const { items, total } = repo.findFilesByProject(projectId);
|
||||
expect(total).toBe(2);
|
||||
expect(items[0].originalName).toBe('b');
|
||||
});
|
||||
|
||||
it('excludes soft-deleted files', () => {
|
||||
const f = createFile('a');
|
||||
repo.delete(f.id);
|
||||
expect(repo.findFileById(f.id)).toBeNull();
|
||||
expect(repo.findFilesByProject(projectId).total).toBe(0);
|
||||
});
|
||||
|
||||
it('isolates files by project', () => {
|
||||
createFile('mine');
|
||||
const p2 = new ProjectRepository(db).create({
|
||||
name: 'P2',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
repo.create({
|
||||
projectId: p2,
|
||||
uploaderId: userId,
|
||||
filename: 'x.bin',
|
||||
originalName: 'theirs',
|
||||
mimeType: 'application/octet-stream',
|
||||
size: 1,
|
||||
path: '/tmp/x.bin',
|
||||
});
|
||||
expect(repo.findFilesByProject(projectId).total).toBe(1);
|
||||
expect(repo.findFilesByProject(p2).total).toBe(1);
|
||||
});
|
||||
|
||||
it('paginates files', () => {
|
||||
for (let i = 0; i < 5; i++) createFile(`f${i}`);
|
||||
expect(repo.findFilesByProject(projectId, 1, 2).items).toHaveLength(2);
|
||||
expect(repo.findFilesByProject(projectId, 1, 2).total).toBe(5);
|
||||
});
|
||||
});
|
||||
160
tests/unit/services/FileStorageService.test.ts
Normal file
160
tests/unit/services/FileStorageService.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { FileStorageService } from '@/services/FileStorageService';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import { ForbiddenError, ValidationError } from '@/lib/errors';
|
||||
|
||||
describe('FileStorageService', () => {
|
||||
let db: SqliteDatabase;
|
||||
let service: FileStorageService;
|
||||
let uploadsDir: string;
|
||||
let projectId: number;
|
||||
let authorId: number;
|
||||
let memberId: number;
|
||||
let outsiderId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
uploadsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uploads-'));
|
||||
const users = new UserRepository(db);
|
||||
authorId = users.create({
|
||||
name: 'A',
|
||||
email: 'a@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
memberId = users.create({
|
||||
name: 'M',
|
||||
email: 'm@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
outsiderId = users.create({
|
||||
name: 'O',
|
||||
email: 'o@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: authorId,
|
||||
}).id;
|
||||
const members = new ProjectMemberRepository(db);
|
||||
members.add(projectId, authorId, 'admin');
|
||||
members.add(projectId, memberId, 'member');
|
||||
service = new FileStorageService(
|
||||
new FileRepository(db),
|
||||
members,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
new SseHub(),
|
||||
uploadsDir
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uploads a file: writes to FS, stores metadata, notifies members, logs activity', () => {
|
||||
const file = service.upload(authorId, projectId, {
|
||||
originalName: 'photo.png',
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from('fake-png-bytes'),
|
||||
});
|
||||
|
||||
expect(file.id).toBeGreaterThan(0);
|
||||
expect(file.originalName).toBe('photo.png');
|
||||
expect(fs.existsSync(file.path)).toBe(true);
|
||||
// メンバー(memberId)へ file_shared 通知
|
||||
expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(1);
|
||||
expect(
|
||||
new ActivityLogRepository(db)
|
||||
.findByProject(projectId)
|
||||
.items.some((l) => l.action === 'file_uploaded')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a disallowed MIME type', () => {
|
||||
expect(() =>
|
||||
service.upload(authorId, projectId, {
|
||||
originalName: 'evil.exe',
|
||||
mimeType: 'application/x-msdownload',
|
||||
data: Buffer.from('x'),
|
||||
})
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects an empty file', () => {
|
||||
expect(() =>
|
||||
service.upload(authorId, projectId, {
|
||||
originalName: 'empty.txt',
|
||||
mimeType: 'text/plain',
|
||||
data: Buffer.alloc(0),
|
||||
})
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('forbids a non-member from uploading', () => {
|
||||
expect(() =>
|
||||
service.upload(outsiderId, projectId, {
|
||||
originalName: 'x.png',
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from('x'),
|
||||
})
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('sanitizes the saved filename to a unique uuid-based name', () => {
|
||||
const file = service.upload(authorId, projectId, {
|
||||
originalName: 'photo.png',
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from('x'),
|
||||
});
|
||||
expect(file.filename).toMatch(/^[0-9a-f-]{36}\.png$/);
|
||||
});
|
||||
|
||||
it('getFileInfo requires membership', () => {
|
||||
const file = service.upload(authorId, projectId, {
|
||||
originalName: 'a.txt',
|
||||
mimeType: 'text/plain',
|
||||
data: Buffer.from('hi'),
|
||||
});
|
||||
expect(service.getFileInfo(authorId, file.id).id).toBe(file.id);
|
||||
expect(() => service.getFileInfo(outsiderId, file.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
});
|
||||
|
||||
it('delete: uploader can delete; non-uploader non-admin cannot', () => {
|
||||
const file = service.upload(memberId, projectId, {
|
||||
originalName: 'm.txt',
|
||||
mimeType: 'text/plain',
|
||||
data: Buffer.from('hi'),
|
||||
});
|
||||
expect(() => service.delete(outsiderId, file.id)).toThrow(ForbiddenError);
|
||||
// 作者(member)は削除可
|
||||
service.delete(memberId, file.id);
|
||||
expect(fs.existsSync(file.path)).toBe(false);
|
||||
});
|
||||
|
||||
it('admin can delete another member file', () => {
|
||||
const file = service.upload(memberId, projectId, {
|
||||
originalName: 'm.txt',
|
||||
mimeType: 'text/plain',
|
||||
data: Buffer.from('hi'),
|
||||
});
|
||||
service.delete(authorId, file.id); // authorId is admin
|
||||
expect(() => service.getFileInfo(memberId, file.id)).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user