release: v0.18.0 (#725)
Release v0.18.0 — merges `dev` into `main`. ## Thanks - [@lancetm714](https://github.com/lancetm714) for the accent-contrast fix (#680 → #724). - [@RichKidsDev](https://github.com/RichKidsDev) for reporting the outdated Impressum (#697). ### Security - **Journal drafts and password-protected entries no longer render publicly as a subpage essay** ([#684](https://github.com/ralksta/immich-folio/pull/684), [GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9), medium). A subpage's `essayFile` read the journal entry straight from disk, skipping the draft and password rules `/journal/<slug>` applies. It now resolves through the same rules: a draft reads as missing for visitors, and a protected entry shows its password gate, sharing the `lb_auth_journal_<slug>` cookie. - **Immich descriptions are escaped in generated subpage essays** ([#684](https://github.com/ralksta/immich-folio/pull/684), [GHSA-8ppx-39jv-q5xm](https://github.com/ralksta/immich-folio/security/advisories/GHSA-8ppx-39jv-q5xm), low). The essay built from a subpage's albums (`layout: essay` without markdown) put each asset's Immich description into the page as raw HTML. Captions are now HTML-escaped, and they go through `assetCaption()`, so `exif.caption: false` is respected there as well. Tracked in [#689](https://github.com/ralksta/immich-folio/issues/689). - **Admin drafts no longer keep a typed password, and the contact form's email check runs in linear time** ([#727](https://github.com/ralksta/immich-folio/pull/727)). The admin's session drafts wrote a password as typed to `sessionStorage`; a draft holding a plaintext password is now not kept (the unsaved-changes guard still warns). The email pattern could backtrack polynomially on a crafted domain; its labels now exclude the dot and the length is checked first. Both found by CodeQL before release. ### Added - **A built-in contact form with an inbox in the admin** ([#703](https://github.com/ralksta/immich-folio/pull/703), closes [#702](https://github.com/ralksta/immich-folio/issues/702)). `/contact` needs neither SMTP nor a form service: each message is one file in `content/messages/` (mode `0600`), read under the new _Messages_ tab, where _Reply by email_ opens the owner's mail client with the message quoted. Messages older than `contact.retentionDays` (default 90) are deleted. An optional ntfy push goes to `contact.notifyUrl` or `CONTACT_NOTIFY_URL`, and since [#709](https://github.com/ralksta/immich-folio/pull/709) it carries a fixed "new message" line and nothing about the sender — no name, no address, no text. Spam is kept out without a captcha: an off-screen honeypot, a minimum fill time, three submissions a minute per IP and a cap of 500 stored messages. With the Impressum on and no `legal.contactUrl`, the Impressum links the form as its second contact channel. A new doctor check warns when the form is on but nobody is notified. - **A privacy policy page, next to a list of what the site processes** ([#708](https://github.com/ralksta/immich-folio/pull/708)). `/privacy` renders `content/privacy.md`, edited under Settings → Legal, and the footer links it once the file has text; `privacy.enabled: false` hides it. Folio writes no legal text. Beside the editor it lists what _this_ installation does, read off the configuration — the map, statistics, contact form, password cookies, CDN — and marks every entry that involves a third party. _Insert headings_ adds the matching section headings and nothing else. The doctor warns while the Impressum is on and there is no policy. - **French, Spanish, Italian and Dutch interface translations** ([#681](https://github.com/ralksta/immich-folio/pull/681)), selected with `settings.yaml: lang` as before. The admin panel stays English. - **CDN mode for photos and videos** ([#681](https://github.com/ralksta/immich-folio/pull/681)). With `CDN_URL` set, image and video URLs point at a pull CDN whose origin is this server, so repeat views reach neither the server nor Immich. A site password switches the mode off, since a CDN would hand out cached photos without checking the unlock cookie. The CDN origin is added to the CSP, and a doctor check covers the password and `TRUSTED_PROXY_HOPS` cases. See `docs/deployment.md#cdn-mode`. - **A reworked admin panel** ([#712](https://github.com/ralksta/immich-folio/pull/712), [#713](https://github.com/ralksta/immich-folio/pull/713), [#714](https://github.com/ralksta/immich-folio/pull/714), [#716](https://github.com/ralksta/immich-folio/pull/716), [#717](https://github.com/ralksta/immich-folio/pull/717), [#718](https://github.com/ralksta/immich-folio/pull/718), [#719](https://github.com/ralksta/immich-folio/pull/719), [#721](https://github.com/ralksta/immich-folio/pull/721)): - **A sidebar instead of the header row**, grouped by task, with an unread count on Messages and a dot on Diagnostics while Immich is down. It collapses to an icon rail, with names shown on hover and focus, and remembers that per browser. - **`/admin` is an overview**: what needs attention (unread messages, doctor warnings), published pages, journal entries and recent views. - **The site's own visual language** — the preset's fonts, hairline borders, the square marker — and one page header on every screen. The accent is kept for the current place and the primary action; switches are now pills reading ON or OFF, in green rather than the accent, so Studio Modern's red no longer makes an enabled setting look like an error. - **The page builder is a structure list and a panel.** Hero, subpages and standalone albums sit in one list on the left, subpages drag-sorted in place; the selected entry is edited on the right instead of in an overlay. - **Dialogs only where a change is permanent.** Deleting a message or a journal entry, and resetting the favicon (which used to delete the file without asking), go through one confirm dialog in the admin's own look. Removals in the page builder, which only touch unsaved state, happen at once and offer _Undo_. No `window.confirm()` is left. - **Feature switches are grouped by meaning** as one-line rows under Settings → General; page transitions and scroll-to-top moved to Theme → Look & motion. No setting keys changed. ### Changed - **Fonts and the Leaflet stylesheet are served from the site itself** ([#700](https://github.com/ralksta/immich-folio/pull/700)). Every page used to link `fonts.googleapis.com`, which sends each visitor's IP address to Google. The server now fetches a theme's Google fonts once, rewrites the stylesheet and caches the files in `content/.fonts/`; custom fonts in `theme.fonts` keep working. If Google cannot be reached before a font is cached, the page falls back to the theme's system fonts. `leaflet.css` comes from the npm package instead of unpkg. The CSP drops `fonts.googleapis.com`, `fonts.gstatic.com` and `unpkg.com`. - **Email addresses are kept out of the served HTML** ([#705](https://github.com/ralksta/immich-folio/pull/705)). The Impressum and footer render the address only after hydration, from an encoded form, so harvesters reading raw HTML find nothing. Without JavaScript the Impressum shows `name [at] example [dot] de`. This defeats bulk harvesting, not a person with a browser. - **The Impressum cites § 5 DDG and links its contacts** ([#698](https://github.com/ralksta/immich-folio/pull/698), reported in [#697](https://github.com/ralksta/immich-folio/issues/697) by [@RichKidsDev](https://github.com/RichKidsDev)). The TMG was replaced by the DDG in May 2024; `legal.heading` replaces the line for other laws (e.g. § 5 ECG in Austria). `legal.contactUrl` and `legal.contactLabel` add a contact form as the second channel, so a phone number stays optional. Email, phone and contact URL are now links, and the "Created with the Impressum generator by eRecht24" line, shown on every site whether true or not, is gone. Settings → Legal gains fields for heading, contact URL, VAT ID and tax number, and a doctor check flags missing details. - **Missing pages answer `404`** ([#704](https://github.com/ralksta/immich-folio/pull/704)). Every missing page used to answer `200`, because streaming had started before `notFound()` ran. `/contact`, `/impressum`, `/map` and `/privacy` while switched off, and `/journal/<slug>` without an entry, now return a real 404 with the same not-found page. Album and subpage slugs still depend on Immich and stay soft 404s. - **Passwords set in the admin are stored as scrypt hashes** ([#706](https://github.com/ralksta/immich-folio/pull/706), closes [#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage, album, journal-entry and site passwords were written as typed. The save routes now hash them; a stored hash reads _Protected_ with _Change_ and _Remove_ and is never put into an input. Visitors who unlocked a page stay signed in across unrelated saves. Existing plaintext passwords are hashed the next time their file is saved in the admin. - **One notification surface in the admin** ([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces every `alert()`: errors stay until dismissed, successes clear themselves, and screen readers announce both. ### Fixed - **Map tiles load again** ([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO started answering every tile with an "API KEY REQUIRED" image, leaving `/map` and journal map blocks as a grey grid. Tiles now come from OpenStreetMap; the dark look is a CSS filter, so the map now follows the colour mode. - **The page builder no longer drops a subpage's `proofing:` flag on save** ([#711](https://github.com/ralksta/immich-folio/pull/711)). A page switched off for proofing by hand reverted after any unrelated edit. - **Unsaved edits in Settings and About survive switching admin tabs, and a backup restore is no longer overwritten by the next save** ([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that were open during a restore now reload and offer unsaved edits back as a conflict. Also: "Restore my changes" after a conflict found nothing, removing an album left its drawer showing the next album, and the journal saved (rotating a backup) without changes. - **Accent-backed buttons stay readable with a light or malformed accent** ([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes [#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed by [@lancetm714](https://github.com/lancetm714). A white accent made the proofing dialog's _Download selected (.zip)_ button white on white. `theme.accent` is now accepted as hex only and normalised to `#rrggbb`, anything else falls back to the preset, and the proofing dialog and filter pills use the mode-aware `--on-accent` text colour from #688. - **Every preset's accent is visible in both colour modes** ([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had one accent for both modes; minimal's dark mode drew black buttons on black. Presets now carry a light and a dark accent, and text on accent-filled buttons picks black or white for contrast. A custom accent stays the owner's choice in both modes. - **Visitor-side inconsistencies** ([#685](https://github.com/ralksta/immich-folio/pull/685)): after visiting `/impressum`, every subpage header turned centred and uppercase; back links announced "Back to Back to …"; the hero EXIF chip ignored `exif.location`/`camera`/`settings`; videos in journal entries opened as stills; the setup screen named a non-existent `.env.example`. - **An essay's lightbox walks only the photos the story shows** ([#686](https://github.com/ralksta/immich-folio/pull/686)), not every photo of the subpage's albums, and the proofing bar in essays shows the selection count instead of a filter button that did nothing. - **The page panel scrolls with the mouse wheel** ([#720](https://github.com/ralksta/immich-folio/pull/720)). - **The journal editor's top bar wraps on phones** ([#723](https://github.com/ralksta/immich-folio/pull/723), closes [#675](https://github.com/ralksta/immich-folio/issues/675)), so the title stays editable and Save stays on screen. - **Every admin form field has a name for assistive technology** ([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two inputs had a linked label; 47 are linked now, and clicking a label focuses its field. ### Internal - **`SettingsEditor` and `PageBuilder` are split up** ([#710](https://github.com/ralksta/immich-folio/pull/710), [#711](https://github.com/ralksta/immich-folio/pull/711)). The 2,200-line settings editor is a shell with one file per section; the page builder's YAML handling and edits are pure functions under test. Both were checked side by side against `dev` for identical output. - **`useAdminFetch()`** ([#707](https://github.com/ralksta/immich-folio/pull/707)) gives Analytics, Messages, Backups and the journal list one loading and error pattern. - README brought up to v0.17.0 ([#678](https://github.com/ralksta/immich-folio/pull/678), [#679](https://github.com/ralksta/immich-folio/pull/679)). ### Upgrade notes **`content/` gains two directories the app writes to:** `content/.fonts/` for the cached fonts and `content/messages/` for contact-form messages. Both are created on demand, so a writable `content/` is enough, as before. **The server needs outbound HTTPS to Google once per font** to fill the font cache. Until then pages fall back to the theme's system fonts. Visitors' browsers no longer contact Google. **A custom CSP or reverse-proxy allowlist** must now allow `tile.openstreetmap.org` for images instead of `*.basemaps.cartocdn.com`, and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or `unpkg.com`. The built-in CSP is updated. **`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`). Anything else, including a value without `#`, falls back to the preset's accent. **Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal entries** are hashed the next time that file is saved in the admin; one save clears the doctor's warning. **New, optional settings:** `contact.*` (the form is off by default), `privacy.enabled`, `legal.heading`, `legal.contactUrl`, `legal.contactLabel`, and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See `content/settings.yaml.example` and `.env.local.example`. In the example file, `vatId` and `taxId` had swapped sample values; check yours if you copied them. No `IMAGE_CACHE_VERSION` bump is needed this time. --------- 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:
110
lib/__tests__/cdn.test.ts
Normal file
110
lib/__tests__/cdn.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const config = { authSecret: 'test-auth-secret-32-chars-long-min', sitePassword: '' };
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => config,
|
||||
getConfigOrNull: () => config,
|
||||
}));
|
||||
|
||||
const ASSET = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
describe('normalizeCdnUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
async function normalize(raw: string | undefined) {
|
||||
const { normalizeCdnUrl } = await import('@/lib/env');
|
||||
return normalizeCdnUrl(raw);
|
||||
}
|
||||
|
||||
it('keeps an https origin and drops the trailing slash', async () => {
|
||||
expect(await normalize('https://cdn.example.net/')).toBe('https://cdn.example.net');
|
||||
});
|
||||
|
||||
it('keeps a path prefix', async () => {
|
||||
expect(await normalize(' https://cdn.example.net/folio// ')).toBe(
|
||||
'https://cdn.example.net/folio',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects what cannot prefix a path', async () => {
|
||||
expect(await normalize('')).toBe('');
|
||||
expect(await normalize(undefined)).toBe('');
|
||||
expect(await normalize('cdn.example.net')).toBe('');
|
||||
expect(await normalize('ftp://cdn.example.net')).toBe('');
|
||||
expect(await normalize('https://cdn.example.net/?x=1')).toBe('');
|
||||
expect(await normalize('https://cdn.example.net/#top')).toBe('');
|
||||
expect(await normalize('https://user:pw@cdn.example.net')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CDN mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
config.sitePassword = '';
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const urls = await import('@/lib/urls');
|
||||
const cdn = await import('@/lib/cdn');
|
||||
return { ...urls, ...cdn };
|
||||
}
|
||||
|
||||
it('keeps URLs relative without CDN_URL', async () => {
|
||||
const { imageUrl, videoUrl, cdnOrigin } = await load();
|
||||
expect(imageUrl(ASSET)).toMatch(/^\/api\/image\/[^?]+\?size=preview$/);
|
||||
expect(videoUrl(ASSET)).toMatch(/^\/api\/video\/[^?]+$/);
|
||||
expect(cdnOrigin()).toBeNull();
|
||||
});
|
||||
|
||||
it('puts images and videos on the CDN', async () => {
|
||||
vi.stubEnv('CDN_URL', 'https://cdn.example.net/folio/');
|
||||
const { imageUrl, videoUrl, exifUrl, downloadUrl, cdnOrigin } = await load();
|
||||
|
||||
expect(imageUrl(ASSET, 'thumbnail')).toMatch(
|
||||
/^https:\/\/cdn\.example\.net\/folio\/api\/image\/[^?]+\?size=thumbnail$/,
|
||||
);
|
||||
expect(videoUrl(ASSET)).toMatch(/^https:\/\/cdn\.example\.net\/folio\/api\/video\//);
|
||||
expect(cdnOrigin()).toBe('https://cdn.example.net');
|
||||
|
||||
// Routes that check cookies or are not cacheable stay on this host.
|
||||
expect(exifUrl(ASSET)).toMatch(/^\/api\/exif\//);
|
||||
expect(downloadUrl('album', ASSET)).toMatch(/^\/api\/download\//);
|
||||
});
|
||||
|
||||
it('keeps the cache buster on CDN URLs', async () => {
|
||||
vi.stubEnv('CDN_URL', 'https://cdn.example.net');
|
||||
vi.stubEnv('IMAGE_CACHE_VERSION', '7');
|
||||
const { imageUrl } = await load();
|
||||
expect(imageUrl(ASSET)).toMatch(/^https:\/\/cdn\.example\.net\/api\/image\/.+&v=7$/);
|
||||
});
|
||||
|
||||
it('stays off while a site password is set, and follows it at runtime', async () => {
|
||||
vi.stubEnv('CDN_URL', 'https://cdn.example.net');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { imageUrl, cdnOrigin } = await load();
|
||||
|
||||
config.sitePassword = 'secret';
|
||||
expect(imageUrl(ASSET)).toMatch(/^\/api\/image\//);
|
||||
// The policy still allows the origin; it just goes unused.
|
||||
expect(cdnOrigin()).toBe('https://cdn.example.net');
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
config.sitePassword = '';
|
||||
expect(imageUrl(ASSET)).toMatch(/^https:\/\/cdn\.example\.net\/api\/image\//);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('ignores an invalid CDN_URL', async () => {
|
||||
vi.stubEnv('CDN_URL', 'not a url');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { imageUrl, cdnOrigin } = await load();
|
||||
expect(imageUrl(ASSET)).toMatch(/^\/api\/image\//);
|
||||
expect(cdnOrigin()).toBeNull();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
normalizeSlug,
|
||||
buildSubpageGrid,
|
||||
sanitizeNavLinks,
|
||||
resolveLegal,
|
||||
resolveContact,
|
||||
} from '@/lib/config';
|
||||
|
||||
// EXPERIMENTAL: external navigation links — rendered as <a href> in the
|
||||
@@ -52,6 +54,71 @@ describe('sanitizeNavLinks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// legal.contactUrl lands in an <a href> on /impressum, so it gets the
|
||||
// navLinks rule: http(s) or nothing.
|
||||
describe('resolveLegal', () => {
|
||||
it('keeps an http(s) contact URL and its label', () => {
|
||||
const legal = resolveLegal({
|
||||
enabled: true,
|
||||
contactUrl: ' https://example.com/contact ',
|
||||
contactLabel: 'Write to me',
|
||||
});
|
||||
expect(legal.contactUrl).toBe('https://example.com/contact');
|
||||
expect(legal.contactLabel).toBe('Write to me');
|
||||
});
|
||||
|
||||
it('drops a contact URL with any other scheme', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
for (const contactUrl of ['javascript:alert(1)', 'data:text/html,x', 'example.com/contact']) {
|
||||
expect(resolveLegal({ contactUrl }).contactUrl).toBeUndefined();
|
||||
}
|
||||
expect(warn).toHaveBeenCalledTimes(3);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('turns blank optional fields into undefined so the page can skip them', () => {
|
||||
const legal = resolveLegal({ heading: ' ', email: '', phone: ' ', vatId: '' });
|
||||
expect(legal.heading).toBeUndefined();
|
||||
expect(legal.email).toBeUndefined();
|
||||
expect(legal.phone).toBeUndefined();
|
||||
expect(legal.vatId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is disabled unless enabled is literally true', () => {
|
||||
expect(resolveLegal(undefined).enabled).toBe(false);
|
||||
expect(resolveLegal({ name: 'Ralf' }).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContact', () => {
|
||||
it('is off with a 90-day retention by default', () => {
|
||||
expect(resolveContact(undefined)).toEqual({
|
||||
enabled: false,
|
||||
notifyUrl: undefined,
|
||||
retentionDays: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets CONTACT_NOTIFY_URL override the settings value', () => {
|
||||
expect(
|
||||
resolveContact({ enabled: true, notifyUrl: 'https://a.example/x' }, 'https://b.example/y')
|
||||
.notifyUrl,
|
||||
).toBe('https://b.example/y');
|
||||
});
|
||||
|
||||
it('drops a notify URL that is not http(s)', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(resolveContact({ notifyUrl: 'javascript:alert(1)' }).notifyUrl).toBeUndefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('clamps the retention period', () => {
|
||||
expect(resolveContact({ retentionDays: 0 }).retentionDays).toBe(1);
|
||||
expect(resolveContact({ retentionDays: 10_000 }).retentionDays).toBe(365);
|
||||
expect(resolveContact({ retentionDays: 'soon' as unknown as number }).retentionDays).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('converts a simple name to lowercase with hyphens', () => {
|
||||
expect(slugify('Kloster Chorin')).toBe('kloster-chorin');
|
||||
|
||||
175
lib/__tests__/contact.test.ts
Normal file
175
lib/__tests__/contact.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
MAX_MESSAGES,
|
||||
MIN_FILL_MS,
|
||||
deleteMessage,
|
||||
listMessages,
|
||||
notifyNewMessage,
|
||||
saveMessage,
|
||||
setRead,
|
||||
validateContact,
|
||||
} from '../contact';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const valid = {
|
||||
name: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
message: 'Hello there',
|
||||
website: '',
|
||||
startedAt: NOW - 10_000,
|
||||
};
|
||||
|
||||
describe('validateContact', () => {
|
||||
it('accepts a normal submission and trims it', () => {
|
||||
expect(validateContact({ ...valid, name: ' Ada\nLovelace ' }, NOW)).toEqual({
|
||||
ok: true,
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
message: 'Hello there',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a filled honeypot as spam', () => {
|
||||
expect(validateContact({ ...valid, website: 'http://spam' }, NOW)).toEqual({
|
||||
ok: false,
|
||||
reason: 'spam',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a form sent faster than a person types as spam', () => {
|
||||
expect(validateContact({ ...valid, startedAt: NOW - MIN_FILL_MS + 1 }, NOW).ok).toBe(false);
|
||||
expect(validateContact({ ...valid, startedAt: undefined }, NOW)).toEqual({
|
||||
ok: false,
|
||||
reason: 'spam',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a missing field or a malformed email as invalid', () => {
|
||||
expect(validateContact({ ...valid, name: ' ' }, NOW)).toEqual({ ok: false, reason: 'invalid' });
|
||||
expect(validateContact({ ...valid, email: 'not-an-email' }, NOW).ok).toBe(false);
|
||||
expect(validateContact({ ...valid, email: 'a@b.c\r\nBcc: x@y.z' }, NOW).ok).toBe(false);
|
||||
expect(validateContact({ ...valid, message: 'x'.repeat(5001) }, NOW).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message storage', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-contact-'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(cwd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const fields = { name: 'Ada', email: 'ada@example.com', message: 'Hi' };
|
||||
|
||||
it('stores a message unread and lists newest first', async () => {
|
||||
await saveMessage(fields, 90, NOW - 1000);
|
||||
const second = await saveMessage({ ...fields, name: 'Grace' }, 90, NOW);
|
||||
const list = await listMessages(90, NOW);
|
||||
expect(list.map((m) => m.name)).toEqual(['Grace', 'Ada']);
|
||||
expect(list[0]).toMatchObject({ read: false, id: (second as { id: string }).id });
|
||||
});
|
||||
|
||||
it('writes the file readable by the owner only', async () => {
|
||||
const msg = (await saveMessage(fields, 90, NOW)) as { id: string };
|
||||
const mode = fs.statSync(path.join(cwd, 'content', 'messages', `${msg.id}.json`)).mode;
|
||||
expect(mode & 0o077).toBe(0);
|
||||
});
|
||||
|
||||
it('deletes messages past the retention period when listing', async () => {
|
||||
await saveMessage(fields, 90, NOW - 31 * DAY);
|
||||
await saveMessage(fields, 90, NOW - DAY);
|
||||
expect(await listMessages(30, NOW)).toHaveLength(1);
|
||||
expect(fs.readdirSync(path.join(cwd, 'content', 'messages'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses new messages at the cap instead of evicting old ones', async () => {
|
||||
const dir = path.join(cwd, 'content', 'messages');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (let i = 0; i < MAX_MESSAGES; i++) {
|
||||
const id = `${NOW - i}-${i.toString(16).padStart(8, '0')}`;
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${id}.json`),
|
||||
JSON.stringify({
|
||||
id,
|
||||
receivedAt: '',
|
||||
name: 'x',
|
||||
email: 'x@y.z',
|
||||
message: 'm',
|
||||
read: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(await saveMessage(fields, 90, NOW)).toBe('full');
|
||||
expect(fs.readdirSync(dir)).toHaveLength(MAX_MESSAGES);
|
||||
});
|
||||
|
||||
it('marks read and deletes by id, and ignores ids that could escape the directory', async () => {
|
||||
const msg = (await saveMessage(fields, 90, NOW)) as { id: string };
|
||||
expect(await setRead(msg.id, true)).toBe(true);
|
||||
expect((await listMessages(90, NOW))[0].read).toBe(true);
|
||||
expect(await deleteMessage('../settings')).toBe(false);
|
||||
expect(await deleteMessage(msg.id)).toBe(true);
|
||||
expect(await listMessages(90, NOW)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyNewMessage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('posts nothing about the sender, so the push service gets no personal data', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('ok'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await notifyNewMessage('https://ntfy.sh/topic', 'https://site.example/admin/messages');
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe('https://ntfy.sh/topic');
|
||||
expect(init.body).toBe('A new message arrived through the contact form.');
|
||||
expect((init.headers as Record<string, string>).Click).toBe(
|
||||
'https://site.example/admin/messages',
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing without a URL and never throws on failure', async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error('down');
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await notifyNewMessage(undefined, null);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
await expect(notifyNewMessage('https://ntfy.sh/t', null)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateContact: email pattern', () => {
|
||||
it('accepts ordinary addresses and rejects malformed ones', () => {
|
||||
for (const email of ['a@b.de', 'first.last@mail.example.co.uk']) {
|
||||
expect(validateContact({ ...valid, email }, NOW).ok).toBe(true);
|
||||
}
|
||||
for (const email of ['a@b', 'a@.de', 'a@b.', 'a@b..de', 'a b@c.de', 'a@@b.de']) {
|
||||
expect(validateContact({ ...valid, email }, NOW).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// CodeQL js/polynomial-redos: the old pattern backtracked polynomially on a
|
||||
// domain of many dots. The limit keeps input short, and the pattern no
|
||||
// longer has more than one way to match.
|
||||
it('answers crafted input at once', () => {
|
||||
const crafted = '!@!.' + '!.'.repeat(50_000);
|
||||
const start = performance.now();
|
||||
expect(validateContact({ ...valid, email: crafted }, NOW).ok).toBe(false);
|
||||
expect(performance.now() - start).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
checkCdn,
|
||||
checkAuthSecret,
|
||||
checkProxyHops,
|
||||
countForwardedHops,
|
||||
@@ -9,6 +10,9 @@ import {
|
||||
checkPasswords,
|
||||
checkWritable,
|
||||
checkImmichCalls,
|
||||
checkContact,
|
||||
checkLegal,
|
||||
checkPrivacy,
|
||||
worstLevel,
|
||||
} from '../admin/doctor';
|
||||
|
||||
@@ -305,3 +309,138 @@ describe('worstLevel', () => {
|
||||
).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkCdn', () => {
|
||||
it('stays silent without CDN_URL', () => {
|
||||
expect(checkCdn(undefined, false, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('warns that a site password keeps photos off the CDN', () => {
|
||||
const finding = checkCdn('https://cdn.example.net', true, 1)!;
|
||||
expect(finding.level).toBe('warn');
|
||||
expect(finding.detail).toMatch(/password/);
|
||||
});
|
||||
|
||||
it('warns when the rate limiter would count CDN edges', () => {
|
||||
const finding = checkCdn('https://cdn.example.net', false, 0)!;
|
||||
expect(finding.level).toBe('warn');
|
||||
expect(finding.title).toMatch(/TRUSTED_PROXY_HOPS/);
|
||||
});
|
||||
|
||||
it('reports ok with a proxy chain configured', () => {
|
||||
const finding = checkCdn('https://cdn.example.net', false, 2)!;
|
||||
expect(finding.level).toBe('ok');
|
||||
expect(finding.detail).toContain('https://cdn.example.net');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLegal', () => {
|
||||
const complete = {
|
||||
enabled: true,
|
||||
name: 'Ralf',
|
||||
address: 'Street 1',
|
||||
zipCity: '10247 Berlin',
|
||||
email: 'mail@example.com',
|
||||
contactUrl: 'https://example.com/contact',
|
||||
};
|
||||
|
||||
it('returns nothing while the Impressum is switched off or absent', () => {
|
||||
expect(checkLegal({ ...complete, enabled: false })).toBeNull();
|
||||
expect(checkLegal(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('passes with name, address, email and a second channel', () => {
|
||||
expect(checkLegal(complete)?.level).toBe('ok');
|
||||
expect(checkLegal({ ...complete, contactUrl: undefined, phone: '+49 30 1' })?.level).toBe('ok');
|
||||
});
|
||||
|
||||
it('names the missing address fields', () => {
|
||||
const f = checkLegal({ ...complete, address: '', zipCity: ' ' })!;
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.detail).toContain('street address, ZIP and city');
|
||||
});
|
||||
|
||||
it('asks for an email address', () => {
|
||||
expect(checkLegal({ ...complete, email: undefined })!.detail).toContain('No email address');
|
||||
});
|
||||
|
||||
it('flags email as the only contact channel', () => {
|
||||
const f = checkLegal({ ...complete, contactUrl: undefined })!;
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.detail).toContain('only contact channel');
|
||||
});
|
||||
|
||||
it('reports a contact URL the page drops, and does not count it as a channel', () => {
|
||||
const f = checkLegal({ ...complete, contactUrl: 'javascript:alert(1)' })!;
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.title).toContain('2 gaps');
|
||||
expect(f.detail).toContain('ignored');
|
||||
expect(f.detail).toContain('only contact channel');
|
||||
});
|
||||
|
||||
it('tolerates hand-edited YAML with the wrong types', () => {
|
||||
const f = checkLegal({ ...complete, name: 42, email: ['a'] })!;
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.detail).toContain('Missing: name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLegal with the built-in contact form', () => {
|
||||
const emailOnly = {
|
||||
enabled: true,
|
||||
name: 'Ralf',
|
||||
address: 'Street 1',
|
||||
zipCity: '10247 Berlin',
|
||||
email: 'mail@example.com',
|
||||
};
|
||||
|
||||
it('counts the form as the second contact channel', () => {
|
||||
expect(checkLegal(emailOnly)?.level).toBe('warn');
|
||||
expect(checkLegal(emailOnly, true)?.level).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkContact', () => {
|
||||
it('returns nothing while the form is off', () => {
|
||||
expect(checkContact({ enabled: false })).toBeNull();
|
||||
expect(checkContact(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when nobody hears about new messages', () => {
|
||||
expect(checkContact({ enabled: true })?.level).toBe('warn');
|
||||
});
|
||||
|
||||
it('warns about a URL that is not http(s)', () => {
|
||||
const f = checkContact({ enabled: true, notifyUrl: 'ntfy.sh/topic' })!;
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.title).toContain('ignored');
|
||||
});
|
||||
|
||||
it('is satisfied by the settings value or by CONTACT_NOTIFY_URL', () => {
|
||||
expect(checkContact({ enabled: true, notifyUrl: 'https://ntfy.sh/t' })?.level).toBe('ok');
|
||||
expect(checkContact({ enabled: true }, 'https://ntfy.sh/t')?.level).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPrivacy', () => {
|
||||
it('stays quiet on a site with no legal pages at all', () => {
|
||||
expect(checkPrivacy({ legalEnabled: false, privacyEnabled: true, hasText: false })).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when the Impressum is on and there is no policy', () => {
|
||||
const f = checkPrivacy({ legalEnabled: true, privacyEnabled: true, hasText: false })!;
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.detail).toContain('no text');
|
||||
});
|
||||
|
||||
it('warns when the text exists but the page is switched off', () => {
|
||||
const f = checkPrivacy({ legalEnabled: true, privacyEnabled: false, hasText: true })!;
|
||||
expect(f.detail).toContain('switched off');
|
||||
});
|
||||
|
||||
it('is satisfied by a published policy', () => {
|
||||
expect(checkPrivacy({ legalEnabled: true, privacyEnabled: true, hasText: true })?.level).toBe(
|
||||
'ok',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
39
lib/__tests__/emailObfuscation.test.ts
Normal file
39
lib/__tests__/emailObfuscation.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createElement } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { decodeEmail, encodeEmail, spelledOutEmail } from '../emailObfuscation';
|
||||
import EmailLink from '@/components/EmailLink';
|
||||
|
||||
const ADDRESS = 'ralf.test+impressum@example.de';
|
||||
|
||||
describe('email obfuscation', () => {
|
||||
it('round-trips, including non-ASCII addresses', () => {
|
||||
expect(decodeEmail(encodeEmail(ADDRESS))).toBe(ADDRESS);
|
||||
expect(decodeEmail(encodeEmail('jürgen@müller.de'))).toBe('jürgen@müller.de');
|
||||
});
|
||||
|
||||
it('leaves nothing a harvester would match in the encoded form', () => {
|
||||
const encoded = encodeEmail(ADDRESS);
|
||||
expect(encoded).not.toContain('@');
|
||||
expect(encoded).not.toContain('example');
|
||||
});
|
||||
|
||||
it('decodes garbage to an empty string instead of throwing', () => {
|
||||
expect(decodeEmail('%%%')).toBe('');
|
||||
});
|
||||
|
||||
it('spells the address out for the no-script fallback', () => {
|
||||
expect(spelledOutEmail('a.b@example.de')).toBe('a [dot] b [at] example [dot] de');
|
||||
});
|
||||
|
||||
/*
|
||||
* The point of the exercise: the server HTML carries neither the address
|
||||
* nor a mailto: link. It appears only once the browser has hydrated.
|
||||
*/
|
||||
it('renders no address and no mailto: on the server', () => {
|
||||
const html = renderToString(createElement(EmailLink, { encoded: encodeEmail(ADDRESS) }));
|
||||
expect(html).not.toContain(ADDRESS);
|
||||
expect(html).not.toContain('mailto:');
|
||||
expect(html).not.toContain('[at]');
|
||||
});
|
||||
});
|
||||
46
lib/__tests__/essay-sequence.test.ts
Normal file
46
lib/__tests__/essay-sequence.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { essayPhotoSequence } from '../essaySequence';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
// Six photos in the subpage's albums; the essay shows only some of them.
|
||||
const photos = ['a', 'b', 'c', 'd', 'e', 'f'].map((id) => ({ id }));
|
||||
const resolve = (key: string) => {
|
||||
const byIndex = Number(key);
|
||||
if (Number.isInteger(byIndex) && byIndex >= 1) return photos[byIndex - 1];
|
||||
return photos.find((p) => p.id === key);
|
||||
};
|
||||
const ids = (list: { id: string }[]) => list.map((p) => p.id);
|
||||
|
||||
describe('essayPhotoSequence', () => {
|
||||
it('lists only the photos the blocks show, in reading order', () => {
|
||||
const blocks: JournalBlock[] = [
|
||||
{ type: 'photo', assetId: 'c', layout: 'wide' },
|
||||
{ type: 'paragraph', html: 'text' },
|
||||
{ type: 'photo-pair', assetIds: ['a', 'e'] },
|
||||
];
|
||||
expect(ids(essayPhotoSequence(blocks, undefined, resolve))).toEqual(['c', 'a', 'e']);
|
||||
});
|
||||
|
||||
it('puts the cover first and does not repeat it', () => {
|
||||
const blocks: JournalBlock[] = [{ type: 'photo', assetId: 'b', layout: 'wide' }];
|
||||
expect(ids(essayPhotoSequence(blocks, 'b', resolve))).toEqual(['b']);
|
||||
expect(ids(essayPhotoSequence(blocks, 'd', resolve))).toEqual(['d', 'b']);
|
||||
});
|
||||
|
||||
it('resolves 1-based index references', () => {
|
||||
const blocks: JournalBlock[] = [{ type: 'photo', assetId: '6', layout: 'contained' }];
|
||||
expect(ids(essayPhotoSequence(blocks, undefined, resolve))).toEqual(['f']);
|
||||
});
|
||||
|
||||
it('skips a pair with a missing half, like the renderer', () => {
|
||||
const blocks: JournalBlock[] = [{ type: 'photo-pair', assetIds: ['a', 'missing'] }];
|
||||
expect(essayPhotoSequence(blocks, undefined, resolve)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a grid only while two photos resolve', () => {
|
||||
const one: JournalBlock[] = [{ type: 'photo-grid', assetIds: ['a', 'x', 'y'] }];
|
||||
const two: JournalBlock[] = [{ type: 'photo-grid', assetIds: ['a', 'x', 'b'] }];
|
||||
expect(essayPhotoSequence(one, undefined, resolve)).toEqual([]);
|
||||
expect(ids(essayPhotoSequence(two, undefined, resolve))).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
102
lib/__tests__/essay-source.test.ts
Normal file
102
lib/__tests__/essay-source.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'essay-source-'));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||
subpages: [],
|
||||
albumPasswords: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Journal files come from a temp dir instead of content/journal/. Both the
|
||||
// loader and lib/auth's password lookup go through this module, so the gate
|
||||
// under test reads the same file the essay does.
|
||||
vi.mock('@/lib/admin/journal-service', async () => {
|
||||
const { parseJournalMarkdown } = await import('@/lib/journal');
|
||||
const file = (slug: string) => path.join(dir, `${slug}.md`);
|
||||
return {
|
||||
resolveJournalFilePath: (slug: string) => file(slug),
|
||||
loadEssayFromFile: (slug: string) =>
|
||||
fs.existsSync(file(slug)) ? parseJournalMarkdown(fs.readFileSync(file(slug), 'utf8')) : null,
|
||||
};
|
||||
});
|
||||
|
||||
import { resolveEssayFile, generatedEssayCaption } from '@/lib/essaySource';
|
||||
import { authenticate } from '@/lib/auth';
|
||||
import type { ImmichAsset } from '@/lib/immich';
|
||||
|
||||
const write = (slug: string, frontmatter: string) =>
|
||||
fs.writeFileSync(path.join(dir, `${slug}.md`), `---\n${frontmatter}\n---\n\nBody text.\n`);
|
||||
|
||||
const anonymous = { isAdmin: false, getCookie: () => undefined };
|
||||
|
||||
beforeAll(() => {
|
||||
write('open', 'title: "Open"');
|
||||
write('draft', 'title: "Draft"\ndraft: true');
|
||||
write('locked', 'title: "Locked"\npassword: "letmein"');
|
||||
});
|
||||
|
||||
afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
describe('resolveEssayFile', () => {
|
||||
it('returns a public entry', () => {
|
||||
const result = resolveEssayFile('open', anonymous);
|
||||
expect(result.status).toBe('open');
|
||||
});
|
||||
|
||||
it('treats a missing entry as missing', () => {
|
||||
expect(resolveEssayFile('nope', anonymous)).toEqual({ status: 'missing' });
|
||||
});
|
||||
|
||||
it('hides a draft from visitors', () => {
|
||||
expect(resolveEssayFile('draft', anonymous)).toEqual({ status: 'missing' });
|
||||
});
|
||||
|
||||
it('shows a draft to the admin', () => {
|
||||
expect(resolveEssayFile('draft', { ...anonymous, isAdmin: true }).status).toBe('open');
|
||||
});
|
||||
|
||||
it('locks a password-protected entry without a cookie', () => {
|
||||
expect(resolveEssayFile('locked', anonymous)).toEqual({ status: 'locked', title: 'Locked' });
|
||||
});
|
||||
|
||||
it('keeps the entry locked for the admin, like /journal/<slug> does', () => {
|
||||
expect(resolveEssayFile('locked', { ...anonymous, isAdmin: true }).status).toBe('locked');
|
||||
});
|
||||
|
||||
it('opens a protected entry with the journal cookie', async () => {
|
||||
const setCookie = await authenticate('locked', 'letmein', 'journal');
|
||||
expect(setCookie).not.toBeNull();
|
||||
const [pair] = setCookie!.split(';');
|
||||
const [name, value] = pair.split('=');
|
||||
const result = resolveEssayFile('locked', {
|
||||
isAdmin: false,
|
||||
getCookie: (n) => (n === name ? value : undefined),
|
||||
});
|
||||
expect(result.status).toBe('open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatedEssayCaption', () => {
|
||||
const asset = (description?: string) =>
|
||||
({ exifInfo: { description } }) as Pick<ImmichAsset, 'exifInfo'>;
|
||||
|
||||
it('escapes markup in an Immich description', () => {
|
||||
expect(generatedEssayCaption(asset('<b>hi</b> & "you"'), true)).toBe(
|
||||
'<b>hi</b> & "you"',
|
||||
);
|
||||
});
|
||||
|
||||
it('follows the exif.caption toggle', () => {
|
||||
expect(generatedEssayCaption(asset('A caption'), false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves no caption for an empty description', () => {
|
||||
expect(generatedEssayCaption(asset(' '), true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
124
lib/__tests__/fonts.test.ts
Normal file
124
lib/__tests__/fonts.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
__resetFontsForTests,
|
||||
getFontFile,
|
||||
getGoogleCss,
|
||||
parseFamilies,
|
||||
rewriteCss,
|
||||
} from '../fonts';
|
||||
import { getFontsCssUrl } from '../config/theme';
|
||||
|
||||
const GSTATIC_URL = 'https://fonts.gstatic.com/s/archivo/v19/abc.woff2';
|
||||
const GOOGLE_CSS = `@font-face {
|
||||
font-family: 'Archivo';
|
||||
font-weight: 400;
|
||||
src: url(${GSTATIC_URL}) format('woff2');
|
||||
}`;
|
||||
|
||||
let cwd: string;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// lib/fonts.ts caches under <cwd>/content/.fonts.
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-fonts-'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(cwd);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.startsWith('https://fonts.googleapis.com/')) return new Response(GOOGLE_CSS);
|
||||
if (url === GSTATIC_URL) return new Response(new Uint8Array([1, 2, 3]));
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
__resetFontsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const fileName = () =>
|
||||
rewriteCss(GOOGLE_CSS).match(/\/api\/fonts\/file\/([a-f0-9]{32}\.woff2)/)![1];
|
||||
|
||||
describe('getFontsCssUrl', () => {
|
||||
it('points at this origin, one family parameter per distinct family', () => {
|
||||
expect(getFontsCssUrl(['Archivo', 'Archivo', 'IBM Plex Mono'])).toBe(
|
||||
'/api/fonts/css?family=Archivo&family=IBM%20Plex%20Mono',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFamilies', () => {
|
||||
it('accepts Google Fonts names with spaces and digits', () => {
|
||||
expect(parseFamilies(['Source Sans 3', 'IBM Plex Mono'])).toEqual([
|
||||
'Source Sans 3',
|
||||
'IBM Plex Mono',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects anything that could change the upstream query', () => {
|
||||
expect(parseFamilies(['Inter:wght@100'])).toBeNull();
|
||||
expect(parseFamilies(['Inter&family=Roboto'])).toBeNull();
|
||||
expect(parseFamilies(['../etc'])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty list and more families than a theme has slots', () => {
|
||||
expect(parseFamilies([])).toBeNull();
|
||||
expect(parseFamilies(['A', 'B', 'C', 'D'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteCss', () => {
|
||||
it('replaces every gstatic URL with a same-origin one', () => {
|
||||
const css = rewriteCss(GOOGLE_CSS);
|
||||
expect(css).not.toContain('gstatic');
|
||||
expect(css).toMatch(/url\(\/api\/fonts\/file\/[a-f0-9]{32}\.woff2\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGoogleCss', () => {
|
||||
it('fetches once and serves the cached copy afterwards', async () => {
|
||||
expect(await getGoogleCss(['Archivo'])).toBe(GOOGLE_CSS);
|
||||
__resetFontsForTests();
|
||||
expect(await getGoogleCss(['Archivo'])).toBe(GOOGLE_CSS);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks Google with a browser User-Agent, so the answer is WOFF2', async () => {
|
||||
await getGoogleCss(['IBM Plex Mono']);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain('family=IBM+Plex+Mono:wght@');
|
||||
expect(init.headers['User-Agent']).toMatch(/Chrome/);
|
||||
});
|
||||
|
||||
it('does not retry a failed fetch on every request', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
expect(await getGoogleCss(['Archivo'])).toBeNull();
|
||||
expect(await getGoogleCss(['Archivo'])).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFontFile', () => {
|
||||
it('serves a file that a fetched stylesheet named, and caches it', async () => {
|
||||
await getGoogleCss(['Archivo']);
|
||||
expect(await getFontFile(fileName())).toEqual(new Uint8Array([1, 2, 3]));
|
||||
expect(fs.existsSync(path.join(cwd, 'content', '.fonts', fileName()))).toBe(true);
|
||||
});
|
||||
|
||||
it('finds the file after a restart from the stylesheet on disk', async () => {
|
||||
await getGoogleCss(['Archivo']);
|
||||
__resetFontsForTests();
|
||||
expect(await getFontFile(fileName())).toEqual(new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('refuses a name no stylesheet produced, without fetching anything', async () => {
|
||||
expect(await getFontFile('0'.repeat(32) + '.woff2')).toBeNull();
|
||||
expect(await getFontFile('../../settings.yaml')).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,17 +11,27 @@ import { describe, it, expect } from 'vitest';
|
||||
import { resolveLocale, getDictionary, dictionaryFor, SUPPORTED_LOCALES } from '../i18n';
|
||||
import { en } from '../i18n/locales/en';
|
||||
import { de } from '../i18n/locales/de';
|
||||
import { fr } from '../i18n/locales/fr';
|
||||
import { es } from '../i18n/locales/es';
|
||||
import { it as itIT } from '../i18n/locales/it';
|
||||
import { nl } from '../i18n/locales/nl';
|
||||
import type { Dictionary } from '../i18n';
|
||||
|
||||
describe('resolveLocale', () => {
|
||||
it('accepts the supported locales', () => {
|
||||
expect(resolveLocale('en')).toBe('en');
|
||||
expect(resolveLocale('de')).toBe('de');
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
expect(resolveLocale(locale)).toBe(locale);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the region subtag', () => {
|
||||
expect(resolveLocale('de-DE')).toBe('de');
|
||||
expect(resolveLocale('de-AT')).toBe('de');
|
||||
expect(resolveLocale('en_GB')).toBe('en');
|
||||
expect(resolveLocale('fr-CA')).toBe('fr');
|
||||
expect(resolveLocale('es-MX')).toBe('es');
|
||||
expect(resolveLocale('it-CH')).toBe('it');
|
||||
expect(resolveLocale('nl-BE')).toBe('nl');
|
||||
});
|
||||
|
||||
it('is case- and whitespace-insensitive', () => {
|
||||
@@ -30,8 +40,8 @@ describe('resolveLocale', () => {
|
||||
});
|
||||
|
||||
it('falls back to English for languages without a dictionary', () => {
|
||||
expect(resolveLocale('fr')).toBe('en');
|
||||
expect(resolveLocale('ja-JP')).toBe('en');
|
||||
expect(resolveLocale('pt')).toBe('en');
|
||||
expect(resolveLocale('')).toBe('en');
|
||||
expect(resolveLocale(undefined)).toBe('en');
|
||||
expect(resolveLocale(null)).toBe('en');
|
||||
@@ -47,7 +57,8 @@ describe('getDictionary', () => {
|
||||
|
||||
it('dictionaryFor resolves and looks up in one step', () => {
|
||||
expect(dictionaryFor('de-DE')).toBe(de);
|
||||
expect(dictionaryFor('fr')).toBe(en);
|
||||
expect(dictionaryFor('fr-FR')).toBe(fr);
|
||||
expect(dictionaryFor('ja')).toBe(en);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,37 +78,110 @@ function invoke(fn: (...args: never[]) => string): string {
|
||||
return (fn as (...a: unknown[]) => string)(...args);
|
||||
}
|
||||
|
||||
describe('dictionary parity', () => {
|
||||
const enLeaves = leaves(en);
|
||||
const deLeaves = new Map(leaves(de));
|
||||
/**
|
||||
* Keys a locale legitimately shares with English — proper nouns, loanwords,
|
||||
* units and cognates that read the same in both languages. Anything not listed
|
||||
* here and still identical to English is an untranslated string.
|
||||
*/
|
||||
const SHARED_EVERYWHERE = [
|
||||
'lightbox.iso',
|
||||
// Pure interpolation with no words of its own.
|
||||
'subpage.coverAria',
|
||||
];
|
||||
|
||||
it('German covers every English key', () => {
|
||||
expect([...deLeaves.keys()].sort()).toEqual(enLeaves.map(([k]) => k).sort());
|
||||
const TRANSLATIONS: [string, Dictionary, string[]][] = [
|
||||
[
|
||||
'de',
|
||||
de,
|
||||
[
|
||||
'nav.journal',
|
||||
'journal.title',
|
||||
'lightbox.copyLinkShort',
|
||||
'lightbox.downloadShort',
|
||||
'common.website',
|
||||
'contact.name',
|
||||
],
|
||||
],
|
||||
[
|
||||
'fr',
|
||||
fr,
|
||||
[
|
||||
'nav.journal',
|
||||
'journal.title',
|
||||
'about.portraitAlt',
|
||||
'legal.contact',
|
||||
'lightbox.downloadShort',
|
||||
'subpage.sectionsNav',
|
||||
'subpage.collectionKicker',
|
||||
// French shares the words "photo(s)", "album(s)" and "collection(s)".
|
||||
'common.photos',
|
||||
'common.albums',
|
||||
'common.collections',
|
||||
'contact.navLabel',
|
||||
'contact.title',
|
||||
'contact.message',
|
||||
],
|
||||
],
|
||||
['es', es, ['lightbox.downloadShort', 'lightbox.info']],
|
||||
[
|
||||
'it',
|
||||
itIT,
|
||||
['nav.home', 'common.home', 'lightbox.info', 'lightbox.copyLinkShort', 'privacy.navLabel'],
|
||||
],
|
||||
[
|
||||
'nl',
|
||||
nl,
|
||||
[
|
||||
'nav.journal',
|
||||
'journal.title',
|
||||
'common.website',
|
||||
'common.albums',
|
||||
'lightbox.camera',
|
||||
'lightbox.info',
|
||||
'lightbox.copyLinkShort',
|
||||
'legal.contact',
|
||||
'contact.navLabel',
|
||||
'contact.title',
|
||||
'privacy.navLabel',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
describe('supported locales', () => {
|
||||
it('have a parity check below for every non-English dictionary', () => {
|
||||
expect(TRANSLATIONS.map(([code]) => code).sort()).toEqual(
|
||||
SUPPORTED_LOCALES.filter((l) => l !== 'en').sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('each dictionary is the one getDictionary serves', () => {
|
||||
for (const [code, dict] of TRANSLATIONS) {
|
||||
expect(getDictionary(code as (typeof SUPPORTED_LOCALES)[number])).toBe(dict);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(TRANSLATIONS)('dictionary parity: %s', (code, dict, sharedKeys) => {
|
||||
const enLeaves = leaves(en);
|
||||
const otherLeaves = new Map(leaves(dict));
|
||||
|
||||
it('covers every English key', () => {
|
||||
expect([...otherLeaves.keys()].sort()).toEqual(enLeaves.map(([k]) => k).sort());
|
||||
});
|
||||
|
||||
it('matches value kinds — a string never stands in for an interpolator', () => {
|
||||
for (const [path, value] of enLeaves) {
|
||||
expect(typeof deLeaves.get(path), path).toBe(typeof value);
|
||||
expect(typeof otherLeaves.get(path), path).toBe(typeof value);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no English string untranslated', () => {
|
||||
// Proper nouns, loanwords and units read the same in both languages;
|
||||
// `coverAria` is pure interpolation with no words of its own.
|
||||
const shared = new Set([
|
||||
'nav.journal',
|
||||
'journal.title',
|
||||
'lightbox.iso',
|
||||
'lightbox.copyLinkShort',
|
||||
'lightbox.downloadShort',
|
||||
'common.website',
|
||||
'subpage.coverAria',
|
||||
]);
|
||||
const shared = new Set([...SHARED_EVERYWHERE, ...sharedKeys]);
|
||||
|
||||
const identical = enLeaves
|
||||
.filter(([path]) => !shared.has(path))
|
||||
.filter(([path, value]) => {
|
||||
const other = deLeaves.get(path);
|
||||
const other = otherLeaves.get(path);
|
||||
if (typeof value === 'function') {
|
||||
return invoke(other as (...a: never[]) => string) === invoke(value as never);
|
||||
}
|
||||
@@ -108,11 +192,30 @@ describe('dictionary parity', () => {
|
||||
expect(identical).toEqual([]);
|
||||
});
|
||||
|
||||
it('plural helpers actually vary on their count', () => {
|
||||
for (const dict of [en, de]) {
|
||||
it('lists only keys that really are identical, so the allowance cannot go stale', () => {
|
||||
const english = new Map(enLeaves);
|
||||
const stale = sharedKeys.filter((path) => {
|
||||
const value = english.get(path);
|
||||
const other = otherLeaves.get(path);
|
||||
return typeof value === 'function'
|
||||
? invoke(other as (...a: never[]) => string) !== invoke(value as never)
|
||||
: other !== value;
|
||||
});
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it('formats dates for its own language', () => {
|
||||
expect(dict.dateLocale.split('-')[0]).toBe(code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plural helpers', () => {
|
||||
it.each([['en', en] as const, ...TRANSLATIONS.map(([c, d]) => [c, d] as const)])(
|
||||
'vary on their count in %s',
|
||||
(_code, dict) => {
|
||||
expect(dict.common.photos(1)).not.toBe(dict.common.photos(2));
|
||||
expect(dict.common.albums(1)).not.toBe(dict.common.albums(2));
|
||||
expect(dict.common.collections(1)).not.toBe(dict.common.collections(2));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
86
lib/__tests__/password-hashing.test.ts
Normal file
86
lib/__tests__/password-hashing.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hashFrontmatterPassword, hashPassword, hashPasswordKeys } from '../admin/passwordHashing';
|
||||
import { generateScryptHash, verifyScrypt } from '../password';
|
||||
|
||||
const KEY = new Set(['password']);
|
||||
|
||||
describe('hashPassword', () => {
|
||||
it('hashes plaintext, and leaves empty, scrypt and bcrypt values alone', async () => {
|
||||
const hashed = await hashPassword('secret', []);
|
||||
expect(hashed).toMatch(/^scrypt:/);
|
||||
expect(await verifyScrypt('secret', hashed)).toBe(true);
|
||||
expect(await hashPassword('', [])).toBe('');
|
||||
expect(await hashPassword(hashed, [])).toBe(hashed);
|
||||
expect(await hashPassword('$2b$10$abcdefghijklmno', [])).toBe('$2b$10$abcdefghijklmno');
|
||||
});
|
||||
|
||||
/*
|
||||
* The visitor's unlock cookie signs the stored value. The admin form re-sends
|
||||
* the plaintext after a save; a fresh salt each time would sign everyone out.
|
||||
*/
|
||||
it('keeps an existing hash when the plaintext still matches it', async () => {
|
||||
const existing = await generateScryptHash('secret');
|
||||
expect(await hashPassword('secret', [existing])).toBe(existing);
|
||||
expect(await hashPassword('other', [existing])).not.toBe(existing);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashPasswordKeys', () => {
|
||||
it('hashes every password in a gallery, however deep, and nothing else', async () => {
|
||||
const gallery = {
|
||||
albums: [{ 'album-1': { title: 'Kyoto', password: 'a' } }],
|
||||
subpages: [
|
||||
{
|
||||
name: 'Clients',
|
||||
password: 'b',
|
||||
sections: [{ title: 'S', albums: [{ 'album-2': { password: 'c' } }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = await hashPasswordKeys(gallery, KEY, null);
|
||||
const albums = out.albums as Array<Record<string, { title: string; password: string }>>;
|
||||
const section = (
|
||||
out.subpages[0].sections![0].albums as Array<Record<string, { password: string }>>
|
||||
)[0];
|
||||
expect(albums[0]['album-1'].password).toMatch(/^scrypt:/);
|
||||
expect(albums[0]['album-1'].title).toBe('Kyoto');
|
||||
expect(out.subpages[0].password).toMatch(/^scrypt:/);
|
||||
expect(section['album-2'].password).toMatch(/^scrypt:/);
|
||||
// The input is not mutated.
|
||||
expect(gallery.subpages[0].password).toBe('b');
|
||||
});
|
||||
|
||||
it('reuses a hash from the file on disk, wherever the entry moved to', async () => {
|
||||
const onDisk = { subpages: [{ name: 'A', password: await generateScryptHash('b') }] };
|
||||
const out = await hashPasswordKeys(
|
||||
{ subpages: [{ name: 'Renamed', password: 'b' }] },
|
||||
KEY,
|
||||
onDisk,
|
||||
);
|
||||
expect(out.subpages[0].password).toBe(onDisk.subpages[0].password);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashFrontmatterPassword', () => {
|
||||
const entry = (pw: string) =>
|
||||
`---\ntitle: "Price: $5 & more"\npassword: ${pw}\ndraft: true\n---\n\nBody with password: not-this\n`;
|
||||
|
||||
it('hashes only the frontmatter line and leaves the rest byte for byte', async () => {
|
||||
const out = await hashFrontmatterPassword(entry('secret'), null);
|
||||
const line = out.match(/^password: "(scrypt:[^"]+)"$/m)!;
|
||||
expect(await verifyScrypt('secret', line[1])).toBe(true);
|
||||
expect(out).toContain('title: "Price: $5 & more"');
|
||||
expect(out).toContain('Body with password: not-this');
|
||||
});
|
||||
|
||||
it('understands quoted values and keeps the previous hash when unchanged', async () => {
|
||||
const previous = await hashFrontmatterPassword(entry('secret'), null);
|
||||
const again = await hashFrontmatterPassword(entry('"secret"'), previous);
|
||||
expect(again).toBe(previous);
|
||||
});
|
||||
|
||||
it('leaves an entry without a password untouched', async () => {
|
||||
const md = '---\ntitle: Open\n---\n\nText\n';
|
||||
expect(await hashFrontmatterPassword(md, null)).toBe(md);
|
||||
});
|
||||
});
|
||||
97
lib/__tests__/preset-contrast.test.ts
Normal file
97
lib/__tests__/preset-contrast.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
THEME_PRESETS,
|
||||
resolveTheme,
|
||||
accentForMode,
|
||||
onAccent,
|
||||
contrastRatio,
|
||||
} from '../config/theme';
|
||||
|
||||
/**
|
||||
* Every preset's accent must stay visible on its own page background in both
|
||||
* colour modes, and text on an accent-filled button must stay readable. Minimal
|
||||
* shipped a black accent on its black dark mode: the password gate's button and
|
||||
* the error page's "Try again" were invisible.
|
||||
*
|
||||
* The backgrounds are read from app/tokens.css, so a palette change that breaks
|
||||
* an accent fails here too.
|
||||
*/
|
||||
const css = fs.readFileSync(path.join(process.cwd(), 'app', 'tokens.css'), 'utf8');
|
||||
|
||||
function bgPrimary(selector: string): string | undefined {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const block = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(css)?.[1];
|
||||
return block ? /--bg-primary:\s*(#[0-9a-fA-F]{3,6})/.exec(block)?.[1] : undefined;
|
||||
}
|
||||
|
||||
const defaults = { dark: bgPrimary(':root')!, light: bgPrimary("[data-theme='light']")! };
|
||||
|
||||
function background(preset: string, mode: 'dark' | 'light'): string {
|
||||
const own =
|
||||
mode === 'dark'
|
||||
? bgPrimary(`[data-preset='${preset}']`)
|
||||
: bgPrimary(`[data-preset='${preset}'][data-theme='light']`);
|
||||
return own ?? defaults[mode];
|
||||
}
|
||||
|
||||
// 3:1 is WCAG's floor for UI components and large text (non-text contrast,
|
||||
// 1.4.11); the accent draws dots, rules, active states and button fills.
|
||||
const ACCENT_MIN = 3;
|
||||
const BUTTON_TEXT_MIN = 4.5;
|
||||
|
||||
describe('preset accent contrast', () => {
|
||||
it('finds a background for every preset and mode', () => {
|
||||
expect(defaults.dark).toBeDefined();
|
||||
expect(defaults.light).toBeDefined();
|
||||
});
|
||||
|
||||
for (const name of Object.keys(THEME_PRESETS)) {
|
||||
for (const mode of ['dark', 'light'] as const) {
|
||||
it(`${name} (${mode}): accent is visible on the page`, () => {
|
||||
const accent = accentForMode(resolveTheme(name), mode);
|
||||
const ratio = contrastRatio(accent, background(name, mode))!;
|
||||
expect(ratio).toBeGreaterThanOrEqual(ACCENT_MIN);
|
||||
});
|
||||
|
||||
it(`${name} (${mode}): button text is readable on the accent`, () => {
|
||||
const accent = accentForMode(resolveTheme(name), mode);
|
||||
expect(contrastRatio(onAccent(accent), accent)!).toBeGreaterThanOrEqual(BUTTON_TEXT_MIN);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('per-mode accents', () => {
|
||||
it('apply while the owner keeps the preset accent', () => {
|
||||
const theme = resolveTheme({ preset: 'minimal', accent: '#000000' });
|
||||
expect(accentForMode(theme, 'dark')).toBe('#ffffff');
|
||||
expect(accentForMode(theme, 'light')).toBe('#000000');
|
||||
});
|
||||
|
||||
it('give way to an accent of the owner’s own, in both modes', () => {
|
||||
const theme = resolveTheme({ preset: 'minimal', accent: '#0055ff' });
|
||||
expect(accentForMode(theme, 'dark')).toBe('#0055ff');
|
||||
expect(accentForMode(theme, 'light')).toBe('#0055ff');
|
||||
});
|
||||
|
||||
it('fall back to the single accent where a preset has none', () => {
|
||||
const theme = resolveTheme('studio-modern');
|
||||
expect(accentForMode(theme, 'dark')).toBe(theme.accent);
|
||||
expect(accentForMode(theme, 'light')).toBe(theme.accent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAccent', () => {
|
||||
it('picks the stronger of black and white', () => {
|
||||
expect(onAccent('#000000')).toBe('#ffffff');
|
||||
expect(onAccent('#ffffff')).toBe('#000000');
|
||||
expect(onAccent('#c49a3c')).toBe('#000000');
|
||||
expect(onAccent('#e60012')).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('keeps white for a colour it cannot parse', () => {
|
||||
expect(onAccent('rebeccapurple')).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
89
lib/__tests__/privacy.test.ts
Normal file
89
lib/__tests__/privacy.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseMarkdownBlocks } from '../markdownBlocks';
|
||||
import { privacyAvailable, processingFacts, starterHeadings } from '../privacy';
|
||||
import type { AppConfig } from '../config/schema';
|
||||
|
||||
describe('parseMarkdownBlocks', () => {
|
||||
it('reads headings, lists and paragraphs', () => {
|
||||
const blocks = parseMarkdownBlocks(
|
||||
'# Datenschutz\n\nErster Satz\nzweiter Satz.\n\n### Detail\n- eins\n- **zwei**\n\n1. a\n2. b',
|
||||
);
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'heading', level: 2, html: 'Datenschutz' },
|
||||
{ type: 'paragraph', html: 'Erster Satz zweiter Satz.' },
|
||||
{ type: 'heading', level: 3, html: 'Detail' },
|
||||
{ type: 'list', ordered: false, items: ['eins', '<strong>zwei</strong>'] },
|
||||
{ type: 'list', ordered: true, items: ['a', 'b'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('escapes the author text before adding its own tags', () => {
|
||||
const [p] = parseMarkdownBlocks('<script>alert(1)</script> [x](javascript:alert(1))');
|
||||
const html = p.type === 'paragraph' ? p.html : '';
|
||||
expect(html).toContain('<script>');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('<a ');
|
||||
});
|
||||
});
|
||||
|
||||
const config = (over: Partial<AppConfig> = {}) =>
|
||||
({
|
||||
map: false,
|
||||
analytics: false,
|
||||
contact: { enabled: false, retentionDays: 90 },
|
||||
proofing: { enabled: false },
|
||||
privacy: { enabled: true },
|
||||
...over,
|
||||
}) as unknown as AppConfig;
|
||||
|
||||
describe('processingFacts', () => {
|
||||
it('lists only what this installation actually does', () => {
|
||||
const topics = processingFacts(config(), { hasPasswords: false }).map((f) => f.topic);
|
||||
expect(topics).toEqual(['Photos', 'Server log', 'Fonts', 'Browser storage']);
|
||||
});
|
||||
|
||||
it('names third parties for the map, the notification host and a CDN', () => {
|
||||
const facts = processingFacts(
|
||||
config({
|
||||
map: true,
|
||||
contact: { enabled: true, retentionDays: 30, notifyUrl: 'https://ntfy.sh/secret' },
|
||||
}),
|
||||
{ hasPasswords: true, CDN_URL: 'https://cdn.example.net' },
|
||||
);
|
||||
const third = facts.filter((f) => f.thirdParty).map((f) => f.topic);
|
||||
// The notification carries nothing about the sender (#702), so the
|
||||
// contact form is not a third-party transfer.
|
||||
expect(third).toEqual(['Map', 'CDN']);
|
||||
const contact = facts.find((f) => f.topic === 'Contact form')!;
|
||||
expect(contact.detail).toContain('30 days');
|
||||
// The host, never the topic path: the topic name is the secret.
|
||||
expect(contact.detail).toContain('ntfy.sh');
|
||||
expect(contact.detail).not.toContain('secret');
|
||||
expect(facts.some((f) => f.topic === 'Cookies')).toBe(true);
|
||||
});
|
||||
|
||||
it('turns the facts into headings in the site language, and nothing else', () => {
|
||||
const facts = processingFacts(config({ map: true }), { hasPasswords: false });
|
||||
const de = starterHeadings('de', facts);
|
||||
expect(de).toContain('## Kartendienst');
|
||||
expect(de).not.toContain('Kontaktformular');
|
||||
expect(de.split('\n').filter((l) => l && !l.startsWith('## '))).toEqual([]);
|
||||
expect(starterHeadings('en', facts)).toContain('## Your rights');
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacyAvailable', () => {
|
||||
it('needs the switch on and text in the file', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-privacy-'));
|
||||
expect(privacyAvailable(config(), dir)).toBe(false);
|
||||
fs.writeFileSync(path.join(dir, 'privacy.md'), ' \n');
|
||||
expect(privacyAvailable(config(), dir)).toBe(false);
|
||||
fs.writeFileSync(path.join(dir, 'privacy.md'), '## Text\n');
|
||||
expect(privacyAvailable(config(), dir)).toBe(true);
|
||||
expect(privacyAvailable(config({ privacy: { enabled: false } }), dir)).toBe(false);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -12,11 +12,27 @@ vi.mock('@/lib/auth', () => ({
|
||||
isSiteUnlocked: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/cdn', () => ({
|
||||
cdnOrigin: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
// Null by default: isKnownMissing() then leaves every path to the page.
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfigOrNull: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { isSiteUnlocked } from '@/lib/auth';
|
||||
import { cdnOrigin } from '@/lib/cdn';
|
||||
import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
|
||||
import { proxy, config } from '@/proxy';
|
||||
import { proxy, config, isKnownMissing } from '@/proxy';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const mockUnlocked = isSiteUnlocked as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockCdnOrigin = cdnOrigin as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockConfig = getConfigOrNull as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Next.js 16 renamed the "middleware" file convention to "proxy": the file must
|
||||
// be proxy.ts and must export proxy(), not middleware(). A silent regression here
|
||||
@@ -27,6 +43,28 @@ describe('proxy', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockUnlocked.mockReturnValue(true);
|
||||
mockCdnOrigin.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe('CDN mode', () => {
|
||||
it('leaves the policy untouched without a CDN', () => {
|
||||
const csp = run().headers.get('Content-Security-Policy')!;
|
||||
expect(csp).not.toContain('media-src');
|
||||
});
|
||||
|
||||
it('allows the CDN origin for images and video', () => {
|
||||
mockCdnOrigin.mockReturnValue('https://cdn.example.net');
|
||||
const csp = run().headers.get('Content-Security-Policy')!;
|
||||
const directive = (name: string) =>
|
||||
csp
|
||||
.split(';')
|
||||
.map((d) => d.trim())
|
||||
.find((d) => d.startsWith(`${name} `));
|
||||
expect(directive('img-src')).toContain('https://cdn.example.net');
|
||||
expect(directive('media-src')).toBe("media-src 'self' https://cdn.example.net");
|
||||
// Scripts never come from the CDN.
|
||||
expect(directive('script-src')).not.toContain('cdn.example.net');
|
||||
});
|
||||
});
|
||||
|
||||
it('is exported under the name Next.js 16 expects', () => {
|
||||
@@ -229,3 +267,61 @@ describe('proxy', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* app/loading.tsx makes every page stream, so notFound() in a page can no
|
||||
* longer change the 200 that was already sent. Routes whose existence the
|
||||
* proxy can decide on its own get the 404 before streaming starts.
|
||||
*/
|
||||
describe('isKnownMissing', () => {
|
||||
let contentDir: string;
|
||||
const settings = {
|
||||
contact: { enabled: false },
|
||||
legal: { enabled: true },
|
||||
map: false,
|
||||
privacy: { enabled: true },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
contentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-proxy-'));
|
||||
fs.mkdirSync(path.join(contentDir, 'journal'));
|
||||
fs.writeFileSync(path.join(contentDir, 'journal', 'kyoto.md'), '---\ntitle: Kyoto\n---\n');
|
||||
mockConfig.mockReturnValue(settings);
|
||||
});
|
||||
|
||||
it('knows the pages that are switched off in settings', () => {
|
||||
expect(isKnownMissing('/contact', contentDir)).toBe(true);
|
||||
expect(isKnownMissing('/map', contentDir)).toBe(true);
|
||||
expect(isKnownMissing('/impressum', contentDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('knows /privacy by its switch and its file', () => {
|
||||
expect(isKnownMissing('/privacy', contentDir)).toBe(true);
|
||||
fs.writeFileSync(path.join(contentDir, 'privacy.md'), '## Datenschutz\n');
|
||||
expect(isKnownMissing('/privacy', contentDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('knows a journal entry by its file, and rejects slugs that are not slugs', () => {
|
||||
expect(isKnownMissing('/journal/kyoto', contentDir)).toBe(false);
|
||||
expect(isKnownMissing('/journal/osaka', contentDir)).toBe(true);
|
||||
expect(isKnownMissing('/journal/..%2Fsettings', contentDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves album and subpage slugs to the page, which needs Immich to decide', () => {
|
||||
expect(isKnownMissing('/japan', contentDir)).toBe(false);
|
||||
expect(isKnownMissing('/japan/tokyo', contentDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('decides nothing when the config cannot be read', () => {
|
||||
mockConfig.mockReturnValue(null);
|
||||
expect(isKnownMissing('/contact', contentDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('makes proxy() answer 404 with the policy still set', () => {
|
||||
const res = proxy(new NextRequest('https://example.com/contact'));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get('Content-Security-Policy')).toBeTruthy();
|
||||
mockConfig.mockReturnValue(null);
|
||||
expect(proxy(new NextRequest('https://example.com/contact')).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,12 @@ describe('resolveSiteUrl', () => {
|
||||
});
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('passes an already absolute URL through — a photo on the CDN', () => {
|
||||
const cdn = 'https://cdn.example.net/api/image/v2:abc?size=preview';
|
||||
expect(absoluteUrl('https://folio.example', cdn)).toBe(cdn);
|
||||
expect(absoluteUrl(null, cdn)).toBe(cdn);
|
||||
});
|
||||
|
||||
it('joins a path', () => {
|
||||
expect(absoluteUrl('https://folio.example', '/travel')).toBe('https://folio.example/travel');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveTheme, THEME_PRESETS, DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
import {
|
||||
resolveTheme,
|
||||
accentForMode,
|
||||
onAccent,
|
||||
THEME_PRESETS,
|
||||
DEFAULT_PRESET,
|
||||
} from '@/lib/config/theme';
|
||||
|
||||
/**
|
||||
* `radius` reaches CSS as `${radius}px` and `${radius * 1.5}px` (app/layout.tsx),
|
||||
@@ -45,6 +51,41 @@ describe('resolveTheme scalar coercion (#633)', () => {
|
||||
expect(resolveTheme({ accent: '#123456' }).accent).toBe('#123456');
|
||||
});
|
||||
|
||||
it('normalises a 3-digit accent to the #rrggbb every consumer survives', () => {
|
||||
expect(resolveTheme({ accent: '#f00' }).accent).toBe('#ff0000');
|
||||
expect(resolveTheme({ accent: '#FFF' }).accent).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('normalises an 8-digit accent by dropping the alpha', () => {
|
||||
expect(resolveTheme({ accent: '#e6001280' }).accent).toBe('#e60012');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from an accent', () => {
|
||||
expect(resolveTheme({ accent: ' #e60012 ' }).accent).toBe('#e60012');
|
||||
});
|
||||
|
||||
it('falls back to the preset accent for a hex literal without the #', () => {
|
||||
// The nastiest input: defined, so var() substitutes it and any accent-
|
||||
// backed background becomes transparent instead of taking its fallback.
|
||||
expect(resolveTheme({ accent: 'e60012' }).accent).toBe(base.accent);
|
||||
});
|
||||
|
||||
it('falls back to the preset accent for a 5-digit hex', () => {
|
||||
expect(resolveTheme({ accent: '#12345' }).accent).toBe(base.accent);
|
||||
});
|
||||
|
||||
it('falls back to the preset accent for named and functional colours', () => {
|
||||
// They would render, but onAccent() reads only hex and would answer white.
|
||||
expect(resolveTheme({ accent: 'red' }).accent).toBe(base.accent);
|
||||
expect(resolveTheme({ accent: 'rgb(230 0 18)' }).accent).toBe(base.accent);
|
||||
});
|
||||
|
||||
it('gives a white accent dark button text, the proofing ZIP button included', () => {
|
||||
// #ffffff on the light theme rendered Download selected white-on-white.
|
||||
const theme = resolveTheme({ accent: '#FFF' });
|
||||
expect(onAccent(accentForMode(theme, 'light'))).toBe('#000000');
|
||||
});
|
||||
|
||||
it('falls back to the preset grain/headerDot when not a boolean', () => {
|
||||
const theme = resolveTheme({
|
||||
grain: 'yes' as unknown as boolean,
|
||||
|
||||
@@ -173,6 +173,53 @@ export function checkProxyHops(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CDN mode (lib/cdn.ts). Only reported when `CDN_URL` is set.
|
||||
*
|
||||
* Two ways it quietly goes wrong. A site password keeps the photos on this
|
||||
* host on purpose, which reads as "the CDN does nothing". And with
|
||||
* `TRUSTED_PROXY_HOPS=0`, every cache miss is rate-limited by the CDN edge's
|
||||
* IP instead of the visitor's: a handful of edges share one bucket, and a busy
|
||||
* page can push one of them into 429s that then show up as missing photos.
|
||||
*/
|
||||
export function checkCdn(
|
||||
cdnUrl: string | undefined,
|
||||
sitePasswordSet: boolean,
|
||||
trustedProxyHops: number,
|
||||
): DoctorFinding | null {
|
||||
if (!cdnUrl) return null;
|
||||
|
||||
if (sitePasswordSet) {
|
||||
return {
|
||||
id: 'cdn',
|
||||
level: 'warn',
|
||||
title: 'CDN_URL is set, but photos are served from this server',
|
||||
detail:
|
||||
'The site is password-protected, and a CDN would hand cached photos to anyone with a ' +
|
||||
'link, so CDN mode stays off. Remove the site password or unset CDN_URL.',
|
||||
};
|
||||
}
|
||||
|
||||
if (trustedProxyHops === 0) {
|
||||
return {
|
||||
id: 'cdn',
|
||||
level: 'warn',
|
||||
title: 'CDN mode is on, but TRUSTED_PROXY_HOPS is 0',
|
||||
detail:
|
||||
`Photos are served through ${cdnUrl}, and without TRUSTED_PROXY_HOPS the rate limiter ` +
|
||||
'counts the CDN edge instead of the visitor. Set it to the number of proxies in front of ' +
|
||||
'the app, the CDN included.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'cdn',
|
||||
level: 'ok',
|
||||
title: 'CDN mode is on',
|
||||
detail: `Photos and videos are served through ${cdnUrl}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Headers a reverse proxy sets and Next never synthesises. */
|
||||
export const PROXY_MARKER_HEADERS = ['x-real-ip', 'forwarded', 'via'];
|
||||
|
||||
@@ -369,7 +416,8 @@ export function checkPasswords(passwords: PasswordRef[]): DoctorFinding {
|
||||
level: 'warn',
|
||||
title: `${plaintext.length} password${plaintext.length === 1 ? ' is' : 's are'} stored in plaintext`,
|
||||
detail:
|
||||
'Anyone who reads the file reads the password. Log in once and replace it with the ' +
|
||||
'Anyone who reads the file reads the password. Saving the page, entry or setting once ' +
|
||||
'in the admin panel stores it hashed; for a hand-edited file, log in once and paste the ' +
|
||||
`scrypt: hash printed to the server log: ${plaintext.map((p) => p.label).join(', ')}`,
|
||||
};
|
||||
}
|
||||
@@ -391,6 +439,154 @@ export function checkPasswords(passwords: PasswordRef[]): DoctorFinding {
|
||||
};
|
||||
}
|
||||
|
||||
/** The `legal:` block as settings.yaml holds it, before resolveLegal(). */
|
||||
export interface LegalRef {
|
||||
enabled?: unknown;
|
||||
name?: unknown;
|
||||
address?: unknown;
|
||||
zipCity?: unknown;
|
||||
email?: unknown;
|
||||
phone?: unknown;
|
||||
contactUrl?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an enabled Impressum carries what § 5 DDG asks for: a name and a
|
||||
* postal address, an email address, and a second fast way to get in touch
|
||||
* (phone or a contact form, ECJ C-298/07).
|
||||
*
|
||||
* It takes the raw block rather than the resolved one because resolveLegal()
|
||||
* drops a contact URL that is not http(s) and only tells the server log, so
|
||||
* the link would just go missing. The http(s) test repeats isHttpUrl() from
|
||||
* lib/config/schema.ts: this module has no imports, so the CLI can load it.
|
||||
* Returns null when the page is switched off.
|
||||
*/
|
||||
export function checkLegal(
|
||||
legal: LegalRef | null | undefined,
|
||||
/** The built-in form (#702) is linked from the Impressum when no contactUrl is set. */
|
||||
contactFormEnabled = false,
|
||||
): DoctorFinding | null {
|
||||
if (!legal || legal.enabled !== true) return null;
|
||||
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
|
||||
const email = text(legal.email);
|
||||
const phone = text(legal.phone);
|
||||
const rawUrl = text(legal.contactUrl);
|
||||
const contactUrl = /^https?:\/\//i.test(rawUrl) ? rawUrl : '';
|
||||
const secondChannel = phone || contactUrl || contactFormEnabled;
|
||||
|
||||
const problems: string[] = [];
|
||||
const missing = (
|
||||
[
|
||||
['name', legal.name],
|
||||
['street address', legal.address],
|
||||
['ZIP and city', legal.zipCity],
|
||||
] as const
|
||||
)
|
||||
.filter(([, value]) => !text(value))
|
||||
.map(([label]) => label);
|
||||
if (missing.length) problems.push(`Missing: ${missing.join(', ')}.`);
|
||||
if (!email) problems.push('No email address; § 5 DDG requires one.');
|
||||
if (rawUrl && !contactUrl) {
|
||||
problems.push('The contact form URL is ignored because it does not start with http(s)://.');
|
||||
}
|
||||
if (email && !secondChannel) {
|
||||
problems.push(
|
||||
'Email is the only contact channel. Add a phone number, a contact form URL, or turn on the built-in contact form.',
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
return {
|
||||
id: 'legal',
|
||||
level: 'warn',
|
||||
title: `The Impressum has ${problems.length} gap${problems.length === 1 ? '' : 's'}`,
|
||||
detail: problems.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'legal',
|
||||
level: 'ok',
|
||||
title: 'The Impressum is complete',
|
||||
detail: 'Name, address, email and a second contact channel are set.',
|
||||
};
|
||||
}
|
||||
|
||||
/** The `contact:` block as settings.yaml holds it. */
|
||||
export interface ContactRef {
|
||||
enabled?: unknown;
|
||||
notifyUrl?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contact form stores messages without telling anyone unless a
|
||||
* notification URL is set. The ECJ reads the second contact channel as one
|
||||
* that allows "direct and effective" communication, and a form nobody looks
|
||||
* at does not. Returns null when the form is off.
|
||||
*/
|
||||
export function checkContact(
|
||||
contact: ContactRef | null | undefined,
|
||||
envNotifyUrl?: string,
|
||||
): DoctorFinding | null {
|
||||
if (!contact || contact.enabled !== true) return null;
|
||||
const url =
|
||||
envNotifyUrl?.trim() || (typeof contact.notifyUrl === 'string' ? contact.notifyUrl.trim() : '');
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
id: 'contact',
|
||||
level: 'warn',
|
||||
title: 'Contact form messages notify nobody',
|
||||
detail:
|
||||
'New messages only show up under Messages in the admin panel. Set an ntfy URL so you hear about them.',
|
||||
};
|
||||
}
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return {
|
||||
id: 'contact',
|
||||
level: 'warn',
|
||||
title: 'The contact notification URL is ignored',
|
||||
detail: 'It does not start with http(s)://, so no notification is sent.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: 'contact',
|
||||
level: 'ok',
|
||||
title: 'Contact form notifies you',
|
||||
detail: 'Each new message triggers a notification.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A site that needs an Impressum almost always needs a privacy policy too
|
||||
* (Art. 13 GDPR). Returns null while there is neither an Impressum nor a
|
||||
* policy, so a site that has chosen to have no legal pages is not nagged.
|
||||
*/
|
||||
export function checkPrivacy(p: {
|
||||
legalEnabled: boolean;
|
||||
privacyEnabled: boolean;
|
||||
hasText: boolean;
|
||||
}): DoctorFinding | null {
|
||||
if (p.privacyEnabled && p.hasText) {
|
||||
return {
|
||||
id: 'privacy',
|
||||
level: 'ok',
|
||||
title: 'The privacy policy is published',
|
||||
detail: 'Shown at /privacy and linked in the footer.',
|
||||
};
|
||||
}
|
||||
if (!p.legalEnabled) return null;
|
||||
return {
|
||||
id: 'privacy',
|
||||
level: 'warn',
|
||||
title: 'There is no privacy policy',
|
||||
detail: p.hasText
|
||||
? 'The text exists but the privacy page is switched off, so /privacy answers 404.'
|
||||
: 'The Impressum is on but /privacy has no text. Settings → Legal lists what this site processes, as a starting point.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole content directory must be writable: the wizard, the admin panel,
|
||||
* the journal, favicon upload, analytics and backup rotation all write there.
|
||||
|
||||
120
lib/admin/passwordHashing.ts
Normal file
120
lib/admin/passwordHashing.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Passwords typed into the admin panel are stored as scrypt hashes (#690).
|
||||
*
|
||||
* The save routes run every password field through here before writing, so
|
||||
* gallery.yaml, settings.yaml and journal frontmatter never receive the
|
||||
* password as typed. Reading plaintext keeps working for hand-written files
|
||||
* (lib/auth.ts, with its deprecation warning); the doctor still reports it.
|
||||
*
|
||||
* ── Why the previous file is consulted ─────────────────────────────
|
||||
*
|
||||
* A visitor's unlock cookie is an HMAC over the *stored* value (lib/auth.ts),
|
||||
* so replacing a hash with a fresh one, same password but a new salt, signs
|
||||
* out everyone who had unlocked the page. The admin form keeps the plaintext
|
||||
* in memory after a save and sends it again with the next one. Hashing it
|
||||
* again each time would log visitors out on every unrelated edit. So a
|
||||
* plaintext value that an existing hash already verifies keeps that hash.
|
||||
*/
|
||||
|
||||
import { generateScryptHash, isScryptHash, verifyScrypt } from '../password';
|
||||
|
||||
const BCRYPT_RE = /^\$2[aby]\$/;
|
||||
|
||||
/** Every scrypt hash stored under one of `keys`, anywhere in the document. */
|
||||
export function collectHashes(node: unknown, keys: ReadonlySet<string>): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (value: unknown) => {
|
||||
if (Array.isArray(value)) value.forEach(walk);
|
||||
else if (value && typeof value === 'object') {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (keys.has(k) && typeof v === 'string' && isScryptHash(v)) found.push(v);
|
||||
else walk(v);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value to store for one password field. Empty stays empty (no password),
|
||||
* a hash stays as it is, and bcrypt is left alone for the doctor to report,
|
||||
* since it cannot be verified any more and re-hashing it would lock the page
|
||||
* with the hash string as its password.
|
||||
*/
|
||||
export async function hashPassword(value: string, previousHashes: string[]): Promise<string> {
|
||||
if (!value || isScryptHash(value) || BCRYPT_RE.test(value)) return value;
|
||||
for (const hash of previousHashes) {
|
||||
if (await verifyScrypt(value, hash)) return hash;
|
||||
}
|
||||
return generateScryptHash(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of `node` with every string under one of `keys` hashed. `previous` is
|
||||
* the document currently on disk, whose hashes are reused where they match.
|
||||
*/
|
||||
export async function hashPasswordKeys<T>(
|
||||
node: T,
|
||||
keys: ReadonlySet<string>,
|
||||
previous: unknown,
|
||||
): Promise<T> {
|
||||
const previousHashes = collectHashes(previous, keys);
|
||||
const walk = async (value: unknown): Promise<unknown> => {
|
||||
if (Array.isArray(value)) return Promise.all(value.map(walk));
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[k] =
|
||||
keys.has(k) && typeof v === 'string'
|
||||
? await hashPassword(v, previousHashes)
|
||||
: await walk(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return (await walk(node)) as T;
|
||||
}
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
const PASSWORD_LINE_RE = /^(\s*password\s*:\s*)(.*?)\s*$/m;
|
||||
|
||||
function unquote(raw: string): string {
|
||||
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
|
||||
return raw.slice(1, -1).replace(/\\([\\"])/g, '$1');
|
||||
}
|
||||
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function frontmatterPassword(markdown: string | null): string | null {
|
||||
const block = markdown?.match(FRONTMATTER_RE)?.[1];
|
||||
const line = block?.match(PASSWORD_LINE_RE);
|
||||
return line ? unquote(line[2]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The journal entry with its `password:` frontmatter line hashed. Only that
|
||||
* line changes: running the whole entry through parse and serialize would
|
||||
* also rewrite everything else the author wrote.
|
||||
*/
|
||||
export async function hashFrontmatterPassword(
|
||||
markdown: string,
|
||||
previousMarkdown: string | null,
|
||||
): Promise<string> {
|
||||
const block = markdown.match(FRONTMATTER_RE);
|
||||
if (!block) return markdown;
|
||||
const line = block[1].match(PASSWORD_LINE_RE);
|
||||
if (!line) return markdown;
|
||||
|
||||
const value = unquote(line[2]);
|
||||
const previous = frontmatterPassword(previousMarkdown);
|
||||
const hashed = await hashPassword(value, previous && isScryptHash(previous) ? [previous] : []);
|
||||
if (hashed === value) return markdown;
|
||||
|
||||
// Replacer functions, not strings: a `$` in the author's title would
|
||||
// otherwise be read as a replacement pattern.
|
||||
const newBlock = block[1].replace(PASSWORD_LINE_RE, () => `${line[1]}"${hashed}"`);
|
||||
return markdown.replace(block[1], () => newBlock);
|
||||
}
|
||||
@@ -120,7 +120,8 @@ export async function listBackups(filename: string): Promise<string[]> {
|
||||
* readable file over content/settings.yaml — which the admin GET endpoints then
|
||||
* hand straight back.
|
||||
*/
|
||||
const BACKUP_FILENAME = /^(gallery\.yaml|settings\.yaml|about\.md)\.[\w-]+\.(pre-restore\.)?bak$/;
|
||||
const BACKUP_FILENAME =
|
||||
/^(gallery\.yaml|settings\.yaml|about\.md|privacy\.md)\.[\w-]+\.(pre-restore\.)?bak$/;
|
||||
|
||||
/** Restore a specific backup. */
|
||||
export async function restoreBackup(backupFilename: string): Promise<void> {
|
||||
|
||||
69
lib/cdn.ts
Normal file
69
lib/cdn.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* CDN mode — serve photos and videos through a pull CDN.
|
||||
*
|
||||
* With `CDN_URL` set, every image and video URL the server renders points at
|
||||
* the CDN instead of this host: `https://cdn.example.com/api/image/<token>…`.
|
||||
* The CDN is configured with this server as its origin, so a cache miss is
|
||||
* fetched from `/api/image` exactly as a browser would, and every hit after
|
||||
* that never reaches the server — or the Immich box behind it — at all.
|
||||
*
|
||||
* This works because the image and video routes are already CDN-shaped: the
|
||||
* URL is the whole capability (an opaque, deterministic token plus size, width,
|
||||
* quality and the IMAGE_CACHE_VERSION buster), the response does not depend on
|
||||
* cookies, and successful responses are `public, max-age=31536000, immutable`.
|
||||
*
|
||||
* Pages, `/_next` assets, EXIF, the map, downloads and every admin route stay
|
||||
* on this host. Pages are rendered per request, and the others either carry a
|
||||
* cookie check or are small enough not to matter.
|
||||
*
|
||||
* **A site-wide password turns CDN mode off.** On a locked site the image route
|
||||
* checks the visitor's unlock cookie, and a CDN would answer from its cache
|
||||
* without asking — every photo would be public to anyone holding a URL. So the
|
||||
* URLs quietly stay on this host while `sitePassword` is set, whatever CDN_URL
|
||||
* says, and the diagnostics page reports why.
|
||||
*/
|
||||
|
||||
import { env } from './env';
|
||||
import { getConfigOrNull } from './config';
|
||||
|
||||
let warnedLocked = false;
|
||||
|
||||
/**
|
||||
* The prefix to put in front of `/api/image/…` and `/api/video/…`, or `''`
|
||||
* to keep them relative. Evaluated per call, because the site password can be
|
||||
* switched on from the admin panel without a restart.
|
||||
*/
|
||||
export function cdnBase(): string {
|
||||
const base = env.CDN_URL;
|
||||
if (!base) return '';
|
||||
|
||||
let locked: boolean;
|
||||
try {
|
||||
locked = !!getConfigOrNull()?.sitePassword;
|
||||
} catch {
|
||||
// An unreadable config renders the setup screen; no image URLs are made.
|
||||
locked = true;
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
if (!warnedLocked) {
|
||||
warnedLocked = true;
|
||||
console.warn(
|
||||
'[CDN] CDN_URL is set, but the site is password-protected — serving photos from this ' +
|
||||
'server so the password keeps covering them.',
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CDN's origin, for the Content-Security-Policy's `img-src` and
|
||||
* `media-src`. Independent of the site password: allowing an origin the page
|
||||
* happens not to use costs nothing, and the proxy that builds the policy runs
|
||||
* before the config is consulted.
|
||||
*/
|
||||
export function cdnOrigin(): string | null {
|
||||
return env.CDN_URL ? new URL(env.CDN_URL).origin : null;
|
||||
}
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
GalleryYaml,
|
||||
SettingsYaml,
|
||||
GridConfig,
|
||||
LegalConfig,
|
||||
ContactConfig,
|
||||
CONTACT_RETENTION_DEFAULT,
|
||||
CONTACT_RETENTION_MAX,
|
||||
isHttpUrl,
|
||||
} from './schema';
|
||||
|
||||
export * from './schema';
|
||||
@@ -171,6 +176,69 @@ export function sanitizeNavLinks(
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `legal:` block of settings.yaml, as the Impressum page renders it.
|
||||
*
|
||||
* `contactUrl` ends up in an `href`, so it gets the same rule as navLinks:
|
||||
* anything that is not http(s) is dropped with a warning, which keeps a
|
||||
* `javascript:` URL in hand-edited YAML off the page without taking it down.
|
||||
* Empty strings become undefined so the page can test fields with `&&`.
|
||||
*/
|
||||
export function resolveLegal(raw?: Partial<LegalConfig>): LegalConfig {
|
||||
const opt = (value?: string) => value?.trim() || undefined;
|
||||
let contactUrl = opt(raw?.contactUrl);
|
||||
if (contactUrl && !isHttpUrl(contactUrl)) {
|
||||
console.warn(
|
||||
`[Folio] settings.yaml legal.contactUrl: dropping ${JSON.stringify(contactUrl)} — ` +
|
||||
'only http(s) URLs are allowed.',
|
||||
);
|
||||
contactUrl = undefined;
|
||||
}
|
||||
return {
|
||||
enabled: raw?.enabled === true,
|
||||
heading: opt(raw?.heading),
|
||||
name: raw?.name || '',
|
||||
address: raw?.address || '',
|
||||
zipCity: raw?.zipCity || '',
|
||||
country: raw?.country || '',
|
||||
email: opt(raw?.email),
|
||||
phone: opt(raw?.phone),
|
||||
contactUrl,
|
||||
contactLabel: opt(raw?.contactLabel),
|
||||
taxId: opt(raw?.taxId),
|
||||
vatId: opt(raw?.vatId),
|
||||
extraInfo: opt(raw?.extraInfo),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `contact:` block. `CONTACT_NOTIFY_URL` wins over `notifyUrl`, the same
|
||||
* precedence SITE_PASSWORD has: an ntfy topic URL is as good as a password to
|
||||
* anyone who wants to spam the owner's phone, so it may live outside the YAML.
|
||||
* A non-http(s) URL is dropped with a warning, like `legal.contactUrl`.
|
||||
*/
|
||||
export function resolveContact(
|
||||
raw?: SettingsYaml['contact'],
|
||||
envNotifyUrl?: string,
|
||||
): ContactConfig {
|
||||
let notifyUrl = envNotifyUrl?.trim() || raw?.notifyUrl?.trim() || undefined;
|
||||
if (notifyUrl && !isHttpUrl(notifyUrl)) {
|
||||
console.warn(
|
||||
`[Folio] contact.notifyUrl: dropping ${JSON.stringify(notifyUrl)} — only http(s) URLs are allowed.`,
|
||||
);
|
||||
notifyUrl = undefined;
|
||||
}
|
||||
const days = raw?.retentionDays;
|
||||
return {
|
||||
enabled: raw?.enabled === true,
|
||||
notifyUrl,
|
||||
retentionDays:
|
||||
typeof days === 'number' && Number.isFinite(days)
|
||||
? clamp(Math.round(days), 1, CONTACT_RETENTION_MAX)
|
||||
: CONTACT_RETENTION_DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
/** The parts of AppConfig that come from gallery.yaml. */
|
||||
export interface GalleryDerivation {
|
||||
albums: string[];
|
||||
@@ -561,6 +629,8 @@ export function getConfig(): AppConfig {
|
||||
theme: resolveTheme(DEFAULT_PRESET),
|
||||
footer: null,
|
||||
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
|
||||
contact: { enabled: false, retentionDays: CONTACT_RETENTION_DEFAULT },
|
||||
privacy: { enabled: false },
|
||||
map: false,
|
||||
transitions: false,
|
||||
scrollToTop: false,
|
||||
@@ -666,18 +736,9 @@ export function getConfig(): AppConfig {
|
||||
website: settings.footer.website,
|
||||
}
|
||||
: null,
|
||||
legal: {
|
||||
enabled: settings.legal?.enabled === true,
|
||||
name: settings.legal?.name || '',
|
||||
address: settings.legal?.address || '',
|
||||
zipCity: settings.legal?.zipCity || '',
|
||||
country: settings.legal?.country || '',
|
||||
email: settings.legal?.email,
|
||||
phone: settings.legal?.phone,
|
||||
taxId: settings.legal?.taxId,
|
||||
vatId: settings.legal?.vatId,
|
||||
extraInfo: settings.legal?.extraInfo,
|
||||
},
|
||||
legal: resolveLegal(settings.legal),
|
||||
contact: resolveContact(settings.contact, env.CONTACT_NOTIFY_URL),
|
||||
privacy: { enabled: settings.privacy?.enabled !== false },
|
||||
map: settings.map === true,
|
||||
transitions: settings.transitions !== false,
|
||||
scrollToTop: settings.scrollToTop !== false,
|
||||
|
||||
@@ -70,22 +70,58 @@ export interface FooterConfig {
|
||||
website?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a URL may go into an `href` taken from settings: http(s) only, so a
|
||||
* `javascript:` or `data:` URL in hand-edited YAML never reaches a link.
|
||||
*/
|
||||
export function isHttpUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url.trim());
|
||||
}
|
||||
|
||||
export interface LegalConfig {
|
||||
enabled: boolean;
|
||||
/** Replaces the statute line under the title ("Angaben gemäß § 5 DDG"). */
|
||||
heading?: string;
|
||||
name: string;
|
||||
address: string;
|
||||
zipCity: string;
|
||||
country: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
/** Second contact channel next to email, e.g. a contact form. http(s) only. */
|
||||
contactUrl?: string;
|
||||
contactLabel?: string;
|
||||
taxId?: string;
|
||||
vatId?: string;
|
||||
extraInfo?: string;
|
||||
}
|
||||
|
||||
/** The built-in contact form (`/contact`, #702). */
|
||||
export interface ContactConfig {
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Where a "new message" notification is POSTed, e.g. an ntfy topic URL.
|
||||
* `CONTACT_NOTIFY_URL` overrides the settings.yaml value. http(s) only.
|
||||
*/
|
||||
notifyUrl?: string;
|
||||
/** Messages older than this are deleted. */
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export const CONTACT_RETENTION_DEFAULT = 90;
|
||||
export const CONTACT_RETENTION_MAX = 365;
|
||||
|
||||
export interface ThemeConfig {
|
||||
preset: string;
|
||||
accent: string;
|
||||
/**
|
||||
* The accent for one colour mode, where a preset's single `accent` fails
|
||||
* contrast against that mode's background (Minimal's black on its black dark
|
||||
* mode, say). Only presets set these, and resolveTheme keeps them only while
|
||||
* the owner uses the preset's own accent. See accentForMode().
|
||||
*/
|
||||
accentDark?: string;
|
||||
accentLight?: string;
|
||||
fonts: { heading: string; body: string; caption: string };
|
||||
radius: number;
|
||||
photoFrame: 'none' | 'passepartout' | 'shadow';
|
||||
@@ -217,6 +253,9 @@ export interface AppConfig {
|
||||
theme: ThemeConfig;
|
||||
footer: FooterConfig | null;
|
||||
legal: LegalConfig;
|
||||
contact: ContactConfig;
|
||||
/** /privacy, shown while this is on and content/privacy.md has text (#699). */
|
||||
privacy: { enabled: boolean };
|
||||
map: boolean;
|
||||
transitions: boolean;
|
||||
/** Floating back-to-top arrow in the frontend. */
|
||||
@@ -399,6 +438,8 @@ export interface SettingsYaml {
|
||||
};
|
||||
footer?: FooterConfig;
|
||||
legal?: Partial<LegalConfig>;
|
||||
contact?: { enabled?: boolean; notifyUrl?: string; retentionDays?: number };
|
||||
privacy?: { enabled?: boolean };
|
||||
/** EXPERIMENTAL: external links appended to the header navigation. */
|
||||
navLinks?: Array<{ label?: string; url?: string }>;
|
||||
protection?: {
|
||||
|
||||
@@ -96,18 +96,25 @@ export const settingsSchema = z.looseObject({
|
||||
legal: z
|
||||
.looseObject({
|
||||
enabled: bool,
|
||||
heading: str,
|
||||
name: str,
|
||||
address: str,
|
||||
zipCity: str,
|
||||
country: str,
|
||||
email: str,
|
||||
phone: str,
|
||||
contactUrl: str,
|
||||
contactLabel: str,
|
||||
taxId: str,
|
||||
vatId: str,
|
||||
extraInfo: str,
|
||||
})
|
||||
.optional(),
|
||||
|
||||
contact: z.looseObject({ enabled: bool, notifyUrl: str, retentionDays: num }).optional(),
|
||||
|
||||
privacy: z.looseObject({ enabled: bool }).optional(),
|
||||
|
||||
navLinks: z.array(z.looseObject({ label: str, url: str })).optional(),
|
||||
|
||||
protection: z.looseObject({ disableRightClick: bool, disableImageDrag: bool }).optional(),
|
||||
|
||||
@@ -27,6 +27,7 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
minimal: {
|
||||
preset: 'minimal',
|
||||
accent: '#000000',
|
||||
accentDark: '#ffffff',
|
||||
fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
@@ -37,6 +38,7 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
editorial: {
|
||||
preset: 'editorial',
|
||||
accent: '#8B2500',
|
||||
accentDark: '#d9602a',
|
||||
fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
|
||||
radius: 0,
|
||||
photoFrame: 'shadow',
|
||||
@@ -47,6 +49,7 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
classic: {
|
||||
preset: 'classic',
|
||||
accent: '#c49a3c',
|
||||
accentLight: '#8d6f2b',
|
||||
fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
|
||||
radius: 12,
|
||||
photoFrame: 'passepartout',
|
||||
@@ -57,6 +60,7 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
noir: {
|
||||
preset: 'noir',
|
||||
accent: '#ff6b35',
|
||||
accentLight: '#c2410c',
|
||||
fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'passepartout',
|
||||
@@ -67,6 +71,7 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
monograph: {
|
||||
preset: 'monograph',
|
||||
accent: '#333333',
|
||||
accentDark: '#c8c8c8',
|
||||
fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
@@ -110,9 +115,71 @@ function resolveRadius(raw: unknown, fallback: number): number {
|
||||
return Math.min(THEME_RADIUS_MAX, Math.max(0, raw));
|
||||
}
|
||||
|
||||
/** `--accent` and `--accent-dim` (app/layout.tsx) take this verbatim. */
|
||||
/**
|
||||
* The one form of `--accent` every consumer survives: a `#rrggbb` hex.
|
||||
* A value that is not a colour at all is worse than it looks —
|
||||
* `background: var(--accent, #e60012)` does not fall back, because the
|
||||
* variable is *defined*; the declaration becomes invalid at computed-value
|
||||
* time and the background comes out transparent, leaving the button text on
|
||||
* the surface below. Named and functional colours (`red`, `rgb(230 0 18)`)
|
||||
* would render, but `onAccent()` only reads hex and would answer white for
|
||||
* them whatever their lightness. Parsing arbitrary CSS colour syntax
|
||||
* server-side is a dependency this does not need: hex is what the admin's
|
||||
* picker writes and what every preset uses, so everything else falls back to
|
||||
* the preset, the same treatment `resolveRadius` gives `radius`.
|
||||
*/
|
||||
const ACCENT_HEX = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
|
||||
/** Trim, case-fold and widen to `#rrggbb`; refuse anything that is not hex. */
|
||||
function resolveAccent(raw: unknown, fallback: string): string {
|
||||
return typeof raw === 'string' && raw.trim() ? raw : fallback;
|
||||
if (typeof raw !== 'string') return fallback;
|
||||
const value = raw.trim();
|
||||
if (!ACCENT_HEX.test(value)) return fallback;
|
||||
const hex = value.slice(1).toLowerCase();
|
||||
if (hex.length === 3) {
|
||||
return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`;
|
||||
}
|
||||
// 8-digit keeps the hue and drops the alpha — an accent a visitor can read
|
||||
// is worth more than the translucency the 12%-dim variants approximate anyway.
|
||||
return `#${hex.slice(0, 6)}`;
|
||||
}
|
||||
|
||||
/** The accent a colour mode renders with (`--accent-dark` / `--accent-light`). */
|
||||
export function accentForMode(theme: ThemeConfig, mode: 'dark' | 'light'): string {
|
||||
return (mode === 'dark' ? theme.accentDark : theme.accentLight) ?? theme.accent;
|
||||
}
|
||||
|
||||
/** WCAG relative luminance of a `#rgb` / `#rrggbb` colour, or null for anything else. */
|
||||
export function relativeLuminance(color: string): number | null {
|
||||
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
|
||||
if (!m) return null;
|
||||
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
|
||||
const [r, g, b] = [0, 2, 4].map((i) => {
|
||||
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio of two hex colours; null when either is not hex. */
|
||||
export function contrastRatio(a: string, b: string): number | null {
|
||||
const la = relativeLuminance(a);
|
||||
const lb = relativeLuminance(b);
|
||||
if (la === null || lb === null) return null;
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text colour for a button filled with the accent (`--on-accent`): black or
|
||||
* white, whichever contrasts more. Buttons used `--bg-primary` for this, which
|
||||
* is the one colour guaranteed to vanish when the accent matches the page —
|
||||
* Minimal's black-on-black gate button. A non-hex accent keeps white.
|
||||
*/
|
||||
export function onAccent(accent: string): string {
|
||||
const toBlack = contrastRatio(accent, '#000000');
|
||||
const toWhite = contrastRatio(accent, '#ffffff');
|
||||
if (toBlack === null || toWhite === null) return '#ffffff';
|
||||
return toBlack > toWhite ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
/** `String(theme.grain)`/`String(theme.headerDot)` become a `data-*` attribute
|
||||
@@ -144,9 +211,17 @@ export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
);
|
||||
}
|
||||
|
||||
const accent = resolveAccent(raw.accent, base.accent);
|
||||
// An accent of the owner's own is their choice in both modes. The preset's
|
||||
// per-mode variants stand in only for the preset's accent — which the admin
|
||||
// may also have written into settings.yaml verbatim.
|
||||
const ownAccent = accent.trim().toLowerCase() !== base.accent.toLowerCase();
|
||||
|
||||
return {
|
||||
preset: baseName,
|
||||
accent: resolveAccent(raw.accent, base.accent),
|
||||
accent,
|
||||
...(!ownAccent && base.accentDark ? { accentDark: base.accentDark } : {}),
|
||||
...(!ownAccent && base.accentLight ? { accentLight: base.accentLight } : {}),
|
||||
fonts: {
|
||||
heading: raw.fonts?.heading ?? base.fonts.heading,
|
||||
body: raw.fonts?.body ?? base.fonts.body,
|
||||
@@ -164,9 +239,16 @@ export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function getGoogleFontsUrl(theme: ThemeConfig): string {
|
||||
const weights = '300;400;500;600';
|
||||
const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])];
|
||||
const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&');
|
||||
return `https://fonts.googleapis.com/css2?${params}&display=swap`;
|
||||
/**
|
||||
* The stylesheet for a set of Google Fonts families, served from this origin
|
||||
* by /api/fonts/css (lib/fonts.ts) so a visitor's browser never contacts
|
||||
* Google. Client-safe: the dev toolbar builds the same URL.
|
||||
*/
|
||||
export function getFontsCssUrl(families: string[]): string {
|
||||
const unique = [...new Set(families)];
|
||||
return `/api/fonts/css?${unique.map((f) => `family=${encodeURIComponent(f)}`).join('&')}`;
|
||||
}
|
||||
|
||||
export function getThemeFontsUrl(theme: ThemeConfig): string {
|
||||
return getFontsCssUrl([theme.fonts.heading, theme.fonts.body, theme.fonts.caption]);
|
||||
}
|
||||
|
||||
233
lib/contact.ts
Normal file
233
lib/contact.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* The built-in contact form (#702): validation, storage and notification.
|
||||
*
|
||||
* Messages stay on this server, one JSON file each in `content/messages/`.
|
||||
* No SMTP and no form service: the owner reads them in the admin panel and
|
||||
* learns about new ones through an optional plain-text POST to
|
||||
* `contact.notifyUrl` (an ntfy topic, typically). The notification carries
|
||||
* nothing about the sender: no name, no address, no text. A push service is a
|
||||
* third party, and a fixed "new message" needs no line in the privacy policy.
|
||||
*
|
||||
* Retention is enforced on every read and write rather than by a timer, so
|
||||
* nothing older than `retentionDays` survives the next visit to the inbox or
|
||||
* the next submission. The privacy policy can then state a fixed period.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ContactMessage {
|
||||
id: string;
|
||||
/** ISO timestamp. */
|
||||
receivedAt: string;
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export interface ContactInput {
|
||||
name?: unknown;
|
||||
email?: unknown;
|
||||
message?: unknown;
|
||||
/** Honeypot: a field hidden from people. Anything in it means a bot. */
|
||||
website?: unknown;
|
||||
/** When the form was rendered (epoch ms), for the minimum fill time. */
|
||||
startedAt?: unknown;
|
||||
}
|
||||
|
||||
export const LIMITS = { name: 100, email: 200, message: 5000 } as const;
|
||||
|
||||
/** Faster than a person can type a message; slower than a bot posting on load. */
|
||||
export const MIN_FILL_MS = 3000;
|
||||
|
||||
/** A form older than this was not filled in just now. */
|
||||
const MAX_FILL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Hard cap on stored messages. A flood that gets past the rate limit fills the
|
||||
* inbox and then stops, rather than filling the disk. New messages are refused
|
||||
* at the cap instead of evicting old ones: a flood must not delete real mail.
|
||||
*/
|
||||
export const MAX_MESSAGES = 500;
|
||||
|
||||
const ID_RE = /^\d{13}-[a-f0-9]{8}$/;
|
||||
// One `@`, a local part, and a domain of dot-separated labels. The labels
|
||||
// exclude the dot, so each character can match only one way: the previous
|
||||
// pattern let `[^…]+\.[^…]+` split a dotted domain in many places and ran in
|
||||
// polynomial time on crafted input (CodeQL js/polynomial-redos).
|
||||
const EMAIL_RE = /^[^\s@<>()[\]\\,;:"]+@[^\s@<>()[\]\\,;:".]+(?:\.[^\s@<>()[\]\\,;:".]+)+$/;
|
||||
|
||||
export function messagesDir(): string {
|
||||
return path.join(process.cwd(), 'content', 'messages');
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; name: string; email: string; message: string }
|
||||
| { ok: false; reason: 'invalid' }
|
||||
/** Looks like a bot. The route answers as if it had worked, so the bot learns nothing. */
|
||||
| { ok: false; reason: 'spam' };
|
||||
|
||||
export function validateContact(input: ContactInput, now = Date.now()): ValidationResult {
|
||||
if (typeof input.website === 'string' && input.website.trim()) {
|
||||
return { ok: false, reason: 'spam' };
|
||||
}
|
||||
const started = Number(input.startedAt);
|
||||
if (!Number.isFinite(started) || now - started < MIN_FILL_MS || now - started > MAX_FILL_MS) {
|
||||
return { ok: false, reason: 'spam' };
|
||||
}
|
||||
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
|
||||
const name = text(input.name);
|
||||
const email = text(input.email);
|
||||
const message = text(input.message);
|
||||
|
||||
if (
|
||||
!name ||
|
||||
name.length > LIMITS.name ||
|
||||
// Length first: the pattern never sees more than the limit.
|
||||
email.length > LIMITS.email ||
|
||||
!EMAIL_RE.test(email) ||
|
||||
!message ||
|
||||
message.length > LIMITS.message
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
// A name is one line. Keeping newlines out also keeps them out of the
|
||||
// notification text and the reply subject.
|
||||
return { ok: true, name: name.replace(/\s+/g, ' '), email, message };
|
||||
}
|
||||
|
||||
function isMessage(value: unknown): value is ContactMessage {
|
||||
const m = value as ContactMessage;
|
||||
return (
|
||||
!!m &&
|
||||
typeof m.id === 'string' &&
|
||||
ID_RE.test(m.id) &&
|
||||
typeof m.receivedAt === 'string' &&
|
||||
typeof m.name === 'string' &&
|
||||
typeof m.email === 'string' &&
|
||||
typeof m.message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
async function writeMessage(msg: ContactMessage): Promise<void> {
|
||||
await fs.mkdir(messagesDir(), { recursive: true });
|
||||
const target = path.join(messagesDir(), `${msg.id}.json`);
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(msg, null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stored message, newest first, after deleting the ones past their
|
||||
* retention. Unreadable files are skipped rather than failing the inbox.
|
||||
*/
|
||||
export async function listMessages(
|
||||
retentionDays: number,
|
||||
now = Date.now(),
|
||||
): Promise<ContactMessage[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(messagesDir());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
|
||||
const messages: ContactMessage[] = [];
|
||||
for (const name of names) {
|
||||
const id = name.replace(/\.json$/, '');
|
||||
if (!ID_RE.test(id) || !name.endsWith('.json')) continue;
|
||||
const file = path.join(messagesDir(), name);
|
||||
if (Number(id.slice(0, 13)) < cutoff) {
|
||||
await fs.rm(file, { force: true });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
|
||||
if (isMessage(parsed)) messages.push({ ...parsed, read: parsed.read === true });
|
||||
} catch {
|
||||
// A half-written or hand-edited file is not worth failing the inbox for.
|
||||
}
|
||||
}
|
||||
return messages.sort((a, b) => b.id.localeCompare(a.id));
|
||||
}
|
||||
|
||||
export async function saveMessage(
|
||||
fields: { name: string; email: string; message: string },
|
||||
retentionDays: number,
|
||||
now = Date.now(),
|
||||
): Promise<ContactMessage | 'full'> {
|
||||
const existing = await listMessages(retentionDays, now);
|
||||
if (existing.length >= MAX_MESSAGES) return 'full';
|
||||
|
||||
const msg: ContactMessage = {
|
||||
id: `${now}-${crypto.randomBytes(4).toString('hex')}`,
|
||||
receivedAt: new Date(now).toISOString(),
|
||||
...fields,
|
||||
read: false,
|
||||
};
|
||||
await writeMessage(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Ids come from the admin client; the pattern keeps them inside the directory. */
|
||||
export function isMessageId(id: unknown): id is string {
|
||||
return typeof id === 'string' && ID_RE.test(id);
|
||||
}
|
||||
|
||||
export async function setRead(id: string, read: boolean): Promise<boolean> {
|
||||
if (!isMessageId(id)) return false;
|
||||
const file = path.join(messagesDir(), `${id}.json`);
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
|
||||
if (!isMessage(parsed)) return false;
|
||||
await writeMessage({ ...parsed, read });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMessage(id: string): Promise<boolean> {
|
||||
if (!isMessageId(id)) return false;
|
||||
try {
|
||||
await fs.rm(path.join(messagesDir(), `${id}.json`));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the owner a message arrived. Best effort: the message is already
|
||||
* stored, so a failing push service must not fail the visitor's submission.
|
||||
*
|
||||
* The body is plain text, which is what ntfy expects; its `Title` and `Click`
|
||||
* headers are ignored by endpoints that do not know them.
|
||||
*/
|
||||
export async function notifyNewMessage(
|
||||
notifyUrl: string | undefined,
|
||||
inboxUrl: string | null,
|
||||
): Promise<void> {
|
||||
if (!notifyUrl) return;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
Title: 'New contact form message',
|
||||
Tags: 'envelope',
|
||||
};
|
||||
if (inboxUrl) headers.Click = inboxUrl;
|
||||
const res = await fetch(notifyUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: 'A new message arrived through the contact form.',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) console.warn(`[Folio] contact: notification answered ${res.status}`);
|
||||
} catch (err) {
|
||||
console.warn(`[Folio] contact: notification failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
44
lib/emailObfuscation.ts
Normal file
44
lib/emailObfuscation.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Email addresses kept out of the served HTML.
|
||||
*
|
||||
* Address harvesters read raw HTML and do not run scripts. The server writes
|
||||
* the address reversed and base64-encoded; components/EmailLink.tsx decodes
|
||||
* it in the browser, so visitors and screen readers get a normal, clickable
|
||||
* address. The plain address appears neither in the HTML nor in the RSC
|
||||
* payload, because only the encoded form is ever passed to the client.
|
||||
*
|
||||
* This is not encryption: anyone running a browser gets the address. It only
|
||||
* has to defeat bulk harvesting. Client-safe, no Node APIs.
|
||||
*/
|
||||
|
||||
function toBase64(text: string): string {
|
||||
let binary = '';
|
||||
for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(encoded: string): string {
|
||||
const binary = atob(encoded);
|
||||
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
export function encodeEmail(email: string): string {
|
||||
return toBase64([...email].reverse().join(''));
|
||||
}
|
||||
|
||||
export function decodeEmail(encoded: string): string {
|
||||
try {
|
||||
return [...fromBase64(encoded)].reverse().join('');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-script fallback, "name [at] example [dot] com". Weaker than the
|
||||
* encoded form, but it keeps the address reachable for a visitor without
|
||||
* JavaScript, which § 5 DDG asks of the Impressum.
|
||||
*/
|
||||
export function spelledOutEmail(email: string): string {
|
||||
return email.replace('@', ' [at] ').replace(/\./g, ' [dot] ');
|
||||
}
|
||||
35
lib/env.ts
35
lib/env.ts
@@ -18,11 +18,17 @@ export interface Env {
|
||||
WEBHOOK_SECRET?: string;
|
||||
ADMIN_PASSWORD?: string;
|
||||
SITE_PASSWORD?: string;
|
||||
CONTACT_NOTIFY_URL?: string;
|
||||
INSTALL_CONTENT_DIR?: string;
|
||||
/** Whether the admin dashboard may ask GitHub for the latest release. */
|
||||
UPDATE_CHECK: boolean;
|
||||
/** Absolute site URL for sitemap, feed and JSON-LD. Overrides settings.yaml. */
|
||||
SITE_URL?: string;
|
||||
/**
|
||||
* Base URL of a pull CDN in front of this server. Image and video URLs are
|
||||
* rewritten onto it; see lib/cdn.ts.
|
||||
*/
|
||||
CDN_URL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +51,26 @@ export function normalizeApiUrl(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CDN_URL: an absolute http(s) URL, optionally with a path prefix, no
|
||||
* query or fragment (a query would be glued in front of the image path).
|
||||
* Returned without trailing slash so `${base}/api/image/…` never doubles it.
|
||||
* An unusable value returns `''`, which leaves CDN mode off.
|
||||
*/
|
||||
export function normalizeCdnUrl(raw: string | undefined): string {
|
||||
const value = raw?.trim();
|
||||
if (!value) return '';
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return '';
|
||||
if (url.search || url.hash || url.username || url.password) return '';
|
||||
return `${url.origin}${url.pathname}`.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function parseEnv(): Env {
|
||||
const urlRaw = process.env.IMMICH_API_URL;
|
||||
let apiUrl = '';
|
||||
@@ -116,6 +142,13 @@ function parseEnv(): Env {
|
||||
|
||||
if (trustedProxyHops < 0) trustedProxyHops = 0;
|
||||
|
||||
const cdnUrl = normalizeCdnUrl(process.env.CDN_URL);
|
||||
if (process.env.CDN_URL?.trim() && !cdnUrl) {
|
||||
console.warn(
|
||||
'⚠️ CDN_URL is not an absolute http(s) URL without query string — CDN mode stays off.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
IMMICH_API_URL: apiUrl,
|
||||
IMMICH_API_KEY: apiKey as string,
|
||||
@@ -133,12 +166,14 @@ function parseEnv(): Env {
|
||||
WEBHOOK_SECRET: process.env.WEBHOOK_SECRET || undefined,
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
|
||||
SITE_PASSWORD: process.env.SITE_PASSWORD || undefined,
|
||||
CONTACT_NOTIFY_URL: process.env.CONTACT_NOTIFY_URL || undefined,
|
||||
INSTALL_CONTENT_DIR: process.env.INSTALL_CONTENT_DIR || undefined,
|
||||
// On unless switched off: an instance that never learns about a security
|
||||
// release is the worse default. Only `false` disables it, so a typo does
|
||||
// not silently turn the check off.
|
||||
UPDATE_CHECK: process.env.UPDATE_CHECK !== 'false',
|
||||
SITE_URL: process.env.SITE_URL || undefined,
|
||||
CDN_URL: cdnUrl || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
44
lib/essaySequence.ts
Normal file
44
lib/essaySequence.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The photos an essay actually shows, in reading order — the lightbox's
|
||||
* sequence. Client-safe (no `fs`).
|
||||
*
|
||||
* EssayView receives every photo of the subpage's albums, because blocks may
|
||||
* address them by 1-based index as well as by id. Walking that full list in the
|
||||
* lightbox let next/prev step through photos the story never shows; the
|
||||
* journal route already trims it (`shownAssetIds`), the subpage path cannot
|
||||
* without breaking index references, so the trimming happens here.
|
||||
*
|
||||
* Mirrors EssayView's rendering rules: a pair with a missing half renders as
|
||||
* nothing, and so does a grid left with fewer than two photos.
|
||||
*/
|
||||
import type { JournalBlock } from './journal';
|
||||
|
||||
export function essayPhotoSequence<T extends { id: string }>(
|
||||
blocks: JournalBlock[],
|
||||
coverKey: string | undefined,
|
||||
resolve: (key: string) => T | undefined,
|
||||
): T[] {
|
||||
const out: T[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (items: (T | undefined)[]) => {
|
||||
for (const item of items) {
|
||||
if (!item || seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
out.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
if (coverKey) add([resolve(coverKey)]);
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'photo') {
|
||||
add([resolve(block.assetId)]);
|
||||
} else if (block.type === 'photo-pair') {
|
||||
const pair = block.assetIds.map(resolve);
|
||||
if (pair.every(Boolean)) add(pair);
|
||||
} else if (block.type === 'photo-grid') {
|
||||
const grid = block.assetIds.map(resolve).filter(Boolean);
|
||||
if (grid.length >= 2) add(grid);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
53
lib/essaySource.ts
Normal file
53
lib/essaySource.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Server-only helpers for the subpage essay (story) path in
|
||||
* `app/[...path]/page.tsx`.
|
||||
*
|
||||
* A subpage can borrow a journal entry as its essay via `essayFile: <slug>`.
|
||||
* That entry keeps its own `draft` and `password`, and they have to hold here
|
||||
* exactly as they do at `/journal/<slug>` — otherwise the subpage becomes a
|
||||
* second, ungated door into the same content.
|
||||
*/
|
||||
import type { ImmichAsset } from './immich';
|
||||
import { sanitizeHtml, type ParsedJournal } from './journal';
|
||||
import { assetCaption } from './urls';
|
||||
import { isAuthenticated } from './auth';
|
||||
import { loadEssayFromFile } from './admin/journal-service';
|
||||
|
||||
export type EssayFileResult =
|
||||
/** Readable by this visitor. */
|
||||
| { status: 'open'; parsed: ParsedJournal }
|
||||
/** No such entry, or a draft seen by a non-admin — both render as absent. */
|
||||
| { status: 'missing' }
|
||||
/** The entry has a password this visitor has not entered. */
|
||||
| { status: 'locked'; title: string };
|
||||
|
||||
export function resolveEssayFile(
|
||||
slug: string,
|
||||
visitor: { isAdmin: boolean; getCookie: (name: string) => string | undefined },
|
||||
): EssayFileResult {
|
||||
const parsed = loadEssayFromFile(slug);
|
||||
if (!parsed) return { status: 'missing' };
|
||||
|
||||
const { frontmatter } = parsed;
|
||||
if (frontmatter.draft && !visitor.isAdmin) return { status: 'missing' };
|
||||
if (frontmatter.password && !isAuthenticated(slug, visitor.getCookie, 'journal')) {
|
||||
return { status: 'locked', title: frontmatter.title || slug };
|
||||
}
|
||||
return { status: 'open', parsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Caption for a photo in the essay generated from a subpage's albums.
|
||||
*
|
||||
* EssayView renders captions as HTML, because authored captions arrive already
|
||||
* rendered by `renderInlineMarkdown`. An Immich description has not been
|
||||
* through that, so it is escaped here — and it follows the `exif.caption`
|
||||
* toggle like every other place a description is shown.
|
||||
*/
|
||||
export function generatedEssayCaption(
|
||||
asset: Pick<ImmichAsset, 'exifInfo'>,
|
||||
showCaption: boolean,
|
||||
): string | undefined {
|
||||
const caption = assetCaption(asset, showCaption);
|
||||
return caption ? sanitizeHtml(caption) : undefined;
|
||||
}
|
||||
206
lib/fonts.ts
Normal file
206
lib/fonts.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Google Fonts, served from this origin (#699).
|
||||
*
|
||||
* Linking fonts.googleapis.com sends every visitor's IP address to Google
|
||||
* before they have agreed to anything, which LG München I (3 O 17493/20)
|
||||
* ruled unlawful. The server fetches the stylesheet and the font files
|
||||
* instead and hands them out itself, so the only address Google sees is the
|
||||
* server's.
|
||||
*
|
||||
* A theme may name any Google font (`theme.fonts` in settings.yaml), so the
|
||||
* fonts cannot be bundled at build time. They are fetched on first use and
|
||||
* kept in `content/.fonts/`, which also keeps a site's fonts working when
|
||||
* Google is unreachable later.
|
||||
*
|
||||
* - `/api/fonts/css?family=A&family=B` answers with Google's stylesheet, every
|
||||
* `fonts.gstatic.com` URL rewritten to `/api/fonts/file/<hash>.woff2`.
|
||||
* - `/api/fonts/file/<hash>.woff2` serves a file only when some fetched
|
||||
* stylesheet named it, so the route cannot be used to fetch arbitrary URLs.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const FONT_WEIGHTS = '300;400;500;600';
|
||||
|
||||
/** Three slots in a theme; the dev toolbar asks for the same three. */
|
||||
export const MAX_FAMILIES = 3;
|
||||
|
||||
const GOOGLE_CSS = 'https://fonts.googleapis.com/css2';
|
||||
const GSTATIC = 'https://fonts.gstatic.com/';
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
/** How long a failed stylesheet fetch is not retried, so an outage costs one timeout, not one per page. */
|
||||
const RETRY_AFTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Google picks the format by User-Agent. Without a current browser's it
|
||||
* answers with TTF, which is several times larger than WOFF2.
|
||||
*/
|
||||
const BROWSER_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
|
||||
|
||||
/** Google Fonts family names: letters, digits and spaces ("IBM Plex Mono", "Source Sans 3"). */
|
||||
const FAMILY_RE = /^[A-Za-z0-9 ]{1,64}$/;
|
||||
const FILE_RE = /^([a-f0-9]{32})\.woff2$/;
|
||||
|
||||
export function fontsDir(): string {
|
||||
return path.join(process.cwd(), 'content', '.fonts');
|
||||
}
|
||||
|
||||
/** Valid, de-duplicated family names, or null when any name is unusable. */
|
||||
export function parseFamilies(raw: string[]): string[] | null {
|
||||
const families = [...new Set(raw.map((f) => f.trim()))].filter(Boolean);
|
||||
if (!families.length || families.length > MAX_FAMILIES) return null;
|
||||
return families.every((f) => FAMILY_RE.test(f)) ? families : null;
|
||||
}
|
||||
|
||||
function googleCssUrl(families: string[]): string {
|
||||
const params = families
|
||||
.map((f) => `family=${f.replace(/ /g, '+')}:wght@${FONT_WEIGHTS}`)
|
||||
.join('&');
|
||||
return `${GOOGLE_CSS}?${params}&display=swap`;
|
||||
}
|
||||
|
||||
const sha = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 32);
|
||||
|
||||
/** Every gstatic URL in a stylesheet, keyed by the file name it is served under. */
|
||||
function fileUrls(css: string): Map<string, string> {
|
||||
const files = new Map<string, string>();
|
||||
for (const match of css.matchAll(/url\((https:\/\/fonts\.gstatic\.com\/[^)\s'"]+)\)/g)) {
|
||||
files.set(`${sha(match[1])}.woff2`, match[1]);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Google's stylesheet with every font URL pointing at this server. */
|
||||
export function rewriteCss(css: string): string {
|
||||
return css.replace(
|
||||
/url\((https:\/\/fonts\.gstatic\.com\/[^)\s'"]+)\)/g,
|
||||
(_, url: string) => `url(/api/fonts/file/${sha(url)}.woff2)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** file name → gstatic URL, for every stylesheet fetched so far. */
|
||||
const knownFiles = new Map<string, string>();
|
||||
/** stylesheet cache name → when to ask Google again. */
|
||||
const failedUntil = new Map<string, number>();
|
||||
const pending = new Map<string, Promise<unknown>>();
|
||||
|
||||
function coalesce<T>(key: string, run: () => Promise<T>): Promise<T> {
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const promise = run().finally(() => pending.delete(key));
|
||||
pending.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string): Promise<Response> {
|
||||
return fetch(url, {
|
||||
headers: { 'User-Agent': BROWSER_UA },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
/** Write a cache file. A read-only content/ only costs the cache, not the response. */
|
||||
async function store(name: string, data: string | Uint8Array): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(fontsDir(), { recursive: true });
|
||||
const target = path.join(fontsDir(), name);
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, data);
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
console.warn(`[Folio] fonts: could not cache ${name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google's original stylesheet for these families, from the cache or from
|
||||
* Google. Null when neither has it; the page then falls back to the system
|
||||
* fonts named after each family in the theme CSS.
|
||||
*/
|
||||
export async function getGoogleCss(families: string[]): Promise<string | null> {
|
||||
const name = `${sha(families.join('|'))}.css`;
|
||||
const css = await coalesce(`css:${name}`, async () => {
|
||||
try {
|
||||
return await fs.readFile(path.join(fontsDir(), name), 'utf8');
|
||||
} catch {
|
||||
// Not cached yet.
|
||||
}
|
||||
if ((failedUntil.get(name) ?? 0) > Date.now()) return null;
|
||||
try {
|
||||
const res = await fetchWithTimeout(googleCssUrl(families));
|
||||
if (!res.ok) {
|
||||
console.warn(`[Folio] fonts: Google answered ${res.status} for ${families.join(', ')}`);
|
||||
failedUntil.set(name, Date.now() + RETRY_AFTER_MS);
|
||||
return null;
|
||||
}
|
||||
const text = await res.text();
|
||||
await store(name, text);
|
||||
return text;
|
||||
} catch (err) {
|
||||
console.warn(`[Folio] fonts: could not reach Google Fonts: ${(err as Error).message}`);
|
||||
failedUntil.set(name, Date.now() + RETRY_AFTER_MS);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
if (css) for (const [file, url] of fileUrls(css)) knownFiles.set(file, url);
|
||||
return css;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a restart the map is empty until a stylesheet is requested again, and
|
||||
* a browser holding a cached stylesheet asks for the files directly. The cached
|
||||
* stylesheets on disk name every file that may legitimately be asked for.
|
||||
*/
|
||||
async function rebuildKnownFiles(): Promise<void> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(fontsDir());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of names.filter((n) => n.endsWith('.css'))) {
|
||||
try {
|
||||
const css = await fs.readFile(path.join(fontsDir(), name), 'utf8');
|
||||
for (const [file, url] of fileUrls(css)) knownFiles.set(file, url);
|
||||
} catch {
|
||||
// A file that vanished between readdir and read is not worth a warning.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A font file by the name the rewritten stylesheet gave it, or null. */
|
||||
export async function getFontFile(name: string): Promise<Uint8Array | null> {
|
||||
if (!FILE_RE.test(name)) return null;
|
||||
|
||||
try {
|
||||
return new Uint8Array(await fs.readFile(path.join(fontsDir(), name)));
|
||||
} catch {
|
||||
// Not cached yet.
|
||||
}
|
||||
|
||||
return coalesce(`file:${name}`, async () => {
|
||||
if (!knownFiles.has(name)) await rebuildKnownFiles();
|
||||
const url = knownFiles.get(name);
|
||||
if (!url?.startsWith(GSTATIC)) return null;
|
||||
try {
|
||||
const res = await fetchWithTimeout(url);
|
||||
if (!res.ok) return null;
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
await store(name, data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.warn(`[Folio] fonts: could not fetch ${name}: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Test hook: forget what earlier tests fetched. */
|
||||
export function __resetFontsForTests(): void {
|
||||
knownFiles.clear();
|
||||
failedUntil.clear();
|
||||
pending.clear();
|
||||
}
|
||||
@@ -9,15 +9,19 @@
|
||||
* This module is **client-safe** (no `fs`). Server components read the language
|
||||
* from the config via `lib/i18n/server.ts`; client components take the resolved
|
||||
* locale from `components/I18nProvider.tsx` and look the dictionary up here.
|
||||
* Both dictionaries are small enough that shipping them together beats a
|
||||
* The dictionaries are small enough that shipping them together beats a
|
||||
* dynamic import.
|
||||
*/
|
||||
|
||||
import { en } from './locales/en';
|
||||
import { de } from './locales/de';
|
||||
import { fr } from './locales/fr';
|
||||
import { es } from './locales/es';
|
||||
import { it } from './locales/it';
|
||||
import { nl } from './locales/nl';
|
||||
|
||||
/** Locales with a dictionary. Anything else falls back to English. */
|
||||
export const SUPPORTED_LOCALES = ['en', 'de'] as const;
|
||||
export const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'it', 'nl'] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
@@ -26,14 +30,14 @@ export const DEFAULT_LOCALE: Locale = 'en';
|
||||
/** The shape every dictionary must implement — English is the reference. */
|
||||
export type Dictionary = typeof en;
|
||||
|
||||
const DICTIONARIES: Record<Locale, Dictionary> = { en, de };
|
||||
const DICTIONARIES: Record<Locale, Dictionary> = { en, de, fr, es, it, nl };
|
||||
|
||||
/**
|
||||
* Map a `settings.yaml: lang` value onto a locale we have strings for.
|
||||
*
|
||||
* Region subtags are accepted (`de-AT` → `de`), unknown languages fall back to
|
||||
* English. The raw value still reaches `<html lang>` — a French deployment gets
|
||||
* `lang="fr"` for screen readers and an English UI, which beats claiming to be
|
||||
* English. The raw value still reaches `<html lang>` — a Japanese deployment gets
|
||||
* `lang="ja"` for screen readers and an English UI, which beats claiming to be
|
||||
* English.
|
||||
*/
|
||||
export function resolveLocale(lang?: string | null): Locale {
|
||||
|
||||
@@ -208,15 +208,38 @@ export const de: Dictionary = {
|
||||
legal: {
|
||||
navLabel: 'Impressum',
|
||||
title: 'Impressum',
|
||||
subtitle: 'Angaben gemäß § 5 TMG',
|
||||
subtitle: 'Angaben gemäß § 5 DDG',
|
||||
address: 'Anschrift',
|
||||
contact: 'Kontakt',
|
||||
email: 'E-Mail',
|
||||
phone: 'Telefon',
|
||||
contactForm: 'Kontaktformular',
|
||||
taxSection: 'Steuernummer',
|
||||
taxId: 'Steuernummer',
|
||||
vatId: 'Umsatzsteuer-ID',
|
||||
extraInfo: 'Weitere Informationen',
|
||||
source: 'Quelle: Erstellt mit dem Impressum-Generator von eRecht24.',
|
||||
},
|
||||
|
||||
contact: {
|
||||
navLabel: 'Kontakt',
|
||||
title: 'Kontakt',
|
||||
subtitle: 'Schreib mir eine Nachricht',
|
||||
name: 'Name',
|
||||
email: 'E-Mail',
|
||||
message: 'Nachricht',
|
||||
submit: 'Nachricht senden',
|
||||
sending: 'Wird gesendet…',
|
||||
successTitle: 'Danke',
|
||||
successText: 'Deine Nachricht ist angekommen. Ich antworte dir per E-Mail.',
|
||||
invalid: 'Bitte fülle alle Felder aus und gib eine gültige E-Mail-Adresse an.',
|
||||
tooMany: 'Zu viele Nachrichten in kurzer Zeit. Bitte versuch es in einer Minute noch einmal.',
|
||||
failed: 'Die Nachricht konnte nicht gesendet werden. Bitte versuch es später noch einmal.',
|
||||
privacy: (days: number) =>
|
||||
`Deine Nachricht wird auf dem eigenen Server dieser Seite gespeichert, nur für die Antwort verwendet und nach ${days} ${days === 1 ? 'Tag' : 'Tagen'} gelöscht.`,
|
||||
},
|
||||
|
||||
privacy: {
|
||||
navLabel: 'Datenschutz',
|
||||
title: 'Datenschutzerklärung',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,15 +211,38 @@ export const en = {
|
||||
legal: {
|
||||
navLabel: 'Legal Notice',
|
||||
title: 'Legal Notice',
|
||||
subtitle: 'Information pursuant to § 5 TMG',
|
||||
subtitle: 'Information pursuant to § 5 DDG',
|
||||
address: 'Address',
|
||||
contact: 'Contact',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
contactForm: 'Contact form',
|
||||
taxSection: 'Tax Information',
|
||||
taxId: 'Tax number',
|
||||
vatId: 'VAT ID',
|
||||
extraInfo: 'Additional Information',
|
||||
source: 'Source: Created with the Impressum generator by eRecht24.',
|
||||
},
|
||||
|
||||
contact: {
|
||||
navLabel: 'Contact',
|
||||
title: 'Contact',
|
||||
subtitle: 'Write me a message',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
message: 'Message',
|
||||
submit: 'Send message',
|
||||
sending: 'Sending…',
|
||||
successTitle: 'Thank you',
|
||||
successText: 'Your message has arrived. I will get back to you by email.',
|
||||
invalid: 'Please fill in every field with a valid email address.',
|
||||
tooMany: 'Too many messages in a short time. Please try again in a minute.',
|
||||
failed: 'The message could not be sent. Please try again later.',
|
||||
privacy: (days: number) =>
|
||||
`Your message is stored on this site's own server, is only used to reply to you, and is deleted after ${days} ${days === 1 ? 'day' : 'days'}.`,
|
||||
},
|
||||
|
||||
privacy: {
|
||||
navLabel: 'Privacy',
|
||||
title: 'Privacy Policy',
|
||||
},
|
||||
};
|
||||
|
||||
245
lib/i18n/locales/es.ts
Normal file
245
lib/i18n/locales/es.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Spanish dictionary.
|
||||
*
|
||||
* Typed as `Dictionary` (derived from `en.ts`), so a key added to English but
|
||||
* forgotten here fails the type-check rather than falling through to English
|
||||
* at runtime.
|
||||
*/
|
||||
|
||||
import type { Dictionary } from '../index';
|
||||
|
||||
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
|
||||
|
||||
export const es: Dictionary = {
|
||||
dateLocale: 'es-ES',
|
||||
|
||||
nav: {
|
||||
home: 'Inicio',
|
||||
about: 'Sobre mí',
|
||||
map: 'Mapa',
|
||||
journal: 'Diario',
|
||||
skipToContent: 'Saltar al contenido',
|
||||
openMenu: 'Abrir menú',
|
||||
closeMenu: 'Cerrar menú',
|
||||
},
|
||||
|
||||
common: {
|
||||
backTo: (label: string) => `Volver a ${label}`,
|
||||
backToGallery: 'Volver a la galería',
|
||||
backToJournal: 'Volver al diario',
|
||||
email: 'Correo electrónico',
|
||||
website: 'Sitio web',
|
||||
home: 'Inicio',
|
||||
photos: (n: number) => plural(n, 'foto', 'fotos'),
|
||||
albums: (n: number) => plural(n, 'álbum', 'álbumes'),
|
||||
collections: (n: number) => plural(n, 'colección', 'colecciones'),
|
||||
prevAlbum: 'Álbum anterior',
|
||||
nextAlbum: 'Álbum siguiente',
|
||||
prevAlbumAria: (name: string) => `Álbum anterior: ${name}`,
|
||||
nextAlbumAria: (name: string) => `Álbum siguiente: ${name}`,
|
||||
albumNavAria: 'Navegación entre álbumes',
|
||||
prevEntry: 'Entrada anterior',
|
||||
nextEntry: 'Entrada siguiente',
|
||||
prevEntryAria: (title: string) => `Entrada anterior: ${title}`,
|
||||
nextEntryAria: (title: string) => `Entrada siguiente: ${title}`,
|
||||
entryNavAria: 'Navegación entre entradas del diario',
|
||||
loadingGallery: 'Cargando la galería',
|
||||
loadingPhotos: 'Cargando fotos',
|
||||
downloadAlbum: 'Descargar álbum',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Entrar',
|
||||
},
|
||||
|
||||
error: {
|
||||
notFoundTitle: 'No encontrado',
|
||||
notFoundText: 'Esta página no existe o el álbum ya no está publicado.',
|
||||
errorTitle: 'Algo ha salido mal',
|
||||
errorText:
|
||||
'No se ha podido cargar esta página. Suele ser algo temporal: inténtalo de nuevo en un momento.',
|
||||
siteErrorText:
|
||||
'No se ha podido cargar este sitio. Suele ser algo temporal: inténtalo de nuevo en un momento.',
|
||||
tryAgain: 'Reintentar',
|
||||
reference: (digest: string) => `Referencia: ${digest}`,
|
||||
},
|
||||
|
||||
download: {
|
||||
unavailableTitle: 'Descarga no disponible',
|
||||
notAvailable: 'Esta descarga no está disponible.',
|
||||
rateLimited: 'Demasiadas solicitudes de descarga. Espera un momento y vuelve a intentarlo.',
|
||||
immichUnavailable:
|
||||
'La fototeca no está disponible en este momento. Vuelve a intentarlo en breve.',
|
||||
back: 'Volver a la galería',
|
||||
},
|
||||
|
||||
theme: {
|
||||
switchToLight: 'Cambiar a modo claro',
|
||||
switchToDark: 'Cambiar a modo oscuro',
|
||||
scrollToTop: 'Volver arriba',
|
||||
},
|
||||
|
||||
password: {
|
||||
subtitle: 'Esta galería está protegida con contraseña.',
|
||||
siteSubtitle: 'Este sitio está protegido con contraseña.',
|
||||
placeholder: 'Introduce la contraseña',
|
||||
submit: 'Entrar',
|
||||
verifying: 'Verificando…',
|
||||
incorrect: 'Contraseña incorrecta. Inténtalo de nuevo.',
|
||||
failed: 'No se ha podido verificar la contraseña. Inténtalo más tarde.',
|
||||
},
|
||||
|
||||
about: {
|
||||
kicker: 'Sobre mí',
|
||||
title: 'Sobre mí',
|
||||
metaTitle: (name: string) => `Sobre mí — ${name}`,
|
||||
portraitAlt: 'Retrato',
|
||||
gear: 'Equipo',
|
||||
},
|
||||
|
||||
map: {
|
||||
title: 'Mapa',
|
||||
kicker: 'Dónde',
|
||||
subtitle: 'Dónde se tomaron las fotos',
|
||||
loading: 'Cargando el mapa…',
|
||||
loadFailed: (status: number) => `No se pudieron cargar los datos del mapa (${status})`,
|
||||
initFailed: 'No se pudo inicializar el mapa',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
collectionKicker: (index: string) => `${index} — Colección`,
|
||||
sectionsNav: 'Secciones',
|
||||
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
|
||||
nextSubpage: 'Siguiente',
|
||||
nextSubpageAria: (name: string) => `Siguiente: ${name}`,
|
||||
},
|
||||
|
||||
journal: {
|
||||
title: 'Diario',
|
||||
kicker: 'Historias y ensayos',
|
||||
subtitle: 'Ensayos fotográficos, historias visuales y notas de campo.',
|
||||
description: 'Ensayos fotográficos, relatos de viaje y diarios entre bastidores.',
|
||||
entryDescription: 'Entrada del diario',
|
||||
empty: 'Todavía no hay entradas publicadas.',
|
||||
readStory: 'Leer historia →',
|
||||
minRead: (n: number) => `${n} min de lectura`,
|
||||
draft: 'Borrador',
|
||||
by: (author: string) => `por ${author}`,
|
||||
notFound: 'No encontrado',
|
||||
},
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Visor de imágenes',
|
||||
openPhoto: (n: number) => `Ver foto ${n}`,
|
||||
close: 'Cerrar',
|
||||
closeTitle: 'Cerrar (Esc)',
|
||||
previous: 'Foto anterior',
|
||||
previousTitle: 'Foto anterior (flecha izquierda)',
|
||||
next: 'Foto siguiente',
|
||||
nextTitle: 'Foto siguiente (flecha derecha)',
|
||||
toggleInfo: 'Mostrar/ocultar información',
|
||||
toggleInfoTitle: 'Mostrar/ocultar información (i)',
|
||||
hideInfo: 'Ocultar info',
|
||||
info: 'Info',
|
||||
loading: 'Cargando…',
|
||||
camera: 'Cámara',
|
||||
lens: 'Objetivo',
|
||||
focalLength: 'Distancia focal',
|
||||
aperture: 'Apertura',
|
||||
shutter: 'Obturación',
|
||||
iso: 'ISO',
|
||||
location: 'Ubicación',
|
||||
noExif: 'No hay datos EXIF disponibles',
|
||||
copyLink: 'Copiar enlace a esta foto',
|
||||
copyLinkTitle: 'Copiar enlace a esta foto (c)',
|
||||
copyLinkShort: 'Enlace',
|
||||
copied: 'Copiado',
|
||||
copyManual: 'Copia este enlace',
|
||||
shortcuts: 'Atajos de teclado',
|
||||
shortcutNavigate: 'Foto anterior / siguiente',
|
||||
shortcutInfo: 'Información de la foto',
|
||||
shortcutFullscreen: 'Pantalla completa',
|
||||
shortcutExitFullscreen: 'Salir de pantalla completa',
|
||||
download: 'Descargar el archivo original',
|
||||
downloadTitle: 'Descargar el archivo original (d)',
|
||||
downloadShort: 'Original',
|
||||
shortcutDownload: 'Descargar el original',
|
||||
shortcutSlideshow: 'Presentación',
|
||||
shortcutSlideshowRunning: (seconds: number) => `Presentación — cada ${seconds} s`,
|
||||
slideshowStopped: 'Presentación detenida',
|
||||
slideshowRunning: (seconds: number) => `Presentación en curso, avanza cada ${seconds} segundos`,
|
||||
shortcutCopyLink: 'Copiar enlace a esta foto',
|
||||
shortcutList: 'Esta lista',
|
||||
shortcutClose: 'Cerrar el visor',
|
||||
},
|
||||
|
||||
proofing: {
|
||||
addFavorite: 'Añadir favorito',
|
||||
removeFavorite: 'Quitar favorito',
|
||||
addToFavorites: 'Añadir a favoritos',
|
||||
removeFromFavorites: 'Quitar de favoritos',
|
||||
saved: 'Guardado',
|
||||
favorite: 'Favorito',
|
||||
showAll: 'Mostrar todo',
|
||||
selected: (n: number) => `❤️ ${n} ${n === 1 ? 'seleccionada' : 'seleccionadas'}`,
|
||||
shareExport: 'Compartir y exportar',
|
||||
modalTitle: (n: number) => `❤️ Selección (${n})`,
|
||||
closeModal: 'Cerrar ventana',
|
||||
intro: (n: number) =>
|
||||
`Has seleccionado ${plural(n, 'foto', 'fotos')}. Elige una opción de exportación para compartir tu selección:`,
|
||||
copyLink: 'Copiar enlace para compartir',
|
||||
linkCopied: '¡Enlace copiado!',
|
||||
copyList: 'Copiar lista de texto (#1, #2…)',
|
||||
listCopied: '¡Lista copiada!',
|
||||
copyManualLink: 'Copia este enlace',
|
||||
copyManualList: 'Copia esta lista',
|
||||
sendEmail: '✉️ Enviar correo al fotógrafo',
|
||||
downloadSelected: 'Descargar selección (.zip)',
|
||||
clearSelection: 'Borrar selección',
|
||||
confirmClear: '¿Quitar todos los favoritos seleccionados?',
|
||||
listEmpty: (albumName: string) => `${albumName}: ninguna foto seleccionada.`,
|
||||
listSummary: (albumName: string, count: number, indices: string) =>
|
||||
`${albumName} — Fotos seleccionadas (${count}): ${indices}`,
|
||||
mailSubject: (n: number) => `Selección de fotos (${n})`,
|
||||
mailBody: (list: string, url: string) =>
|
||||
`Hola:\n\nEsta es mi selección de fotos:\n\n${list}\n\nEnlace: ${url}\n\nUn saludo,`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Aviso legal',
|
||||
title: 'Aviso legal',
|
||||
subtitle: 'Información conforme al § 5 DDG',
|
||||
address: 'Dirección',
|
||||
contact: 'Contacto',
|
||||
email: 'Correo electrónico',
|
||||
phone: 'Teléfono',
|
||||
contactForm: 'Formulario de contacto',
|
||||
taxSection: 'Información fiscal',
|
||||
taxId: 'Número de identificación fiscal',
|
||||
vatId: 'Número de IVA',
|
||||
extraInfo: 'Información adicional',
|
||||
},
|
||||
|
||||
contact: {
|
||||
navLabel: 'Contacto',
|
||||
title: 'Contacto',
|
||||
subtitle: 'Escríbeme un mensaje',
|
||||
name: 'Nombre',
|
||||
email: 'Correo electrónico',
|
||||
message: 'Mensaje',
|
||||
submit: 'Enviar mensaje',
|
||||
sending: 'Enviando…',
|
||||
successTitle: 'Gracias',
|
||||
successText: 'Tu mensaje ha llegado. Te responderé por correo electrónico.',
|
||||
invalid: 'Rellena todos los campos con una dirección de correo válida.',
|
||||
tooMany: 'Demasiados mensajes en poco tiempo. Inténtalo de nuevo en un minuto.',
|
||||
failed: 'No se ha podido enviar el mensaje. Inténtalo más tarde.',
|
||||
privacy: (days: number) =>
|
||||
`Tu mensaje se guarda en el propio servidor de este sitio, solo se usa para responderte y se borra después de ${days} ${days === 1 ? 'día' : 'días'}.`,
|
||||
},
|
||||
|
||||
privacy: {
|
||||
navLabel: 'Privacidad',
|
||||
title: 'Política de privacidad',
|
||||
},
|
||||
};
|
||||
246
lib/i18n/locales/fr.ts
Normal file
246
lib/i18n/locales/fr.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* French dictionary.
|
||||
*
|
||||
* Typed as `Dictionary` (derived from `en.ts`), so a key added to English but
|
||||
* forgotten here fails the type-check rather than falling through to English
|
||||
* at runtime.
|
||||
*/
|
||||
|
||||
import type { Dictionary } from '../index';
|
||||
|
||||
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
|
||||
|
||||
export const fr: Dictionary = {
|
||||
dateLocale: 'fr-FR',
|
||||
|
||||
nav: {
|
||||
home: 'Accueil',
|
||||
about: 'À propos',
|
||||
map: 'Carte',
|
||||
journal: 'Journal',
|
||||
skipToContent: 'Aller au contenu',
|
||||
openMenu: 'Ouvrir le menu',
|
||||
closeMenu: 'Fermer le menu',
|
||||
},
|
||||
|
||||
common: {
|
||||
backTo: (label: string) => `Retour à ${label}`,
|
||||
backToGallery: 'Retour à la galerie',
|
||||
backToJournal: 'Retour au journal',
|
||||
email: 'E-mail',
|
||||
website: 'Site web',
|
||||
home: 'Accueil',
|
||||
photos: (n: number) => plural(n, 'photo', 'photos'),
|
||||
albums: (n: number) => plural(n, 'album', 'albums'),
|
||||
collections: (n: number) => plural(n, 'collection', 'collections'),
|
||||
prevAlbum: 'Album précédent',
|
||||
nextAlbum: 'Album suivant',
|
||||
prevAlbumAria: (name: string) => `Album précédent : ${name}`,
|
||||
nextAlbumAria: (name: string) => `Album suivant : ${name}`,
|
||||
albumNavAria: 'Navigation entre les albums',
|
||||
prevEntry: 'Article précédent',
|
||||
nextEntry: 'Article suivant',
|
||||
prevEntryAria: (title: string) => `Article précédent : ${title}`,
|
||||
nextEntryAria: (title: string) => `Article suivant : ${title}`,
|
||||
entryNavAria: 'Navigation entre les articles du journal',
|
||||
loadingGallery: 'Chargement de la galerie',
|
||||
loadingPhotos: 'Chargement des photos',
|
||||
downloadAlbum: 'Télécharger l’album',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Entrer',
|
||||
},
|
||||
|
||||
error: {
|
||||
notFoundTitle: 'Page introuvable',
|
||||
notFoundText: 'Cette page n’existe pas, ou l’album n’est plus publié.',
|
||||
errorTitle: 'Une erreur est survenue',
|
||||
errorText:
|
||||
'Cette page n’a pas pu être chargée. C’est généralement temporaire — réessayez dans un instant.',
|
||||
siteErrorText:
|
||||
'Ce site n’a pas pu être chargé. C’est généralement temporaire — réessayez dans un instant.',
|
||||
tryAgain: 'Réessayer',
|
||||
reference: (digest: string) => `Référence : ${digest}`,
|
||||
},
|
||||
|
||||
download: {
|
||||
unavailableTitle: 'Téléchargement indisponible',
|
||||
notAvailable: 'Ce téléchargement n’est pas disponible.',
|
||||
rateLimited: 'Trop de demandes de téléchargement. Patientez un instant, puis réessayez.',
|
||||
immichUnavailable:
|
||||
'La photothèque est momentanément indisponible. Veuillez réessayer dans un instant.',
|
||||
back: 'Retour à la galerie',
|
||||
},
|
||||
|
||||
theme: {
|
||||
switchToLight: 'Passer en mode clair',
|
||||
switchToDark: 'Passer en mode sombre',
|
||||
scrollToTop: 'Revenir en haut',
|
||||
},
|
||||
|
||||
password: {
|
||||
subtitle: 'Cette galerie est protégée par un mot de passe.',
|
||||
siteSubtitle: 'Ce site est protégé par un mot de passe.',
|
||||
placeholder: 'Saisissez le mot de passe',
|
||||
submit: 'Entrer',
|
||||
verifying: 'Vérification…',
|
||||
incorrect: 'Mot de passe incorrect. Veuillez réessayer.',
|
||||
failed: 'Impossible de vérifier le mot de passe. Veuillez réessayer plus tard.',
|
||||
},
|
||||
|
||||
about: {
|
||||
kicker: 'À propos',
|
||||
title: 'À propos',
|
||||
metaTitle: (name: string) => `À propos — ${name}`,
|
||||
portraitAlt: 'Portrait',
|
||||
gear: 'Matériel',
|
||||
},
|
||||
|
||||
map: {
|
||||
title: 'Carte',
|
||||
kicker: 'Où',
|
||||
subtitle: 'Les lieux où les photos ont été prises',
|
||||
loading: 'Chargement de la carte…',
|
||||
loadFailed: (status: number) => `Échec du chargement des données de la carte (${status})`,
|
||||
initFailed: 'Impossible d’initialiser la carte',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
collectionKicker: (index: string) => `${index} — Collection`,
|
||||
sectionsNav: 'Sections',
|
||||
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
|
||||
nextSubpage: 'Suivant',
|
||||
nextSubpageAria: (name: string) => `Suivant : ${name}`,
|
||||
},
|
||||
|
||||
journal: {
|
||||
title: 'Journal',
|
||||
kicker: 'Récits & essais',
|
||||
subtitle: 'Essais photographiques, récits visuels et notes de terrain.',
|
||||
description: 'Essais photographiques, récits de voyage et coulisses.',
|
||||
entryDescription: 'Article du journal',
|
||||
empty: 'Aucun article publié pour le moment.',
|
||||
readStory: 'Lire le récit →',
|
||||
minRead: (n: number) => `${n} min de lecture`,
|
||||
draft: 'Brouillon',
|
||||
by: (author: string) => `par ${author}`,
|
||||
notFound: 'Introuvable',
|
||||
},
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Visionneuse d’images',
|
||||
openPhoto: (n: number) => `Afficher la photo ${n}`,
|
||||
close: 'Fermer',
|
||||
closeTitle: 'Fermer (Échap)',
|
||||
previous: 'Photo précédente',
|
||||
previousTitle: 'Photo précédente (flèche gauche)',
|
||||
next: 'Photo suivante',
|
||||
nextTitle: 'Photo suivante (flèche droite)',
|
||||
toggleInfo: 'Afficher/masquer les informations',
|
||||
toggleInfoTitle: 'Afficher/masquer les informations (i)',
|
||||
hideInfo: 'Masquer les infos',
|
||||
info: 'Infos',
|
||||
loading: 'Chargement…',
|
||||
camera: 'Appareil',
|
||||
lens: 'Objectif',
|
||||
focalLength: 'Focale',
|
||||
aperture: 'Ouverture',
|
||||
shutter: 'Vitesse',
|
||||
iso: 'ISO',
|
||||
location: 'Lieu',
|
||||
noExif: 'Aucune donnée EXIF disponible',
|
||||
copyLink: 'Copier le lien de cette photo',
|
||||
copyLinkTitle: 'Copier le lien de cette photo (c)',
|
||||
copyLinkShort: 'Lien',
|
||||
copied: 'Copié',
|
||||
copyManual: 'Copiez ce lien',
|
||||
shortcuts: 'Raccourcis clavier',
|
||||
shortcutNavigate: 'Photo précédente / suivante',
|
||||
shortcutInfo: 'Informations sur la photo',
|
||||
shortcutFullscreen: 'Plein écran',
|
||||
shortcutExitFullscreen: 'Quitter le plein écran',
|
||||
download: 'Télécharger le fichier original',
|
||||
downloadTitle: 'Télécharger le fichier original (d)',
|
||||
downloadShort: 'Original',
|
||||
shortcutDownload: 'Télécharger l’original',
|
||||
shortcutSlideshow: 'Diaporama',
|
||||
shortcutSlideshowRunning: (seconds: number) => `Diaporama — toutes les ${seconds} s`,
|
||||
slideshowStopped: 'Diaporama arrêté',
|
||||
slideshowRunning: (seconds: number) =>
|
||||
`Diaporama en cours, photo suivante toutes les ${seconds} secondes`,
|
||||
shortcutCopyLink: 'Copier le lien de cette photo',
|
||||
shortcutList: 'Cette liste',
|
||||
shortcutClose: 'Fermer la visionneuse',
|
||||
},
|
||||
|
||||
proofing: {
|
||||
addFavorite: 'Ajouter aux favoris',
|
||||
removeFavorite: 'Retirer des favoris',
|
||||
addToFavorites: 'Ajouter aux favoris',
|
||||
removeFromFavorites: 'Retirer des favoris',
|
||||
saved: 'Enregistré',
|
||||
favorite: 'Favori',
|
||||
showAll: 'Tout afficher',
|
||||
selected: (n: number) => `❤️ ${n} ${n === 1 ? 'sélectionnée' : 'sélectionnées'}`,
|
||||
shareExport: 'Partager & exporter',
|
||||
modalTitle: (n: number) => `❤️ Sélection (${n})`,
|
||||
closeModal: 'Fermer la fenêtre',
|
||||
intro: (n: number) =>
|
||||
`Vous avez sélectionné ${plural(n, 'photo', 'photos')}. Choisissez une option d’export ci-dessous pour partager votre sélection :`,
|
||||
copyLink: 'Copier le lien de partage',
|
||||
linkCopied: 'Lien copié !',
|
||||
copyList: 'Copier la liste (#1, #2…)',
|
||||
listCopied: 'Liste copiée !',
|
||||
copyManualLink: 'Copiez ce lien',
|
||||
copyManualList: 'Copiez cette liste',
|
||||
sendEmail: '✉️ Envoyer un e-mail au photographe',
|
||||
downloadSelected: 'Télécharger la sélection (.zip)',
|
||||
clearSelection: 'Vider la sélection',
|
||||
confirmClear: 'Retirer tous les favoris sélectionnés ?',
|
||||
listEmpty: (albumName: string) => `${albumName} : aucune photo sélectionnée.`,
|
||||
listSummary: (albumName: string, count: number, indices: string) =>
|
||||
`${albumName} — Photos sélectionnées (${count}) : ${indices}`,
|
||||
mailSubject: (n: number) => `Sélection de photos (${n})`,
|
||||
mailBody: (list: string, url: string) =>
|
||||
`Bonjour,\n\nVoici ma sélection de photos :\n\n${list}\n\nLien de partage : ${url}\n\nCordialement,`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Mentions légales',
|
||||
title: 'Mentions légales',
|
||||
subtitle: 'Informations conformément au § 5 DDG',
|
||||
address: 'Adresse',
|
||||
contact: 'Contact',
|
||||
email: 'E-mail',
|
||||
phone: 'Téléphone',
|
||||
contactForm: 'Formulaire de contact',
|
||||
taxSection: 'Informations fiscales',
|
||||
taxId: 'Numéro fiscal',
|
||||
vatId: 'Numéro de TVA',
|
||||
extraInfo: 'Informations complémentaires',
|
||||
},
|
||||
|
||||
contact: {
|
||||
navLabel: 'Contact',
|
||||
title: 'Contact',
|
||||
subtitle: 'Écrivez-moi un message',
|
||||
name: 'Nom',
|
||||
email: 'E-mail',
|
||||
message: 'Message',
|
||||
submit: 'Envoyer le message',
|
||||
sending: 'Envoi…',
|
||||
successTitle: 'Merci',
|
||||
successText: 'Votre message est bien arrivé. Je vous répondrai par e-mail.',
|
||||
invalid: 'Veuillez remplir tous les champs avec une adresse e-mail valide.',
|
||||
tooMany: 'Trop de messages en peu de temps. Réessayez dans une minute.',
|
||||
failed: 'Le message n’a pas pu être envoyé. Réessayez plus tard.',
|
||||
privacy: (days: number) =>
|
||||
`Votre message est conservé sur le serveur de ce site, sert uniquement à vous répondre et est supprimé après ${days} ${days === 1 ? 'jour' : 'jours'}.`,
|
||||
},
|
||||
|
||||
privacy: {
|
||||
navLabel: 'Confidentialité',
|
||||
title: 'Politique de confidentialité',
|
||||
},
|
||||
};
|
||||
245
lib/i18n/locales/it.ts
Normal file
245
lib/i18n/locales/it.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Italian dictionary.
|
||||
*
|
||||
* Typed as `Dictionary` (derived from `en.ts`), so a key added to English but
|
||||
* forgotten here fails the type-check rather than falling through to English
|
||||
* at runtime.
|
||||
*/
|
||||
|
||||
import type { Dictionary } from '../index';
|
||||
|
||||
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
|
||||
|
||||
export const it: Dictionary = {
|
||||
dateLocale: 'it-IT',
|
||||
|
||||
nav: {
|
||||
home: 'Home',
|
||||
about: 'Chi sono',
|
||||
map: 'Mappa',
|
||||
journal: 'Diario',
|
||||
skipToContent: 'Vai al contenuto',
|
||||
openMenu: 'Apri menu',
|
||||
closeMenu: 'Chiudi menu',
|
||||
},
|
||||
|
||||
common: {
|
||||
backTo: (label: string) => `Torna a ${label}`,
|
||||
backToGallery: 'Torna alla galleria',
|
||||
backToJournal: 'Torna al diario',
|
||||
email: 'E-mail',
|
||||
website: 'Sito web',
|
||||
home: 'Home',
|
||||
// Italian "foto" and "album" are invariable — the article carries the plural.
|
||||
photos: (n: number) => `${n} foto`,
|
||||
albums: (n: number) => `${n} album`,
|
||||
collections: (n: number) => plural(n, 'collezione', 'collezioni'),
|
||||
prevAlbum: 'Album precedente',
|
||||
nextAlbum: 'Album successivo',
|
||||
prevAlbumAria: (name: string) => `Album precedente: ${name}`,
|
||||
nextAlbumAria: (name: string) => `Album successivo: ${name}`,
|
||||
albumNavAria: 'Navigazione tra gli album',
|
||||
prevEntry: 'Articolo precedente',
|
||||
nextEntry: 'Articolo successivo',
|
||||
prevEntryAria: (title: string) => `Articolo precedente: ${title}`,
|
||||
nextEntryAria: (title: string) => `Articolo successivo: ${title}`,
|
||||
entryNavAria: 'Navigazione tra gli articoli del diario',
|
||||
loadingGallery: 'Caricamento della galleria',
|
||||
loadingPhotos: 'Caricamento delle foto',
|
||||
downloadAlbum: 'Scarica album',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Entra',
|
||||
},
|
||||
|
||||
error: {
|
||||
notFoundTitle: 'Pagina non trovata',
|
||||
notFoundText: 'Questa pagina non esiste, oppure l’album non è più pubblicato.',
|
||||
errorTitle: 'Qualcosa è andato storto',
|
||||
errorText:
|
||||
'Non è stato possibile caricare questa pagina. Di solito è temporaneo: riprova tra un momento.',
|
||||
siteErrorText:
|
||||
'Non è stato possibile caricare questo sito. Di solito è temporaneo: riprova tra un momento.',
|
||||
tryAgain: 'Riprova',
|
||||
reference: (digest: string) => `Riferimento: ${digest}`,
|
||||
},
|
||||
|
||||
download: {
|
||||
unavailableTitle: 'Download non disponibile',
|
||||
notAvailable: 'Questo download non è disponibile.',
|
||||
rateLimited: 'Troppe richieste di download. Attendi un momento e riprova.',
|
||||
immichUnavailable: 'La libreria fotografica non è al momento disponibile. Riprova a breve.',
|
||||
back: 'Torna alla galleria',
|
||||
},
|
||||
|
||||
theme: {
|
||||
switchToLight: 'Passa alla modalità chiara',
|
||||
switchToDark: 'Passa alla modalità scura',
|
||||
scrollToTop: 'Torna su',
|
||||
},
|
||||
|
||||
password: {
|
||||
subtitle: 'Questa galleria è protetta da password.',
|
||||
siteSubtitle: 'Questo sito è protetto da password.',
|
||||
placeholder: 'Inserisci la password',
|
||||
submit: 'Entra',
|
||||
verifying: 'Verifica in corso…',
|
||||
incorrect: 'Password errata. Riprova.',
|
||||
failed: 'Impossibile verificare la password. Riprova più tardi.',
|
||||
},
|
||||
|
||||
about: {
|
||||
kicker: 'Chi sono',
|
||||
title: 'Chi sono',
|
||||
metaTitle: (name: string) => `Chi sono — ${name}`,
|
||||
portraitAlt: 'Ritratto',
|
||||
gear: 'Attrezzatura',
|
||||
},
|
||||
|
||||
map: {
|
||||
title: 'Mappa',
|
||||
kicker: 'Dove',
|
||||
subtitle: 'Dove sono state scattate le foto',
|
||||
loading: 'Caricamento della mappa…',
|
||||
loadFailed: (status: number) => `Impossibile caricare i dati della mappa (${status})`,
|
||||
initFailed: 'Impossibile inizializzare la mappa',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
collectionKicker: (index: string) => `${index} — Collezione`,
|
||||
sectionsNav: 'Sezioni',
|
||||
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
|
||||
nextSubpage: 'Avanti',
|
||||
nextSubpageAria: (name: string) => `Avanti: ${name}`,
|
||||
},
|
||||
|
||||
journal: {
|
||||
title: 'Diario',
|
||||
kicker: 'Storie e saggi',
|
||||
subtitle: 'Saggi fotografici, racconti visivi e appunti sul campo.',
|
||||
description: 'Saggi fotografici, racconti di viaggio e diari dietro le quinte.',
|
||||
entryDescription: 'Articolo del diario',
|
||||
empty: 'Nessun articolo pubblicato per ora.',
|
||||
readStory: 'Leggi la storia →',
|
||||
minRead: (n: number) => `${n} min di lettura`,
|
||||
draft: 'Bozza',
|
||||
by: (author: string) => `di ${author}`,
|
||||
notFound: 'Non trovato',
|
||||
},
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Visualizzatore di immagini',
|
||||
openPhoto: (n: number) => `Apri la foto ${n}`,
|
||||
close: 'Chiudi',
|
||||
closeTitle: 'Chiudi (Esc)',
|
||||
previous: 'Foto precedente',
|
||||
previousTitle: 'Foto precedente (freccia sinistra)',
|
||||
next: 'Foto successiva',
|
||||
nextTitle: 'Foto successiva (freccia destra)',
|
||||
toggleInfo: 'Mostra/nascondi informazioni',
|
||||
toggleInfoTitle: 'Mostra/nascondi informazioni (i)',
|
||||
hideInfo: 'Nascondi info',
|
||||
info: 'Info',
|
||||
loading: 'Caricamento…',
|
||||
camera: 'Fotocamera',
|
||||
lens: 'Obiettivo',
|
||||
focalLength: 'Lunghezza focale',
|
||||
aperture: 'Diaframma',
|
||||
shutter: 'Tempo di scatto',
|
||||
iso: 'ISO',
|
||||
location: 'Luogo',
|
||||
noExif: 'Nessun dato EXIF disponibile',
|
||||
copyLink: 'Copia il link a questa foto',
|
||||
copyLinkTitle: 'Copia il link a questa foto (c)',
|
||||
copyLinkShort: 'Link',
|
||||
copied: 'Copiato',
|
||||
copyManual: 'Copia questo link',
|
||||
shortcuts: 'Scorciatoie da tastiera',
|
||||
shortcutNavigate: 'Foto precedente / successiva',
|
||||
shortcutInfo: 'Informazioni sulla foto',
|
||||
shortcutFullscreen: 'Schermo intero',
|
||||
shortcutExitFullscreen: 'Esci da schermo intero',
|
||||
download: 'Scarica il file originale',
|
||||
downloadTitle: 'Scarica il file originale (d)',
|
||||
downloadShort: 'Originale',
|
||||
shortcutDownload: 'Scarica l’originale',
|
||||
shortcutSlideshow: 'Presentazione',
|
||||
shortcutSlideshowRunning: (seconds: number) => `Presentazione — ogni ${seconds} s`,
|
||||
slideshowStopped: 'Presentazione interrotta',
|
||||
slideshowRunning: (seconds: number) => `Presentazione in corso, avanza ogni ${seconds} secondi`,
|
||||
shortcutCopyLink: 'Copia il link a questa foto',
|
||||
shortcutList: 'Questo elenco',
|
||||
shortcutClose: 'Chiudi il visualizzatore',
|
||||
},
|
||||
|
||||
proofing: {
|
||||
addFavorite: 'Aggiungi ai preferiti',
|
||||
removeFavorite: 'Rimuovi dai preferiti',
|
||||
addToFavorites: 'Aggiungi ai preferiti',
|
||||
removeFromFavorites: 'Rimuovi dai preferiti',
|
||||
saved: 'Salvato',
|
||||
favorite: 'Preferito',
|
||||
showAll: 'Mostra tutto',
|
||||
selected: (n: number) => `❤️ ${n} ${n === 1 ? 'selezionata' : 'selezionate'}`,
|
||||
shareExport: 'Condividi ed esporta',
|
||||
modalTitle: (n: number) => `❤️ Selezione (${n})`,
|
||||
closeModal: 'Chiudi finestra',
|
||||
intro: (n: number) =>
|
||||
`Hai selezionato ${n} foto. Scegli un’opzione di esportazione per condividere la tua selezione:`,
|
||||
copyLink: 'Copia link di condivisione',
|
||||
linkCopied: 'Link copiato!',
|
||||
copyList: 'Copia elenco testuale (#1, #2…)',
|
||||
listCopied: 'Elenco copiato!',
|
||||
copyManualLink: 'Copia questo link',
|
||||
copyManualList: 'Copia questo elenco',
|
||||
sendEmail: '✉️ Invia e-mail al fotografo',
|
||||
downloadSelected: 'Scarica selezione (.zip)',
|
||||
clearSelection: 'Svuota selezione',
|
||||
confirmClear: 'Rimuovere tutti i preferiti selezionati?',
|
||||
listEmpty: (albumName: string) => `${albumName}: nessuna foto selezionata.`,
|
||||
listSummary: (albumName: string, count: number, indices: string) =>
|
||||
`${albumName} — Foto selezionate (${count}): ${indices}`,
|
||||
mailSubject: (n: number) => `Selezione di foto (${n})`,
|
||||
mailBody: (list: string, url: string) =>
|
||||
`Ciao,\n\necco la mia selezione di foto:\n\n${list}\n\nLink di condivisione: ${url}\n\nCordiali saluti,`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Note legali',
|
||||
title: 'Note legali',
|
||||
subtitle: 'Informazioni ai sensi del § 5 DDG',
|
||||
address: 'Indirizzo',
|
||||
contact: 'Contatti',
|
||||
email: 'E-mail',
|
||||
phone: 'Telefono',
|
||||
contactForm: 'Modulo di contatto',
|
||||
taxSection: 'Informazioni fiscali',
|
||||
taxId: 'Codice fiscale',
|
||||
vatId: 'Partita IVA',
|
||||
extraInfo: 'Ulteriori informazioni',
|
||||
},
|
||||
|
||||
contact: {
|
||||
navLabel: 'Contatto',
|
||||
title: 'Contatto',
|
||||
subtitle: 'Scrivimi un messaggio',
|
||||
name: 'Nome',
|
||||
email: 'E-mail',
|
||||
message: 'Messaggio',
|
||||
submit: 'Invia messaggio',
|
||||
sending: 'Invio…',
|
||||
successTitle: 'Grazie',
|
||||
successText: 'Il tuo messaggio è arrivato. Ti risponderò via e-mail.',
|
||||
invalid: 'Compila tutti i campi con un indirizzo e-mail valido.',
|
||||
tooMany: 'Troppi messaggi in poco tempo. Riprova tra un minuto.',
|
||||
failed: 'Non è stato possibile inviare il messaggio. Riprova più tardi.',
|
||||
privacy: (days: number) =>
|
||||
`Il tuo messaggio viene salvato sul server di questo sito, usato solo per risponderti e cancellato dopo ${days} ${days === 1 ? 'giorno' : 'giorni'}.`,
|
||||
},
|
||||
|
||||
privacy: {
|
||||
navLabel: 'Privacy',
|
||||
title: 'Informativa sulla privacy',
|
||||
},
|
||||
};
|
||||
246
lib/i18n/locales/nl.ts
Normal file
246
lib/i18n/locales/nl.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Dutch dictionary.
|
||||
*
|
||||
* Typed as `Dictionary` (derived from `en.ts`), so a key added to English but
|
||||
* forgotten here fails the type-check rather than falling through to English
|
||||
* at runtime.
|
||||
*/
|
||||
|
||||
import type { Dictionary } from '../index';
|
||||
|
||||
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
|
||||
|
||||
export const nl: Dictionary = {
|
||||
dateLocale: 'nl-NL',
|
||||
|
||||
nav: {
|
||||
home: 'Start',
|
||||
about: 'Over mij',
|
||||
map: 'Kaart',
|
||||
journal: 'Journal',
|
||||
skipToContent: 'Naar de inhoud',
|
||||
openMenu: 'Menu openen',
|
||||
closeMenu: 'Menu sluiten',
|
||||
},
|
||||
|
||||
common: {
|
||||
backTo: (label: string) => `Terug naar ${label}`,
|
||||
backToGallery: 'Terug naar de galerij',
|
||||
backToJournal: 'Terug naar het journal',
|
||||
email: 'E-mail',
|
||||
website: 'Website',
|
||||
home: 'Start',
|
||||
photos: (n: number) => plural(n, 'foto', 'foto’s'),
|
||||
albums: (n: number) => plural(n, 'album', 'albums'),
|
||||
collections: (n: number) => plural(n, 'collectie', 'collecties'),
|
||||
prevAlbum: 'Vorig album',
|
||||
nextAlbum: 'Volgend album',
|
||||
prevAlbumAria: (name: string) => `Vorig album: ${name}`,
|
||||
nextAlbumAria: (name: string) => `Volgend album: ${name}`,
|
||||
albumNavAria: 'Albumnavigatie',
|
||||
prevEntry: 'Vorig verhaal',
|
||||
nextEntry: 'Volgend verhaal',
|
||||
prevEntryAria: (title: string) => `Vorig verhaal: ${title}`,
|
||||
nextEntryAria: (title: string) => `Volgend verhaal: ${title}`,
|
||||
entryNavAria: 'Navigatie tussen journalverhalen',
|
||||
loadingGallery: 'Galerij laden',
|
||||
loadingPhotos: 'Foto’s laden',
|
||||
downloadAlbum: 'Album downloaden',
|
||||
},
|
||||
|
||||
home: {
|
||||
enter: 'Binnenkomen',
|
||||
},
|
||||
|
||||
error: {
|
||||
notFoundTitle: 'Niet gevonden',
|
||||
notFoundText: 'Deze pagina bestaat niet, of het album is niet meer gepubliceerd.',
|
||||
errorTitle: 'Er is iets misgegaan',
|
||||
errorText:
|
||||
'Deze pagina kon niet worden geladen. Meestal is dat tijdelijk — probeer het zo nog eens.',
|
||||
siteErrorText:
|
||||
'Deze site kon niet worden geladen. Meestal is dat tijdelijk — probeer het zo nog eens.',
|
||||
tryAgain: 'Opnieuw proberen',
|
||||
reference: (digest: string) => `Referentie: ${digest}`,
|
||||
},
|
||||
|
||||
download: {
|
||||
unavailableTitle: 'Download niet beschikbaar',
|
||||
notAvailable: 'Deze download is niet beschikbaar.',
|
||||
rateLimited: 'Te veel downloadverzoeken. Wacht even en probeer het opnieuw.',
|
||||
immichUnavailable:
|
||||
'De fotobibliotheek is op dit moment niet bereikbaar. Probeer het zo opnieuw.',
|
||||
back: 'Terug naar de galerij',
|
||||
},
|
||||
|
||||
theme: {
|
||||
switchToLight: 'Naar lichte modus',
|
||||
switchToDark: 'Naar donkere modus',
|
||||
scrollToTop: 'Naar boven',
|
||||
},
|
||||
|
||||
password: {
|
||||
subtitle: 'Deze galerij is met een wachtwoord beveiligd.',
|
||||
siteSubtitle: 'Deze site is met een wachtwoord beveiligd.',
|
||||
placeholder: 'Voer het wachtwoord in',
|
||||
submit: 'Binnenkomen',
|
||||
verifying: 'Controleren…',
|
||||
incorrect: 'Onjuist wachtwoord. Probeer het opnieuw.',
|
||||
failed: 'Het wachtwoord kon niet worden gecontroleerd. Probeer het later opnieuw.',
|
||||
},
|
||||
|
||||
about: {
|
||||
kicker: 'Over mij',
|
||||
title: 'Over mij',
|
||||
metaTitle: (name: string) => `Over mij — ${name}`,
|
||||
portraitAlt: 'Portret',
|
||||
gear: 'Uitrusting',
|
||||
},
|
||||
|
||||
map: {
|
||||
title: 'Kaart',
|
||||
kicker: 'Waar',
|
||||
subtitle: 'Waar de foto’s zijn gemaakt',
|
||||
loading: 'Kaart laden…',
|
||||
loadFailed: (status: number) => `Kaartgegevens konden niet worden geladen (${status})`,
|
||||
initFailed: 'De kaart kon niet worden gestart',
|
||||
},
|
||||
|
||||
subpage: {
|
||||
collectionKicker: (index: string) => `${index} — Collectie`,
|
||||
sectionsNav: 'Onderdelen',
|
||||
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
|
||||
nextSubpage: 'Volgende',
|
||||
nextSubpageAria: (name: string) => `Volgende: ${name}`,
|
||||
},
|
||||
|
||||
journal: {
|
||||
title: 'Journal',
|
||||
kicker: 'Verhalen & essays',
|
||||
subtitle: 'Fotoessays, beeldverhalen en veldnotities.',
|
||||
description: 'Fotoessays, reisverhalen en een kijkje achter de schermen.',
|
||||
entryDescription: 'Journalverhaal',
|
||||
empty: 'Er zijn nog geen verhalen gepubliceerd.',
|
||||
readStory: 'Lees het verhaal →',
|
||||
minRead: (n: number) => `${n} min lezen`,
|
||||
draft: 'Concept',
|
||||
by: (author: string) => `door ${author}`,
|
||||
notFound: 'Niet gevonden',
|
||||
},
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Fotoviewer',
|
||||
openPhoto: (n: number) => `Foto ${n} bekijken`,
|
||||
close: 'Sluiten',
|
||||
closeTitle: 'Sluiten (Esc)',
|
||||
previous: 'Vorige foto',
|
||||
previousTitle: 'Vorige foto (pijl naar links)',
|
||||
next: 'Volgende foto',
|
||||
nextTitle: 'Volgende foto (pijl naar rechts)',
|
||||
toggleInfo: 'Foto-info tonen/verbergen',
|
||||
toggleInfoTitle: 'Foto-info tonen/verbergen (i)',
|
||||
hideInfo: 'Info verbergen',
|
||||
info: 'Info',
|
||||
loading: 'Laden…',
|
||||
camera: 'Camera',
|
||||
lens: 'Objectief',
|
||||
focalLength: 'Brandpuntsafstand',
|
||||
aperture: 'Diafragma',
|
||||
shutter: 'Sluitertijd',
|
||||
iso: 'ISO',
|
||||
location: 'Locatie',
|
||||
noExif: 'Geen EXIF-gegevens beschikbaar',
|
||||
copyLink: 'Link naar deze foto kopiëren',
|
||||
copyLinkTitle: 'Link naar deze foto kopiëren (c)',
|
||||
copyLinkShort: 'Link',
|
||||
copied: 'Gekopieerd',
|
||||
copyManual: 'Kopieer deze link',
|
||||
shortcuts: 'Sneltoetsen',
|
||||
shortcutNavigate: 'Vorige / volgende foto',
|
||||
shortcutInfo: 'Foto-info',
|
||||
shortcutFullscreen: 'Volledig scherm',
|
||||
shortcutExitFullscreen: 'Volledig scherm verlaten',
|
||||
download: 'Het originele bestand downloaden',
|
||||
downloadTitle: 'Het originele bestand downloaden (d)',
|
||||
downloadShort: 'Origineel',
|
||||
shortcutDownload: 'Het origineel downloaden',
|
||||
shortcutSlideshow: 'Diavoorstelling',
|
||||
shortcutSlideshowRunning: (seconds: number) => `Diavoorstelling — elke ${seconds} s`,
|
||||
slideshowStopped: 'Diavoorstelling gestopt',
|
||||
slideshowRunning: (seconds: number) =>
|
||||
`Diavoorstelling loopt, elke ${seconds} seconden de volgende foto`,
|
||||
shortcutCopyLink: 'Link naar deze foto kopiëren',
|
||||
shortcutList: 'Deze lijst',
|
||||
shortcutClose: 'De viewer sluiten',
|
||||
},
|
||||
|
||||
proofing: {
|
||||
addFavorite: 'Favoriet maken',
|
||||
removeFavorite: 'Favoriet verwijderen',
|
||||
addToFavorites: 'Aan favorieten toevoegen',
|
||||
removeFromFavorites: 'Uit favorieten verwijderen',
|
||||
saved: 'Opgeslagen',
|
||||
favorite: 'Favoriet',
|
||||
showAll: 'Alles tonen',
|
||||
selected: (n: number) => `❤️ ${n} geselecteerd`,
|
||||
shareExport: 'Delen & exporteren',
|
||||
modalTitle: (n: number) => `❤️ Selectie (${n})`,
|
||||
closeModal: 'Venster sluiten',
|
||||
intro: (n: number) =>
|
||||
`Je hebt ${plural(n, 'foto', 'foto’s')} geselecteerd. Kies hieronder een exportoptie om je selectie te delen:`,
|
||||
copyLink: 'Deellink kopiëren',
|
||||
linkCopied: 'Link gekopieerd!',
|
||||
copyList: 'Tekstlijst kopiëren (#1, #2…)',
|
||||
listCopied: 'Lijst gekopieerd!',
|
||||
copyManualLink: 'Kopieer deze link',
|
||||
copyManualList: 'Kopieer deze lijst',
|
||||
sendEmail: '✉️ E-mail naar de fotograaf sturen',
|
||||
downloadSelected: 'Selectie downloaden (.zip)',
|
||||
clearSelection: 'Selectie wissen',
|
||||
confirmClear: 'Alle geselecteerde favorieten wissen?',
|
||||
listEmpty: (albumName: string) => `${albumName}: geen foto’s geselecteerd.`,
|
||||
listSummary: (albumName: string, count: number, indices: string) =>
|
||||
`${albumName} — Geselecteerde foto’s (${count}): ${indices}`,
|
||||
mailSubject: (n: number) => `Fotoselectie (${n})`,
|
||||
mailBody: (list: string, url: string) =>
|
||||
`Hallo,\n\nHier is mijn fotoselectie:\n\n${list}\n\nDeellink: ${url}\n\nMet vriendelijke groet,`,
|
||||
},
|
||||
|
||||
legal: {
|
||||
navLabel: 'Colofon',
|
||||
title: 'Colofon',
|
||||
subtitle: 'Gegevens volgens § 5 DDG',
|
||||
address: 'Adres',
|
||||
contact: 'Contact',
|
||||
email: 'E-mail',
|
||||
phone: 'Telefoon',
|
||||
contactForm: 'Contactformulier',
|
||||
taxSection: 'Fiscale gegevens',
|
||||
taxId: 'Fiscaal nummer',
|
||||
vatId: 'Btw-nummer',
|
||||
extraInfo: 'Aanvullende informatie',
|
||||
},
|
||||
|
||||
contact: {
|
||||
navLabel: 'Contact',
|
||||
title: 'Contact',
|
||||
subtitle: 'Stuur me een bericht',
|
||||
name: 'Naam',
|
||||
email: 'E-mail',
|
||||
message: 'Bericht',
|
||||
submit: 'Bericht versturen',
|
||||
sending: 'Versturen…',
|
||||
successTitle: 'Bedankt',
|
||||
successText: 'Je bericht is aangekomen. Ik antwoord je per e-mail.',
|
||||
invalid: 'Vul alle velden in met een geldig e-mailadres.',
|
||||
tooMany: 'Te veel berichten in korte tijd. Probeer het over een minuut opnieuw.',
|
||||
failed: 'Het bericht kon niet worden verstuurd. Probeer het later opnieuw.',
|
||||
privacy: (days: number) =>
|
||||
`Je bericht wordt op de eigen server van deze site bewaard, alleen gebruikt om je te antwoorden en na ${days} ${days === 1 ? 'dag' : 'dagen'} verwijderd.`,
|
||||
},
|
||||
|
||||
privacy: {
|
||||
navLabel: 'Privacy',
|
||||
title: 'Privacyverklaring',
|
||||
},
|
||||
};
|
||||
62
lib/markdownBlocks.ts
Normal file
62
lib/markdownBlocks.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Block-level Markdown for plain pages such as the privacy policy (#699):
|
||||
* headings, lists and paragraphs. Inline formatting goes through
|
||||
* renderInlineMarkdown, which escapes first, so the only tags in the output
|
||||
* are the ones built here and there.
|
||||
*
|
||||
* Deliberately small. The journal has its own block format; this is for a
|
||||
* text a person writes once and reads top to bottom.
|
||||
*/
|
||||
|
||||
import { renderInlineMarkdown } from './journal';
|
||||
|
||||
export type MarkdownBlock =
|
||||
| { type: 'heading'; level: 2 | 3; html: string }
|
||||
| { type: 'list'; ordered: boolean; items: string[] }
|
||||
| { type: 'paragraph'; html: string };
|
||||
|
||||
export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
|
||||
const blocks: MarkdownBlock[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let list: { ordered: boolean; items: string[] } | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (paragraph.length) {
|
||||
blocks.push({ type: 'paragraph', html: renderInlineMarkdown(paragraph.join(' ')) });
|
||||
paragraph = [];
|
||||
}
|
||||
if (list) {
|
||||
blocks.push({ type: 'list', ...list });
|
||||
list = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const raw of markdown.replace(/\r\n/g, '\n').split('\n')) {
|
||||
const line = raw.trim();
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
||||
const numbered = /^\d+[.)]\s+(.*)$/.exec(line);
|
||||
|
||||
if (!line) {
|
||||
flush();
|
||||
} else if (heading) {
|
||||
flush();
|
||||
// The page title is the h1, so # and ## both become h2.
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
level: heading[1].length <= 2 ? 2 : 3,
|
||||
html: renderInlineMarkdown(heading[2]),
|
||||
});
|
||||
} else if (bullet || numbered) {
|
||||
const ordered = !!numbered;
|
||||
if (paragraph.length || (list && list.ordered !== ordered)) flush();
|
||||
list ??= { ordered, items: [] };
|
||||
list.items.push(renderInlineMarkdown((bullet ?? numbered)![1]));
|
||||
} else {
|
||||
if (list) flush();
|
||||
paragraph.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return blocks;
|
||||
}
|
||||
161
lib/privacy.ts
Normal file
161
lib/privacy.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The privacy policy page (#699): `content/privacy.md`, shown at /privacy.
|
||||
*
|
||||
* Folio ships no legal text. What it can do is say what this installation
|
||||
* processes, read off the configuration, so the owner does not have to dig
|
||||
* through the code to write the policy: processingFacts() below, shown next
|
||||
* to the editor in the admin panel.
|
||||
*
|
||||
* Server only (fs).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { AppConfig } from './config/schema';
|
||||
|
||||
export const PRIVACY_FILENAME = 'privacy.md';
|
||||
|
||||
export function privacyPath(contentDir = path.join(process.cwd(), 'content')): string {
|
||||
return path.join(contentDir, PRIVACY_FILENAME);
|
||||
}
|
||||
|
||||
/** The policy text, or '' when there is none. */
|
||||
export function readPrivacy(contentDir?: string): string {
|
||||
try {
|
||||
return fs.readFileSync(privacyPath(contentDir), 'utf8').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether /privacy exists: switched on (the default) and with text in the
|
||||
* file. Synchronous, because proxy.ts decides the 404 before rendering.
|
||||
*/
|
||||
export function privacyAvailable(config: Pick<AppConfig, 'privacy'>, contentDir?: string): boolean {
|
||||
if (!config.privacy.enabled) return false;
|
||||
try {
|
||||
return fs.statSync(privacyPath(contentDir)).size > 0 && readPrivacy(contentDir) !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProcessingFact {
|
||||
/** Short label, e.g. "Contact form". */
|
||||
topic: string;
|
||||
/** What is processed, where it goes, how long it stays. */
|
||||
detail: string;
|
||||
/** Data leaves this server towards a third party. */
|
||||
thirdParty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this installation processes, derived from its configuration. English,
|
||||
* like the rest of the admin panel; it is a checklist for the owner, not text
|
||||
* for the page.
|
||||
*/
|
||||
export function processingFacts(
|
||||
config: AppConfig,
|
||||
env: { CDN_URL?: string; hasPasswords: boolean },
|
||||
): ProcessingFact[] {
|
||||
const facts: ProcessingFact[] = [
|
||||
{
|
||||
topic: 'Photos',
|
||||
detail:
|
||||
'Served through this site from your Immich server. Visitors never contact Immich directly.',
|
||||
thirdParty: false,
|
||||
},
|
||||
{
|
||||
topic: 'Server log',
|
||||
detail:
|
||||
'Folio keeps no access log. It prints a visitor IP address to the server log only when that visitor exceeds a rate limit. Your reverse proxy or hosting may log more, so check those too.',
|
||||
thirdParty: false,
|
||||
},
|
||||
{
|
||||
topic: 'Fonts',
|
||||
detail:
|
||||
'Served from this site. The server fetches them from Google Fonts once; visitors do not contact Google.',
|
||||
thirdParty: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (config.map) {
|
||||
facts.push({
|
||||
topic: 'Map',
|
||||
detail:
|
||||
'The map on /map and in journal entries loads its tiles from tile.openstreetmap.org (OpenStreetMap Foundation, UK). The visitor’s browser requests them, so their IP address reaches OSM.',
|
||||
thirdParty: true,
|
||||
});
|
||||
}
|
||||
if (config.analytics) {
|
||||
facts.push({
|
||||
topic: 'Visitor statistics',
|
||||
detail:
|
||||
'A cookieless counter stores the page path, the date and whether the device is mobile or desktop in content/analytics.json. No IP address and no identifier are stored.',
|
||||
thirdParty: false,
|
||||
});
|
||||
}
|
||||
if (config.contact.enabled) {
|
||||
facts.push({
|
||||
topic: 'Contact form',
|
||||
detail: `Name, email address and message are stored on this server in content/messages/ and deleted after ${config.contact.retentionDays} days.${
|
||||
config.contact.notifyUrl
|
||||
? ` A notification goes to ${hostOf(config.contact.notifyUrl)}, carrying nothing about the sender.`
|
||||
: ''
|
||||
}`,
|
||||
thirdParty: false,
|
||||
});
|
||||
}
|
||||
if (env.hasPasswords) {
|
||||
facts.push({
|
||||
topic: 'Cookies',
|
||||
detail:
|
||||
'Password-protected pages set an HttpOnly cookie after a correct password, valid for 24 hours. It is needed for the page to work and holds no personal data.',
|
||||
thirdParty: false,
|
||||
});
|
||||
}
|
||||
facts.push({
|
||||
topic: 'Browser storage',
|
||||
detail: `The colour-mode choice${
|
||||
config.proofing.enabled ? ' and photo selections (proofing)' : ''
|
||||
} are kept in the visitor’s own browser (localStorage) and never sent to the server.`,
|
||||
thirdParty: false,
|
||||
});
|
||||
if (env.CDN_URL) {
|
||||
facts.push({
|
||||
topic: 'CDN',
|
||||
detail: `Photos and videos are delivered through ${hostOf(env.CDN_URL)}, which receives the visitor’s IP address.`,
|
||||
thirdParty: true,
|
||||
});
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Headings to start from, in the site language. No wording beyond them. */
|
||||
export function starterHeadings(lang: string, facts: ProcessingFact[]): string {
|
||||
const de = lang.toLowerCase().startsWith('de');
|
||||
const topics = new Set(facts.map((f) => f.topic));
|
||||
const h = (en: string, german: string) => `## ${de ? german : en}\n\n`;
|
||||
return [
|
||||
h('Controller', 'Verantwortlicher'),
|
||||
h('Hosting and server logs', 'Hosting und Server-Logfiles'),
|
||||
h('Fonts', 'Schriftarten'),
|
||||
topics.has('Map') ? h('Map', 'Kartendienst') : '',
|
||||
topics.has('Visitor statistics') ? h('Visitor statistics', 'Besucherstatistik') : '',
|
||||
topics.has('Contact form') ? h('Contact form', 'Kontaktformular') : '',
|
||||
h('Cookies and browser storage', 'Cookies und lokaler Speicher'),
|
||||
topics.has('CDN') ? h('Content delivery network', 'Content Delivery Network') : '',
|
||||
h('Your rights', 'Ihre Rechte'),
|
||||
]
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
@@ -71,8 +71,12 @@ export function resolveSiteUrl(
|
||||
* Returns null without a site URL: a relative link in a sitemap is invalid and
|
||||
* a relative link in a feed points at the reader, so there is nothing sensible
|
||||
* to fall back to.
|
||||
*
|
||||
* A path that is already an absolute http(s) URL — an image URL in CDN mode
|
||||
* (lib/cdn.ts) — is returned as it is, site URL or not.
|
||||
*/
|
||||
export function absoluteUrl(siteUrl: string | null, path: string): string | null {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (!siteUrl) return null;
|
||||
const raw = path.startsWith('/') ? path : `/${path}`;
|
||||
const suffix = raw === '/' ? '/' : raw.replace(/\/+$/, '');
|
||||
|
||||
11
lib/urls.ts
11
lib/urls.ts
@@ -5,6 +5,7 @@
|
||||
|
||||
import { encodeAssetId } from './tokens';
|
||||
import { env } from './env';
|
||||
import { cdnBase } from './cdn';
|
||||
import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from './thumbhash';
|
||||
import type { ImmichAsset } from './immich';
|
||||
|
||||
@@ -22,13 +23,14 @@ import type { ImmichAsset } from './immich';
|
||||
const cacheBuster = env.IMAGE_CACHE_VERSION ? `&v=${env.IMAGE_CACHE_VERSION}` : '';
|
||||
|
||||
/**
|
||||
* Generate a public image proxy URL for an asset.
|
||||
* Generate a public image proxy URL for an asset. Absolute, on the CDN, when
|
||||
* CDN mode is on (lib/cdn.ts); relative otherwise.
|
||||
*/
|
||||
export function imageUrl(
|
||||
assetId: string,
|
||||
size: 'thumbnail' | 'preview' | 'original' = 'preview',
|
||||
): string {
|
||||
return `/api/image/${encodeAssetId(assetId)}?size=${size}${cacheBuster}`;
|
||||
return `${cdnBase()}/api/image/${encodeAssetId(assetId)}?size=${size}${cacheBuster}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,12 +41,13 @@ export function exifUrl(assetId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a public video proxy URL for an asset.
|
||||
* Generate a public video proxy URL for an asset — on the CDN in CDN mode,
|
||||
* like imageUrl().
|
||||
*/
|
||||
export function videoUrl(assetId: string): string {
|
||||
// No `size` here, so the buster is the only query parameter.
|
||||
const v = cacheBuster ? `?${cacheBuster.slice(1)}` : '';
|
||||
return `/api/video/${encodeAssetId(assetId)}${v}`;
|
||||
return `${cdnBase()}/api/video/${encodeAssetId(assetId)}${v}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user