feat: implement album-level password protection
This commit is contained in:
@@ -107,18 +107,26 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a PasswordGate element if the slug is protected and not yet
|
||||
* authenticated, otherwise returns null. Centralises the duplicated
|
||||
* cookie-check boilerplate.
|
||||
* Returns a PasswordGate element if the key (slug or ID) is protected and
|
||||
* not yet authenticated, otherwise returns null.
|
||||
*/
|
||||
async function gateIfProtected(slug: string): Promise<React.ReactElement | null> {
|
||||
if (!isProtected(slug)) return null;
|
||||
async function gateIfProtected(
|
||||
key: string,
|
||||
type: 'subpage' | 'album' = 'subpage',
|
||||
titleOverride?: string,
|
||||
): Promise<React.ReactElement | null> {
|
||||
if (!isProtected(key, type)) return null;
|
||||
const cookieStore = await cookies();
|
||||
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
||||
if (isAuthenticated(slug, getCookie)) return null;
|
||||
const subpageData = await immich.getSubpageAlbums(slug);
|
||||
const title = subpageData?.subpage.name ?? slug;
|
||||
return <PasswordGate slug={slug} title={title} />;
|
||||
if (isAuthenticated(key, getCookie, type)) return null;
|
||||
|
||||
let title = titleOverride || key;
|
||||
if (!titleOverride && type === 'subpage') {
|
||||
const subpageData = await immich.getSubpageAlbums(key);
|
||||
title = subpageData?.subpage.name ?? key;
|
||||
}
|
||||
|
||||
return <PasswordGate slug={key} title={title} type={type} />;
|
||||
}
|
||||
|
||||
export default async function PathPage({ params }: PathPageProps) {
|
||||
@@ -162,6 +170,10 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
const images = toPhotoItems(album.assets, config.exifOnHover);
|
||||
|
||||
// Password gate for protected albums
|
||||
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
||||
if (albumGate) return albumGate;
|
||||
|
||||
return (
|
||||
<AlbumDetailView
|
||||
album={album}
|
||||
@@ -197,6 +209,10 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
const images = toPhotoItems(album.assets, config.exifOnHover);
|
||||
|
||||
// Password gate for protected albums
|
||||
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
||||
if (albumGate) return albumGate;
|
||||
|
||||
return (
|
||||
<AlbumDetailView
|
||||
album={album}
|
||||
@@ -241,6 +257,10 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
const images = toPhotoItems(album.assets, config.exifOnHover);
|
||||
|
||||
// Password gate for protected albums
|
||||
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
||||
if (albumGate) return albumGate;
|
||||
|
||||
return (
|
||||
<AlbumDetailView
|
||||
album={album}
|
||||
|
||||
@@ -21,14 +21,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { slug, password } = body;
|
||||
const { slug, password, type = 'subpage' } = body;
|
||||
|
||||
if (!slug || !password) {
|
||||
return NextResponse.json({ error: 'Missing slug or password' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 🛡️ SECURITY: Validate input types and limit length to prevent DoS attacks
|
||||
// (e.g. from extremely long strings being passed to expensive crypto functions)
|
||||
if (typeof slug !== 'string' || slug.length > 100) {
|
||||
return NextResponse.json({ error: 'Invalid slug format or length' }, { status: 400 });
|
||||
}
|
||||
@@ -37,11 +36,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid password format or length' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isProtected(slug)) {
|
||||
return NextResponse.json({ error: 'Album is not password-protected' }, { status: 400 });
|
||||
if (type !== 'subpage' && type !== 'album') {
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
}
|
||||
|
||||
const setCookie = authenticate(slug, password);
|
||||
if (!isProtected(slug, type)) {
|
||||
return NextResponse.json({
|
||||
error: `${type === 'subpage' ? 'Subpage' : 'Album'} is not password-protected`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const setCookie = authenticate(slug, password, type);
|
||||
if (!setCookie) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return new NextResponse(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
'ETag': etag,
|
||||
ETag: etag,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-RateLimit-Remaining': String(remaining),
|
||||
},
|
||||
@@ -87,7 +87,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
: result.contentType,
|
||||
// Images are immutable once uploaded to Immich — cache aggressively
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'ETag': etag,
|
||||
ETag: etag,
|
||||
'X-RateLimit-Remaining': String(remaining),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user