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 <noreply@anthropic.com>
This commit is contained in:
simonandClaude Opus 5.5 committed 2026-11-09 01:22:02 +01:00
1 parent cd44d8fbba
commit 256906e9fe
12 files changed
+256 -12

No files matched your search

+7
View File
@@ -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"]
+5
View File
@@ -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:
+3 -2
View File
@@ -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
+40 -2
View File
@@ -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.",
+40 -2
View File
@@ -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",
+42
View File
@@ -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<Metadata> {
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 (
<article>
<PageHeader title={t('title')} lede={t('intro')} />
<div className="prose">
<Notice tone="caution">{t('draft')}</Notice>
<section>
<h2>{t('controllerHeading')}</h2>
<p>{name ?? t('missing')}<br />{address ?? t('missing')}</p>
<p>{email ? <a href={`mailto:${email}`}>{email}</a> : t('missing')}</p>
</section>
{SECTIONS.map((key) => (
<section key={key}>
<h2>{t(`${key}Heading`)}</h2>
<p>{t(key)}</p>
</section>
))}
</div>
</article>
);
}
+52
View File
@@ -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<Metadata> {
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 (
<article>
<PageHeader title={t('title')} lede={t('intro')} />
<div className="prose">
{(!name || !address || !email) && <Notice tone="caution">{t('notConfigured')}</Notice>}
<section>
<h2>{t('ownerHeading')}</h2>
<p>{name ?? missing}<br />{address ?? missing}</p>
<p>{t('email')}: {email ? <a href={`mailto:${email}`}>{email}</a> : missing}</p>
</section>
<section>
<h2>{t('purposeHeading')}</h2>
<p>{t('purpose')}</p>
</section>
<section>
<h2>{t('directionHeading')}</h2>
<p>{t('direction')}</p>
</section>
<section id="korrekturen">
<h2>{t('correctionsHeading')}</h2>
<p>{t('corrections')}</p>
<p>{correctionsEmail ? <a href={`mailto:${correctionsEmail}`}>{correctionsEmail}</a> : missing}</p>
</section>
<section>
<h2>{t('liabilityHeading')}</h2>
<p>{t('liability')}</p>
</section>
</div>
</article>
);
}
+2 -1
View File
@@ -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
</section>
<section id={ANCHORS.corrections}>
<h2>{t('section.corrections')}</h2>
<p>{t('corrections')}</p>
<p>{t('corrections')} <Link href="/impressum#korrekturen">{t('correctionsContact')}</Link></p>
</section>
</div>
</article>
+47
View File
@@ -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<Response> {
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});
}
+3 -3
View File
@@ -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 <img src={src} alt="" className={className} loading="lazy" decoding="async" referrerPolicy="no-referrer" />;
return <img src={`/api/img?u=${encodeURIComponent(src)}`} alt="" className={className} loading="lazy" decoding="async" />;
}
return <span className={className} aria-hidden="true">{initials(name)}</span>;
}
+4 -2
View File
@@ -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<string, NavItem>;
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]}
];
+11
View File
@@ -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
};
}