diff --git a/__tests__/access-rules.test.ts b/__tests__/access-rules.test.ts new file mode 100644 index 0000000..142521c --- /dev/null +++ b/__tests__/access-rules.test.ts @@ -0,0 +1,120 @@ +import { + type Viewer, + type Visibility, + canTouchFileSet, + canUploadToCategory, + canViewCategory, + categoryWhereFor, + fileSetWhereFor, +} from '@/lib/access-rules'; +import { describe, expect, it } from 'vitest'; + +const VISIBILITIES: Visibility[] = ['private', 'internal', 'public']; + +const admin: NonNullable = { id: 1, role: 'admin' }; +const member: NonNullable = { id: 2, role: 'member' }; +const otherMember: NonNullable = { id: 3, role: 'member' }; +const anon: Viewer = null; + +describe('categoryWhereFor(相册列表的服务端过滤条件)', () => { + it('匿名只能看到 public', () => { + expect(categoryWhereFor(anon)).toEqual({ visibility: 'public' }); + }); + + it('管理员无过滤', () => { + expect(categoryWhereFor(admin)).toEqual({}); + }); + + it('成员看到 internal 与 public,永远不含 private', () => { + const where = categoryWhereFor(member) as { visibility: { in: Visibility[] } }; + expect(where.visibility.in).toEqual(['internal', 'public']); + expect(where.visibility.in).not.toContain('private'); + }); +}); + +describe('canViewCategory 裁决矩阵', () => { + const expected: Record> = { + admin: { private: 'allow', internal: 'allow', public: 'allow' }, + member: { private: 'not-found', internal: 'allow', public: 'allow' }, + anon: { private: 'not-found', internal: 'login', public: 'allow' }, + }; + + for (const [who, verdicts] of Object.entries(expected)) { + const viewer: Viewer = who === 'admin' ? admin : who === 'member' ? member : anon; + for (const visibility of VISIBILITIES) { + it(`${who} 访问 ${visibility} 相册 ⇒ ${verdicts[visibility]}`, () => { + expect(canViewCategory(viewer, { visibility })).toBe(verdicts[visibility]); + }); + } + } + + it('同一函数同时给出页面与 API 所需的两种信息:私有对被拒读者是 not-found 而非 login', () => { + expect(canViewCategory(anon, { visibility: 'private' })).toBe('not-found'); + expect(canViewCategory(anon, { visibility: 'internal' })).toBe('login'); + }); +}); + +describe('canUploadToCategory', () => { + it('匿名不可上传任何相册', () => { + for (const visibility of VISIBILITIES) { + expect(canUploadToCategory(anon, { visibility })).toBe(false); + } + }); + + it('成员只能上传到 internal/public,私有相册仅管理员', () => { + expect(canUploadToCategory(member, { visibility: 'private' })).toBe(false); + expect(canUploadToCategory(member, { visibility: 'internal' })).toBe(true); + expect(canUploadToCategory(member, { visibility: 'public' })).toBe(true); + }); + + it('管理员可上传到任意相册', () => { + for (const visibility of VISIBILITIES) { + expect(canUploadToCategory(admin, { visibility })).toBe(true); + } + }); +}); + +describe('fileSetWhereFor(文件集是 owner-relative,与相册不同)', () => { + it('管理员无过滤', () => { + expect(fileSetWhereFor(admin)).toEqual({}); + }); + + it('成员的条件包含自己创建的集合,即使它是 private', () => { + expect(fileSetWhereFor(member)).toEqual({ + OR: [{ visibility: 'internal' }, { visibility: 'public' }, { createdBy: 2 }], + }); + }); +}); + +describe('canTouchFileSet(读取与上传共用同一谓词)', () => { + const privateOwnedByMember = { visibility: 'private' as const, createdBy: member.id }; + const privateOwnedByOther = { visibility: 'private' as const, createdBy: otherMember.id }; + + it('成员可以访问自己的私有文件集', () => { + expect(canTouchFileSet(member, privateOwnedByMember)).toBe(true); + }); + + it('成员不能访问他人的私有文件集', () => { + expect(canTouchFileSet(otherMember, privateOwnedByMember)).toBe(false); + expect(canTouchFileSet(member, privateOwnedByOther)).toBe(false); + }); + + it('internal/public 对任意登录成员开放', () => { + for (const visibility of ['internal', 'public'] as Visibility[]) { + expect(canTouchFileSet(member, { visibility, createdBy: otherMember.id })).toBe(true); + expect(canTouchFileSet(otherMember, { visibility, createdBy: member.id })).toBe(true); + } + }); + + it('管理员可访问任意文件集', () => { + for (const visibility of VISIBILITIES) { + expect(canTouchFileSet(admin, { visibility, createdBy: otherMember.id })).toBe(true); + } + }); + + it('谓词与列表过滤对"自己的私有集"结论一致', () => { + const where = fileSetWhereFor(member) as { OR: Array> }; + expect(where.OR).toContainEqual({ createdBy: member.id }); + expect(canTouchFileSet(member, privateOwnedByMember)).toBe(true); + }); +}); diff --git a/app/admin/_tab-content.tsx b/app/admin/_tab-content.tsx new file mode 100644 index 0000000..62462e7 --- /dev/null +++ b/app/admin/_tab-content.tsx @@ -0,0 +1,194 @@ +import { AdminFileSets } from '@/components/admin-filesets'; +import { AdminCategoriesTab } from '@/components/admin/admin-categories-tab'; +import { AdminShareTab } from '@/components/admin/admin-share-tab'; +import { AdminUsersTab } from '@/components/admin/admin-users-tab'; +import type { + AdminTab, + CategoryItem, + FileSetItem, + ShareLinkItem, + UserItem, +} from '@/components/admin/types'; +import { prisma } from '@/lib/db'; +import { type SearchParams, clampPage, readInt, readString } from '@/lib/params'; + +const USER_PAGE_SIZES = [10, 20, 50] as const; +const USER_PAGE_DEFAULT = 20; +const SHARE_PAGE_SIZE = 20; + +type CategoryRow = { + id: number; + name: string; + description: string | null; + createdAt: Date; + visibility: 'private' | 'internal' | 'public'; + _count: { photos: number }; +}; + +type UserRow = { + id: number; + username: string; + role: 'admin' | 'member'; + status: 'pending' | 'active' | 'rejected'; + createdAt: Date; + _count: { photos: number }; +}; + +type ShareLinkRow = { + id: number; + categoryId: number; + token: string; + expiresAt: Date | null; + createdAt: Date; + category: { name: string }; +}; + +type FileSetRow = { + id: number; + name: string; + description: string | null; + visibility: 'private' | 'internal' | 'public'; + createdAt: Date; + _count: { files: number }; +}; + +function toUserItem(row: UserRow): UserItem { + return { + id: row.id, + username: row.username, + role: row.role, + status: row.status, + photoCount: row._count.photos, + createdAt: row.createdAt.toISOString(), + }; +} + +/** + * 只查询当前 tab 需要的数据。切换 tab 因此是一次往返, + * 换来的是文档负载从"四张无界表"降到"一张表的一页"。 + */ +export async function AdminTabContent({ + tab, + params, + shareBaseUrl, +}: { + tab: AdminTab; + params: SearchParams; + shareBaseUrl: string; +}) { + if (tab === 'categories') { + const rows = (await prisma.category.findMany({ + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { photos: true } } }, + })) as CategoryRow[]; + + const categories: CategoryItem[] = rows.map(row => ({ + id: row.id, + name: row.name, + description: row.description, + photoCount: row._count.photos, + createdAt: row.createdAt.toISOString(), + visibility: row.visibility, + })); + + return ; + } + + if (tab === 'filesets') { + const rows = (await prisma.fileSet.findMany({ + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { files: true } } }, + })) as FileSetRow[]; + + const fileSets: FileSetItem[] = rows.map(row => ({ + id: row.id, + name: row.name, + description: row.description, + visibility: row.visibility, + fileCount: row._count.files, + createdAt: row.createdAt.toISOString(), + })); + + return ; + } + + if (tab === 'users') { + const query = readString(params, 'q').trim(); + const rawRole = readString(params, 'role'); + const role = rawRole === 'admin' || rawRole === 'member' ? rawRole : null; + const requestedPageSize = readInt(params, 'pageSize'); + const pageSize = + requestedPageSize && (USER_PAGE_SIZES as readonly number[]).includes(requestedPageSize) + ? requestedPageSize + : USER_PAGE_DEFAULT; + + const where = { + status: 'active' as const, + ...(role ? { role } : {}), + ...(query ? { username: { contains: query } } : {}), + }; + + const total = (await prisma.user.count({ where })) as number; + const page = clampPage(readInt(params, 'p'), total, pageSize); + + const [activeRows, pendingRows] = (await Promise.all([ + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { _count: { select: { photos: true } } }, + }), + prisma.user.findMany({ + where: { status: 'pending' }, + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { photos: true } } }, + }), + ])) as [UserRow[], UserRow[]]; + + return ( + + ); + } + + const total = (await prisma.shareLink.count()) as number; + const page = clampPage(readInt(params, 'p'), total, SHARE_PAGE_SIZE); + + const [rows, categoryRows] = (await Promise.all([ + prisma.shareLink.findMany({ + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * SHARE_PAGE_SIZE, + take: SHARE_PAGE_SIZE, + include: { category: { select: { name: true } } }, + }), + prisma.category.findMany({ select: { id: true, name: true }, orderBy: { name: 'asc' } }), + ])) as [ShareLinkRow[], Array<{ id: number; name: string }>]; + + const shareLinks: ShareLinkItem[] = rows.map(row => ({ + id: row.id, + token: row.token, + categoryId: row.categoryId, + categoryName: row.category.name, + expiresAt: row.expiresAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + })); + + return ( + + ); +} diff --git a/app/admin/error.tsx b/app/admin/error.tsx new file mode 100644 index 0000000..1c334cf --- /dev/null +++ b/app/admin/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from '@/components/error-state'; + +export default function AdminError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 6816259..8d0cb27 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,110 +1,49 @@ -import { AdminDashboard } from '@/components/admin-dashboard'; -import { auth } from '@/lib/auth'; -import { prisma } from '@/lib/db'; -import type { CategoryVisibility, FileSetVisibility, UserRole, UserStatus } from '@prisma/client'; -import { redirect } from 'next/navigation'; +import { AdminShell } from '@/components/admin/admin-shell'; +import { ADMIN_TABS, type AdminTab } from '@/components/admin/types'; +import { AdminTabSkeleton } from '@/components/skeletons/admin-tab-skeleton'; +import { requireAdminViewer } from '@/lib/access'; +import { type SearchParams, readEnum } from '@/lib/params'; +import { headers } from 'next/headers'; +import { Suspense } from 'react'; -type CategoryWithCount = { - id: number; - name: string; - description: string | null; - createdAt: Date; - visibility: CategoryVisibility; - _count: { photos: number }; -}; +import { AdminTabContent } from './_tab-content'; -type UserWithPhotoCount = { - id: number; - username: string; - role: UserRole; - status: UserStatus; - createdAt: Date; - _count: { photos: number }; -}; +async function requestOrigin(): Promise { + const store = await headers(); + const host = store.get('x-forwarded-host') ?? store.get('host'); + if (host) { + const proto = store.get('x-forwarded-proto') ?? 'http'; + return `${proto}://${host}`; + } -type ShareLinkWithCategory = { - id: number; - categoryId: number; - token: string; - expiresAt: Date | null; - createdAt: Date; - category: { name: string }; -}; + if (process.env.NEXTAUTH_URL) { + try { + return new URL(process.env.NEXTAUTH_URL).origin; + } catch { + // 配置值不是合法 URL,交给下面的相对路径兜底 + } + } -type FileSetWithCount = { - id: number; - name: string; - description: string | null; - visibility: FileSetVisibility; - createdAt: Date; - _count: { files: number }; -}; + return ''; +} -export default async function AdminPage() { - const session = await auth(); - if (!session?.user || session.user.role !== 'admin') { - redirect('/login?callbackUrl=/admin'); - } +export default async function AdminPage({ searchParams }: { searchParams: Promise }) { + await requireAdminViewer('/admin'); - const [categories, users, shareLinks, fileSets] = await Promise.all< - [CategoryWithCount[], UserWithPhotoCount[], ShareLinkWithCategory[], FileSetWithCount[]] - >([ - prisma.category.findMany({ - orderBy: { createdAt: 'desc' }, - include: { _count: { select: { photos: true } } }, - }), - prisma.user.findMany({ - orderBy: { createdAt: 'desc' }, - include: { _count: { select: { photos: true } } }, - }), - prisma.shareLink.findMany({ - orderBy: { createdAt: 'desc' }, - include: { - category: { - select: { name: true }, - }, - }, - }), - prisma.fileSet.findMany({ - orderBy: { createdAt: 'desc' }, - include: { _count: { select: { files: true } } }, - }), - ]); + const params = await searchParams; + const tab = readEnum( + params, + 'tab', + ADMIN_TABS.map(item => item.value), + 'categories' + ); + const shareBaseUrl = `${await requestOrigin()}/share/`; return ( - ({ - id: category.id, - name: category.name, - description: category.description, - photoCount: category._count.photos, - createdAt: category.createdAt.toISOString(), - visibility: category.visibility, - }))} - users={users.map(user => ({ - id: user.id, - username: user.username, - role: user.role, - status: user.status, - photoCount: user._count.photos, - createdAt: user.createdAt.toISOString(), - }))} - shareLinks={shareLinks.map(link => ({ - id: link.id, - token: link.token, - categoryId: link.categoryId, - categoryName: link.category.name, - expiresAt: link.expiresAt?.toISOString() ?? null, - createdAt: link.createdAt.toISOString(), - }))} - fileSets={fileSets.map(fs => ({ - id: fs.id, - name: fs.name, - description: fs.description, - visibility: fs.visibility, - fileCount: fs._count.files, - createdAt: fs.createdAt.toISOString(), - }))} - /> + + }> + + + ); } diff --git a/app/album/[id]/error.tsx b/app/album/[id]/error.tsx new file mode 100644 index 0000000..5108808 --- /dev/null +++ b/app/album/[id]/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from '@/components/error-state'; + +export default function AlbumError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index 343efe1..835263f 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -1,35 +1,24 @@ -import { PhotoGrid } from '@/components/photo-grid'; +import { PHOTO_PAGE_SIZE, PhotoPage } from '@/components/photo-page'; import { SearchTrigger } from '@/components/search-trigger'; +import { PhotoGridSkeleton } from '@/components/skeletons/photo-grid-skeleton'; import { SortToggle } from '@/components/sort-toggle'; import { UploadDialog } from '@/components/upload-dialog'; -import { auth } from '@/lib/auth'; +import { getViewer } from '@/lib/access'; +import { type Visibility, canViewCategory, categoryWhereFor } from '@/lib/access-rules'; import { prisma } from '@/lib/db'; -import { getPublicObjectUrl, getPublicThumbnailUrl } from '@/lib/storage'; -import type { CategoryVisibility } from '@prisma/client'; +import { type SearchParams, clampPage, readInt, readSort } from '@/lib/params'; import { format } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import { CalendarClock, CalendarDays, Image as ImageIcon, Images } from 'lucide-react'; import { notFound, redirect } from 'next/navigation'; +import { Suspense } from 'react'; -type PhotoRecord = { - id: number; - filename: string; - originalName: string; - description: string | null; - categoryId: number; - uploaderId: number; - mediaType: 'image' | 'video'; - createdAt: Date; - uploader: { username: string }; -}; - -type CategoryWithPhotos = { +type CategoryRow = { id: number; name: string; description: string | null; - visibility: CategoryVisibility; + visibility: Visibility; createdAt: Date; - photos: PhotoRecord[]; }; export default async function AlbumPage({ @@ -37,82 +26,58 @@ export default async function AlbumPage({ searchParams, }: { params: Promise<{ id: string }>; - searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; + searchParams?: Promise; }) { const { id } = await params; const q = (await searchParams) ?? {}; - const sort = (typeof q['sort'] === 'string' ? q['sort'] : undefined) === 'asc' ? 'asc' : 'desc'; + const sort = readSort(q); const categoryId = Number.parseInt(id, 10); if (!Number.isInteger(categoryId)) { notFound(); } - const session = await auth(); + const viewer = await getViewer(); + // 这里只取相册本身:照片窗口交给下面的 流式补齐, + // 否则页头会被最慢的那个大列表查询一起阻塞住。 const category = (await prisma.category.findUnique({ where: { id: categoryId }, - include: { - photos: { - orderBy: { createdAt: sort }, - include: { - uploader: { select: { username: true } }, - }, - }, - }, - })) as CategoryWithPhotos | null; + select: { id: true, name: true, description: true, visibility: true, createdAt: true }, + })) as CategoryRow | null; if (!category) { notFound(); } - const isAdmin = session?.user?.role === 'admin'; - const isLoggedIn = Boolean(session?.user); - - if (!isAdmin) { - if (category.visibility === 'private') { - notFound(); - } - if (category.visibility === 'internal' && !isLoggedIn) { - redirect(`/login?callbackUrl=/album/${category.id}`); - } + const verdict = canViewCategory(viewer, category); + if (verdict === 'not-found') { + notFound(); } + if (verdict === 'login') { + redirect(`/login?callbackUrl=${encodeURIComponent(`/album/${category.id}`)}`); + } + + const stats = (await prisma.photo.aggregate({ + where: { categoryId: category.id }, + _count: { _all: true }, + _max: { createdAt: true }, + })) as { _count: { _all: number }; _max: { createdAt: Date | null } }; - const viewerId = (() => { - if (!session?.user?.id) return null; - const parsed = Number.parseInt(session.user.id, 10); - return Number.isNaN(parsed) ? null : parsed; - })(); - const canManageAll = Boolean(isAdmin); - const allowOwnActions = viewerId !== null; + const total = stats._count._all; + const page = clampPage(readInt(q, 'p'), total, PHOTO_PAGE_SIZE); + // ?photo= 一般只由客户端浅层写入,这里读一次是为了让深链指向的照片 + // 即使不在当前页窗口里也能打开灯箱 + const deepPhotoId = readInt(q, 'photo'); - const uploadCategories = session?.user + const uploadCategories = viewer ? await prisma.category.findMany({ - where: isAdmin - ? {} - : { - visibility: { - in: ['internal', 'public'] as CategoryVisibility[], - }, - }, + where: categoryWhereFor(viewer), select: { id: true, name: true }, orderBy: { name: 'asc' }, }) : []; - const photos = category.photos.map(photo => ({ - id: photo.id, - filename: photo.filename, - originalName: photo.originalName, - description: photo.description, - createdAt: photo.createdAt.toISOString(), - uploader: photo.uploader.username, - mediaType: photo.mediaType, - thumbnailUrl: photo.mediaType === 'image' ? getPublicThumbnailUrl(photo.filename) : null, - fileUrl: getPublicObjectUrl(photo.filename), - isOwner: viewerId !== null && photo.uploaderId === viewerId, - })); - - const latestPhotoDate = category.photos[0]?.createdAt ?? category.createdAt; + const latestPhotoDate = stats._max.createdAt ?? category.createdAt; return (
@@ -128,7 +93,7 @@ export default async function AlbumPage({
- {photos.length} 个媒体 + {total} 个媒体 @@ -143,7 +108,7 @@ export default async function AlbumPage({
- {session?.user ? ( + {viewer ? (
- {photos.length === 0 ? ( -
- 暂无媒体,欢迎上传。 -
- ) : ( - }> + - )} +
); } - -// client-only search trigger moved to components/search-trigger.tsx - -// client-only search trigger moved to components/search-trigger.tsx diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts index 14683cf..77a10b5 100644 --- a/app/api/categories/route.ts +++ b/app/api/categories/route.ts @@ -1,16 +1,15 @@ -import { auth } from '@/lib/auth'; +import { getViewer } from '@/lib/access'; +import { type Visibility, categoryWhereFor } from '@/lib/access-rules'; import { requireAdmin } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; -import type { CategoryVisibility, Prisma } from '@prisma/client'; +import { visibilitySchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; import { z } from 'zod'; -const visibilityEnum = z.enum(['private', 'internal', 'public'] satisfies CategoryVisibility[]); - const categoryCreateSchema = z.object({ name: z.string().min(1, '分类名称不能为空'), description: z.string().optional(), - visibility: visibilityEnum.default('internal'), + visibility: visibilitySchema.default('internal'), }); const categoryUpdateSchema = categoryCreateSchema.extend({ @@ -26,18 +25,13 @@ type CategoryWithCount = { name: string; description: string | null; createdAt: Date; - visibility: CategoryVisibility; + visibility: Visibility; _count: { photos: number }; }; export async function GET() { - const session = await auth(); - const internalVisibilities: CategoryVisibility[] = ['internal', 'public']; - const where: Prisma.CategoryWhereInput = !session?.user - ? { visibility: 'public' } - : session.user.role === 'admin' - ? {} - : { visibility: { in: internalVisibilities } }; + const viewer = await getViewer(); + const where = categoryWhereFor(viewer); const categories = (await prisma.category.findMany({ where, diff --git a/app/api/files/[id]/route.ts b/app/api/files/[id]/route.ts index 616327e..d94b0be 100644 --- a/app/api/files/[id]/route.ts +++ b/app/api/files/[id]/route.ts @@ -1,3 +1,4 @@ +import { canTouchFileSet } from '@/lib/access-rules'; import { requireAdmin, requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { deleteFileAsset, getPublicFileUrl } from '@/lib/storage'; @@ -19,9 +20,7 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const session = authCheck.session; - const userId = Number(session.user.id); - const isAdmin = session.user.role === 'admin'; + const { viewer } = authCheck; const file = await prisma.file.findUnique({ where: { id }, @@ -44,14 +43,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string if (!file) return NextResponse.json({ message: '未找到' }, { status: 404 }); - // Permission check - const canView = - isAdmin || - file.fileSet.visibility === 'public' || - file.fileSet.visibility === 'internal' || - file.fileSet.createdBy === userId; - - if (!canView) return NextResponse.json({ message: '无权限' }, { status: 403 }); + if (!canTouchFileSet(viewer, file.fileSet)) { + return NextResponse.json({ message: '无权限' }, { status: 403 }); + } return NextResponse.json({ item: { @@ -88,9 +82,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const session = authCheck.session; - const userId = Number(session.user.id); - const isAdmin = session.user.role === 'admin'; + const { viewer } = authCheck; const file = await prisma.file.findUnique({ where: { id }, @@ -100,7 +92,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri if (!file) return NextResponse.json({ message: '未找到' }, { status: 404 }); // Only uploader or admin can update - if (!isAdmin && file.uploaderId !== userId) { + if (viewer.role !== 'admin' && file.uploaderId !== viewer.id) { return NextResponse.json({ message: '无权限' }, { status: 403 }); } @@ -152,9 +144,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const session = authCheck.session; - const userId = Number(session.user.id); - const isAdmin = session.user.role === 'admin'; + const { viewer } = authCheck; const file = await prisma.file.findUnique({ where: { id }, @@ -164,7 +154,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str if (!file) return NextResponse.json({ message: '未找到' }, { status: 404 }); // Only uploader or admin can delete - if (!isAdmin && file.uploaderId !== userId) { + if (viewer.role !== 'admin' && file.uploaderId !== viewer.id) { return NextResponse.json({ message: '无权限' }, { status: 403 }); } diff --git a/app/api/files/inline-url/route.ts b/app/api/files/inline-url/route.ts index 5494aba..dc67171 100644 --- a/app/api/files/inline-url/route.ts +++ b/app/api/files/inline-url/route.ts @@ -1,3 +1,4 @@ +import { canTouchFileSet } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { getPresignedInlineFileUrl } from '@/lib/storage'; @@ -11,9 +12,7 @@ export async function GET(req: Request) { try { const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const session = authCheck.session; - const userId = Number(session.user.id); - const isAdmin = session.user.role === 'admin'; + const { viewer } = authCheck; const url = new URL(req.url); const fileIdStr = url.searchParams.get('fileId'); @@ -39,13 +38,7 @@ export async function GET(req: Request) { return NextResponse.json({ message: '文件不存在' }, { status: 404 }); } - const canView = - isAdmin || - file.fileSet.visibility === 'public' || - file.fileSet.visibility === 'internal' || - file.fileSet.createdBy === userId; - - if (!canView) { + if (!canTouchFileSet(viewer, file.fileSet)) { return NextResponse.json({ message: '无权限' }, { status: 403 }); } diff --git a/app/api/files/preview-proxy/route.ts b/app/api/files/preview-proxy/route.ts index 184f133..1ae9d47 100644 --- a/app/api/files/preview-proxy/route.ts +++ b/app/api/files/preview-proxy/route.ts @@ -1,3 +1,4 @@ +import { canTouchFileSet } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { getFileBuffer, guessMimeFromFilename } from '@/lib/storage'; @@ -12,9 +13,7 @@ export async function GET(req: Request) { try { const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const session = authCheck.session; - const userId = Number(session.user.id); - const isAdmin = session.user.role === 'admin'; + const { viewer } = authCheck; const url = new URL(req.url); const fileIdStr = url.searchParams.get('fileId'); @@ -39,13 +38,7 @@ export async function GET(req: Request) { return NextResponse.json({ message: '文件不存在' }, { status: 404 }); } - const canView = - isAdmin || - file.fileSet.visibility === 'public' || - file.fileSet.visibility === 'internal' || - file.fileSet.createdBy === userId; - - if (!canView) { + if (!canTouchFileSet(viewer, file.fileSet)) { return NextResponse.json({ message: '无权限' }, { status: 403 }); } diff --git a/app/api/files/route.ts b/app/api/files/route.ts index 7be3779..9539fa3 100644 --- a/app/api/files/route.ts +++ b/app/api/files/route.ts @@ -1,3 +1,4 @@ +import { canTouchFileSet } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { deleteFileAsset, getPublicFileUrl, persistFile } from '@/lib/storage'; @@ -22,9 +23,7 @@ export async function GET(req: Request) { try { const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const session = authCheck.session; - const userId = Number(session.user.id); - const isAdmin = session.user.role === 'admin'; + const { viewer } = authCheck; const url = new URL(req.url); const filesetId = url.searchParams.get('filesetId'); @@ -44,13 +43,7 @@ export async function GET(req: Request) { return NextResponse.json({ message: '文件集不存在' }, { status: 404 }); } - const canView = - isAdmin || - fileset.visibility === 'public' || - fileset.visibility === 'internal' || - fileset.createdBy === userId; - - if (!canView) { + if (!canTouchFileSet(viewer, fileset)) { return NextResponse.json({ message: '无权限' }, { status: 403 }); } @@ -97,9 +90,7 @@ export async function POST(req: Request) { try { const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const session = authCheck.session; - const userId = Number(session.user.id); - const isAdmin = session.user.role === 'admin'; + const { viewer } = authCheck; const formData = await req.formData(); const file = formData.get('file') as File | null; @@ -122,15 +113,7 @@ export async function POST(req: Request) { return NextResponse.json({ message: '文件集不存在' }, { status: 404 }); } - // Only admin or members can upload to private fileset - // Internal/public filesets allow all authenticated users - const canUpload = - isAdmin || - fileset.createdBy === userId || - fileset.visibility === 'internal' || - fileset.visibility === 'public'; - - if (!canUpload) { + if (!canTouchFileSet(viewer, fileset)) { return NextResponse.json({ message: '无权限上传' }, { status: 403 }); } @@ -146,7 +129,7 @@ export async function POST(req: Request) { mimeType: file.type || 'application/octet-stream', size: file.size, filesetId, - uploaderId: userId, + uploaderId: viewer.id, }, select: { id: true, diff --git a/app/api/filesets/[id]/route.ts b/app/api/filesets/[id]/route.ts index 0475931..4735aa7 100644 --- a/app/api/filesets/[id]/route.ts +++ b/app/api/filesets/[id]/route.ts @@ -1,13 +1,15 @@ +import { canTouchFileSet } from '@/lib/access-rules'; import { requireAdmin, requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { deleteFileAsset } from '@/lib/storage'; +import { visibilitySchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; import { z } from 'zod'; const updateSchema = z.object({ name: z.string().min(1).max(100).optional(), description: z.string().max(500).optional(), - visibility: z.enum(['private', 'internal', 'public']).optional(), + visibility: visibilitySchema.optional(), }); /** @@ -21,8 +23,7 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const userId = Number(authCheck.session.user.id); - const isAdmin = authCheck.session.user.role === 'admin'; + const { viewer } = authCheck; const item = await prisma.fileSet.findUnique({ where: { id }, @@ -40,14 +41,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string if (!item) return NextResponse.json({ message: '未找到' }, { status: 404 }); - // Permission check: private (admin only), internal/public (authenticated), or creator - const canView = - isAdmin || - item.visibility === 'public' || - item.visibility === 'internal' || - item.createdBy === userId; - - if (!canView) return NextResponse.json({ message: '无权限' }, { status: 403 }); + if (!canTouchFileSet(viewer, item)) { + return NextResponse.json({ message: '无权限' }, { status: 403 }); + } return NextResponse.json({ item: { diff --git a/app/api/filesets/route.ts b/app/api/filesets/route.ts index 1788d3a..7b328d6 100644 --- a/app/api/filesets/route.ts +++ b/app/api/filesets/route.ts @@ -1,12 +1,14 @@ +import { fileSetWhereFor } from '@/lib/access-rules'; import { requireAdmin, requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; +import { visibilitySchema } from '@/lib/validation'; import { NextResponse } from 'next/server'; import { z } from 'zod'; const createSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), - visibility: z.enum(['private', 'internal', 'public']).default('internal'), + visibility: visibilitySchema.default('internal'), }); /** @@ -17,23 +19,8 @@ export async function GET(req: Request) { try { const authCheck = await requireAuth(); if (!authCheck.ok) return authCheck.error; - const session = authCheck.session; - const userId = Number(session.user.id); - const isAdmin = session.user.role === 'admin'; - // Visibility logic: - // - private: admin only - // - internal: all authenticated users - // - public: everyone (but we require auth in this endpoint) - const where = isAdmin - ? {} // Admin sees all - : { - OR: [ - { visibility: 'internal' as const }, - { visibility: 'public' as const }, - { createdBy: userId }, - ], - }; + const where = fileSetWhereFor(authCheck.viewer); type FileSetItem = { id: number; @@ -78,7 +65,6 @@ export async function POST(req: Request) { try { const adminCheck = await requireAdmin(); if (!adminCheck.ok) return adminCheck.error; - const session = adminCheck.session; const body = await req.json().catch(() => undefined); const parsed = createSchema.safeParse(body); @@ -94,7 +80,7 @@ export async function POST(req: Request) { name: parsed.data.name, description: parsed.data.description, visibility: parsed.data.visibility, - createdBy: Number(session.user.id), + createdBy: adminCheck.viewer.id, }, select: { id: true, diff --git a/app/api/photos/route.ts b/app/api/photos/route.ts index af6c168..d95fb72 100644 --- a/app/api/photos/route.ts +++ b/app/api/photos/route.ts @@ -1,4 +1,5 @@ -import { auth } from '@/lib/auth'; +import { getViewer } from '@/lib/access'; +import { categoryWhereFor } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { @@ -10,7 +11,6 @@ import { getPublicThumbnailUrl, isNotFoundError, } from '@/lib/storage'; -import type { CategoryVisibility, Prisma } from '@prisma/client'; import JSZip from 'jszip'; import { NextResponse } from 'next/server'; import { z } from 'zod'; @@ -62,18 +62,11 @@ export async function GET(request: Request) { const page = Math.max(Number.parseInt(pageParam, 10) || 1, 1); const pageSize = Math.min(Math.max(Number.parseInt(pageSizeParam, 10) || 24, 1), 96); - const session = await auth(); - const internalVisibilities: CategoryVisibility[] = ['internal', 'public']; - const visibilityFilter: Prisma.PhotoWhereInput = !session?.user - ? { category: { visibility: 'public' } } - : session.user.role === 'admin' - ? {} - : { category: { visibility: { in: internalVisibilities } } }; - const parsedCategoryId = categoryIdParam ? Number.parseInt(categoryIdParam, 10) : undefined; - const where: Prisma.PhotoWhereInput = { + const viewer = await getViewer(); + const where = { ...(Number.isInteger(parsedCategoryId) ? { categoryId: parsedCategoryId } : {}), - ...visibilityFilter, + category: categoryWhereFor(viewer), }; const photos = (await prisma.photo.findMany({ @@ -133,8 +126,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: parsed.error.flatten().fieldErrors }, { status: 400 }); } + // 打包下载同样受可见性约束:否则只要猜到 id,就能取到无权浏览的相册原图 const photos = await prisma.photo.findMany({ - where: { id: { in: parsed.data.ids } }, + where: { id: { in: parsed.data.ids }, category: categoryWhereFor(authCheck.viewer) }, select: { id: true, filename: true, @@ -209,12 +203,8 @@ export async function DELETE(request: Request) { return NextResponse.json({ deleted: 0 }, { status: 200 }); } - const requesterId = Number.parseInt(authCheck.session.user!.id, 10); - if (Number.isNaN(requesterId)) { - return NextResponse.json({ error: '用户信息异常' }, { status: 400 }); - } - - const isAdmin = authCheck.session.user?.role === 'admin'; + const requesterId = authCheck.viewer.id; + const isAdmin = authCheck.viewer.role === 'admin'; const unauthorized = targetPhotos.filter(photo => photo.uploaderId !== requesterId && !isAdmin); if (unauthorized.length > 0) { return NextResponse.json({ error: '仅可操作自己上传的照片' }, { status: 403 }); @@ -260,13 +250,8 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: '媒体不存在' }, { status: 404 }); } - const requesterId = Number.parseInt(authCheck.session.user!.id, 10); - if (Number.isNaN(requesterId)) { - return NextResponse.json({ error: '用户信息异常' }, { status: 400 }); - } - - const isAdmin = authCheck.session.user?.role === 'admin'; - if (!isAdmin && photo.uploaderId !== requesterId) { + const { viewer } = authCheck; + if (viewer.role !== 'admin' && photo.uploaderId !== viewer.id) { return NextResponse.json({ error: '仅可操作自己上传的照片' }, { status: 403 }); } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index 96df68f..c836de2 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -25,10 +25,7 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: parsed.error }, { status: 400 }); } - const userId = Number.parseInt(authCheck.session.user!.id, 10); - if (Number.isNaN(userId)) { - return NextResponse.json({ error: '用户信息异常' }, { status: 400 }); - } + const userId = authCheck.viewer.id; if (parsed.data.type === 'username') { const exists = await prisma.user.findUnique({ where: { username: parsed.data.username } }); diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 982fd7e..0a22b5a 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,54 +1,117 @@ -import { auth } from '@/lib/auth'; +import { getViewer } from '@/lib/access'; +import { canViewCategory, categoryWhereFor } from '@/lib/access-rules'; import { prisma } from '@/lib/db'; +import { getPublicObjectUrl, getPublicThumbnailUrl } from '@/lib/storage'; import { NextResponse } from 'next/server'; -export async function GET(req: Request) { - const session = await auth(); - const url = new URL(req.url); - const q = (url.searchParams.get('q') || '').trim(); - const filesetIdParam = url.searchParams.get('filesetId'); - const mime = url.searchParams.get('mime'); +const CATEGORY_LIMIT = 5; +const PHOTO_LIMIT = 45; - const where: Record = {}; +type CategoryRow = { + id: number; + name: string; + description: string | null; + _count: { photos: number }; +}; - // Text search on originalName or description - if (q) { - where.OR = [{ originalName: { contains: q } }, { description: { contains: q } }]; - } +type PhotoRow = { + id: number; + filename: string; + originalName: string; + description: string | null; + mediaType: 'image' | 'video'; + categoryId: number; + category: { name: string }; +}; - // Filter by fileset - if (filesetIdParam) { - const filesetId = Number(filesetIdParam); - if (!Number.isNaN(filesetId)) { - where.filesetId = filesetId; - } +/** + * GET /api/search?q=&categoryId= + * 相册域的全局搜索。categoryId 用于"在某个相册内搜索",此时只返回该相册的媒体。 + */ +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const q = (searchParams.get('q') ?? '').trim(); + if (!q) { + return NextResponse.json({ categories: [], photos: [] }); } - // Filter by MIME - if (mime) { - where.mimeType = mime; - } + const viewer = await getViewer(); + const categoryFilter = categoryWhereFor(viewer); + + const rawCategoryId = searchParams.get('categoryId'); + const parsedCategoryId = rawCategoryId ? Number.parseInt(rawCategoryId, 10) : NaN; + const scopedCategoryId = Number.isInteger(parsedCategoryId) ? parsedCategoryId : null; - // Visibility for unauthenticated users: only public file sets - if (!session?.user) { - where.fileSet = { visibility: 'public' } as any; + if (scopedCategoryId !== null) { + const scope = await prisma.category.findUnique({ + where: { id: scopedCategoryId }, + select: { visibility: true }, + }); + if (!scope || canViewCategory(viewer, scope) !== 'allow') { + return NextResponse.json({ categories: [], photos: [] }); + } } - const items = await prisma.file.findMany({ - where, - orderBy: { updatedAt: 'desc' }, + const photoWhere = { + category: + scopedCategoryId === null + ? categoryFilter + : { AND: [categoryFilter, { id: scopedCategoryId }] }, + OR: [{ originalName: { contains: q } }, { description: { contains: q } }], + }; + + const photoQuery = prisma.photo.findMany({ + where: photoWhere, + orderBy: { createdAt: 'desc' }, + take: PHOTO_LIMIT, select: { id: true, filename: true, originalName: true, description: true, - size: true, - mimeType: true, - filesetId: true, - createdAt: true, - updatedAt: true, + mediaType: true, + categoryId: true, + category: { select: { name: true } }, + }, + }) as Promise; + + if (scopedCategoryId !== null) { + const photos = await photoQuery; + return NextResponse.json({ categories: [], photos: mapPhotos(photos) }); + } + + const categoryQuery = prisma.category.findMany({ + where: { + AND: [categoryFilter, { OR: [{ name: { contains: q } }, { description: { contains: q } }] }], }, - take: 50, + orderBy: { createdAt: 'desc' }, + take: CATEGORY_LIMIT, + select: { id: true, name: true, description: true, _count: { select: { photos: true } } }, + }) as Promise; + + const [categories, photos] = await Promise.all([categoryQuery, photoQuery]); + + return NextResponse.json({ + categories: categories.map(category => ({ + id: category.id, + name: category.name, + description: category.description, + photoCount: category._count.photos, + })), + photos: mapPhotos(photos), }); - return NextResponse.json({ items }); +} + +function mapPhotos(photos: PhotoRow[]) { + return photos.map(photo => ({ + id: photo.id, + categoryId: photo.categoryId, + categoryName: photo.category.name, + filename: photo.filename, + originalName: photo.originalName, + description: photo.description, + mediaType: photo.mediaType, + thumbnailUrl: photo.mediaType === 'image' ? getPublicThumbnailUrl(photo.filename) : null, + fileUrl: getPublicObjectUrl(photo.filename), + })); } diff --git a/app/api/share/unlock/route.ts b/app/api/share/unlock/route.ts new file mode 100644 index 0000000..dd8c6b8 --- /dev/null +++ b/app/api/share/unlock/route.ts @@ -0,0 +1,50 @@ +import { prisma } from '@/lib/db'; +import { buildUnlockCookie } from '@/lib/share-auth'; +import bcrypt from 'bcryptjs'; +import { isAfter } from 'date-fns'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const unlockSchema = z.object({ + token: z.string().min(8), + password: z.string().min(1).max(200), +}); + +/** + * POST /api/share/unlock + * 密码走请求体而不是查询串:查询串会留在浏览器历史与服务器访问日志里。 + */ +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const parsed = unlockSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: '参数错误' }, { status: 400 }); + } + + const { token, password } = parsed.data; + + const shareLink = await prisma.shareLink.findUnique({ + where: { token }, + select: { password: true, expiresAt: true }, + }); + + if (!shareLink) { + return NextResponse.json({ error: '分享链接不存在' }, { status: 404 }); + } + + if (shareLink.expiresAt && isAfter(new Date(), shareLink.expiresAt)) { + return NextResponse.json({ error: '分享链接已过期' }, { status: 410 }); + } + + if (shareLink.password) { + const match = await bcrypt.compare(password, shareLink.password); + if (!match) { + return NextResponse.json({ error: '密码错误' }, { status: 401 }); + } + } + + const response = NextResponse.json({ ok: true }); + const cookie = buildUnlockCookie(token, shareLink.expiresAt); + response.cookies.set(cookie.name, cookie.value, cookie.options); + return response; +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 833617b..e9726b0 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,3 +1,4 @@ +import { canUploadToCategory } from '@/lib/access-rules'; import { requireAuth } from '@/lib/auth-guards'; import { prisma } from '@/lib/db'; import { @@ -46,15 +47,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: '分类不存在' }, { status: 404 }); } - const isAdmin = authCheck.session.user?.role === 'admin'; - if (category.visibility === 'private' && !isAdmin) { + const { viewer } = authCheck; + if (!canUploadToCategory(viewer, category)) { return NextResponse.json({ error: '无权在该分类上传' }, { status: 403 }); } - const uploaderId = Number.parseInt(authCheck.session.user!.id, 10); - if (Number.isNaN(uploaderId)) { - return NextResponse.json({ error: '用户信息异常' }, { status: 400 }); - } + const uploaderId = viewer.id; try { const isImage = file.type.startsWith('image/'); diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..3aaeadf --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { ErrorState } from '@/components/error-state'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/app/files/_file-list.tsx b/app/files/_file-list.tsx new file mode 100644 index 0000000..a268c1e --- /dev/null +++ b/app/files/_file-list.tsx @@ -0,0 +1,71 @@ +import { FilesTable } from '@/components/files/table'; +import type { FileItem } from '@/components/files/types'; +import { PaginationControls } from '@/components/pagination-controls'; +import { prisma } from '@/lib/db'; +import { clampPage } from '@/lib/params'; +import { getPublicFileUrl } from '@/lib/storage'; + +const PAGE_SIZE = 50; + +type FileRow = { + id: number; + filename: string; + originalName: string; + description: string | null; + mimeType: string; + size: number; + uploaderId: number; + createdAt: Date; +}; + +export async function FileList({ + filesetId, + query, + page, +}: { + filesetId: number; + query: string; + page: number | null; +}) { + const where = { filesetId, ...(query ? { originalName: { contains: query } } : {}) }; + + // 先数总数再钳页码:skip 依赖钳后的值,否则越界的 ?p= 会渲染出一片空白 + const total = (await prisma.file.count({ where })) as number; + const currentPage = clampPage(page, total, PAGE_SIZE); + + const rows = (await prisma.file.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (currentPage - 1) * PAGE_SIZE, + take: PAGE_SIZE, + select: { + id: true, + filename: true, + originalName: true, + description: true, + mimeType: true, + size: true, + uploaderId: true, + createdAt: true, + }, + })) as FileRow[]; + + const files: FileItem[] = rows.map(row => ({ + id: row.id, + filename: row.filename, + originalName: row.originalName, + description: row.description, + mimeType: row.mimeType, + size: row.size, + uploaderId: row.uploaderId, + createdAt: row.createdAt.toISOString(), + url: getPublicFileUrl(row.filename), + })); + + return ( +
+ + +
+ ); +} diff --git a/app/files/error.tsx b/app/files/error.tsx new file mode 100644 index 0000000..d270c51 --- /dev/null +++ b/app/files/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from '@/components/error-state'; + +export default function FilesError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/app/files/page.tsx b/app/files/page.tsx index 1cdc614..0693498 100644 --- a/app/files/page.tsx +++ b/app/files/page.tsx @@ -1,7 +1,80 @@ -import { FileManager } from '@/components/file-manager'; +import { FilesSidebar } from '@/components/files/sidebar'; +import { FilesToolbar } from '@/components/files/toolbar'; +import { FileTableSkeleton } from '@/components/skeletons/file-table-skeleton'; +import { EmptyState } from '@/components/ui/empty-state'; +import { requireViewer } from '@/lib/access'; +import { fileSetWhereFor } from '@/lib/access-rules'; +import { prisma } from '@/lib/db'; +import { type SearchParams, readInt, readString } from '@/lib/params'; +import { FileIcon } from 'lucide-react'; +import { Suspense } from 'react'; -export const dynamic = 'force-dynamic'; +import { FileList } from './_file-list'; -export default function FilesPage() { - return ; +type FileSetRow = { + id: number; + name: string; + description: string | null; + _count: { files: number }; +}; + +export default async function FilesPage({ searchParams }: { searchParams: Promise }) { + const viewer = await requireViewer('/files'); + const params = await searchParams; + + const fileSets = (await prisma.fileSet.findMany({ + where: fileSetWhereFor(viewer), + orderBy: { updatedAt: 'desc' }, + select: { id: true, name: true, description: true, _count: { select: { files: true } } }, + })) as FileSetRow[]; + + // ?fileset= 指向无权访问或不存在的集合时,静默回落到第一个可见集合 + const requestedId = readInt(params, 'fileset'); + const activeSet = fileSets.find(set => set.id === requestedId) ?? fileSets[0] ?? null; + const query = readString(params, 'q'); + + return ( +
+
+
+
+ +

文件管理

+
+
+ +
+ +
+ ({ + id: set.id, + name: set.name, + description: set.description, + fileCount: set._count.files, + }))} + activeFileSetId={activeSet?.id ?? null} + /> + +
+ {activeSet && ( +
+

{activeSet.name}

+ {activeSet.description && ( +

{activeSet.description}

+ )} +
+ )} + + {activeSet ? ( + }> + + + ) : ( + + )} +
+
+
+ ); } diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..2f26600 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { ErrorState } from '@/components/error-state'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + + + + + ); +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..195c269 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,5 @@ +import { PageSkeleton } from '@/components/skeletons/page-skeleton'; + +export default function Loading() { + return ; +} diff --git a/app/login/page.tsx b/app/login/page.tsx index a9ce2fa..7c15f7b 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,11 +1,13 @@ import { LoginTabs } from '@/components/profile/login-tabs'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { auth } from '@/lib/auth'; +import { getViewer } from '@/lib/access'; import { redirect } from 'next/navigation'; export default async function LoginPage() { - const session = await auth(); - if (session?.user) { + // 用 getViewer 而不是 auth():被拒绝/待审核的账号可能仍持有旧 JWT, + // 若按会话存在就跳回首页,会与 /profile 等地的 requireViewer 形成重定向死循环。 + const viewer = await getViewer(); + if (viewer) { redirect('/'); } diff --git a/app/page.tsx b/app/page.tsx index 4171b70..3a61aad 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,34 @@ import { SearchTrigger } from '@/components/search-trigger'; +import { CategoryGridSkeleton } from '@/components/skeletons/category-grid-skeleton'; import { SortToggle } from '@/components/sort-toggle'; import { Card } from '@/components/ui/card'; +import { EmptyState } from '@/components/ui/empty-state'; import { UploadDialog } from '@/components/upload-dialog'; -import { auth } from '@/lib/auth'; +import { getViewer } from '@/lib/access'; +import { type Visibility, categoryWhereFor } from '@/lib/access-rules'; import { prisma } from '@/lib/db'; import { getPublicThumbnailUrl } from '@/lib/storage'; -import type { CategoryVisibility, Prisma } from '@prisma/client'; import { Images } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; +import { Suspense } from 'react'; -export default async function HomePage({ +/** 首页不做分页:相册是人工创建的,这个上限只是防止无界负载 */ +const CATEGORY_CAP = 200; + +export default function HomePage({ + searchParams, +}: { + searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + return ( + }> + + + ); +} + +async function HomeContent({ searchParams, }: { searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; @@ -18,26 +36,22 @@ export default async function HomePage({ const params = (await searchParams) ?? {}; const sort = (typeof params['sort'] === 'string' ? params['sort'] : undefined) === 'asc' ? 'asc' : 'desc'; - const session = await auth(); + const viewer = await getViewer(); type CategoryCard = { id: number; name: string; description: string | null; - visibility: CategoryVisibility; + visibility: Visibility; createdAt: Date; photos: Array<{ filename: string; createdAt: Date }>; _count: { photos: number }; }; - const internalVisibilities: CategoryVisibility[] = ['internal', 'public']; - const where: Prisma.CategoryWhereInput = !session?.user - ? { visibility: 'public' } - : session.user.role === 'admin' - ? {} - : { visibility: { in: internalVisibilities } }; + const where = categoryWhereFor(viewer); const categories = (await prisma.category.findMany({ where, orderBy: { createdAt: sort }, + take: CATEGORY_CAP, include: { _count: { select: { photos: true } }, photos: { @@ -65,7 +79,7 @@ export default async function HomePage({
- {session?.user && categories.length > 0 && ( + {viewer && categories.length > 0 && (
({ @@ -81,7 +95,10 @@ export default async function HomePage({
{categories.length === 0 ? ( - + } + title="暂无相册,请先在控制台中创建分类。" + /> ) : (
{categories.map(category => ( @@ -110,6 +127,12 @@ export default async function HomePage({ ))}
)} + + {categories.length >= CATEGORY_CAP ? ( +

+ 相册过多,仅显示前 {CATEGORY_CAP} 个。 +

+ ) : null} ); } @@ -130,14 +153,5 @@ function ImageFill({ filename }: { filename: string }) { ); } -function EmptyState() { - return ( -
- -

暂无相册,请先在控制台中创建分类。

-
- ); -} - // client-only search trigger moved to components/search-trigger.tsx // client-only search trigger moved to components/search-trigger.tsx diff --git a/app/profile/page.tsx b/app/profile/page.tsx index c1035eb..c14a118 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -1,22 +1,17 @@ import { AccountForms } from '@/components/profile/account-forms'; -import { auth } from '@/lib/auth'; +import { getViewer } from '@/lib/access'; import { prisma } from '@/lib/db'; import { UserCircle } from 'lucide-react'; import { redirect } from 'next/navigation'; export default async function ProfilePage() { - const session = await auth(); - if (!session?.user) { + const viewer = await getViewer(); + if (!viewer) { redirect('/login?callbackUrl=/profile'); } - const userId = Number.parseInt(session.user.id, 10); - if (Number.isNaN(userId)) { - redirect('/login'); - } - const user = await prisma.user.findUnique({ - where: { id: userId }, + where: { id: viewer.id }, select: { id: true, username: true, diff --git a/app/share/[token]/error.tsx b/app/share/[token]/error.tsx new file mode 100644 index 0000000..6d487f4 --- /dev/null +++ b/app/share/[token]/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from '@/components/error-state'; + +export default function ShareError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/app/share/[token]/not-found.tsx b/app/share/[token]/not-found.tsx new file mode 100644 index 0000000..b9b167c --- /dev/null +++ b/app/share/[token]/not-found.tsx @@ -0,0 +1,5 @@ +import { EmptyState } from '@/components/ui/empty-state'; + +export default function ShareNotFound() { + return ; +} diff --git a/app/share/[token]/page.tsx b/app/share/[token]/page.tsx index 1d36bcf..a46453b 100644 --- a/app/share/[token]/page.tsx +++ b/app/share/[token]/page.tsx @@ -1,6 +1,97 @@ -import { ShareViewer } from '@/components/share-viewer'; +import { PHOTO_PAGE_SIZE, PhotoPage } from '@/components/photo-page'; +import { SharePasswordForm } from '@/components/share-password-form'; +import { PhotoGridSkeleton } from '@/components/skeletons/photo-grid-skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { EmptyState } from '@/components/ui/empty-state'; +import { prisma } from '@/lib/db'; +import { type SearchParams, clampPage, readInt } from '@/lib/params'; +import { isShareUnlocked } from '@/lib/share-auth'; +import { format, isAfter } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; +import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; -export default async function SharePage({ params }: { params: Promise<{ token: string }> }) { +type ShareRow = { + token: string; + expiresAt: Date | null; + password: string | null; + category: { id: number; name: string; description: string | null }; +}; + +export default async function SharePage({ + params, + searchParams, +}: { + params: Promise<{ token: string }>; + searchParams?: Promise; +}) { const { token } = await params; - return ; + const q = (await searchParams) ?? {}; + + const shareLink = (await prisma.shareLink.findUnique({ + where: { token }, + select: { + token: true, + expiresAt: true, + password: true, + category: { select: { id: true, name: true, description: true } }, + }, + })) as ShareRow | null; + + if (!shareLink) { + notFound(); + } + + if (shareLink.expiresAt && isAfter(new Date(), shareLink.expiresAt)) { + return ; + } + + const { category } = shareLink; + + // 需要密码却尚未解锁时,连媒体数量都不透露——解锁判断在服务端完成, + // 不再靠"先发请求、再看是不是 401"猜出来 + if (shareLink.password && !(await isShareUnlocked(token))) { + return ( +
+ + 访问受限 + 请输入访问密码 + + +
+ ); + } + + const total = (await prisma.photo.count({ where: { categoryId: category.id } })) as number; + const page = clampPage(readInt(q, 'p'), total, PHOTO_PAGE_SIZE); + + return ( +
+
+

{category.name}

+ {category.description && ( +

{category.description}

+ )} +

+ {total} 个媒体 + {shareLink.expiresAt && + ` · 链接将在 ${format(shareLink.expiresAt, 'yyyy-MM-dd HH:mm', { locale: zhCN })} 过期`} +

+
+ + }> + + +
+ ); } diff --git a/app/upload/page.tsx b/app/upload/page.tsx index 9e5655d..f7cd56e 100644 --- a/app/upload/page.tsx +++ b/app/upload/page.tsx @@ -1,9 +1,9 @@ -import { auth } from '@/lib/auth'; +import { getViewer } from '@/lib/access'; import { redirect } from 'next/navigation'; export default async function UploadPage() { - const session = await auth(); - if (!session?.user) { + const viewer = await getViewer(); + if (!viewer) { redirect('/login'); } diff --git a/components/admin-dashboard.tsx b/components/admin-dashboard.tsx deleted file mode 100644 index 6df1fdb..0000000 --- a/components/admin-dashboard.tsx +++ /dev/null @@ -1,1007 +0,0 @@ -'use client'; - -import { AdminFileSets } from '@/components/admin-filesets'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Textarea } from '@/components/ui/textarea'; -import type { CategoryVisibility } from '@prisma/client'; -import { Copy, LayoutDashboard, Trash2 } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useEffect, useMemo, useState, useTransition } from 'react'; - -interface CategoryItem { - id: number; - name: string; - description: string | null; - photoCount: number; - createdAt: string; - visibility: 'private' | 'internal' | 'public'; -} - -interface UserItem { - id: number; - username: string; - role: string; - status: 'pending' | 'active' | 'rejected'; - photoCount: number; - createdAt: string; -} - -interface ShareLinkItem { - id: number; - token: string; - categoryId: number; - categoryName: string; - expiresAt: string | null; - createdAt: string; -} - -interface FileSetItem { - id: number; - name: string; - description: string | null; - visibility: 'private' | 'internal' | 'public'; - fileCount: number; - createdAt: string; -} - -interface AdminDashboardProps { - categories: CategoryItem[]; - users: UserItem[]; - shareLinks: ShareLinkItem[]; - fileSets: FileSetItem[]; -} - -export function AdminDashboard({ categories, users, shareLinks, fileSets }: AdminDashboardProps) { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState(null); - - const pendingUsers = users.filter(u => u.status === 'pending'); - const activeUsersInitial = users.filter(u => u.status === 'active'); - const [activeUsers, setActiveUsers] = useState(activeUsersInitial); - - const [editingCategoryId, setEditingCategoryId] = useState(null); - const [categoryName, setCategoryName] = useState(''); - const [categoryDescription, setCategoryDescription] = useState(''); - const [categoryVisibility, setCategoryVisibility] = useState('internal'); - - const [selectedUserRole, setSelectedUserRole] = useState>({}); - const [userQuery, setUserQuery] = useState(''); - const [userPage, setUserPage] = useState(1); - const [userPageSize, setUserPageSize] = useState(20); - const [userTotal, setUserTotal] = useState(activeUsersInitial.length); - const [usersLoading, setUsersLoading] = useState(false); - const [usersError, setUsersError] = useState(null); - const [filterRole, setFilterRole] = useState(''); - - const [deletingUserId, setDeletingUserId] = useState(null); - const [deleteTransferUserId, setDeleteTransferUserId] = useState(null); - const [deletePhotosDirectly, setDeletePhotosDirectly] = useState(false); - - const [resettingPasswordUserId, setResettingPasswordUserId] = useState(null); - const [newPassword, setNewPassword] = useState(''); - const [confirmNewPassword, setConfirmNewPassword] = useState(''); - - const [shareCategoryId, setShareCategoryId] = useState(null); - const [sharePassword, setSharePassword] = useState(''); - const [expireHours, setExpireHours] = useState('24'); - const [shareMessage, setShareMessage] = useState(null); - const [copySuccessMessage, setCopySuccessMessage] = useState(null); - - const shareBaseUrl = useMemo(() => { - if (typeof window !== 'undefined') { - return `${window.location.origin}/share/`; - } - return '/share/'; - }, []); - - const resetCategoryForm = () => { - setEditingCategoryId(null); - setCategoryName(''); - setCategoryDescription(''); - setCategoryVisibility('internal'); - }; - - const handleCategorySubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setError(null); - - if (!categoryName.trim()) { - setError('分类名称不能为空'); - return; - } - - const payload = { - name: categoryName.trim(), - description: categoryDescription.trim() || undefined, - visibility: categoryVisibility, - }; - - const isEditing = editingCategoryId !== null; - const response = await fetch('/api/categories', { - method: isEditing ? 'PUT' : 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( - isEditing - ? { - ...payload, - id: editingCategoryId, - } - : payload - ), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setError(body.error ?? '操作失败'); - return; - } - - resetCategoryForm(); - startTransition(() => router.refresh()); - }; - - const handleCategoryDelete = async (id: number) => { - setError(null); - if (!window.confirm('确认删除该分类?分类内的照片将一并删除。')) { - return; - } - - const response = await fetch('/api/categories', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setError(body.error ?? '删除失败'); - return; - } - - startTransition(() => router.refresh()); - }; - - const handleUserRoleChange = async (id: number, role: string) => { - setError(null); - - const response = await fetch('/api/users', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, role }), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setError(body.error ?? '角色更新失败'); - return; - } - - startTransition(() => router.refresh()); - }; - - const fetchUsers = async () => { - setUsersLoading(true); - setUsersError(null); - try { - const params = new URLSearchParams({ - page: String(userPage), - pageSize: String(userPageSize), - }); - if (userQuery.trim()) params.set('q', userQuery.trim()); - if (filterRole) params.set('role', filterRole); - params.set('status', 'active'); - const res = await fetch(`/api/users?${params.toString()}`); - const body = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(body.error ?? '加载失败'); - const list: UserItem[] = (body.data ?? []).filter((u: unknown): u is UserItem => { - return ( - !!u && typeof (u as UserItem).status === 'string' && (u as UserItem).status === 'active' - ); - }); - setActiveUsers(list); - setUserTotal(body.meta?.total ?? list.length); - } catch (e) { - setUsersError(e instanceof Error ? e.message : '加载失败'); - } finally { - setUsersLoading(false); - } - }; - - useEffect(() => { - fetchUsers(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userPage, userPageSize]); - - const handleUserStatusChange = async (id: number, status: 'pending' | 'active' | 'rejected') => { - setError(null); - - const response = await fetch('/api/users', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, status }), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setError(body.error ?? '状态更新失败'); - return; - } - - startTransition(() => router.refresh()); - }; - - const handlePasswordReset = async () => { - if (!resettingPasswordUserId) return; - setError(null); - - if (!newPassword || !confirmNewPassword) { - setError('请输入新密码'); - return; - } - - if (newPassword !== confirmNewPassword) { - setError('两次输入的密码不一致'); - return; - } - - if (newPassword.length < 6) { - setError('密码至少 6 位'); - return; - } - - const response = await fetch('/api/users/password', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userId: resettingPasswordUserId, - newPassword: newPassword, - }), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setError(body.error ?? '密码重置失败'); - return; - } - - setResettingPasswordUserId(null); - setNewPassword(''); - setConfirmNewPassword(''); - alert('密码重置成功'); - }; - - const handleUserDelete = async () => { - if (!deletingUserId) return; - setError(null); - - const user = users.find(u => u.id === deletingUserId); - if (!user) return; - - if (user.photoCount > 0 && !deletePhotosDirectly && !deleteTransferUserId) { - setError('该用户有照片,请选择转移到其他用户或直接删除照片'); - return; - } - - const payload: { id: number; transferToUserId?: number; deletePhotos?: boolean } = { - id: deletingUserId, - }; - - if (deletePhotosDirectly) { - payload.deletePhotos = true; - } else if (deleteTransferUserId) { - payload.transferToUserId = deleteTransferUserId; - } - - const response = await fetch('/api/users', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setError(body.error ?? '删除用户失败'); - return; - } - - setDeletingUserId(null); - setDeleteTransferUserId(null); - setDeletePhotosDirectly(false); - startTransition(() => router.refresh()); - }; - - const handleShareCreate = async (event: React.FormEvent) => { - event.preventDefault(); - setShareMessage(null); - setError(null); - - if (!shareCategoryId) { - setError('请选择分类'); - return; - } - - const response = await fetch('/api/share', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - categoryId: shareCategoryId, - password: sharePassword.trim() || undefined, - expireInHours: Number.parseInt(expireHours, 10) || undefined, - }), - }); - - const body = await response.json().catch(() => ({})); - - if (!response.ok) { - setError(body.error ?? '分享链接创建失败'); - return; - } - - setShareMessage(`${shareBaseUrl}${body.token}`); - setSharePassword(''); - setExpireHours('24'); - startTransition(() => router.refresh()); - }; - - const handleCopyShareLink = async (token: string) => { - const fullUrl = `${shareBaseUrl}${token}`; - setCopySuccessMessage(null); - setError(null); - try { - await navigator.clipboard.writeText(fullUrl); - setCopySuccessMessage('链接已复制到剪贴板'); - setTimeout(() => setCopySuccessMessage(null), 3000); - } catch { - setError('复制失败,请手动复制链接'); - } - }; - - const handleDeleteShareLink = async (id: number) => { - setError(null); - if (!window.confirm('确认删除该分享链接?')) { - return; - } - - const response = await fetch('/api/share', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setError(body.error ?? '删除分享链接失败'); - return; - } - - startTransition(() => router.refresh()); - }; - - return ( -
-
-
- -

控制台

-
-

管理分类、成员以及分享链接。

-
- - {error && ( - - 操作失败 - {error} - - )} - - - - 相册 - 文件 - 成员 - 分享 - - - -
-
- - setCategoryName(event.target.value)} - placeholder="例如:活动照片" - required - /> -
-
- -