release: v0.13.0 (#535)

Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.

Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.

Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralksta
2026-08-25 10:08:12 +02:00
committed by GitHub
parent 10ec68f493
commit a813433891
73 changed files with 4697 additions and 453 deletions

View File

@@ -6,11 +6,17 @@ vi.mock('@/lib/config', () => ({
getConfig: () => ({
immich: { apiKey: 'test-api-key-for-auth-tests' },
authSecret: 'test-auth-secret-32-chars-long-min',
standaloneAlbums: ['00000000-0000-0000-0000-000000000004'],
albumPasswords: { '00000000-0000-0000-0000-000000000005': 'albumpass' },
subpages: [
{
name: 'Private',
slug: 'private',
albumIds: ['00000000-0000-0000-0000-000000000001'],
albumIds: [
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000004',
'00000000-0000-0000-0000-000000000005',
],
password: 'secret123',
},
{
@@ -32,7 +38,13 @@ vi.mock('@/lib/config', () => ({
}),
}));
import { isProtected, authenticate, isAuthenticated, findSubpageBySlug } from '@/lib/auth';
import {
isProtected,
authenticate,
isAuthenticated,
isAlbumReachable,
findSubpageBySlug,
} from '@/lib/auth';
describe('findSubpageBySlug', () => {
it('returns the subpage config for a known slug', () => {
@@ -197,3 +209,52 @@ describe('token expiry', () => {
expect(Math.abs(exp - (Date.now() + maxAge * 1000))).toBeLessThan(2000);
});
});
/**
* A route handler is reached without ever passing through the page that would
* have asked for the subpage password, so it has to ask the same question
* itself (#475).
*/
describe('isAlbumReachable', () => {
const IN_PRIVATE = '00000000-0000-0000-0000-000000000001';
const IN_PUBLIC = '00000000-0000-0000-0000-000000000002';
const ALSO_STANDALONE = '00000000-0000-0000-0000-000000000004';
const OWN_PASSWORD = '00000000-0000-0000-0000-000000000005';
const none = () => undefined;
async function cookieFor(key: string, password: string, type: 'subpage' | 'album' = 'subpage') {
const header = (await authenticate(key, password, type))!;
const token = header.split('=')[1].split(';')[0];
const name = header.split('=')[0];
return (asked: string) => (asked === name ? token : undefined);
}
it('allows an album in a subpage that asks for nothing', () => {
expect(isAlbumReachable(IN_PUBLIC, none)).toBe(true);
});
it('refuses an album whose only route is a protected subpage', () => {
expect(isAlbumReachable(IN_PRIVATE, none)).toBe(false);
});
it('allows it once that subpage has been unlocked', async () => {
const getCookie = await cookieFor('private', 'secret123');
expect(isAlbumReachable(IN_PRIVATE, getCookie)).toBe(true);
});
// Listing an album on the home page publishes it, whatever else it is in.
it('allows a standalone album even when a protected subpage also lists it', () => {
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
});
it('still refuses an album that carries its own password', async () => {
const subpageOnly = await cookieFor('private', 'secret123');
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);
});
it('allows that one once its own password is given too', async () => {
const subpage = await cookieFor('private', 'secret123');
const album = await cookieFor(OWN_PASSWORD, 'albumpass', 'album');
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
});
});