feat(beacon): tæl installationer, sig præcis hvad der sendes, og lad folk sige nej
Spørgsmålet er ét: hvor mange bruger det her. Svaret er to værdier én gang i døgnet — et tilfældigt id, installationen selv fandt på, og versionen. Ikke andet. Beskeden er lille nok til at stå i fuld længde på indstillingssiden, og den gør det, fordi et løfte om telemetri er præcis så meget værd som læserens mulighed for at efterprøve det. **Ikke obligatorisk, og grunden er praktisk frem for principiel:** det kan ikke håndhæves. Domænet kan blokeres, binæren kan patches, og en fork fjerner den i første commit — så obligatorisk køber intet over slået-til-som-udgangspunkt og koster den tillid, resten af programmet arbejder for. Hemmeligheder forsegles her, nøglen holdes uden for databasen, og sikkerhedskopisiden advarer om, hvad en kopi indeholder. Telemetri, man ikke må sige nej til, er den ene sætning i den historie, der ikke passer. Uenigheden med `config.UpdateCheck`, som er slået fra med den modsatte begrundelse, står skrevet i pakkens egen dokumentation frem for at blive glattet ud. Den er reel, og læseren fortjener at se begge halvdele. Id'et findes på og gemmes ved første brug frem for at blive udledt af værtsnavn eller adresse. En hash af noget om maskinen ville være stabil, bekvem og samtidig en værdi, modtageren kunne holde op mod et gæt — og det er forskellen mellem anonym og pseudonym. Modtagerens side er den samme binary med en kontakt: `POST /api/v1/beacon` uden login, fordi den, der ringer, er en fremmed installation. Den svarer 404, når instansen ikke er tælleren, så en almindelig installation ikke er en spamspand, der svarer 200 på alt. Ratebegrænset som login-ruterne, og tabellen har et loft: endepunktet indsætter en række på et id, den, der ringer, har fundet på, og uden et loft kunne enhver fylde disken. Tallet er "set inden for tredive dage" og ikke et løbende total. Et total stiger kun og holder op med at betyde "hvor mange bruger det" og begynder at betyde "hvor mange har prøvet det". `internal/safedial` kom til undervejs: modtagerens adresse kan skrives i fladen, og en adresse, en bruger angiver, må ikke kunne pege indad. Kontrollen ligger på den opslåede adresse og ikke på URL'en, fordi et navn kan svare 127.0.0.1 — og svare noget andet anden gang, efter en kontrol ved parsning er gået godt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4b5f3f0864
commit
78891bae6f
@@ -227,6 +227,9 @@ export const api = {
|
||||
gmailClient: () => get('/gmail/client'),
|
||||
setGmailClient: (data) => put('/gmail/client', data),
|
||||
|
||||
beacon: () => get('/beacon/settings'),
|
||||
setBeacon: (body) => put('/beacon/settings', body),
|
||||
|
||||
listBackups: () => get('/backups'),
|
||||
runBackup: () => post('/backups'),
|
||||
// A link rather than a fetch: the browser's own download handles a file of
|
||||
|
||||
@@ -29,6 +29,21 @@ export const da = {
|
||||
'notes.sortUpdated': 'Senest rørt',
|
||||
'notes.sortCreated': 'Oprettet',
|
||||
'notes.sortTitle': 'Navn',
|
||||
'beacon.title': 'Tælling af installationer',
|
||||
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes **kun** de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
||||
'beacon.send': 'Send en anonym daglig melding',
|
||||
'beacon.exactly': 'Præcis det her — ikke mere:',
|
||||
'beacon.lastPing': 'Sidst sendt: {when}',
|
||||
'beacon.collector': 'Modtagerens adresse',
|
||||
'beacon.collectorHint': 'Hvor meldingen sendes hen. Lad den stå, medmindre du kører din egen tæller.',
|
||||
'beacon.beCollector': 'Denne instans er tælleren',
|
||||
'beacon.receiving': 'Modtager anonyme meldinger — id og version, aldrig en IP.',
|
||||
'beacon.total': 'I alt',
|
||||
'beacon.active7': 'Aktive 7 dage',
|
||||
'beacon.active30': 'Aktive 30 dage',
|
||||
'beacon.byVersion': 'Efter version (30 dage):',
|
||||
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
|
||||
@@ -25,6 +25,21 @@ export const en = {
|
||||
'notes.sortUpdated': 'Last touched',
|
||||
'notes.sortCreated': 'Created',
|
||||
'notes.sortTitle': 'Name',
|
||||
'beacon.title': 'Counting installations',
|
||||
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends **only** the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
||||
'beacon.send': 'Send an anonymous daily report',
|
||||
'beacon.exactly': 'Exactly this — nothing more:',
|
||||
'beacon.lastPing': 'Last sent: {when}',
|
||||
'beacon.collector': 'Collector address',
|
||||
'beacon.collectorHint': 'Where the report goes. Leave it unless you run your own collector.',
|
||||
'beacon.beCollector': 'This instance is the collector',
|
||||
'beacon.receiving': 'Receiving anonymous reports — id and version, never an IP.',
|
||||
'beacon.total': 'Total',
|
||||
'beacon.active7': 'Active 7 days',
|
||||
'beacon.active30': 'Active 30 days',
|
||||
'beacon.byVersion': 'By version (30 days):',
|
||||
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.showSource': 'Show as code',
|
||||
'notes.source': 'Note source',
|
||||
|
||||
+49
-29
@@ -47,36 +47,51 @@ const escapeHtml = (s) =>
|
||||
*/
|
||||
const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* Billeder løftes ud af teksten, før noget andet rører den.
|
||||
*
|
||||
* De stod før først i kæden, og så kørte de fire resterende regler hen over en
|
||||
* streng, der nu indeholdt rigtig HTML — ind i den `alt`, der lige var lavet.
|
||||
* Det gik godt, så længe ingen af erstatningerne indeholdt et anførselstegn, og
|
||||
* det er en betingelse, der stod skrevet ingen steder: den dag nogen tilføjer en
|
||||
* regel for links, er den brudt, og det er præcis den vej, XSS-en kom ind ad.
|
||||
*
|
||||
* Værre endnu var undslupningen. `escapeHtml` kørte på hele teksten, og `attr`
|
||||
* kørte den igen på `alt` — så et og-tegn blev til en dobbelt undslupning, der
|
||||
* voksede ved hver eneste åbning og gemning. En note med "Kaffe & te" i et
|
||||
* billede blev længere, hver gang nogen så på den.
|
||||
*
|
||||
* En pladsholder løser begge dele. Billedet tages ud af den rå tekst, resten
|
||||
* undslippes og formateres som altid, og billedet lægges tilbage til sidst med en
|
||||
* `alt`, der er undsluppet nøjagtig én gang. Rækkefølgen af de øvrige regler kan
|
||||
* så ikke længere betyde noget.
|
||||
*
|
||||
* Nul-tegnet som mærke, fordi det er det ene tegn, en note ikke kan indeholde:
|
||||
* det overlever hverken en contenteditable eller en Markdown-fil.
|
||||
*/
|
||||
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
|
||||
const MARK = "\u0000";
|
||||
|
||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||
function inlineToHtml(text) {
|
||||
return (
|
||||
escapeHtml(text)
|
||||
// Billedet først, og før alt andet.
|
||||
//
|
||||
// Der var ingen regel for det overhovedet, så en note fra Apple Noter
|
||||
// viste `` som ord. Billedet var hentet, gemt og
|
||||
// hæftet på noten — det eneste, der manglede, var at nogen tegnede det.
|
||||
//
|
||||
// Kun vores egen adresse. Et `` fra en indsat tekst ville
|
||||
// ellers hente fra en fremmed vært, når noten åbnes, og fortælle den, at
|
||||
// den er blevet læst — og en note er det sidste sted, man vil have en
|
||||
// sporingspixel. Alt andet står som den tekst, det er.
|
||||
//
|
||||
// `alt` skal citat-undslippes her, og det er ikke en formalitet.
|
||||
// escapeHtml tager &, < og > — ikke anførselstegn, fordi den er skrevet
|
||||
// til tekst mellem to tags, hvor et anførselstegn ikke betyder noget. Her
|
||||
// står værdien inde i en attribut, og der lukker et anførselstegn den:
|
||||
// `` blev til et img med
|
||||
// en onerror på. En note er delt gennem et projekt, så det ville køre på
|
||||
// vores eget domæne med sessionscookien, hos den, der åbnede noten.
|
||||
.replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g,
|
||||
(_, alt, src) => `<img src="${src}" alt="${attr(alt)}">`)
|
||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
||||
);
|
||||
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
||||
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
|
||||
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
|
||||
// have en sporingspixel. Alt andet står som den tekst, det er.
|
||||
const images = [];
|
||||
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
|
||||
images.push(`<img src="${src}" alt="${attr(alt)}">`);
|
||||
return `${MARK}${images.length - 1}${MARK}`;
|
||||
});
|
||||
|
||||
const marked = escapeHtml(lifted)
|
||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
|
||||
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +154,12 @@ export function markdownToHtml(markdown) {
|
||||
if (fenced) {
|
||||
closeList();
|
||||
fence = fenced[1] ?? '';
|
||||
out.push(`<pre data-lang="${escapeHtml(fence)}"><code>`);
|
||||
// attr, ikke escapeHtml: værdien står inde i en attribut. Den er sikker i
|
||||
// dag alene fordi hegn-regexen er \w*, som ikke rummer anførselstegn — og
|
||||
// den dag nogen udvider den for at tage imod "c++" eller "f#", er det en
|
||||
// XSS. Undslupningen skal passe til stedet, ikke til det, der
|
||||
// tilfældigvis kan stå der nu.
|
||||
out.push(`<pre data-lang="${attr(fence)}"><code>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,29 @@
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ago } from '$lib/when.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
// --- beaconet -----------------------------------------------------------------
|
||||
|
||||
// Hvad denne installation melder, og til hvem. Se internal/beacon for hvorfor
|
||||
// den er slået til som udgangspunkt frem for at være obligatorisk.
|
||||
let beacon = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!app.user?.is_admin) return;
|
||||
api.beacon().then((r) => (beacon = r)).catch(() => {});
|
||||
});
|
||||
|
||||
async function saveBeacon(change) {
|
||||
try {
|
||||
beacon = await api.setBeacon(change);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
// Læst forfra, så en afvist ændring ikke bliver stående som et flueben,
|
||||
// der ikke svarer til, hvad serveren gør.
|
||||
api.beacon().then((r) => (beacon = r)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// --- backups ---------------------------------------------------------------------
|
||||
|
||||
@@ -486,6 +508,95 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Beaconet.
|
||||
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
||||
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
||||
dem, ikke gemt væk under en fane, ingen åbner. -->
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('beacon.title')}</h2>
|
||||
<p class="hint">{t('beacon.hint')}</p>
|
||||
</header>
|
||||
|
||||
{#if beacon}
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={beacon.enabled}
|
||||
onchange={(e) => saveBeacon({ enabled: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t('beacon.send')}</span>
|
||||
</label>
|
||||
|
||||
<!-- Hele beskeden, ord for ord. Et løfte om telemetri er præcis så meget
|
||||
værd som læserens mulighed for at efterprøve det, så den står her frem
|
||||
for som en sætning om, hvad der cirka bliver sendt. -->
|
||||
<pre class="payload">{t('beacon.exactly')}
|
||||
{JSON.stringify({ instance_id: beacon.instance_id, version: beacon.version }, null, 2)}</pre>
|
||||
|
||||
{#if beacon.last_ping_at}
|
||||
<p class="hint">{t('beacon.lastPing', { when: new Date(beacon.last_ping_at).toLocaleString(tag()) })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="collector">{t('beacon.collector')}</label>
|
||||
<input
|
||||
id="collector"
|
||||
value={beacon.collector_url}
|
||||
onchange={(e) => saveBeacon({ collector_url: e.currentTarget.value })}
|
||||
/>
|
||||
<p class="hint">{t('beacon.collectorHint')}</p>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={beacon.is_collector}
|
||||
onchange={(e) => saveBeacon({ is_collector: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t('beacon.beCollector')}</span>
|
||||
</label>
|
||||
|
||||
{#if beacon.is_collector && beacon.stats}
|
||||
<p class="hint">{t('beacon.receiving')}</p>
|
||||
<div class="counts">
|
||||
<div class="count">
|
||||
<strong>{beacon.stats.total}</strong>
|
||||
<span>{t('beacon.total')}</span>
|
||||
</div>
|
||||
<div class="count">
|
||||
<strong>{beacon.stats.active_7d}</strong>
|
||||
<span>{t('beacon.active7')}</span>
|
||||
</div>
|
||||
<div class="count">
|
||||
<strong>{beacon.stats.active_30d}</strong>
|
||||
<span>{t('beacon.active30')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys(beacon.stats.by_version ?? {}).length}
|
||||
<p class="hint">
|
||||
{t('beacon.byVersion')}
|
||||
{Object.entries(beacon.stats.by_version)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([v, n]) => `${v} ×${n}`)
|
||||
.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={beacon.publish_count}
|
||||
onchange={(e) => saveBeacon({ publish_count: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t('beacon.publish')}</span>
|
||||
</label>
|
||||
<p class="hint">{t('beacon.publishHint')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* The backup list, which is a list of rows with a right-hand action — the same
|
||||
shape the settings pages use elsewhere, said once here because this is the
|
||||
@@ -596,4 +707,48 @@
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Beskeden som den sendes. Monospace, fordi den skal kunne sammenlignes tegn
|
||||
for tegn med det, der står i dokumentationen. */
|
||||
.payload {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--s2) var(--s3);
|
||||
overflow-x: auto;
|
||||
margin: var(--s2) 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.counts {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
margin: var(--s2) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: 1 1 7rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.count strong {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.count span {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user