Files
f0ckm/views/privacy.html
T
2026-09-28 22:12:38 +02:00

189 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="pv">
<header class="pv-head">
<h1>Privacy</h1>
<p>What this instance stores, in which form, and for how long. This page is generated from the server's running configuration, so the settings below are the ones actually in effect.</p>
</header>
{{-- ── Live configuration ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-sliders"></i> Current settings</h2>
<div class="pv-status">
<div class="pv-stat">
<span class="pv-stat-label">IP logging</span>
@if(pv.log_ips)<span class="pv-pill is-on">On</span>@else<span class="pv-pill is-off">Off</span>@endif
</div>
<div class="pv-stat">
<span class="pv-stat-label">IP storage</span>
@if(pv.ip_mode === 'hashed')<span class="pv-pill is-good">Hashed (HMAC-SHA256)</span>@elseif(pv.ip_mode === 'raw')<span class="pv-pill is-warn">Plain text</span>@else<span class="pv-pill is-good">Not stored</span>@endif
</div>
<div class="pv-stat">
<span class="pv-stat-label">Anonymous login</span>
@if(pv.anon)<span class="pv-pill is-on">Enabled</span>@else<span class="pv-pill is-off">Disabled</span>@endif
</div>
<div class="pv-stat">
<span class="pv-stat-label">Transport</span>
@if(pv.https)<span class="pv-pill is-good">HTTPS</span>@else<span class="pv-pill is-warn">HTTP</span>@endif
</div>
</div>
<p class="pv-small">
@if(pv.ip_mode === 'hashed')IP addresses are logged, but only ever written to the database as <code>HMAC-SHA256(ip, server_secret)</code>. The raw address is not persisted.@endif
@if(pv.ip_mode === 'raw')IP addresses are logged and written to the database in plain text.@endif
@if(pv.ip_mode === 'off')IP addresses are not written to the database. They are only held in memory while a request is processed (e.g. to check bans and rate limits).@endif
</p>
</section>
{{-- ── Retention ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-hourglass-half"></i> Retention</h2>
<p>A cleanup job runs hourly and deletes or blanks data older than these periods.</p>
<div class="pv-table">
<div class="pv-row pv-row-head"><span>Data</span><span>Kept for</span><span>What happens after</span></div>
<div class="pv-row"><span>Stored IP addresses</span><span class="@if(pv.ret_on.ip)pv-ok@else pv-warn@endif">{{ pv.ret.ip }}</span><span><code>user_ips</code> rows deleted; IP columns on sessions, anonymous identities, uploads, comments, reports and ToS acceptances set to <code>NULL</code>.</span></div>
<div class="pv-row"><span>Anonymous activity log</span><span class="@if(pv.ret_on.activity)pv-ok@else pv-warn@endif">{{ pv.ret.activity }}</span><span>Rows deleted (action, IP, identity and device fingerprint).</span></div>
<div class="pv-row"><span>Login attempts</span><span class="@if(pv.ret_on.login)pv-ok@else pv-warn@endif">{{ pv.ret.login }}</span><span>Rows deleted (hashed IP, attempted username, result).</span></div>
<div class="pv-row"><span>Unused sessions</span><span class="@if(pv.ret_on.sessions)pv-ok@else pv-warn@endif">{{ pv.ret.sessions }}</span><span>Session deleted after this long without use; that device is logged out.</span></div>
<div class="pv-row"><span>Device fingerprint</span><span class="@if(pv.ret_on.fp)pv-ok@else pv-warn@endif">{{ pv.ret.fp }}</span><span>Cleared from anonymous identities that haven't been used for this long.</span></div>
<div class="pv-row"><span>Active bans</span><span>Until expiry</span><span>Banned IP hashes and fingerprints are kept until the ban expires or is lifted.</span></div>
<div class="pv-row"><span>Your content</span><span>Until deleted</span><span>Uploads, comments, favourites and your account itself.</span></div>
</div>
</section>
{{-- ── IP addresses ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-network-wired"></i> IP addresses</h2>
<p>The client IP is taken from the first of <code>CF-Connecting-IP</code>, <code>True-Client-IP</code>, <code>X-Client-IP</code>, <code>X-Real-IP</code>, <code>X-Forwarded-For</code> (first entry) or the TCP peer address.</p>
@if(pv.log_ips)
<p>With IP logging on, the IP is recorded @if(pv.hash_ips)as an HMAC@else in plain text@endif in:</p>
<ul class="pv-list">
<li><code>user_sessions.ip</code>: updated on each request of a logged-in session</li>
<li><code>user_ips</code>: one row per account and IP with first/last seen time</li>
<li><code>anon_identities.created_ip / last_ip</code> and <code>anon_activity_log.ip</code> for anonymous identities</li>
<li><code>items.uploader_ip</code>, <code>comments.ip</code>, <code>reports.reporter_ip</code></li>
</ul>
@endif
@if(pv.hash_ips)
<p><strong>Hashing:</strong> <code>HMAC-SHA256</code> keyed with a server-side secret, stored as 64 hex characters. The same IP always yields the same hash, which is what makes bans and abuse correlation work; without the secret the hash can't be reversed or recomputed. This is pseudonymisation, not anonymisation: whoever holds the secret could test candidate IPs against it.</p>
@endif
<p><strong>Always, regardless of the logging setting:</strong> login and registration attempts store an HMAC of the IP in <code>login_attempts</code> for brute-force rate limiting, and a moderator ban stores the banned IP's hash in <code>banned_ips</code>.</p>
</section>
@if(pv.anon)
{{-- ── Anonymous login ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-user-secret"></i> Anonymous login (WebAuthn passkey)</h2>
<p>No email, password or name is involved. <em>Login as Anonymous</em> creates a standard WebAuthn passkey in your authenticator (browser, OS, Bitwarden, iCloud Keychain, …).</p>
<h3>Registration</h3>
<ol class="pv-steps">
<li>The server sends creation options: relying party <code>{{ pv.domain }}</code>, a random single-use challenge, algorithm <strong>ES256</strong> (ECDSA P-256, COSE <code>-7</code>), <code>attestation: "none"</code>, and a user handle of 16 random bytes named <code>anon@{{ pv.domain }}</code> / "Anonymous". Nothing about you goes into it.</li>
<li>Your authenticator generates a key pair. The <strong>private key never leaves the authenticator</strong>.</li>
<li>The server verifies the response and stores: the <strong>credential ID</strong>, the <strong>public key</strong> (SPKI), the signature counter, and the authenticator's <strong>AAGUID</strong> (identifies the authenticator model, e.g. a password manager; with attestation "none" it is often all zeros).</li>
<li>Your identity is derived from the credential ID: <code>SHA256:base64(SHA-256(credential_id))</code>; the account name is <code>anon_</code> plus the first 8 hex characters of that hash (e.g. <code>anon_1ad1e20c</code>).</li>
</ol>
<h3>Login</h3>
<p>The server issues a random single-use challenge; your authenticator signs it with the private key and the server verifies the signature with the stored public key. Up to 4 passkeys can be attached to one identity. If all of them are lost, the identity cannot be recovered: nothing else links it to you.</p>
</section>
{{-- ── Device fingerprint ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-microchip"></i> Device fingerprint</h2>
<p>At anonymous login and when adding a passkey, your browser computes a device fingerprint used <strong>only for ban enforcement</strong> (so a banned user can't just create a new identity). It is not used for advertising or cross-site tracking.</p>
<p>Inputs, all read locally in your browser:</p>
<ul class="pv-list">
<li>WebGL: unmasked GPU vendor and renderer, 6 capability limits (max texture/renderbuffer size, vertex attribs, uniform/varying vectors, texture units)</li>
<li>WebGPU adapter info (architecture, vendor, description), where available</li>
<li><code>navigator.hardwareConcurrency</code>, <code>deviceMemory</code>, <code>platform</code>, <code>maxTouchPoints</code></li>
<li>Screen width × height, colour depth, device pixel ratio</li>
<li>Canvas 2D: checksum of a small rendered test image (text + shapes)</li>
<li>Audio: sum of samples from an <code>OfflineAudioContext</code> rendering a test tone through a compressor</li>
</ul>
<p>The values are concatenated and hashed <strong>in the browser</strong> with SHA-256; only <code>HW:&lt;64 hex&gt;</code> is sent. The raw values never reach the server. The hash is cached in <code>localStorage</code> (<code>f0ck_anon_hw_fp</code>) and stored server-side in <code>anon_identities.hw_fingerprint</code> and the activity log, and compared against banned device hashes.</p>
</section>
{{-- ── Activity log ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-list-check"></i> Anonymous activity log</h2>
<p><code>anon_activity_log</code> records these actions of anonymous identities: login/session handshake, favourite, unfavourite, favourites import. Each row holds the action, target item, time, identity fingerprint, device fingerprint, and the IP @if(pv.ip_mode === 'hashed')(hashed)@endif @if(pv.ip_mode === 'off')(empty, as IP logging is off)@endif. It exists for moderation and ban cascades.</p>
</section>
@endif
{{-- ── Sessions & browser storage ── --}}
<section class="pv-sec">
<h2><i class="fa-solid fa-cookie"></i> Sessions, cookies and browser storage</h2>
<ul class="pv-list">
<li><strong><code>session</code> cookie</strong>: 32 random bytes; the server stores only its SHA-256, so a database leak doesn't expose usable sessions. Flags: <code>HttpOnly</code>, <code>SameSite=Lax</code>@if(pv.https), <code>Secure</code>@endif.</li>
<li>Per session the server keeps: user agent string, creation time, last-used time, the last path requested, a CSRF token@if(pv.log_ips), and the IP@endif.</li>
<li><code>localStorage</code>: UI preferences, and for anonymous users the device fingerprint hash.</li>
<li><code>f0ck_banned</code> cookie / <code>f0ck_anon_tombstone</code>: only set if you are banned, to show the ban notice.</li>
</ul>
<p>Registered accounts: passwords are hashed with <strong>scrypt</strong> (random 16-byte salt, 64-byte key). The plain password is never stored.</p>
</section>
<section class="pv-sec">
<h2><i class="fa-solid fa-ban"></i> Not collected</h2>
<p>For anonymous identities: no email, real name, phone number or password. No third-party analytics, trackers or ad networks are involved in authentication.</p>
</section>
<p class="pv-foot">Questions? See <a href="/about">About</a>@if(mail) or write to <a href="mailto:{!! mail !!}">{!! mail !!}</a>@endif.</p>
</div>
<style>
.pv {
--pv-accent: var(--accent, #0096ff);
--pv-text: var(--text-color, #fff);
--pv-muted: var(--text-muted, #8a8f98);
--pv-surface: rgba(255, 255, 255, 0.04);
--pv-border: rgba(255, 255, 255, 0.1);
--pv-good: #3ecf8e;
--pv-warn: #ffb020;
max-width: 860px; margin: 0 auto; padding: 32px 16px 60px; color: var(--pv-text); line-height: 1.6;
}
.pv * { border-radius: 0 !important; }
.pv-head { padding-bottom: 18px; margin-bottom: 26px; border-bottom: 1px solid var(--pv-accent); }
.pv-head h1 { margin: 0; font-size: 1.8em; font-weight: 800; }
.pv-head p { margin: 6px 0 0; color: var(--pv-muted); }
.pv-sec { margin-bottom: 32px; }
.pv-sec h2 { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; font-size: 1.1em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; }
.pv-sec h2 i { color: var(--pv-accent); font-size: 0.9em; }
.pv-sec h3 { margin: 18px 0 8px; font-size: 0.9em; font-weight: 700; color: var(--pv-muted); text-transform: uppercase; letter-spacing: 0.08em; }
.pv p { margin: 0 0 10px; }
.pv a { color: var(--pv-accent); }
.pv code { padding: 1px 5px; font-size: 0.86em; background: var(--pv-surface); border: 1px solid var(--pv-border); word-break: break-word; }
.pv-small { font-size: 0.88em; color: var(--pv-muted); }
.pv-steps, .pv-list { margin: 0 0 12px; padding-left: 22px; }
.pv-steps li, .pv-list li { margin-bottom: 6px; }
.pv-status { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 12px; background: var(--pv-border); border: 1px solid var(--pv-border); }
.pv-stat { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: var(--bg, #000); }
.pv-stat-label { font-size: 0.72em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--pv-muted); }
.pv-pill { align-self: flex-start; padding: 3px 9px; font-size: 0.78em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; border: 1px solid currentColor; }
.pv-pill.is-on { color: var(--pv-accent); }
.pv-pill.is-off { color: var(--pv-muted); }
.pv-pill.is-good { color: var(--pv-good); }
.pv-pill.is-warn { color: var(--pv-warn); }
.pv-table { border: 1px solid var(--pv-border); font-size: 0.88em; }
.pv-row { display: grid; grid-template-columns: 1fr 0.7fr 2fr; gap: 12px; padding: 10px 14px; background: var(--pv-surface); }
.pv-row + .pv-row { border-top: 1px solid var(--pv-border); }
.pv-row-head { background: transparent; font-size: 0.8em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--pv-muted); }
.pv-row span:first-child { font-weight: 700; }
.pv-row span:last-child { color: var(--pv-muted); }
.pv-row-head span { font-weight: 800 !important; }
.pv-ok { color: var(--pv-good); font-weight: 700; }
.pv-warn { color: var(--pv-warn); font-weight: 700; }
.pv-foot { margin-top: 36px; padding-top: 16px; border-top: 1px solid var(--pv-border); color: var(--pv-muted); font-size: 0.9em; }
@media (max-width: 700px) {
.pv-status { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.pv-row { grid-template-columns: 1fr; gap: 2px; }
.pv-row-head { display: none; }
}
</style>
</div>
</div>
@include(snippets/footer)