From 256906e9fe431204b19cd1e1e2db2fbfa5e37553 Mon Sep 17 00:00:00 2001 From: Simon Diesenreiter Date: Mon, 9 Nov 2026 01:22:02 +0100 Subject: [PATCH] Prepare for going public: legal pages, corrections contact, portrait proxy /impressum and /datenschutz read the operator's details from the environment so they stay out of this repository, and show a visible notice until set. Portraits go through an allowlisted /api/img so visitors' IPs stay with us. Co-Authored-By: Claude Opus 5.5 --- .env.example | 7 +++ docker-compose.yml | 5 +++ docs/roadmap.md | 5 ++- web/messages/de.json | 42 +++++++++++++++++- web/messages/en.json | 42 +++++++++++++++++- web/src/app/[locale]/datenschutz/page.tsx | 42 ++++++++++++++++++ web/src/app/[locale]/impressum/page.tsx | 52 +++++++++++++++++++++++ web/src/app/[locale]/methodik/page.tsx | 3 +- web/src/app/api/img/route.ts | 47 ++++++++++++++++++++ web/src/components/Avatar.tsx | 6 +-- web/src/components/nav.ts | 6 ++- web/src/lib/legal.ts | 11 +++++ 12 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 web/src/app/[locale]/datenschutz/page.tsx create mode 100644 web/src/app/[locale]/impressum/page.tsx create mode 100644 web/src/app/api/img/route.ts create mode 100644 web/src/lib/legal.ts diff --git a/.env.example b/.env.example index aac2b11..278e9c9 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,13 @@ POLITRACK_READER_PASSWORD=change-me-too POLITRACK_USER_AGENT=PolitrackBot/0.1 (+https://example.org/about; contact: you@example.org) POLITRACK_MIN_REQUEST_INTERVAL=2.0 +# Shown on /impressum and /datenschutz. Required before the site goes public (§ 5 ECG, §§ 24, 25 MedienG). +# POLITRACK_LEGAL_NAME= +# POLITRACK_LEGAL_ADDRESS= +# POLITRACK_LEGAL_EMAIL= +# Defaults to POLITRACK_LEGAL_EMAIL. +# POLITRACK_CORRECTIONS_EMAIL= + # Legislative periods of the Austrian parliament to ingest in detail (JSON list). POLITRACK_PARLAMENT_PERIODS=["XXVII","XXVIII"] diff --git a/docker-compose.yml b/docker-compose.yml index d407fa5..376a3ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,11 @@ services: restart: unless-stopped environment: API_INTERNAL_URL: http://api:8000 + POLITRACK_USER_AGENT: ${POLITRACK_USER_AGENT:-} + POLITRACK_LEGAL_NAME: ${POLITRACK_LEGAL_NAME:-} + POLITRACK_LEGAL_ADDRESS: ${POLITRACK_LEGAL_ADDRESS:-} + POLITRACK_LEGAL_EMAIL: ${POLITRACK_LEGAL_EMAIL:-} + POLITRACK_CORRECTIONS_EMAIL: ${POLITRACK_CORRECTIONS_EMAIL:-} ports: - "${WEB_PORT:-3000}:3000" depends_on: diff --git a/docs/roadmap.md b/docs/roadmap.md index 0d51cd6..5eb3ef9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,6 +33,7 @@ Next.js frontend (de/en), Docker Compose for Unraid. party/institution channels; curated `social_accounts.yaml` - [x] X deletion sync (`social.x_deletions`): posts gone from X keep a marked record without their text - [x] EP final roll-call votes of Austrian MEPs (`europarl.votes`) +- [x] Draft legal pages (`/impressum`, `/datenschutz`), corrections contact, portrait proxy (`/api/img`) - [x] Pages: organisations, money explorer, social feed, Wikipedia edits, person network ("follow the money"), say-vs-vote on roll calls @@ -59,8 +60,8 @@ Next.js frontend (de/en), Docker Compose for Unraid. - [ ] Wayback Machine archiving of evidence URLs (`document.archive_url`) **Product** -- [ ] Before going public: Impressum/Offenlegung (§ 5 ECG, § 25 MedienG), privacy policy, a corrections contact -- [ ] Image proxy for portraits and logos (no visitor IPs to Wikimedia/parliament) +- [ ] Before going public: have `/impressum` and `/datenschutz` legally reviewed, set `POLITRACK_LEGAL_*` in `.env`, + and document the balancing test (see legal.md) - [ ] Compare two people or parties; topic pages (`/themen/{slug}`); Open Graph share images; CSV export - [ ] Review queue for editors in the web UI (today `politrack review`) - [ ] Community link submissions and a corrections/right-of-reply form diff --git a/web/messages/de.json b/web/messages/de.json index 6083fb8..cef0122 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -16,7 +16,44 @@ "organizations": "Organisationen", "money": "Geld", "social": "Social Media", - "wikipedia": "Wikipedia-Bearbeitungen" + "wikipedia": "Wikipedia-Bearbeitungen", + "imprint": "Impressum", + "privacy": "Datenschutz" + }, + "Imprint": { + "title": "Impressum", + "intro": "Offenlegung gemäß § 5 ECG und §§ 24, 25 MedienG.", + "notConfigured": "Die Angaben zum Medieninhaber sind noch nicht eingetragen. Vor einer Veröffentlichung müssen sie vollständig sein.", + "missing": "[noch nicht eingetragen]", + "ownerHeading": "Medieninhaber und Herausgeber", + "email": "E-Mail", + "purposeHeading": "Unternehmensgegenstand", + "purpose": "Keiner. Politrack ist ein privates, nicht gewinnorientiertes Projekt ohne Werbung.", + "directionHeading": "Grundlegende Richtung", + "direction": "Information über die öffentliche Tätigkeit österreichischer Politikerinnen, Politiker und Parteien auf Grundlage öffentlich zugänglicher Quellen, unabhängig von Parteien und Interessengruppen.", + "correctionsHeading": "Korrekturen und Gegendarstellung", + "corrections": "Eine Angabe ist falsch oder unvollständig? Schreib uns bitte die Nummer der Angabe und eine Quelle. Widersprochene Angaben markieren wir als umstritten, falsche korrigieren wir.", + "liabilityHeading": "Haftung", + "liability": "Alle Angaben stammen aus den jeweils verlinkten Quellen; maßgeblich sind diese. Für die Inhalte verlinkter Seiten sind deren Betreiber verantwortlich." + }, + "Privacy": { + "title": "Datenschutz", + "intro": "Welche Daten Politrack verarbeitet und welche Rechte du hast.", + "draft": "Entwurf: Dieser Text wurde noch nicht rechtlich geprüft.", + "missing": "[noch nicht eingetragen]", + "controllerHeading": "Verantwortlicher", + "visitorsHeading": "Wenn du diese Website besuchst", + "visitors": "Politrack verwendet keine Werbung, kein Tracking und keine Skripte, Schriften oder Einbettungen von Dritten. Beim Abruf fallen technisch notwendige Zugriffsdaten an (IP-Adresse, Zeitpunkt, aufgerufene Seite), die nur so lange gespeichert werden, wie es für den sicheren Betrieb nötig ist.", + "cookiesHeading": "Cookies", + "cookies": "Wir speichern nur deine eigenen Einstellungen in Cookies: Quellen- und Themenfilter sowie das Farbschema. Sie dienen keiner Nachverfolgung.", + "imagesHeading": "Porträts", + "images": "Porträts laden wir über unseren eigenen Server, sodass deine IP-Adresse nicht an das Parlament oder andere Quellen übermittelt wird. Links zu externen Quellen öffnest du selbst; dort gelten die Bestimmungen der jeweiligen Betreiber.", + "subjectsHeading": "Daten über Politikerinnen und Politiker", + "subjects": "Politrack verarbeitet personenbezogene Daten ausschließlich zur öffentlichen Rolle von Personen: Funktionen, Abstimmungen, öffentliche Aussagen und Auftritte, gemeldete Nebentätigkeiten, Parteispenden und öffentlich bekannte Verfahren. Private Spender werden nur so genannt, wie es die amtliche Veröffentlichung vorsieht.", + "basisHeading": "Rechtsgrundlage", + "basis": "Berechtigtes Interesse und öffentliches Interesse an politischer Transparenz (Art. 6 Abs. 1 lit. f DSGVO) sowie die Freiheit der Meinungsäußerung und Information (Art. 85 DSGVO).", + "rightsHeading": "Deine Rechte", + "rights": "Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung und Widerspruch. Wende dich dazu an die oben genannte Adresse. Außerdem kannst du dich bei der Österreichischen Datenschutzbehörde beschweren." }, "Home": { "headline": "Politik in Österreich – nachvollziehbar.", @@ -336,7 +373,8 @@ "kind": "Art", "notCollected": "Wir umgehen keine Bezahlschranken, Logins oder technischen Sperren und halten uns an robots.txt und Nutzungsbedingungen. Von Medien speichern wir nur Überschrift, Teaser und Link.", "data": "Die Betreiber:innen dieser Instanz stellen die Daten auch maschinenlesbar bereit: als REST-API mit OpenAPI-Dokumentation, als MCP-Server für KI-Agenten und als Lesezugriff auf die Datenbank. Jede Angabe kommt dort mit denselben Belegen wie hier.", - "corrections": "Fehler gefunden? Wir korrigieren falsche Angaben, markieren widersprochene als umstritten und löschen nichts stillschweigend. Bitte nenne die Nummer der Angabe (siehe Permalink) und eine Quelle für die Korrektur." + "corrections": "Fehler gefunden? Wir korrigieren falsche Angaben, markieren widersprochene als umstritten und löschen nichts stillschweigend. Bitte nenne die Nummer der Angabe (siehe Permalink) und eine Quelle für die Korrektur.", + "correctionsContact": "Kontakt für Korrekturen" }, "Common": { "loadError": "Die Daten konnten nicht geladen werden. Bitte versuche es gleich noch einmal.", diff --git a/web/messages/en.json b/web/messages/en.json index b4fbded..25da520 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -16,7 +16,9 @@ "organizations": "Organisations", "money": "Money", "social": "Social media", - "wikipedia": "Wikipedia edits" + "wikipedia": "Wikipedia edits", + "imprint": "Legal notice", + "privacy": "Privacy" }, "Home": { "headline": "Austrian politics – traceable.", @@ -336,7 +338,8 @@ "kind": "Kind", "notCollected": "We don't get around paywalls, logins or technical barriers, and we respect robots.txt and terms of use. From media we store only the headline, teaser and link.", "data": "The operators of this instance also provide the data in machine-readable form: a REST API with OpenAPI documentation, an MCP server for AI agents and read access to the database. Every fact comes with the same sources as here.", - "corrections": "Found a mistake? We correct wrong facts, mark contradicted ones as disputed and never delete anything silently. Please quote the fact's number (see its permalink) and a source for the correction." + "corrections": "Found a mistake? We correct wrong facts, mark contradicted ones as disputed and never delete anything silently. Please quote the fact's number (see its permalink) and a source for the correction.", + "correctionsContact": "Contact for corrections" }, "Common": { "loadError": "The data could not be loaded. Please try again in a moment.", @@ -586,6 +589,41 @@ "about": "About Politrack" } }, + "Imprint": { + "title": "Legal notice", + "intro": "Disclosure under § 5 ECG and §§ 24, 25 MedienG (Austrian law).", + "notConfigured": "The publisher's details have not been entered yet. They must be complete before publication.", + "missing": "[not entered yet]", + "ownerHeading": "Media owner and publisher", + "email": "Email", + "purposeHeading": "Business purpose", + "purpose": "None. Politrack is a private, non-profit project without advertising.", + "directionHeading": "Editorial line", + "direction": "Information on the public activity of Austrian politicians and parties, based on publicly available sources and independent of parties and interest groups.", + "correctionsHeading": "Corrections and right of reply", + "corrections": "Is a fact wrong or incomplete? Please send us its number and a source. We mark contradicted facts as disputed and correct wrong ones.", + "liabilityHeading": "Liability", + "liability": "All facts come from the sources linked with them, which are authoritative. Linked sites are the responsibility of their operators." + }, + "Privacy": { + "title": "Privacy", + "intro": "What data Politrack processes and what rights you have.", + "draft": "Draft: this text has not been legally reviewed yet.", + "missing": "[not entered yet]", + "controllerHeading": "Controller", + "visitorsHeading": "When you visit this website", + "visitors": "Politrack uses no advertising, no tracking and no third-party scripts, fonts or embeds. Serving a page produces technically necessary access data (IP address, time, page requested), kept only as long as needed to run the site securely.", + "cookiesHeading": "Cookies", + "cookies": "Cookies only store your own settings: source and topic filters and the colour scheme. They are not used for tracking.", + "imagesHeading": "Portraits", + "images": "Portraits are loaded through our own server, so your IP address is not passed to the parliament or other sources. External source links are yours to open; the operators' own terms apply there.", + "subjectsHeading": "Data about politicians", + "subjects": "Politrack processes personal data only about people's public role: offices, votes, public statements and appearances, declared side jobs, party donations and publicly known proceedings. Private donors are named only as the official publication names them.", + "basisHeading": "Legal basis", + "basis": "Legitimate and public interest in political transparency (Art. 6(1)(f) GDPR) and freedom of expression and information (Art. 85 GDPR).", + "rightsHeading": "Your rights", + "rights": "You have the right to access, rectification, erasure, restriction and objection. Please contact the address above. You can also complain to the Austrian Data Protection Authority." + }, "Footer": { "tagline": "Austrian politics, sourced. An independent project that collects public information on politicians and parties – every fact with its source.", "principleEvidence": "Every fact with its source", diff --git a/web/src/app/[locale]/datenschutz/page.tsx b/web/src/app/[locale]/datenschutz/page.tsx new file mode 100644 index 0000000..bb69b23 --- /dev/null +++ b/web/src/app/[locale]/datenschutz/page.tsx @@ -0,0 +1,42 @@ +import type {Metadata} from 'next'; +import {getTranslations, setRequestLocale} from 'next-intl/server'; +import {Notice} from '@/components/Notice'; +import {PageHeader} from '@/components/PageHeader'; +import {legalDetails} from '@/lib/legal'; + +export const dynamic = 'force-dynamic'; // the operator's details are read from the environment at request time + +const SECTIONS = ['visitors', 'cookies', 'images', 'subjects', 'basis', 'rights'] as const; + +export async function generateMetadata({params}: {params: Promise<{locale: string}>}): Promise { + const {locale} = await params; + const t = await getTranslations({locale, namespace: 'Privacy'}); + return {title: t('title')}; +} + +export default async function PrivacyPage({params}: {params: Promise<{locale: string}>}) { + const {locale} = await params; + setRequestLocale(locale); + const t = await getTranslations('Privacy'); + const {name, address, email} = legalDetails(); + + return ( +
+ +
+ {t('draft')} +
+

{t('controllerHeading')}

+

{name ?? t('missing')}
{address ?? t('missing')}

+

{email ? {email} : t('missing')}

+
+ {SECTIONS.map((key) => ( +
+

{t(`${key}Heading`)}

+

{t(key)}

+
+ ))} +
+
+ ); +} diff --git a/web/src/app/[locale]/impressum/page.tsx b/web/src/app/[locale]/impressum/page.tsx new file mode 100644 index 0000000..d500407 --- /dev/null +++ b/web/src/app/[locale]/impressum/page.tsx @@ -0,0 +1,52 @@ +import type {Metadata} from 'next'; +import {getTranslations, setRequestLocale} from 'next-intl/server'; +import {Notice} from '@/components/Notice'; +import {PageHeader} from '@/components/PageHeader'; +import {legalDetails} from '@/lib/legal'; + +export const dynamic = 'force-dynamic'; // the operator's details are read from the environment at request time + +export async function generateMetadata({params}: {params: Promise<{locale: string}>}): Promise { + const {locale} = await params; + const t = await getTranslations({locale, namespace: 'Imprint'}); + return {title: t('title')}; +} + +export default async function ImprintPage({params}: {params: Promise<{locale: string}>}) { + const {locale} = await params; + setRequestLocale(locale); + const t = await getTranslations('Imprint'); + const {name, address, email, correctionsEmail} = legalDetails(); + const missing = t('missing'); + + return ( +
+ +
+ {(!name || !address || !email) && {t('notConfigured')}} +
+

