Commit graph

67 commits

Author SHA1 Message Date
Mitchell R
3ffaf780e3
feat(kiosk): improve display controls and health 2026-05-21 02:03:12 +02:00
Mitchell R
96f5e6a330
feat(ota): add OS update release endpoints 2026-05-20 06:19:46 +02:00
Mitchell R
9942957bcf
feat(onvif): run discovery from selected kiosk 2026-05-20 06:16:27 +02:00
Mitchell R
444bb4c116
feat(firmware): allow env import token 2026-05-20 05:02:12 +02:00
Mitchell R
dae5d0ce88
feat(managed-config): server-side scaffold for Pi-image device config
Kiosks running our pre-built image (managed_image=true at pairing) can
have their hostname, timezone, network (DHCP/static + VLAN), and Wi-Fi
configured from the admin UI. Pull-model: server stores desired-state
JSON, kiosk heartbeat returns pending_config when version exceeds
applied_version, kiosk echoes applied_version back. Wi-Fi PSK encrypted
with the cluster key so ciphertext at rest is shipped to the kiosk
without per-kiosk re-encryption.

Server side only — kiosk Rust applier (betterframe-apply-config helper
+ rollback timer) and pair-initiate marker file are next.

ci(pi-gen): use action's image-path output for asset upload

pi-gen writes the .img.xz into pi-gen-action's own working dir, not our
repo deploy/. Glob never matched. Use steps.pigen.outputs.image-path
directly — no glob needed.
2026-05-20 03:18:11 +02:00
Mitchell R
6473f0fc95
fix(firmware): diagnostic dump + smart-quote / BOM / multi-quote handling
Adds aggressive normalisation to tryParsePrivateKey:
- Strip UTF-8 BOM
- Replace smart quotes (" " ' ') with ASCII
- Strip multiple layers of wrapping quotes
- Combine escape-unfold with quote-strip (env vars that quote AND escape)
- Strip whitespace inside base64 candidate before decode

On parse failure, dumps length + head/tail samples + first-byte hex so
the operator can spot exactly what shape the env var arrived in.
2026-05-18 22:52:35 +02:00
Mitchell R
936e6170a6
feat(store): Postgres adapter foundation + BF_DB selector (phase 1)
Lays groundwork for sqlite|postgres backend selection without yet
converting Repository. Adds:

- db-adapter.ts: async DbAdapter interface (run/get/all/exec/transaction)
- sqlite-adapter.ts: wraps node:sqlite sync API in Promise-returning shape,
  caches prepared statements
- pg-adapter.ts: pg Pool + ? → $N placeholder rewrite + RETURNING-id
  capture + savepoint-nested transactions
- service-store config: driver (sqlite|postgres), pgUrl
- BF_DB env override, plumbed via envStr

Selecting BF_DB=postgres throws at init() until the Repository is
converted off DatabaseSync. This commit ships the foundation only.

Next phases (separate commits):
  2. Convert Repository methods sync → async via DbAdapter
  3. Update every caller to await
  4. Split MIGRATIONS into sqlite + portable / pg-specific sets
  5. UUIDv7 IDs for new tables on PG path

Adds deps: pg ^8.13.1, uuidv7 ^1.0.2, @types/pg ^8.20.0
2026-05-18 22:50:48 +02:00
Mitchell R
8082571b03
fix(firmware): tolerate mangled PEM in BF_FIRMWARE_SIGNING_KEY env
Coolify / docker compose env injection routinely strips real newlines or
wraps in quotes, causing createPrivateKey to throw ERR_OSSL_UNSUPPORTED
and crashing the server before it can even start.

tryParsePrivateKey now attempts: literal, \n→LF, CRLF→LF, quote-stripped,
base64-decoded, and single-line PEM re-wrapped to 64-col. On total
failure, logs a clear warning and falls back to on-disk / generated key
instead of crashing.
2026-05-18 22:47:07 +02:00
Mitchell R
150972a272 fix(server): move rate-limit creation inside register fns (BSB schema extractor)
Schema extractor evaluates module top-level statically; createRateLimiter
calls at module scope threw ReferenceError during bsb-plugin-cli build.
Lifting into the per-route register functions keeps build clean.

