Relay 0.7.0: een weg naar binnen, en de melding bij elke klik weg
Vier punten van de gebruiker, opgekomen tijdens het beproeven met de testclient. De getimede leerstand is eruit; de wachtlijst is de enige weg naar binnen. Zijn redenering: allebei de wegen vragen iemand die bij de app kan, dus het is dubbelop, en het venster is de zwakste omdat het iedereen toelaat die er toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek wss-adres kan staan. De oorspronkelijke reden voor de leerstand, dat je je eigen OwnerId nergens kon aflezen, verviel toen de weigerlijst dat id ging tonen. Daarmee verdwijnt ook de bug die hij dezelfde dag meldde: een geleerde eigenaar bleef in de weigerlijst staan terwijl hij al kon schrijven en lezen, want decideOwner haalde hem niet van die lijst af en de knop allow wel. "Refused owners" heet "Waiting list", met de badge Waiting en een teller waar de widget van het tijdvenster stond. Het veld op schijf blijft rejected: hernoemen zou een migratie zijn voor een woord dat niemand ziet. Het adres onderaan zei http:// en dat kan nergens werken, want de relay spreekt WebSocket en nooit HTTP. Nu ws://<host>:3852, met een regel over wss://<domein> zonder poort achter een reverse proxy. Dat is precies de fout die diezelfde dag een ronde kostte bij het koppelen van de testclient. De melding bij elke klik is weg. Die stond in de gewone stroom van de pagina, dus alles eronder schoof omlaag en weer omhoog. Nu gaan de knoppen in de lijsten even op slot tot de ronde de nieuwe stand heeft; foutmeldingen blijven wel staan, want die zeggen iets wat je nergens anders ziet. STATE_VERSION blijft 1 en een owners.json van 0.6.0 leest door: learning en learningUntil worden gelezen, genegeerd en niet teruggeschreven. Een verhoging zou store.js de allowlist van een werkende installatie opzij laten schuiven. Twee toetsen bewaken dat de leerstand niet terugsluipt: een onbekende eigenaar wordt geweigerd ook met learning: true in het bestand, en set-learning is een onbekende actie. Beide mutatie-getest. De compose staat op 0.7.0 zonder digest, zodat het hard faalt tot de image bestaat. Bouwen, duwen en pinnen ligt bij de gebruiker. Suite: 424 goed, 0 fout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,6 @@ import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
@@ -59,7 +58,7 @@ APP_VERSION = os.environ.get("RELAY_APP_VERSION", "")
|
||||
# Wat de pagina mag vragen. Expliciet en niet doorgeven wat er binnenkomt: dit
|
||||
# bestand wordt door een ander proces uitgevoerd, en een onbekende actie hoort
|
||||
# hier te stranden en niet daar.
|
||||
ALLOWED_ACTIONS = ("set-learning", "block", "allow", "forget")
|
||||
ALLOWED_ACTIONS = ("block", "allow", "forget")
|
||||
|
||||
MAX_BODY_BYTES = 4096
|
||||
MAX_OWNER_ID_LENGTH = 256
|
||||
@@ -73,11 +72,6 @@ MAX_LABEL_LENGTH = 48
|
||||
# ongeluk blijft schrijven. Ruim boven het aantal eigenaars dat iemand ooit heeft.
|
||||
MAX_LABELS = 200
|
||||
|
||||
# Hoelang de pagina de deur voor nieuwe eigenaars openzet. Dezelfde waarde staat
|
||||
# in de pagina; die stuurt hem mee en het relay-proces begrenst hem nog een keer.
|
||||
# Hier staat hij omdat de agent hem moet toestaan, niet omdat hij hem kiest.
|
||||
MAX_LEARNING_SECONDS = 3600
|
||||
|
||||
# Eén schrijver per bestand is de afspraak, maar de agent zelf is meerdradig:
|
||||
# ThreadingHTTPServer geeft elk verzoek zijn eigen draad. Twee labels die op
|
||||
# hetzelfde moment binnenkomen zouden elkaar dus kunnen overschrijven, want
|
||||
@@ -179,59 +173,6 @@ def write_labels(labels):
|
||||
temporary.replace(LABELS_FILE)
|
||||
|
||||
|
||||
def parse_moment(value):
|
||||
"""Een ISO-tijdstip uit owners.json als datetime, of None.
|
||||
|
||||
Het relay-proces schrijft `new Date().toISOString()`, dus met milliseconden en
|
||||
met een Z erachter. `fromisoformat` neemt die Z sinds Python 3.11; de image is
|
||||
python:3-alpine en dus nieuwer. Faalt het alsnog, dan is None het antwoord en
|
||||
beslist de aanroeper.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
when = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if when.tzinfo is None:
|
||||
return when.replace(tzinfo=timezone.utc)
|
||||
return when
|
||||
|
||||
|
||||
def learning_facts(state):
|
||||
"""De leerstand zoals de pagina hem hoort te zien.
|
||||
|
||||
Het veld in het bestand is niet het hele antwoord: staat er een tijdstip in dat
|
||||
verstreken is, dan is de deur dicht, ook al staat `learning` nog op true. Het
|
||||
relay-proces ruimt dat op in zijn eigen lus, en tussen het aflopen en die ronde
|
||||
zit een seconde of twee. De pagina hoort daar niet "open" te tonen.
|
||||
|
||||
De resterende tijd wordt hier uitgerekend en niet in de browser. Dat is met
|
||||
opzet: dan telt de klok van de Umbrel en niet die van de bezoeker, en die twee
|
||||
lopen niet per definitie gelijk.
|
||||
"""
|
||||
learning = state.get("learning")
|
||||
until = state.get("learningUntil")
|
||||
|
||||
if learning is not True:
|
||||
return {"learning": learning, "learningUntil": None, "learningSecondsLeft": None}
|
||||
|
||||
when = parse_moment(until)
|
||||
if when is None:
|
||||
# Geen tijdslot: open tot de gebruiker hem zelf sluit. Dat is de
|
||||
# begintoestand van een verse installatie.
|
||||
return {"learning": True, "learningUntil": None, "learningSecondsLeft": None}
|
||||
|
||||
resterend = (when - datetime.now(timezone.utc)).total_seconds()
|
||||
if resterend <= 0:
|
||||
return {"learning": False, "learningUntil": None, "learningSecondsLeft": 0}
|
||||
return {
|
||||
"learning": True,
|
||||
"learningUntil": until,
|
||||
"learningSecondsLeft": int(resterend),
|
||||
}
|
||||
|
||||
|
||||
def database_facts():
|
||||
try:
|
||||
stat = DATABASE_FILE.stat()
|
||||
@@ -262,11 +203,6 @@ def build_status():
|
||||
state = owners["state"] or {}
|
||||
labels = read_labels()
|
||||
|
||||
# Ontbreekt de staat, dan is 'learning' onbekend en niet 'false'. De pagina
|
||||
# hoort dat verschil te tonen: onbekend is een reden om te kijken, uit is een
|
||||
# keuze. learning_facts() geeft None door zoals het binnenkwam.
|
||||
learning = learning_facts(state)
|
||||
|
||||
return {
|
||||
"version": APP_VERSION or None,
|
||||
"relay": {
|
||||
@@ -276,9 +212,6 @@ def build_status():
|
||||
},
|
||||
"owners": {
|
||||
"problem": owners["problem"],
|
||||
"learning": learning["learning"],
|
||||
"learningUntil": learning["learningUntil"],
|
||||
"learningSecondsLeft": learning["learningSecondsLeft"],
|
||||
"allowed": met_label(
|
||||
[
|
||||
entry
|
||||
@@ -321,28 +254,6 @@ def valid_command(payload):
|
||||
if action not in ALLOWED_ACTIONS:
|
||||
return None, "onbekende actie"
|
||||
|
||||
if action == "set-learning":
|
||||
value = payload.get("value")
|
||||
if not isinstance(value, bool):
|
||||
return None, "waarde moet true of false zijn"
|
||||
|
||||
seconds = payload.get("seconds")
|
||||
if seconds is None:
|
||||
# Zonder tijdslot: open tot de gebruiker hem zelf sluit. Dat pad blijft
|
||||
# bestaan voor een verse installatie, waar een venster van twee minuten
|
||||
# zou aflopen terwijl je nog aan het koppelen bent.
|
||||
return {"action": action, "value": value}, None
|
||||
|
||||
# isinstance(True, int) is in Python waar, dus een boolean zou hier als
|
||||
# aantal seconden doorglippen. Vandaar de uitsluiting.
|
||||
if isinstance(seconds, bool) or not isinstance(seconds, int):
|
||||
return None, "seconds moet een heel getal zijn"
|
||||
if seconds <= 0 or seconds > MAX_LEARNING_SECONDS:
|
||||
return None, "seconds valt buiten het toegestane bereik"
|
||||
if value is not True:
|
||||
return None, "seconds hoort alleen bij openzetten"
|
||||
return {"action": action, "value": value, "seconds": seconds}, None
|
||||
|
||||
owner_id = payload.get("ownerId")
|
||||
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
|
||||
return None, "ontbrekende of te lange ownerId"
|
||||
|
||||
@@ -42,7 +42,7 @@ set -eu
|
||||
# want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een dag gekost.
|
||||
# Sinds alles in de image zit lopen ze meestal gelijk; de regel blijft: verhoog je
|
||||
# VERSION, dan ook het manifest.
|
||||
VERSION="0.6.0"
|
||||
VERSION="0.7.0"
|
||||
|
||||
# Het register staat er expres in en dit is geen smaakkwestie: umbreld haalt élke
|
||||
# image op via de Docker Engine API, dus een tag die alleen lokaal bestaat is voor
|
||||
|
||||
+104
-139
@@ -431,6 +431,20 @@ body {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* De regel onder het adres: hoe het van buiten heet. Geen kader eromheen, want
|
||||
het is uitleg bij de waarde erboven en geen tweede waarde. */
|
||||
.note {
|
||||
margin: 0.7rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-sec);
|
||||
}
|
||||
.note code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Lijstregels ────────────────────────────────────────────────────────── */
|
||||
.list-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
@@ -462,6 +476,13 @@ body {
|
||||
.btn:hover { background: var(--neutral-bg); }
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
|
||||
/* Bevroren terwijl er een opdracht onderweg is. Alleen de lijsten, want alleen
|
||||
daar staan knoppen die iets veranderen, en de kaders erboven horen gewoon te
|
||||
blijven bijwerken. Geen verschuiving en geen overlay: dit verandert niets aan
|
||||
de afmetingen, en dat is precies waarom het de melding bovenaan vervangt. */
|
||||
body.is-busy .list-item { opacity: 0.55; }
|
||||
body.is-busy .list-item button { cursor: progress; }
|
||||
.btn-solid { background: var(--accent); color: #fff; border-color: transparent; }
|
||||
.btn-solid:hover { background: var(--accent); opacity: 0.88; }
|
||||
.btn-danger { color: var(--neg); border-color: color-mix(in srgb, var(--neg) 45%, transparent); }
|
||||
@@ -710,28 +731,17 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Het tijdvenster voor nieuwe eigenaars. De twee knoppen staan in de kop
|
||||
en verschijnen bij hover over dit kader.
|
||||
|
||||
De teller loopt in het relay-proces en niet hier: een teller in de
|
||||
browser verdwijnt als je het tabblad sluit, en dan blijft de deur open
|
||||
staan zonder dat iemand dat ziet. Wat de pagina doet is de resterende
|
||||
tijd van de agent aftellen tussen twee rondes. -->
|
||||
<!-- Hoeveel eigenaars er op een beslissing wachten.
|
||||
Tot 0.6.0 stond hier het tijdvenster voor nieuwe eigenaars, met een
|
||||
knop om het twee minuten open te zetten. Dat is eruit in 0.7.0: er is
|
||||
nog één weg naar binnen, en dat is de wachtlijst hieronder. Twee wegen
|
||||
was dubbelop, en het venster was de zwakste van de twee omdat het
|
||||
iederéén toeliet die er toevallig in verbond. -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2 class="t-h3">New owners</h2>
|
||||
<!-- "Open" en niet "Open 2 min": de twee minuten staan op de onderregel,
|
||||
waar er ruimte voor is. Met de lange tekst erin brak de kop van dit
|
||||
kader over twee regels en stond de waarde lager dan in de drie
|
||||
kaders ernaast. -->
|
||||
<div class="hover-only">
|
||||
<button class="btn btn-solid hidden" id="learn-open" type="button">Open</button>
|
||||
<button class="btn btn-danger hidden" id="learn-stop" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-head"><h2 class="t-h3">Waiting</h2></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-val text unknown" id="learning-state">unknown</div>
|
||||
<div class="stat-sub" id="learning-sub"></div>
|
||||
<div class="stat-val text unknown" id="waiting-state">unknown</div>
|
||||
<div class="stat-sub" id="waiting-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- De statuswidget staat RECHTS en niet links (keuze gebruiker,
|
||||
@@ -759,14 +769,20 @@ body {
|
||||
<div id="allowed"></div>
|
||||
</div>
|
||||
|
||||
<!-- Geweigerd en geblokkeerd in één kader, op verzoek van de gebruiker
|
||||
<!-- Wachtend en geblokkeerd in één kader, op verzoek van de gebruiker
|
||||
(30-08-2026). Het waren twee kaders, en het onderscheid ertussen is voor
|
||||
de gebruiker geen verschil in wat hij ermee doet: allebei zijn ze
|
||||
buitengesloten en allebei kun je ze toelaten. Dat het bij de een een
|
||||
eerdere keuze van jou was en bij de ander een poging van buiten, staat als
|
||||
badge op de regel. -->
|
||||
badge op de regel.
|
||||
|
||||
"Waiting list" en niet "Refused owners" sinds 0.7.0, op voorstel van de
|
||||
gebruiker. Dat is wat het is: kandidaten die op een beslissing wachten, en
|
||||
sinds het tijdvenster eruit is, is dit de enige weg naar binnen. Het veld
|
||||
heet in owners.json nog `rejected`; hernoemen zou een migratie zijn voor
|
||||
een woord dat niemand ziet. -->
|
||||
<div class="card col-6">
|
||||
<div class="card-head"><h2 class="t-h3">Refused owners</h2></div>
|
||||
<div class="card-head"><h2 class="t-h3">Waiting list</h2></div>
|
||||
<div id="refused"></div>
|
||||
</div>
|
||||
|
||||
@@ -776,6 +792,15 @@ body {
|
||||
<div class="card col-full">
|
||||
<div class="card-head"><h2 class="t-h3">Relay address</h2></div>
|
||||
<div class="url-box" id="relay-url">unknown</div>
|
||||
<!-- De tweede regel is er sinds 0.7.0. Het adres hierboven werkt binnen je
|
||||
eigen netwerk; van buiten loopt het via een reverse proxy, en dan is de
|
||||
vorm anders én is dit de meest gemaakte fout: het schema wordt wss://
|
||||
en de poort valt weg, want de proxy luistert op 443. -->
|
||||
<p class="note">
|
||||
That address works inside your own network. Through a reverse proxy with a certificate it
|
||||
becomes <code>wss://your.domain</code>, <strong>without</strong> a port number: the proxy
|
||||
listens on 443 and forwards to this one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -833,14 +858,15 @@ body {
|
||||
<h3>Who may use it</h3>
|
||||
<p>
|
||||
What this app adds is the part a self-hosted relay needs and a public one does not: control
|
||||
over who may use it. Opening the window for new owners accepts and remembers whoever connects
|
||||
while it is open, so open it only while you pair a device of your own. One app can use more
|
||||
than one owner id, so wait until nothing new appears before you close it again.
|
||||
over who may use it. Nobody gets in by default. An owner that connects for the first time is
|
||||
turned away and lands on the <strong>waiting list</strong> with its owner id, and you decide
|
||||
there whether it may stay. One app can use more than one owner id, so expect more than one
|
||||
row per device.
|
||||
</p>
|
||||
<p>
|
||||
An owner that is refused syncs nothing at all, and nothing of it is stored: the relay turns
|
||||
the connection away before it starts. Its device keeps everything locally and retries, so
|
||||
allowing it later still brings the whole history across.
|
||||
An owner on the waiting list syncs nothing at all, and nothing of it is stored: the relay
|
||||
turns the connection away before it starts. Its device keeps everything locally and retries,
|
||||
so allowing it later still brings the whole history across, and you do not have to be quick.
|
||||
</p>
|
||||
<p>
|
||||
Owner ids are long and meaningless on purpose. Hover a row and use <strong>Label</strong> to
|
||||
@@ -854,12 +880,6 @@ body {
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Hoelang de deur open gaat als je op de knop drukt. Twee minuten, op verzoek
|
||||
// van de gebruiker (30-08-2026). Deze waarde gaat mee in de opdracht; de agent
|
||||
// en het relay-proces begrenzen hem allebei nog een keer, want dit is de kant
|
||||
// die van buiten komt.
|
||||
var LEARNING_SECONDS = 120;
|
||||
|
||||
var root = document.documentElement;
|
||||
|
||||
// Eén opdracht tegelijk: de agent weigert een tweede zolang de eerste nog in de
|
||||
@@ -885,12 +905,6 @@ body {
|
||||
// een class-attribuut die er ook om opmaakredenen kan staan.
|
||||
var refusal = false;
|
||||
|
||||
// De resterende seconden van het tijdvenster, zoals de agent ze doorgaf, en het
|
||||
// moment waarop dat was. Daartussen telt de pagina zelf af, zodat de teller
|
||||
// loopt in plaats van elke vijf seconden te verspringen.
|
||||
var learningLeft = null;
|
||||
var learningLeftAt = 0;
|
||||
|
||||
// ── Hulpjes ───────────────────────────────────────────────────────────────
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
@@ -951,12 +965,6 @@ body {
|
||||
return Math.round(hours / 24) + ' days ago';
|
||||
}
|
||||
|
||||
function formatClock(seconds) {
|
||||
var whole = Math.max(0, Math.round(seconds));
|
||||
var minutes = Math.floor(whole / 60);
|
||||
var rest = whole % 60;
|
||||
return minutes + ':' + (rest < 10 ? '0' : '') + rest;
|
||||
}
|
||||
|
||||
function shorten(ownerId) {
|
||||
if (typeof ownerId !== 'string') { return 'unknown'; }
|
||||
@@ -1045,19 +1053,39 @@ body {
|
||||
hide('alert-notice');
|
||||
}
|
||||
|
||||
// Bevriezen in plaats van een melding bovenaan.
|
||||
//
|
||||
// Tot 0.6.0 verscheen bij élke klik "Sent. The relay applies changes within a
|
||||
// few seconds." in de balk bovenaan. Die balk staat in de gewone stroom van de
|
||||
// pagina, dus alles eronder schoof omlaag en bij het verdwijnen weer omhoog.
|
||||
// Gemeld door de gebruiker op 09-09-2026, en terecht: dat is een melding over
|
||||
// het verzendmechanisme en niet over wat jij deed. Wat je deed, zie je aan de
|
||||
// lijsten zodra de relay de opdracht heeft opgepakt.
|
||||
//
|
||||
// In plaats daarvan gaan de knoppen even op slot. Dat verschuift niets, het
|
||||
// toont dat er iets loopt, en het voorkomt een tweede klik terwijl de eerste
|
||||
// nog onderweg is. De foutmeldingen blijven wél in de balk staan: die zeggen
|
||||
// iets wat je nergens anders ziet, en die hóórt de pagina in de weg te leggen.
|
||||
function setFrozen(frozen) {
|
||||
document.body.classList.toggle('is-busy', frozen);
|
||||
var knoppen = document.querySelectorAll('.list-item button');
|
||||
for (var i = 0; i < knoppen.length; i += 1) { knoppen[i].disabled = frozen; }
|
||||
}
|
||||
|
||||
function send(command) {
|
||||
if (busy) { return; }
|
||||
busy = true;
|
||||
setFrozen(true);
|
||||
clearNotice();
|
||||
// De melding is met opzet kort van leven: de ronde hieronder haalt hem weg
|
||||
// zodra het relay-proces de opdracht heeft opgepakt, en dan is de nieuwe stand
|
||||
// in de kaders zelf te zien. Blijft hij staan, dan heeft de relay hem nog
|
||||
// niet gezien, en dán is er iets te melden.
|
||||
post('api/command', command, function () {
|
||||
show('alert-notice', 'Sent. The relay applies changes within a few seconds.', 'alert-neutral');
|
||||
}).then(function () {
|
||||
busy = false;
|
||||
window.setTimeout(refresh, 2500);
|
||||
post('api/command', command).then(function () {
|
||||
// Verversen en pas daarná ontdooien: de ronde bouwt de lijsten opnieuw op,
|
||||
// en tot dat gebeurd is staat de oude stand er nog. Ontdooien vóór de
|
||||
// ronde zou een klikbare knop opleveren die nog bij de vorige stand hoort.
|
||||
window.setTimeout(function () {
|
||||
refresh();
|
||||
busy = false;
|
||||
setFrozen(false);
|
||||
}, 2500);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1069,13 +1097,6 @@ body {
|
||||
post('api/label', { ownerId: ownerId, label: label }).then(refresh);
|
||||
}
|
||||
|
||||
el('learn-open').addEventListener('click', function () {
|
||||
send({ action: 'set-learning', value: true, seconds: LEARNING_SECONDS });
|
||||
});
|
||||
el('learn-stop').addEventListener('click', function () {
|
||||
send({ action: 'set-learning', value: false });
|
||||
});
|
||||
|
||||
// ── Een regel in een lijst ────────────────────────────────────────────────
|
||||
// `kind` is 'allowed', 'blocked' of 'rejected'. Dat bepaalt de badge en welke
|
||||
// knoppen er staan; de rest van de regel is voor alle drie hetzelfde.
|
||||
@@ -1128,7 +1149,7 @@ body {
|
||||
var tags = document.createElement('div');
|
||||
tags.className = 'owner-tags';
|
||||
if (kind === 'blocked') { tags.appendChild(badge('Blocked', 'badge-neg')); }
|
||||
if (kind === 'rejected') { tags.appendChild(badge('Turned away', 'badge-neutral')); }
|
||||
if (kind === 'rejected') { tags.appendChild(badge('Waiting', 'badge-neutral')); }
|
||||
row.appendChild(tags);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
@@ -1284,66 +1305,6 @@ body {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Het tijdvenster ───────────────────────────────────────────────────────
|
||||
function drawLearning(owners) {
|
||||
var open = el('learn-open');
|
||||
var stop = el('learn-stop');
|
||||
var node = el('learning-state');
|
||||
|
||||
if (owners.learning === true) {
|
||||
// Met een tijdslot toont de widget de teller; zonder tijdslot alleen dat hij
|
||||
// openstaat. Dat verschil is echt en hoort zichtbaar te zijn: "open" zonder
|
||||
// teller sluit zichzelf niet.
|
||||
if (typeof learningLeft === 'number') {
|
||||
put('learning-state', formatClock(remainingSeconds()));
|
||||
el('learning-sub').textContent = 'closes on its own';
|
||||
} else {
|
||||
put('learning-state', 'Open');
|
||||
el('learning-sub').textContent = 'stays open until you close it';
|
||||
}
|
||||
node.classList.remove('bad');
|
||||
node.classList.add('ok');
|
||||
open.classList.remove('hidden');
|
||||
open.textContent = 'Restart';
|
||||
stop.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
node.classList.remove('ok', 'bad');
|
||||
if (owners.learning === false) {
|
||||
put('learning-state', 'Closed');
|
||||
// Hier staat wat de knop niet zegt: hoe lang hij open gaat. Dat is de reden
|
||||
// dat de knop "Open" mag heten en niet "Open 2 min" hoeft te heten.
|
||||
el('learning-sub').textContent = 'opens for two minutes at a time';
|
||||
open.classList.remove('hidden');
|
||||
open.textContent = 'Open';
|
||||
stop.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Onbekend is geen synonym voor uit: uit is een keuze, onbekend is een reden
|
||||
// om te kijken. Dat verschil hoort de pagina te tonen.
|
||||
put('learning-state', null);
|
||||
el('learning-sub').textContent = 'the relay has not reported this yet';
|
||||
open.classList.add('hidden');
|
||||
stop.classList.add('hidden');
|
||||
}
|
||||
|
||||
function remainingSeconds() {
|
||||
if (typeof learningLeft !== 'number') { return 0; }
|
||||
return Math.max(0, learningLeft - (Date.now() - learningLeftAt) / 1000);
|
||||
}
|
||||
|
||||
// Eén keer per seconde de teller bijwerken. Alleen de teller: de rest van de
|
||||
// pagina komt uit de ronde hieronder, die elke vijf seconden loopt.
|
||||
window.setInterval(function () {
|
||||
if (typeof learningLeft !== 'number') { return; }
|
||||
var node = el('learning-state');
|
||||
if (node.classList.contains('ok')) {
|
||||
node.textContent = formatClock(remainingSeconds());
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// ── De ronde ──────────────────────────────────────────────────────────────
|
||||
function refresh() {
|
||||
fetch('api/status', { cache: 'no-store' }).then(function (response) {
|
||||
@@ -1380,26 +1341,19 @@ body {
|
||||
put('db-modified', formatAgo(status.database.modified));
|
||||
el('db-modified-sub').textContent = formatTime(status.database.modified) || 'never written to';
|
||||
|
||||
// ws:// en niet http://, en dat is geen schoonheidsfoutje: de relay is een
|
||||
// WebSocket-server en spreekt nooit HTTP tegen een cliënt. Er stond http://
|
||||
// tot 0.7.0, en dat kostte op 09-09-2026 een ronde zoeken bij het koppelen
|
||||
// van de testcliënt.
|
||||
el('relay-url').textContent =
|
||||
'http://' + window.location.hostname + ':' + status.relay.publicPort;
|
||||
'ws://' + window.location.hostname + ':' + status.relay.publicPort;
|
||||
el('relay-url').classList.remove('unknown');
|
||||
|
||||
// Het tijdvenster. De resterende tijd komt van de agent, want die rekent met
|
||||
// de klok van de Umbrel en niet met die van de bezoeker.
|
||||
if (typeof status.owners.learningSecondsLeft === 'number'
|
||||
&& status.owners.learningSecondsLeft > 0) {
|
||||
learningLeft = status.owners.learningSecondsLeft;
|
||||
learningLeftAt = Date.now();
|
||||
} else {
|
||||
learningLeft = null;
|
||||
}
|
||||
drawLearning(status.owners);
|
||||
|
||||
// De lijsten, tenzij er iemand aan een label bezig is.
|
||||
if (editing === null) {
|
||||
fillList('allowed', [
|
||||
{ kind: 'allowed', entries: status.owners.allowed }
|
||||
], 'None yet. Open the window above and connect your app.');
|
||||
], 'None yet. Connect your app; it will show up in the waiting list.');
|
||||
|
||||
fillList('refused', [
|
||||
{ kind: 'blocked', entries: status.owners.blocked },
|
||||
@@ -1407,9 +1361,20 @@ body {
|
||||
], 'None.');
|
||||
}
|
||||
|
||||
// De teller met wachtenden. Dit is de plek waar tot 0.6.0 het tijdvenster
|
||||
// stond; nu telt hij wie er op een beslissing wacht, want dat is sinds
|
||||
// 0.7.0 de enige weg naar binnen.
|
||||
var wachtend = (status.owners.rejected || []).length;
|
||||
put('waiting-state', String(wachtend));
|
||||
el('waiting-state').classList.remove('unknown');
|
||||
el('waiting-sub').textContent = wachtend === 0
|
||||
? 'nobody is asking to get in'
|
||||
: (wachtend === 1 ? 'one owner wants in; allow it below if it is yours'
|
||||
: 'owners want in; allow the ones you recognise below');
|
||||
|
||||
if (status.owners.problem === 'onleesbaar') {
|
||||
show('alert-owners', 'The list of owners could not be read and was moved aside. New owners '
|
||||
+ 'are refused until you open the window above again.');
|
||||
show('alert-owners', 'The list of owners could not be read and was moved aside. Everyone is '
|
||||
+ 'on the waiting list again; allow the owners you recognise below.');
|
||||
} else {
|
||||
hide('alert-owners');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { installPolyfills } from '@evolu/common/polyfills';
|
||||
import { createRelay, createRelayDeps, runMain } from '@evolu/nodejs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
import { applyCommand, decideOwner, expireLearning } from './policy.js';
|
||||
import { applyCommand, decideOwner } from './policy.js';
|
||||
import { readState, takeCommand, writeState } from './store.js';
|
||||
|
||||
installPolyfills();
|
||||
@@ -58,8 +58,8 @@ let dirty = start.status === 'new';
|
||||
|
||||
if (start.status === 'corrupt') {
|
||||
console.log(
|
||||
'[warn] owners.json was unreadable and has been moved aside. New owners are refused until you ' +
|
||||
'enable learning again on the status page.',
|
||||
'[warn] owners.json was unreadable and has been moved aside. Everyone is on the waiting list ' +
|
||||
'again; allow the owners you recognise on the status page.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,27 +100,8 @@ const pollCommands = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Het tijdvenster voor nieuwe eigenaars ─────────────────────────────────────
|
||||
// De pagina kan de leerstand voor een aantal seconden openzetten. Dat aflopen
|
||||
// gebeurt niet híer maar in `isLearningOpen`, dat `decideOwner` gebruikt: een
|
||||
// verlopen venster weigert al vóór deze lus langskomt. Wat deze lus doet is het
|
||||
// bestand bijwerken, zodat de pagina "closed" toont in plaats van een venster dat
|
||||
// afgelopen is.
|
||||
//
|
||||
// Waarom de teller in de relay zit en niet in de pagina: een teller in de browser
|
||||
// verdwijnt als je het tabblad sluit, en dan blijft de deur openstaan zonder dat
|
||||
// iemand dat ziet. Dit is de enige plek waar de staat gezaghebbend is.
|
||||
const closeExpiredLearning = () => {
|
||||
const result = expireLearning(state, new Date().toISOString());
|
||||
if (!result.changed) return;
|
||||
state = result.state;
|
||||
markDirty();
|
||||
console.log('[info] the window for new owners has closed on its own');
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
pollCommands();
|
||||
closeExpiredLearning();
|
||||
flush();
|
||||
}, 2000).unref();
|
||||
|
||||
@@ -166,16 +147,13 @@ await runMain({ ...createRelayDeps(), console })(
|
||||
if (decision.changed) markDirty();
|
||||
|
||||
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
|
||||
if (decision.reason === 'learned') {
|
||||
console.log(`[info] learned a new owner ${short}; one app can use more than one, so keep ` +
|
||||
'learning open until nothing new shows up');
|
||||
} else if (decision.allowed) {
|
||||
if (decision.allowed) {
|
||||
announceOnce(`ok:${short}`, `[info] owner ${short} connected`);
|
||||
} else {
|
||||
announceOnce(
|
||||
`no:${short}`,
|
||||
`[info] refused owner ${short} (${decision.reason}); allow it on the status page if it is ` +
|
||||
'yours',
|
||||
`[info] owner ${short} is on the waiting list (${decision.reason}); allow it on the ` +
|
||||
'status page if it is yours',
|
||||
);
|
||||
}
|
||||
return decision.allowed;
|
||||
|
||||
+40
-146
@@ -2,6 +2,26 @@
|
||||
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
|
||||
// onbekende eigenaar.
|
||||
//
|
||||
// **Er is sinds 0.7.0 (09-09-2026) precies één weg naar binnen: de wachtlijst.**
|
||||
// Een onbekende eigenaar wordt geweigerd en onthouden; op de statuspagina staat
|
||||
// hij met zijn `OwnerId` erbij en daar laat je hem toe. De cliënt verbindt
|
||||
// vanzelf opnieuw en is dan binnen.
|
||||
//
|
||||
// Tot 0.6.0 was er daarnaast een getimede leerstand die de eerstvolgende
|
||||
// onbekende eigenaars toeliet. Die is eruit op voorstel van de gebruiker, en de
|
||||
// reden is dat het dubbelop was: allebei de wegen vragen iemand die bij de app
|
||||
// kan, en het venster is de zwakste van de twee omdat het iederéén toelaat die
|
||||
// er toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek
|
||||
// wss-adres kan staan. De oorspronkelijke reden voor de leerstand was dat je je
|
||||
// eigen `OwnerId` nergens kon aflezen, en die reden verviel toen de weigerlijst
|
||||
// dat id ging tonen.
|
||||
//
|
||||
// De velden `learning` en `learningUntil` worden nog wél gelézen, en dat is met
|
||||
// opzet: `STATE_VERSION` verhogen zou `normalizeState` een bestaand bestand
|
||||
// laten afwijzen, en dan schuift `store.js` de allowlist van een werkende
|
||||
// installatie opzij en gaat de deur dicht voor eigenaars die er al in stonden.
|
||||
// Ze worden gelezen, genegeerd en niet teruggeschreven.
|
||||
//
|
||||
// Alles hier is een pure functie: geen bestanden, geen netwerk, geen klok. De
|
||||
// aanroeper geeft de huidige staat en het huidige tijdstip mee en krijgt een
|
||||
// nieuwe staat terug. Dat is met opzet, want dit is de enige plek waar staat wie
|
||||
@@ -14,9 +34,14 @@
|
||||
|
||||
export const STATE_VERSION = 1;
|
||||
|
||||
// Hoeveel geweigerde eigenaars we onthouden om op de pagina te tonen. Er zit een
|
||||
// grens op omdat een onbekende die blijft proberen anders het bestand vol
|
||||
// schrijft: elke poging is een eigenaar-id dat hij zelf verzint.
|
||||
// Hoeveel wachtenden we onthouden om op de pagina te tonen. Er zit een grens op
|
||||
// omdat een onbekende die blijft proberen anders het bestand vol schrijft: elke
|
||||
// poging is een eigenaar-id dat hij zelf verzint.
|
||||
//
|
||||
// Het veld heet op schijf nog `rejected` en dat blijft zo. Op de pagina heet het
|
||||
// sinds 0.7.0 de wachtlijst, want dat is wat het is: kandidaten die op een
|
||||
// beslissing wachten. De naam in het bestand hernoemen zou een migratie zijn, en
|
||||
// die is het niet waard voor een woord dat niemand ziet.
|
||||
export const MAX_REJECTED = 20;
|
||||
|
||||
// Een `OwnerId` is een publieke identificatie en geen geheim, maar hij komt van
|
||||
@@ -24,29 +49,8 @@ export const MAX_REJECTED = 20;
|
||||
// verder geen aannames over de vorm.
|
||||
export const MAX_OWNER_ID_LENGTH = 256;
|
||||
|
||||
// De langste tijdvenster dat de pagina mag vragen. De pagina vraagt er twee
|
||||
// minuten; deze grens staat er voor het geval iets anders de postbus vult. Een
|
||||
// venster van een dag is geen venster meer, en dit is een toegangscontrole: bij
|
||||
// twijfel de kortere kant.
|
||||
export const MAX_LEARNING_SECONDS = 3600;
|
||||
|
||||
export const createEmptyState = () => ({
|
||||
version: STATE_VERSION,
|
||||
// Leerstand. Aan betekent: de eerstvolgende onbekende eigenaar wordt
|
||||
// toegelaten. Dit staat aan bij een verse installatie, want anders kan de
|
||||
// eigenaar zichzelf nooit aanmelden: Trezor Suite toont je `OwnerId` nergens.
|
||||
learning: true,
|
||||
// Tot wanneer de leerstand open is, als ISO-tijdstip, of `null` voor "tot je
|
||||
// hem zelf sluit". Dat tweede is de begintoestand: een verse installatie moet
|
||||
// te koppelen zijn zonder dat er iemand op tijd op een knop drukt.
|
||||
//
|
||||
// Toegevoegd zonder STATE_VERSION te verhogen, en dat is een keuze. Een
|
||||
// verhoging zou `normalizeState` het bestaande bestand laten afwijzen, en dan
|
||||
// schuift `store.js` de allowlist van een werkende installatie opzij en gaat de
|
||||
// deur dicht. Een veld bijzetten dat ontbrekend `null` betekent is niet
|
||||
// brekend: een oud bestand leest goed, en een oude relay leest een nieuw
|
||||
// bestand ook goed omdat hij het veld gewoon niet kent.
|
||||
learningUntil: null,
|
||||
owners: [],
|
||||
rejected: [],
|
||||
});
|
||||
@@ -56,63 +60,6 @@ const isUsableOwnerId = (value) =>
|
||||
|
||||
const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId);
|
||||
|
||||
/** Het tijdstip als getal, of `NaN` als er iets onleesbaars staat. */
|
||||
const asMoment = (value) => (typeof value === 'string' ? Date.parse(value) : NaN);
|
||||
|
||||
/**
|
||||
* `now` plus een aantal seconden, als ISO-tijdstip. `null` als dat niet kan.
|
||||
*
|
||||
* Geen klok hierin: `now` komt van de aanroeper, net als bij alles in dit
|
||||
* bestand. Vandaar dat een onleesbare `now` een uitkomst heeft en geen fout: de
|
||||
* aanroeper beslist wat hij met `null` doet, en in `applyCommand` is dat de
|
||||
* opdracht weigeren. Stil "open zonder tijdslot" zou de verkeerde kant zijn.
|
||||
*/
|
||||
const addSeconds = (now, seconds) => {
|
||||
const start = asMoment(now);
|
||||
if (Number.isNaN(start)) return null;
|
||||
return new Date(start + seconds * 1000).toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Staat de deur op dit moment open voor een onbekende eigenaar?
|
||||
*
|
||||
* Dit is de gezaghebbende vraag en niet het veld `learning` op zichzelf. Een
|
||||
* venster dat verlopen is, is dicht, ook al staat er in het bestand nog dat de
|
||||
* leerstand aan is: dat bestand wordt door de lus in `index.js` bijgewerkt en die
|
||||
* loopt op zijn eigen moment. De correctheid mag niet aan die lus hangen, want
|
||||
* dan zit er een gat van een seconde of twee in waarin een onbekende alsnog
|
||||
* binnenkomt. Vandaar dat `decideOwner` deze functie gebruikt en niet het veld.
|
||||
*/
|
||||
export const isLearningOpen = (state, now) => {
|
||||
if (state.learning !== true) return false;
|
||||
if (state.learningUntil === null || state.learningUntil === undefined) return true;
|
||||
|
||||
const deadline = asMoment(state.learningUntil);
|
||||
const moment = asMoment(now);
|
||||
// Onleesbaar aan één van de twee kanten: dicht. Dat is de veilige kant, en het
|
||||
// is dezelfde regel die `store.js` volgt bij een onleesbaar bestand.
|
||||
if (Number.isNaN(deadline) || Number.isNaN(moment)) return false;
|
||||
return moment < deadline;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ruimt een verlopen venster op.
|
||||
*
|
||||
* Puur opruimwerk: `isLearningOpen` weigert al vóórdat dit gebeurd is. Wat dit
|
||||
* oplevert is dat het bestand en de pagina hetzelfde zeggen als de klok, en dat
|
||||
* je op de pagina "closed" ziet in plaats van een venster dat afgelopen is.
|
||||
*
|
||||
* @returns {{state: object, changed: boolean}}
|
||||
*/
|
||||
export const expireLearning = (state, now) => {
|
||||
if (state.learning !== true) return { state, changed: false };
|
||||
if (state.learningUntil === null || state.learningUntil === undefined) {
|
||||
return { state, changed: false };
|
||||
}
|
||||
if (isLearningOpen(state, now)) return { state, changed: false };
|
||||
return { state: { ...state, learning: false, learningUntil: null }, changed: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt.
|
||||
*
|
||||
@@ -123,9 +70,13 @@ export const expireLearning = (state, now) => {
|
||||
export const normalizeState = (raw) => {
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
if (raw.version !== STATE_VERSION) return null;
|
||||
if (typeof raw.learning !== 'boolean') return null;
|
||||
if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null;
|
||||
|
||||
// `learning` en `learningUntil` mogen er nog in staan en worden overgeslagen.
|
||||
// Een bestand van 0.6.0 of ouder heeft ze, en dat bestand hoort gewoon te
|
||||
// lezen; zie de kop van dit bestand voor waarom hier geen versieverhoging
|
||||
// tegenover staat.
|
||||
|
||||
const owners = [];
|
||||
for (const owner of raw.owners) {
|
||||
if (owner === null || typeof owner !== 'object') return null;
|
||||
@@ -151,20 +102,10 @@ export const normalizeState = (raw) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Ontbrekend of onleesbaar wordt `null`, en dat betekent "geen tijdslot". Dat
|
||||
// klinkt als de onveilige kant maar is het niet: `learning` moet daarnaast ook
|
||||
// nog `true` zijn, en dat staat in hetzelfde bestand. Een oud bestand zonder
|
||||
// dit veld hoort te lezen als de leerstand die het beschreef, en niet als een
|
||||
// venster dat meteen verlopen is.
|
||||
const learningUntil =
|
||||
typeof raw.learningUntil === 'string' && !Number.isNaN(asMoment(raw.learningUntil))
|
||||
? raw.learningUntil
|
||||
: null;
|
||||
|
||||
return { version: STATE_VERSION, learning: raw.learning, learningUntil, owners, rejected };
|
||||
return { version: STATE_VERSION, owners, rejected };
|
||||
};
|
||||
|
||||
const rememberRejected = (rejected, ownerId, now) => {
|
||||
const rememberWaiting = (rejected, ownerId, now) => {
|
||||
const existing = rejected.find((entry) => entry.id === ownerId);
|
||||
if (existing) {
|
||||
return rejected.map((entry) =>
|
||||
@@ -205,15 +146,13 @@ export const decideOwner = (state, ownerId, now) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (isLearningOpen(state, now)) {
|
||||
const owners = [...state.owners, { id: ownerId, allowed: true, firstSeen: now, lastSeen: now }];
|
||||
return { allowed: true, state: { ...state, owners }, reason: 'learned', changed: true };
|
||||
}
|
||||
|
||||
// Onbekend: weigeren en op de wachtlijst zetten. Dat is sinds 0.7.0 de enige
|
||||
// uitkomst voor een onbekende, en daarmee ook de enige weg naar binnen: op de
|
||||
// statuspagina staat hij met zijn `OwnerId` erbij en daar laat je hem toe.
|
||||
return {
|
||||
allowed: false,
|
||||
state: { ...state, rejected: rememberRejected(state.rejected, ownerId, now) },
|
||||
reason: 'not-learning',
|
||||
state: { ...state, rejected: rememberWaiting(state.rejected, ownerId, now) },
|
||||
reason: 'waiting',
|
||||
changed: true,
|
||||
};
|
||||
};
|
||||
@@ -233,51 +172,6 @@ export const applyCommand = (state, command, now) => {
|
||||
if (command === null || typeof command !== 'object') return unchanged('malformed-command');
|
||||
|
||||
switch (command.action) {
|
||||
case 'set-learning': {
|
||||
if (typeof command.value !== 'boolean') return unchanged('malformed-command');
|
||||
|
||||
// Dicht is dicht: een tijdslot dat nog liep gaat mee weg. Zou het blijven
|
||||
// staan, dan zou een volgende `set-learning true` zonder seconden een
|
||||
// venster erven dat de gebruiker niet gevraagd heeft.
|
||||
if (command.value === false) {
|
||||
if (state.learning === false && !state.learningUntil) {
|
||||
return { state, changed: false, error: null };
|
||||
}
|
||||
return {
|
||||
state: { ...state, learning: false, learningUntil: null },
|
||||
changed: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Open, en `seconds` bepaalt of dat met een tijdslot is. Ontbreekt het veld,
|
||||
// dan is dat het oude gedrag: open tot je hem zelf sluit. Dat pad blijft
|
||||
// bestaan omdat een verse installatie er niet mee gered is als het venster
|
||||
// afloopt terwijl je nog aan het installeren bent.
|
||||
if (command.seconds === undefined || command.seconds === null) {
|
||||
if (state.learning === true && !state.learningUntil) {
|
||||
return { state, changed: false, error: null };
|
||||
}
|
||||
return { state: { ...state, learning: true, learningUntil: null }, changed: true, error: null };
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(command.seconds) ||
|
||||
command.seconds <= 0 ||
|
||||
command.seconds > MAX_LEARNING_SECONDS
|
||||
) {
|
||||
return unchanged('malformed-command');
|
||||
}
|
||||
|
||||
const until = addSeconds(now, command.seconds);
|
||||
if (until === null) return unchanged('malformed-command');
|
||||
|
||||
// Altijd `changed`, ook als de leerstand al open stond: opnieuw op de knop
|
||||
// drukken hoort de klok terug te zetten. Vergelijken met de oude waarde zou
|
||||
// hier een venster laten aflopen terwijl de gebruiker net verlengde.
|
||||
return { state: { ...state, learning: true, learningUntil: until }, changed: true, error: null };
|
||||
}
|
||||
|
||||
case 'block': {
|
||||
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
||||
if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner');
|
||||
|
||||
Reference in New Issue
Block a user