release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

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>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -25,6 +25,13 @@ vi.mock('@/lib/config', () => ({
albumIds: ['00000000-0000-0000-0000-000000000002'],
// no password
},
{
// Taken offline with `enabled: false`, and asks for nothing.
name: 'Offline',
slug: 'offline',
albumIds: ['00000000-0000-0000-0000-000000000006'],
enabled: false,
},
{
name: 'Hashed',
slug: 'hashed',
@@ -34,6 +41,13 @@ vi.mock('@/lib/config', () => ({
password:
'scrypt:0123456789abcdef0123456789abcdef:4850eef18a09612ef11fdcd943bf64ce98d0eca2b0f677f9c0c51d1f648b8a101cf7ea36b6f94cfc05236b2fda6fffabd0929dc7e14c07626522fba68bf26de5',
},
{
// A leftover bcrypt hash: refused outright, whatever the attempt.
name: 'Legacy',
slug: 'legacy',
albumIds: ['00000000-0000-0000-0000-000000000007'],
password: '$2b$10$abcdefghijklmnopqrstuuPjJ7x1b0c9vX8rZ0Yx0mZ1n2o3p4q5r',
},
],
}),
}));
@@ -129,6 +143,41 @@ describe('password hashing does not stall the process', () => {
});
});
/**
* A key with an scrypt hash took ~50ms to answer, every other key ~2ms — the
* early return skipped hashing. Identical reply bodies did not help: the clock
* alone told a prober which pages, albums and journal entries are locked,
* drafts included. Every attempt now runs exactly one scrypt. Counting the
* calls keeps the test independent of how fast the machine is.
*/
describe('authenticate — one scrypt per attempt, whatever the key', () => {
const attempts: [string, () => Promise<string | null>][] = [
['scrypt hash, right password', () => authenticate('hashed', 'scrypted-pass')],
['scrypt hash, wrong password', () => authenticate('hashed', 'wrong')],
['plaintext, right password', () => authenticate('private', 'secret123')],
['plaintext, wrong password', () => authenticate('private', 'wrong')],
['key without a password', () => authenticate('public', 'anything')],
['unknown key', () => authenticate('no-such-page', 'anything')],
['unknown journal entry', () => authenticate('no-such-entry', 'x', 'journal')],
['unknown album', () => authenticate('00000000-0000-0000-0000-00000000dead', 'x', 'album')],
['legacy bcrypt hash', () => authenticate('legacy', 'anything')],
];
it.each(attempts)('%s', async (_label, attempt) => {
const scrypt = vi.spyOn(crypto, 'scrypt');
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
await attempt();
expect(scrypt).toHaveBeenCalledTimes(1);
} finally {
scrypt.mockRestore();
error.mockRestore();
warn.mockRestore();
}
});
});
describe('isAuthenticated', () => {
it('returns true when a valid cookie is present', async () => {
// First authenticate to get the expected token
@@ -249,6 +298,11 @@ describe('isAlbumReachable', () => {
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
});
// `enabled: false` takes the subpage offline; its albums have no route left.
it('refuses an album whose only subpage is offline', () => {
expect(isAlbumReachable('00000000-0000-0000-0000-000000000006', none)).toBe(false);
});
it('still refuses an album that carries its own password', async () => {
const subpageOnly = await cookieFor('private', 'secret123');
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);