Also: standardise display.standby/wake audit hooks.
2026-05-14 07:49:57 +02:00
Mitchell R
17f8c7ce02 feat(server): generic MQTT telemetry bridge (off by default) 2026-05-14 07:46:56 +02:00
Mitchell R
aa4e91491b feat(server): backup + restore (AES-256-GCM, PBKDF2, admin UI) 2026-05-14 07:44:01 +02:00
Mitchell R
a6c1fb4d8d feat(server): rate limit (login + pair) + CSP/security headers 2026-05-14 07:40:22 +02:00
Mitchell R
3ec2f3bf85 feat(server): audit log — schema, helper, admin UI, hooks for login/pair/firmware 2026-05-14 07:38:18 +02:00
Mitchell R
d1fd128ea0 feat(server): env-var overrides for sec-config keys + docker healthchecks 2026-05-14 07:33:10 +02:00
Mitchell R
69cd0391b5 feat(ota): phase 3 — rollouts + automated rollback
Rollouts (server side):
- /admin/firmware/rollouts page lists + creates campaigns. Pick release,
  target kiosk_ids (empty = whole channel), percentage (1-100).
- Active rollouts override channel-latest in /api/kiosk/firmware/check.
- Deterministic bucket via sha256(rollout_id:kiosk_id) % 100 — same kiosk
  consistently lands in the same bucket across re-checks.
- Pause / resume / complete state controls.

Rollback (kiosk side):
- Before swap, kiosk writes firmware-applying.json marker.
- After clean boot + first successful heartbeat, marker deleted.
- New ExecStartPre hook (/usr/local/sbin/betterframe-firmware-rollback.sh)
  runs every service start; stale marker (>120s) + .prev present →
  restore .prev. Pairs with systemd's StartLimit to catch crash loops.
2026-05-14 07:28:20 +02:00
Mitchell R
6a8f6d76af feat(kiosk): LAN-side local HTTP server (GET layout API + admin proxy)
Kiosk now exposes :18090 with two surfaces:

- GET /local/layout/:id?key=<kiosk_local_key>
  Bookmark-friendly layout switch on this kiosk. Auth = kiosk-generated
  local key (32 random bytes, hex, stored at <state_dir>/local.key).

