188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
/**
|
||
* 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;
|
||
/**
|
||
* At least one proofing link exists in content/proofing.json. Links are
|
||
* created in the admin panel whatever `proofing.enabled` says, so the
|
||
* setting cannot stand in for this.
|
||
*/
|
||
hasProofingLinks: boolean;
|
||
/** PROOFING_WEBHOOK_URL, already parsed (lib/proofWebhook.ts). */
|
||
proofingWebhookUrl?: string | null;
|
||
},
|
||
): 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,
|
||
});
|
||
}
|
||
// A subpage's own `proofing: true` turns the hearts on even with the global
|
||
// switch off (resolveProofing), so the global flag alone would under-report.
|
||
const favourites = config.proofing.enabled || config.subpages.some((sp) => sp.proofing);
|
||
facts.push({
|
||
topic: 'Browser storage',
|
||
detail: favourites
|
||
? 'The colour-mode choice and the photos a visitor marks as favourites on an album are kept in the visitor’s own browser (localStorage). Folio does not store them on the server.'
|
||
: 'The colour-mode choice is kept in the visitor’s own browser (localStorage). Folio does not store it on the server.',
|
||
thirdParty: false,
|
||
});
|
||
if (env.hasProofingLinks) {
|
||
facts.push({
|
||
topic: 'Proofing links',
|
||
detail: `Each client proofing link (/proof/…) is stored on this server in content/proofing.json: the client name you entered, the photos the client selects, when the selection was last changed and submitted, and how many ZIP downloads were used. The data stays until you delete the link in the admin panel; an expired link stops working but keeps its data.${
|
||
env.proofingWebhookUrl
|
||
? ` When the client submits, a notification with the client name, the album name and the number of selected photos goes to ${hostOf(env.proofingWebhookUrl)}.`
|
||
: ''
|
||
}`,
|
||
thirdParty: !!env.proofingWebhookUrl,
|
||
});
|
||
}
|
||
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') : '',
|
||
topics.has('Proofing links') ? h('Client proofing', 'Bildauswahl durch Kunden') : '',
|
||
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();
|
||
}
|