{t('ownerHeading')}

+

{name ?? missing}
{address ?? missing}

+

{t('email')}: {email ? {email} : missing}

+
+
+

{t('purposeHeading')}

+

{t('purpose')}

+
+
+

{t('directionHeading')}

+

{t('direction')}

+
+
+

{t('correctionsHeading')}

+

{t('corrections')}

+

{correctionsEmail ? {correctionsEmail} : missing}

+
+
+

{t('liabilityHeading')}

+

{t('liability')}

+
+
+
+ ); +} diff --git a/web/src/app/[locale]/methodik/page.tsx b/web/src/app/[locale]/methodik/page.tsx index 39e6822..071a3c0 100644 --- a/web/src/app/[locale]/methodik/page.tsx +++ b/web/src/app/[locale]/methodik/page.tsx @@ -5,6 +5,7 @@ import {SourceMeter, StatusBadge} from '@/components/badges'; import {ExternalLink} from '@/components/ExternalLink'; import {Notice} from '@/components/Notice'; import {PageHeader} from '@/components/PageHeader'; +import {Link} from '@/i18n/navigation'; import {api, type LegalStage} from '@/lib/api'; const LEGAL_STAGES: LegalStage[] = ['allegation_reported', 'investigation', 'immunity_waiver_requested', 'immunity_waived', @@ -125,7 +126,7 @@ export default async function MethodPage({params}: {params: Promise<{locale: str

{t('section.corrections')}

-

{t('corrections')}

+

{t('corrections')} {t('correctionsContact')}

diff --git a/web/src/app/api/img/route.ts b/web/src/app/api/img/route.ts new file mode 100644 index 0000000..06b2460 --- /dev/null +++ b/web/src/app/api/img/route.ts @@ -0,0 +1,47 @@ +// Image proxy for portraits, so that visitors' IP addresses don't reach the publishers. +// Only raster images from the hosts below: anything else would turn this into an open proxy. +import type {NextRequest} from 'next/server'; + +const ALLOWED_HOSTS = new Set(['www.parlament.gv.at', 'www.europarl.europa.eu']); +const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); +const MAX_BYTES = 5 * 1024 * 1024; +const MAX_REDIRECTS = 3; +const WEEK = 7 * 24 * 60 * 60; + +function allowedUrl(raw: string | null): URL | null { + try { + const url = new URL(raw ?? ''); + return url.protocol === 'https:' && ALLOWED_HOSTS.has(url.hostname) ? url : null; + } catch { + return null; + } +} + +export async function GET(request: NextRequest): Promise { + let url = allowedUrl(request.nextUrl.searchParams.get('u')); + if (!url) return new Response(null, {status: 400}); + const headers = {'User-Agent': process.env.POLITRACK_USER_AGENT ?? 'PolitrackBot/0.1'}; + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + let upstream: Response; + try { + upstream = await fetch(url, {headers, redirect: 'manual', signal: AbortSignal.timeout(10_000), next: {revalidate: WEEK}}); + } catch { + return new Response(null, {status: 502}); + } + if (upstream.status >= 300 && upstream.status < 400) { + url = allowedUrl(new URL(upstream.headers.get('location') ?? '', url).href); + if (!url) return new Response(null, {status: 502}); + continue; + } + if (!upstream.ok) return new Response(null, {status: 404}); + const type = (upstream.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + if (!IMAGE_TYPES.has(type)) return new Response(null, {status: 415}); + const body = await upstream.arrayBuffer(); + if (body.byteLength > MAX_BYTES) return new Response(null, {status: 413}); + return new Response(body, { + headers: {'Content-Type': type, 'Cache-Control': `public, max-age=${WEEK}`, 'X-Content-Type-Options': 'nosniff'} + }); + } + return new Response(null, {status: 502}); +} diff --git a/web/src/components/Avatar.tsx b/web/src/components/Avatar.tsx index 450bb41..305f444 100644 --- a/web/src/components/Avatar.tsx +++ b/web/src/components/Avatar.tsx @@ -1,13 +1,13 @@ import {initials} from '@/lib/format'; /** - * Portrait or initials. Portraits come from Wikimedia/parliament: loaded lazily and without a referrer. - * Decorative (alt=""), because the name is always printed next to it. + * Portrait or initials. Portraits go through our own image proxy (app/api/img), so that visitors' IP addresses + * don't reach third parties. Decorative (alt=""), because the name is always printed next to it. */ export function Avatar({name, src, size = 'md'}: {name: string; src?: string | null; size?: 'md' | 'lg'}) { const className = size === 'lg' ? 'avatar avatar--lg' : 'avatar'; if (src) { - return ; + return ; } return ; } diff --git a/web/src/components/nav.ts b/web/src/components/nav.ts index 541d178..4cf34d5 100644 --- a/web/src/components/nav.ts +++ b/web/src/components/nav.ts @@ -16,7 +16,9 @@ const ITEMS = { former: {href: '/ehemalige', key: 'former', icon: 'history'}, method: {href: '/methodik', key: 'method', icon: 'book'}, settings: {href: '/einstellungen', key: 'settings', icon: 'sliders'}, - search: {href: '/suche', key: 'search', icon: 'search'} + search: {href: '/suche', key: 'search', icon: 'search'}, + imprint: {href: '/impressum', key: 'imprint', icon: 'document'}, + privacy: {href: '/datenschutz', key: 'privacy', icon: 'shield'} } satisfies Record; export const PRIMARY_NAV: NavItem[] = [ITEMS.persons, ITEMS.parties, ITEMS.votes, ITEMS.money, ITEMS.organizations, ITEMS.calendar]; @@ -26,5 +28,5 @@ export const NAV_GROUPS: {key: string; items: NavItem[]}[] = [ {key: 'people', items: [ITEMS.persons, ITEMS.parties, ITEMS.former]}, {key: 'decisions', items: [ITEMS.votes, ITEMS.calendar, ITEMS.social]}, {key: 'influence', items: [ITEMS.money, ITEMS.organizations, ITEMS.wikipedia]}, - {key: 'about', items: [ITEMS.method, ITEMS.settings, ITEMS.search]} + {key: 'about', items: [ITEMS.method, ITEMS.settings, ITEMS.search, ITEMS.imprint, ITEMS.privacy]} ]; diff --git a/web/src/lib/legal.ts b/web/src/lib/legal.ts new file mode 100644 index 0000000..8ab7511 --- /dev/null +++ b/web/src/lib/legal.ts @@ -0,0 +1,11 @@ +// The operator's details for the legal pages. They come from the environment (.env on the server), so the +// operator's name and address stay out of this public repository. Read at request time, not at build time. +export function legalDetails() { + const email = process.env.POLITRACK_LEGAL_EMAIL || null; + return { + name: process.env.POLITRACK_LEGAL_NAME || null, + address: process.env.POLITRACK_LEGAL_ADDRESS || null, + email, + correctionsEmail: process.env.POLITRACK_CORRECTIONS_EMAIL || email + }; +}