- ANY /proxy/* — forwards to BF server with the request's Authorization
  header preserved. Lets LAN clients reach a cloud-hosted BF server via
  the kiosk's local socket; kiosk adds no auth of its own.

Heartbeat reports {local_key, local_port}; kiosks table grows
local_key/local_port/local_last_ip columns. Admin kiosk edit page now
shows the local URLs as a copy-paste block.

Override port: BF_KIOSK_LOCAL_PORT. Disable: BF_KIOSK_LOCAL_DISABLE=1.
2026-05-14 07:24:21 +02:00
Mitchell R
e5009fdd14 feat(ota): replacement pairing + firmware OTA (admin UI, kiosk client, CI) 2026-05-13 20:56:42 +02:00
Mitchell R
b10958def7 fix(nodered): kiosk-side layout.changed events + provisioning retries
Three related fixes:

1. Idle reverts (and any other kiosk-initiated layout switch) now POST
   layout.changed to /api/kiosk/event. Previously the server only emitted
   on admin-initiated switches, so Node-RED never saw the idle revert.

2. Server's /api/kiosk/event splays the payload to the top level when
   the topic has a dedicated trigger node (layout.changed, kiosk.changed,
   kiosk.status, display.power.changed, camera.changed). The trigger
   nodes expect flat shapes matching the admin emit; the old wrapped
   shape left every field undefined.

3. Auto-provisioning of bf-server-config in Node-RED: extend retry
   window to ~5 min, log per attempt, force v2 API + full-deploy header
   so credentials inline get accepted, surface response body on failure.
2026-05-13 13:03:51 +02:00
Mitchell R
7c88d7f733 fix(displays): use kiosk-local indices
Kiosk heartbeat reports local display positions so the server can sync physical outputs without consuming global display indices.

Migrate displays.index away from global uniqueness because display numbering is only meaningful within a kiosk.
2026-05-13 03:57:12 +02:00
Mitchell R
d018b34955 fix(displays): sync layout attachment UI 2026-05-13 03:46:58 +02:00
Mitchell R
1b47911ce5 fix(store): delete displays with kiosk 2026-05-13 03:43:29 +02:00
Mitchell R
122509de0d feat(nodered): auto-provision bf-server-config on boot
Server mints a dedicated admin API key on first boot (persisted plaintext
encrypted in setup_state.extras) and POSTs a bf-server-config node into
Node-RED's flow graph via /nrdp/flows. Idempotent — skips if any
bf-server-config already exists, so user-owned configs win.

New admin-http config 'selfUrl' (defaults to http://127.0.0.1:18080)
tells Node-RED how to reach the BF server. Docker compose sets it to
http://server:18080 so requests stay inside the compose network.
2026-05-13 03:09:25 +02:00
Mitchell R
faaa2cef39 feat(display): admin enable/disable toggle
is_enabled column on displays (default 1). Disabled displays are filtered
from the kiosk bundle so the kiosk never opens a window on them. Admin
edit page exposes a checkbox; list page shows a "disabled" badge.
2026-05-13 02:59:28 +02:00
Mitchell R
887db013ef fix: trigger nodes self-register + move to angie-blocked path
Trigger nodes now self-contained inputs (inputs:0):
- Each registers POST /api/internal/<topic> on RED.httpNode
- Angie returns 404 for any /api/* not whitelisted (kiosk/pair/admin)
  so external requests cannot trigger BF nodes
- Server bridge POSTs direct to nodered container (bypasses Angie)
- nodered-bridge.ts updated to use /api/internal/<topic>
- 6 trigger nodes converted: display-power, layout-changed,
  kiosk-changed, camera-changed, status, kiosk-camera-event
- Optional per-node filters (display_id, kiosk_id, camera_id)
- close handler removes only this node's route layer
2026-05-13 02:42:37 +02:00
Mitchell R
acb4a353f9 feat: bf-status (action + trigger) + bf-snapshot (action) nodes
- bf-status: query kiosk state by ID via /api/admin/kiosks/:id
- bf-trigger-status: dedicated heartbeat-only topic kiosk.status
  (skips connect/disconnect noise from kiosk.changed)
- bf-snapshot: GET /admin/entities/:id/snapshot as Buffer for
  motion → email/telegram flows
- coordinator-ws now forwards both kiosk.changed (event=heartbeat)
  AND kiosk.status on every status message
2026-05-13 02:29:12 +02:00
Mitchell R
bd48c853e6 feat: restructure Node-RED nodes + server event emission
Renames:
- bf-config → bf-server-config (config node clarity)
- bf-event-in → bf-kiosk-camera-event (specific camera trigger)

New trigger nodes (input-only, under "BetterFrame Triggers"):
- bf-trigger-display-power, bf-trigger-layout-changed,
  bf-trigger-kiosk-changed, bf-trigger-camera-changed

New flow nodes:
- bf-config-get: query state by type (displays/kiosks/cameras/layouts/
  entities, or by-id)
- bf-config-set: mutate via typed setters (default-layout, enabled,
  priority, name)

Server-side event emission:
- shared/strip-secrets.ts: recursive password scrub
- New JSON admin endpoints: GET/POST /api/admin/{displays,kiosks,
  layouts,entities}[/:id]
- Coordinator-ws fires kiosk.changed on connect/disconnect/heartbeat
- Layout/power/camera routes call nodered.forward() on state change
2026-05-13 02:26:08 +02:00
Mitchell R
b83782b8e0 feat: Node-RED custom nodes + dashboard entity type
Node-RED nodes (nodered/):
- bf-config: shared server URL + admin API key
- bf-event-in: filter kiosk events by topic glob
- bf-layout-switch: POST display layout-switch
- bf-power: kiosk wake/standby
- bf-fan: kiosk fan control
- bf-cameras: query camera list
- Drag-droppable from Node-RED palette

Server:
- Admin Bearer API key auth on /admin/* (NodeRED can call admin API)
- GET /api/admin/cameras for bf-cameras node
- Dashboard entity type:
  - entities.type CHECK adds 'dashboard'
  - entities.dashboard_id column
  - shared/nodered-bridge.ts listDashboards() polls /nrdp/flows
  - Bundle resolves dashboard entity → web cell at /dash/<id>
  - POST /admin/entities/sync-dashboards mirrors Node-RED tabs
  - EntitiesPage shows Dashboards section + Sync button
  - EntityEditPage for dashboard: read-only + "Open in Node-RED"
  - No create/delete from BF UI — managed in Node-RED
- sec-config: noderedUrl on admin-http (was already on api-http)
2026-05-13 01:47:53 +02:00
Mitchell R
f40b730fe9 refactor: htmx audit — convert kiosk/display/label actions
Action buttons that don't need a redirect now use hx-post:
- Kiosk power (wake/standby), fan (auto/off/50%/full)
- Kiosk switch-layout dropdown
- Kiosk GPIO delete (row swap-out)
- Camera labels add/remove (list re-render)
- Kiosk labels add/remove (list re-render)
- Display attach/detach layout (list re-render)

Server routes return HTML fragments via isHtmxRequest() check,
otherwise still 302 redirect for direct-URL access.

Forms that legitimately redirect (create/edit/delete, auth flows)
stay as standard form posts.
2026-05-13 01:37:15 +02:00
Mitchell R
766db445c4 fix: Dockerfile npm run build + htmx for layout switch buttons
- Dockerfile.server: RUN npm run build during builder stage so the
  image ships pre-compiled lib/ + bsb-plugin.json. Runtime image also
  installs ffmpeg (for camera snapshot endpoint).
- DisplayEditPage Show buttons + Switch dropdown now use hx-post
  with hx-swap=none — no page reload, just fires the command.
2026-05-13 01:32:25 +02:00
Mitchell R
5669222f48 feat: add 'Show' button per attached layout on display edit 2026-05-13 01:24:24 +02:00
Mitchell R
975cc184b3 feat: multi-display + snapshot + health + GPIO + nodered embed
Multi-display:
- Bundle ships displays[] each with own layouts + idle/sleep
- Rust kiosk creates one ApplicationWindow per gdk monitor
- Per-display state (layout, idle, sleep) via HashMap
- WARM_CAMERAS pool shared across displays
- Backward-compat top-level display/layouts still emitted

System Health (/admin/health):
- Online status, CPU temp (color-coded), fan RPM/PWM
- Bundle version mismatch detection
- 30s auto-refresh

Camera snapshot/test:
- shared/snapshot.ts: ffmpeg/gst-launch fallback, 5s timeout
- /admin/entities/:id/snapshot returns JPEG
- EntityEditPage shows live preview with Refresh

GPIO (Pi buttons/sensors):
- kiosk_gpio_bindings table + CRUD admin UI
- Bundle ships gpio_bindings[]
- kiosk/src/gpio.rs with gpiod crate, worker thread per pin
- Edge events POST to /api/kiosk/event with source_type=gpio

Layout switch fixes:
- GET aliases added so direct URL hits work
- New /admin/displays/:displayId/layout/:layoutId for multi-display
- DisplayEditPage gets "Switch Layout Now" section

Node-RED embed:
- /admin/nodered renders iframe at /nrdp/
- Sandbox attrs allow scripts/forms/popups
- Sidebar link now opens embedded view
2026-05-13 01:18:22 +02:00
Mitchell R
1c0fe02fcf feat: layout switch push + idle/sleep timer + offline bundle cache
Layout switch push:
- POST /admin/kiosks/:id/layout/:layoutId — coordinator sends
  {type:"layout-switch", layout_id} via WS
- Kiosk renders specified layout from cached bundle
- KioskEditPage adds Switch Layout dropdown + button

Idle/sleep timer:
- thread_local LAST_ACTIVITY + IS_ASLEEP + CURRENT_LAYOUT_ID
- mark_activity() on render/switch/wake; wakes if asleep
- glib timeout_add_local every 1s checks elapsed:
  - elapsed >= idle_timeout AND not on default + resets_idle_timer
    → switch to default layout
  - elapsed >= sleep_timeout AND !asleep → cec::standby()
- Display idle/sleep timeouts from bundle.display

Offline cache:
- server::save_bundle → ~/.betterframe-kiosk/bundle.json
- server::load_cached_bundle on offline boot
- fetch_bundle no longer panics; returns Option
- 30s retry loop until server reachable
- Reload-bundle gracefully handles fetch failures
2026-05-13 01:00:11 +02:00
Mitchell R
1e09582379 feat: per-cell content fit (cover|contain|fill), default cover
- Migration adds layout_cells.fit column (default 'cover')
- LayoutCell type + mapper + repo accept/persist fit
- Bundle ships fit per cell
- Admin cell edit form: Fit dropdown with industry-default Cover
- Rust kiosk applies ContentFit::Cover|Contain|Fill per cell.fit

Cover = fill cell, crop overflow (industry default — Nx Witness etc)
Contain = letterbox, no crop
Fill = stretch, distort
2026-05-11 13:52:22 +02:00
Mitchell R
51c58e7abf feat: Pi fan control + temp monitoring + stream swap on layout change
Kiosk:
- hwmon.rs reads /sys/class/thermal + /sys/class/hwmon for CPU temp,
  fan RPM, fan PWM
- Heartbeat reports cpu_temp_c, fan_rpm, fan_pwm
- WS message "fan" with {pwm: N} or {mode: "auto"} sets pwm1_enable+pwm1
- Picture content_fit Cover → Contain (no more cropping/overlay cuts)
- ensure_warm tears down + rebuilds pipeline when desired stream
  changes (M↔S swap on layout change)

Server:
- Migration v0.8: add cpu_temp_c, fan_rpm, fan_pwm to kiosks
- Heartbeat persists hwmon fields
- KioskEditPage shows CPU/fan/PWM + Auto/Off/50%/Full buttons
- POST /admin/kiosks/:id/fan dispatches via coordinator WS
2026-05-11 11:47:07 +02:00
Mitchell R
820e0a5945
fix(proxy): split Node-RED route surfaces
Route backend, kiosk ingest, kiosk dashboards, and public Node-RED HTTP-in separately. Keep Node-RED editor under admin auth and attach kiosk auth when kiosk loads protected dashboard URLs.
2026-05-11 10:44:45 +02:00
Mitchell R
96d7cc45ba
fix(deploy): require proxied local services
Bind native backend services and Node-RED to loopback so Angie remains the public auth boundary. Keep Docker on an internal compose network and stop kiosk fallback to a layout when display default is none.
2026-05-11 09:51:00 +02:00
Mitchell R
026325ccd0
feat(layout): add branded none cells
Migrate empty layout cells to an explicit none state so kiosk renders the BetterFrame placeholder instead of blank HTML.
2026-05-11 09:38:50 +02:00
Mitchell R
e38c92f753
fix(power): add monitor fallback checks 2026-05-11 08:55:42 +02:00
Mitchell R
0d9451ae95
feat(onvif): batch import discovered cameras 2026-05-11 08:40:25 +02:00
Mitchell R
bd20580f06
fix(layouts): anchor side action menu 2026-05-11 00:31:44 +02:00
Mitchell R
ab8eeb1d09
fix(layouts): push cells on expand 2026-05-11 00:29:33 +02:00
Mitchell R
02e57a5d54
fix(onvif): import profiles as streams 2026-05-11 00:20:48 +02:00
Mitchell R
3be1a9a624 feat: entities (unified content pool) + ONVIF discovery flow
Entities:
- New entities table — id, name, type (camera|html|web), camera_id,
  html_content, web_url
- Auto-create entity per camera on createCamera
- Layout cells reference entity_id (replaces inline content_type/
  camera_id/html_content/web_url)
- Bundle resolves entities back to legacy cell fields for kiosk compat
  (Rust kiosk unchanged)
- Full CRUD: /admin/entities, /admin/entities/new, /admin/entities/:id
- Cell editor: single entity dropdown with type badges

ONVIF discovery:
- /admin/cameras/discover — host/port/user/pass form
- Server queries ONVIF device, lists profiles with name/resolution/
  encoding/framerate
- "Add" creates camera + main stream from chosen profile
- shared/onvif.ts: minimal SOAP+UsernameToken+PasswordDigest client
  (no external dep)
- Camera new form simplified to RTSP-only with discover link
2026-05-10 23:18:44 +02:00
Mitchell R
f61c3db0e8 feat: Node-RED outbound bridge — forward kiosk events to Node-RED
- shared/nodered-bridge.ts: fire-and-forget POST to Node-RED HTTP-in
- api-http: kiosk event endpoint now forwards to Node-RED at /in/<topic>
- Best-effort, never blocks. 3s timeout, warn on failure.
- sec-config: noderedUrl on api-http (defaults to http://127.0.0.1:1880)

Node-RED flows can attach http-in nodes at /in/<topic> to receive
camera motion, GPIO events, etc. Inbound commands (Node-RED → server)
go through the admin API with admin Bearer token (no new endpoints
needed for v0.1).
2026-05-10 22:49:59 +02:00
Mitchell R
cbb1683c5d feat: deployment artifacts + CEC relay + auth-check endpoint
Deployment (deploy/):
- systemd units for server (system) and kiosk (user session)
- Angie/nginx proxy config — routes admin, api, ws, node-red
- Dockerfile + docker-compose for containerized deployment
- deploy/README.md with install instructions

Auth:
- /api/admin/_check endpoint for proxy auth_request subrequest
- Returns 200 if admin session valid, 401/403 otherwise
- Sets X-BetterFrame-User header for upstream

CEC (Pi5 HDMI control):
- kiosk/src/cec.rs wraps cec-ctl subprocess
- Standby/wake/active-source commands
- WS message types "standby" / "wake" dispatched to CEC
- Admin UI: Wake/Standby buttons on kiosk edit page
- Server sendToKiosk via coordinator
2026-05-10 22:45:56 +02:00
Mitchell R
766bf8dee0 feat: WebKit for web/html cells + display auto-discovery via heartbeat
Rust kiosk:
- web cells now use webkit6 WebView (load_uri)
- html cells use WebView.load_html (full HTML rendering)
- query_displays() reads /sys/class/drm/ for connected HDMI/DP outputs
- Heartbeat reports display geometry every 60s

Server:
- /api/kiosk/heartbeat accepts displays array
- Syncs kiosk-reported displays to display records
- Updates dimensions when changed, creates new displays for new ports
2026-05-10 22:39:53 +02:00
Mitchell R
722ddcfb12 feat: layout builder — resize cells + in-place htmx editing
UI improvements:
- Click cell → htmx swaps in edit form inside the cell (no page reload)
- Cancel re-fetches cell in read mode
- Save returns updated cell HTML, htmx swaps it
- Edit form includes Width/Height inputs for col_span/row_span
- Inline +W/-W/+H/-H buttons on each cell for quick resize
- Add (+) and delete (×) buttons also htmx — only the grid swaps

Routes:
- GET /admin/layouts/:id/cells/:cellId — cell fragment (read mode)
- GET /admin/layouts/:id/cells/:cellId/edit — cell fragment (edit mode)
- POST /admin/layouts/:id/cells/:cellId/resize — adjust span by delta
- All cell ops return fragment if hx-request header present, else 302

All mutations trigger notifyKiosks() — kiosks live-update via WS.
2026-05-10 22:31:37 +02:00
Mitchell R
374a2e091b fix: proper migration version tracking via user_version PRAGMA
- Migrations now run exactly once per DB lifetime, tracked via
  SQLite's user_version PRAGMA
- Re-runs become no-ops after schema reaches target version
- v0.2 also made defensive — skips if template_id already dropped

Fixes "no such column: layouts.template_id" on second startup after
v0.5 rebuild dropped the legacy columns.
2026-05-10 22:18:03 +02:00
Mitchell R
16ab165b06 feat: live updates via WebSocket — server pushes, kiosk reloads
Server side:
- service-coordinator-ws: full WS implementation using ws package
- Auth via ?token=<kiosk_key> query param
- Coordinator registry for cross-plugin notification
- Admin mutations call notifyKiosks() → server pushes reload-bundle
- 30s ping/pong heartbeat

Kiosk side:
- Rust ws_client with tokio runtime + tokio-tungstenite
- Auto-reconnect with exponential backoff (1s → 60s cap)
- On reload-bundle: re-fetches bundle, re-renders layout
- Pong replies to server pings

Also fix: auto-suffix kiosk name on UNIQUE collision (re-pair with
same hostname no longer fails).
2026-05-10 22:15:58 +02:00
Mitchell R
2398be6853 fix: drop legacy layouts.template_id/display_id columns via table rebuild
- Migration v0.5: rebuild layouts table without template_id, display_id,
  regions, grid_cols, grid_rows, is_default
- Migration v0.6: rebuild layout_cells table without region_name
- Migration v0.7: drop layout_templates table entirely (concept removed)
- createLayout simplified to clean column set (no sentinel values)
- createLayoutCell simplified (no region_name placeholder)
- Removed all layout_template repo methods (dead code)

Answers user question "why template_id/display_id in template": SQLite
can't drop columns without rebuilding the table. Now done properly.
2026-05-10 22:03:32 +02:00