feat: Backend mit OIDC-Anmeldung, Apps und Einstellungen im Server

Aus der statischen Seite wird ein Go-Binary mit eingebetteter Oberfläche.
Grund: Apps über .env zu pflegen wurde unübersichtlich, und kosmetische
Einstellungen sollen auf jedem Gerät gleich sein.

- Anmeldung über den eigenen OIDC-Anbieter (PocketID), mit PKCE. Ohne
  Anmeldung gibt es nichts zu sehen – auch keine App-Liste.
- Sitzung als signiertes Cookie ohne Gegenstück im Server; Schreibzugriffe
  zusätzlich über ein lesbares CSRF-Cookie abgesichert
- Apps werden in der Oberfläche angelegt und bearbeitet, nicht mehr in der
  .env. Der Server prüft jeden Eintrag und meldet zurück, was er behält.
- Einstellungen liegen je Nutzer im Server; localStorage ist nur noch der
  Vorschuss, damit die Röhre beim Laden nicht in der falschen Farbe blitzt
- die Leiste holt die App-Liste aus /api/apps – freigegeben sind allein die
  Ursprünge, die selbst als App eingetragen sind
- Tests fahren einen echten OIDC-Anbieter hoch und gehen den Anmeldeweg
  einmal ganz durch

Ohne HONEYOS_OIDC_ISSUER läuft alles offen – für die Entwicklung, mit
deutlicher Warnung beim Start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 15:20:55 +02:00
1 parent 84cc5d619e
commit 283f9a1850
20 files changed
+1831 -438

No files matched your search

+33 -75
View File
@@ -1,88 +1,46 @@
# ─────────────────────────────────────────────────────────────────────────────
# HoneyOS — Konfiguration
# HoneyOS
#
# cp .env.example .env && anpassen
# cp .env.example .env && ausfüllen
# docker compose pull && docker compose up -d
#
# Alles hier landet beim Containerstart in config.json und wird vom Browser
# gelesen. Das heißt: **es ist öffentlich.** Adressen und Namen gehören hier
# hinein, Geheimnisse niemals — HoneyOS hat kein Backend, das eines hüten
# könnte. Nach jeder Änderung: docker compose up -d (Neustart genügt).
# Apps werden NICHT hier eingetragen — die legst du in der Oberfläche an
# (Einstellungen → Programme). Sie liegen im Server unter /data/honeyos.json.
# ─────────────────────────────────────────────────────────────────────────────
HONEYOS_PORT=8088
# Anzeigename, solange keine App einen besseren nennt (siehe _ME weiter unten).
HONEYOS_USER=marc
HONEYOS_HOSTNAME=honeyos
HONEYOS_VERSION=0.1
# Wo HoneyOS von außen erreichbar ist — ohne Schrägstrich am Ende.
# Daraus entsteht die Rückrufadresse: <PUBLIC_URL>/api/auth/callback
HONEYOS_PUBLIC_URL=https://os.honeyy.duckdns.org
# ── Apps ─────────────────────────────────────────────────────────────────────
# Durchnummeriert ab 1. Eine App entsteht durch ihre _URL — fehlt die, wird die
# Nummer übersprungen. Bis zu 12 Stück.
#
# _URL Pflicht. Wohin der Start springt.
# _ID Kurzname, klein. Muss zu data-app der Leiste passen. Standard: appN
# _NAME Beschriftung. Standard: die ID in Großbuchstaben
# _SUBTITLE Zeile darunter auf der Kachel
# _GLYPH Symbol: cloud | note | box | chip | folder | gear
# _KEY Zifferntaste zum Starten im Terminal-Modus
# _ME optional, siehe „Anmeldung" unten
# _TOKEN_KEY optional, siehe „Anmeldung" unten
# Unterschreibt das Sitzungscookie. Mindestens 32 Zeichen.
# openssl rand -hex 32
# Wird der Wert getauscht, sind alle Anmeldungen weg — sonst passiert nichts.
HONEYOS_SESSION_SECRET=
HONEYOS_APP_1_ID=aether
HONEYOS_APP_1_NAME=AETHER
HONEYOS_APP_1_SUBTITLE=Cloud & Dateien
HONEYOS_APP_1_URL=https://cloud.honeyy.duckdns.org
HONEYOS_APP_1_GLYPH=cloud
HONEYOS_APP_1_KEY=1
HONEYOS_APP_1_ME=/api/v1/auth/me
# Wo die Daten liegen (Apps + Einstellungen je Nutzer, eine JSON-Datei).
# Das ist das Einzige, was gesichert werden muss.
HONEYOS_DATA_DIR=/srv/honeyos
HONEYOS_APP_2_ID=inknote
HONEYOS_APP_2_NAME=INKNOTE
HONEYOS_APP_2_SUBTITLE=Notizen & Zeichnen
HONEYOS_APP_2_URL=https://notes.honeyy.duckdns.org
HONEYOS_APP_2_GLYPH=note
HONEYOS_APP_2_KEY=2
HONEYOS_APP_2_ME=/api/auth/me
HONEYOS_APP_2_TOKEN_KEY=inknote_token
# ── Anmeldung ────────────────────────────────────────────────────────────────
# Leerer HONEYOS_OIDC_ISSUER = KEINE Anmeldung, jeder kommt rein. Das ist zum
# Entwickeln gedacht und gehört nicht ins offene Netz.
#
# In PocketID einen Client anlegen und dort als Rückrufadresse exakt
# https://os.honeyy.duckdns.org/api/auth/callback
# eintragen. HoneyOS schickt PKCE mit, ein Client-Geheimnis braucht es
# trotzdem — also keinen „public client" anlegen.
HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org
HONEYOS_OIDC_CLIENT_ID=honeyos
HONEYOS_OIDC_CLIENT_SECRET=
# Nur nötig, wenn die Rückrufadresse von PUBLIC_URL abweicht:
# HONEYOS_OIDC_REDIRECT_URL=
# HONEYOS_APP_3_ID=paperwall
# HONEYOS_APP_3_URL=https://…
# HONEYOS_APP_3_GLYPH=box
# HONEYOS_APP_3_KEY=3
# Beschriftung des einen Knopfes auf dem Anmeldeschirm.
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
# ─────────────────────────────────────────────────────────────────────────────
# Anmeldung — was HoneyOS kann und was nicht
# ─────────────────────────────────────────────────────────────────────────────
#
# HoneyOS meldet NIEMANDEN an. Es ist eine statische Seite; ein OIDC-Client
# bräuchte ein Backend und ein Client-Geheimnis, und beides gibt es hier nicht
# — absichtlich. Die Anmeldung bleibt Sache von Aether und InkNote, die beide
# an PocketID hängen.
#
# Was HoneyOS kann: **anzeigen**, wer angemeldet ist. Dafür ist _ME da — der
# Pfad, unter dem die App „wer bin ich" beantwortet. Relativ zu _URL oder
# absolut. Aus der Antwort wird der Anzeigename gefischt (display_name,
# displayName, name, username), verschachtelt unter `user` wird auch gefunden.
#
# Zwei Wege, wie das gelesen wird — und sie verhalten sich unterschiedlich:
#
# 1. Die Leiste IN der App (honeybar.js) fragt ihre eigene App.
# Gleicher Ursprung, also **funktioniert das sofort**, ohne dass an Aether
# oder InkNote etwas geändert werden muss.
#
# 2. Der Desktop auf os.… fragt über die Domaingrenze.
# Dafür muss die App diesen Ursprung erlauben, sonst blockt der Browser:
# InkNote: ALLOWED_ORIGINS=https://os.honeyy.duckdns.org
# Aether: CORS mit Access-Control-Allow-Credentials — noch nicht drin
# Ohne das bleibt die Anzeige einfach leer. Nichts geht kaputt, die
# Lampe „online" funktioniert weiter.
#
# _TOKEN_KEY brauchen nur Apps, die einen Bearer-Token im localStorage halten
# statt eines Cookies (InkNote tut das). Der Token ist pro Domain sichtbar —
# von os.… aus also NICHT lesbar. Für die Leiste in der App reicht es.
#
# Und wichtig: alle Adressen müssen unter derselben Domain liegen
# (…​.honeyy.duckdns.org). Sonst behandelt der Browser sie als fremde Site und
# schickt die Sitzung gar nicht erst mit.
# Zweiter Riegel neben den Gruppen in PocketID: nur diese Konten dürfen rein
# (Benutzername oder sub, kommagetrennt). Leer = jeder, den der Anbieter
# durchlässt.
# HONEYOS_ALLOWED_USERS=marc
+1
View File
@@ -1,3 +1,4 @@
devdata/
.env
*.tmp
shots/
+22 -11
View File
@@ -1,20 +1,31 @@
# HoneyOS — statisch. Kein Build, kein Backend: nginx liefert web/ aus.
#
# Die Konfiguration entsteht beim Start aus der .env — das Skript in
# /docker-entrypoint.d/ schreibt config.json, bevor nginx hochkommt.
FROM nginx:1.27-alpine
# HoneyOS — ein Binary, die Oberfläche steckt per embed mit drin.
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
COPY web/ ./web/
RUN CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o /honeyos .
FROM alpine:3.20
LABEL org.opencontainers.image.title="HoneyOS" \
org.opencontainers.image.description="Retro-Schreibtisch und Umschalter für die eigenen Apps" \
org.opencontainers.image.source="https://gitea.honeyy.duckdns.org/Docker/HoneyOS"
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.d/20-honeyos-config.sh /docker-entrypoint.d/20-honeyos-config.sh
COPY web/ /usr/share/nginx/html/
# ca-certificates: ohne die scheitert die Anfrage an den OIDC-Anbieter über https.
RUN apk add --no-cache ca-certificates wget && \
adduser -D -u 1000 honeyos && \
mkdir -p /data && chown honeyos:honeyos /data
RUN chmod +x /docker-entrypoint.d/20-honeyos-config.sh
COPY --from=build /honeyos /usr/local/bin/honeyos
EXPOSE 80
USER honeyos
VOLUME /data
EXPOSE 8080
ENV HONEYOS_ADDR=:8080 HONEYOS_DATA_DIR=/data
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1/healthz || exit 1
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/honeyos"]
+92 -76
View File
@@ -1,55 +1,80 @@
# HoneyOS
Ein Retro-Schreibtisch vor den eigenen Apps. Amber-CRT, Boot-Log, zwei Betriebsarten —
und dieselbe Leiste zum Einhängen in Aether und InkNote.
Ein Retro-Schreibtisch vor den eigenen Apps. Amber-CRT, Boot-Log, zwei Betriebsarten,
Anmeldung über den eigenen OIDC-Anbieter — und dieselbe Leiste zum Einhängen in die Apps.
**Was es ist:** eine Oberfläche davor. Jede App bleibt ihre eigene PWA auf ihrer eigenen
Domain mit ihrer eigenen Arbeitsumgebung und ihrer eigenen Anmeldung. HoneyOS ist der
Startpunkt, der Umschalter und die gemeinsame Leiste — **kein Rahmen, der die Apps
einsperrt** (kein iframe).
Domain mit ihrer eigenen Anmeldung. HoneyOS ist der Startpunkt, der Umschalter und die
gemeinsame Leiste — **kein Rahmen, der die Apps einsperrt** (kein iframe).
**Was es nicht ist:** eine Anmeldung. HoneyOS ist eine statische Seite ohne Backend; ein
OIDC-Client bräuchte ein Client-Geheimnis und eine Sitzung, und beides gehört nicht in
etwas, das der Browser vollständig lesen kann. Angemeldet wird bei Aether und InkNote,
die beide an PocketID hängen. HoneyOS kann den Stand nur **anzeigen** — siehe unten.
**Wo der Zustand liegt:** im Server. Die App-Liste gilt für alle, die Einstellungen gehören
je Nutzer — deshalb sieht dein HoneyOS auf jedem Gerät gleich aus, an dem du angemeldet bist.
Der Browser hält dieselben Einstellungen nur als Vorschuss, damit die Röhre beim Laden nicht
in der falschen Farbe aufflackert.
## Betrieb
```bash
cp .env.example .env # Adressen und Namen eintragen
mkdir -p /srv/honeyos && chown 1000:1000 /srv/honeyos
cp .env.example .env # PUBLIC_URL, SESSION_SECRET, OIDC-Zugang
docker login gitea.honeyy.duckdns.org
docker compose pull && docker compose up -d
```
Es gibt nichts zu sichern: keine Datenbank, kein Volume. Der ganze Zustand ist die `.env` —
und was sich der Browser pro Gerät merkt (Betriebsart, Röhrenfarbe).
Zu sichern ist genau eine Datei: `${HONEYOS_DATA_DIR}/honeyos.json`. Darin stehen die Apps
und die Einstellungen je Nutzer. Sichere sie zusammen mit der `.env` — ohne
`HONEYOS_SESSION_SECRET` müssen sich alle neu anmelden, mehr passiert aber nicht.
Hinter deinem Reverse Proxy erwartet HoneyOS nichts Besonderes: `proxy_pass` auf den Port,
fertig. `/healthz` antwortet mit `ok`.
`/healthz` antwortet mit `ok`. Hinter deinem Reverse Proxy braucht es nichts Besonderes,
außer dass `X-Forwarded-Proto: https` durchkommt — sonst setzt HoneyOS das Sitzungscookie
ohne `Secure`.
## Konfiguration
## Anmeldung
Alles kommt aus der `.env`. Beim Containerstart schreibt
[`docker-entrypoint.d/20-honeyos-config.sh`](docker-entrypoint.d/20-honeyos-config.sh)
daraus `config.json`, die der Browser liest. Nach jeder Änderung `docker compose up -d`.
In PocketID einen Client anlegen und dort als Rückrufadresse **exakt** eintragen:
**Alles darin ist öffentlich.** Adressen und Namen gehören hinein, Geheimnisse niemals.
Eine App entsteht durch ihre `_URL`; fehlt die, wird die Nummer übersprungen (bis 12):
```dotenv
HONEYOS_APP_1_ID=aether
HONEYOS_APP_1_NAME=AETHER
HONEYOS_APP_1_SUBTITLE=Cloud & Dateien
HONEYOS_APP_1_URL=https://cloud.honeyy.duckdns.org
HONEYOS_APP_1_GLYPH=cloud # cloud | note | box | chip | folder | gear
HONEYOS_APP_1_KEY=1 # Zifferntaste im Terminal-Modus
HONEYOS_APP_1_ME=/api/v1/auth/me # optional, siehe „Wer ist angemeldet"
```
https://os.honeyy.duckdns.org/api/auth/callback
```
Ohne `HONEYOS_APP_1_URL` bleibt die eingebackene `web/config.json` gültig — ein
`docker run` ohne `.env` zeigt also die Standard-Apps. Adressen lassen sich zusätzlich im
Browser unter *Einstellungen* überschreiben; das gilt dann nur für dieses Gerät.
HoneyOS schickt PKCE mit, braucht aber trotzdem ein Client-Geheimnis — also **keinen
„public client"** anlegen. Danach in die `.env`:
```dotenv
HONEYOS_PUBLIC_URL=https://os.honeyy.duckdns.org
HONEYOS_SESSION_SECRET=… # openssl rand -hex 32
HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org
HONEYOS_OIDC_CLIENT_ID=honeyos
HONEYOS_OIDC_CLIENT_SECRET=…
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
# HONEYOS_ALLOWED_USERS=marc # zweiter Riegel neben den PocketID-Gruppen
```
Wer nicht angemeldet ist, sieht **nur** den Anmeldeschirm — keine App-Liste, keine Namen.
Die Sitzung ist ein signiertes Cookie ohne Gegenstück auf dem Server: ein Neustart wirft
niemanden hinaus, und es gibt nichts aufzuräumen. Sie hält 30 Tage.
**Ohne `HONEYOS_OIDC_ISSUER` läuft HoneyOS ganz ohne Anmeldung.** Das ist zum Entwickeln
gedacht und sagt beim Start deutlich, dass es so nicht ins offene Netz gehört.
## Apps anlegen
**In der Oberfläche**, nicht in der `.env`: *Einstellungen → Programme* oder die Kachel
„+ app hinzufügen". Pro App:
| Feld | wofür |
|---|---|
| Kennung | klein, ohne Leerzeichen — muss zu `data-app` der Leiste passen |
| Name, Untertitel | Beschriftung auf Kachel und Symbol |
| Adresse | wohin der Start springt |
| Symbol | cloud · note · box · chip · folder · gear · globe · disk |
| Taste | Zifferntaste im Terminal-Modus |
| Wer-bin-ich | optional, siehe unten |
| Token-Schlüssel | nur für Apps mit Bearer-Token im localStorage statt Cookie |
Gespeichert wird die Liste als Ganzes; der Server prüft sie noch einmal und antwortet mit
dem, was er wirklich behalten hat. Eine Adresse ohne `https://` kommt als Fehlermeldung
zurück, nicht als kaputter Eintrag.
## Zwei Betriebsarten, ein Datenstand
@@ -59,11 +84,8 @@ Browser unter *Einstellungen* überschreiben; das gilt dann nur für dieses Ger
| Fenster | Dialog in der Mitte | verschiebbar, minimierbar |
| unten | Statuszeile mit Tastenhinweisen | Taskleiste mit Startmenü |
Umschalten mit **`M`**, über *Einstellungen → Betriebsart* oder im Startmenü.
Gleich bleiben in beiden: die Menüleiste oben, die Palette, die Einstellungen, `config.json`.
`renderTiles()` und `renderDeskIcons()` in `os.js` zeichnen aus denselben Daten — eine neue
App erscheint ohne Zutun in beiden.
Umschalten mit **`M`**, über *Einstellungen → Betriebsart* oder im Startmenü. Gleich bleiben
in beiden: Menüleiste, Palette, Einstellungen, App-Liste.
**Tasten:** `1` `2` App starten · `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
`Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge.
@@ -77,8 +99,10 @@ Eine Zeile ins `frontend/index.html` der App:
data-app="aether"></script>
```
`data-app` ist die `id` aus der Konfiguration — danach markiert sich die Leiste selbst als
aktiv. Die App-Liste holt sie von allein neben ihrem eigenen Skript.
`data-app` ist die Kennung aus der App-Liste. Die Liste holt sich die Leiste selbst von
`/api/apps` — mit deiner HoneyOS-Sitzung, weil alle Adressen unter derselben Domain liegen.
**Freigegeben sind nur die Ursprünge, die selbst als App eingetragen sind**: die App-Liste
ist zugleich ihre eigene CORS-Freigabe.
Sie rendert in einem **Shadow DOM**: Tailwind (Aether) und die InkNote-Palette kommen nicht
hinein, ihre Stile nicht hinaus. Sie schiebt den Seiteninhalt um 32 px nach unten
@@ -90,11 +114,11 @@ hinein, ihre Stile nicht hinaus. Sie schiebt den Seiteninhalt um 32 px nach unte
Vorschau ohne Einbau: `/honeybar/demo.html`.
## Wer ist angemeldet
## Wer ist wo angemeldet
`_ME` ist der Pfad, unter dem eine App „wer bin ich" beantwortet (relativ zur `_URL` oder
absolut). Aus der Antwort wird der Anzeigename gefischt — `display_name`, `displayName`,
`name`, `username`, auch verschachtelt unter `user`.
Das Feld **Wer-bin-ich** ist der Pfad, unter dem eine App „wer bin ich" beantwortet
(relativ zur Adresse oder absolut). Aus der Antwort wird der Anzeigename gefischt —
`display_name`, `displayName`, `name`, `username`, auch verschachtelt unter `user`.
Gelesen wird das an zwei Stellen, und die verhalten sich **unterschiedlich**:
@@ -105,38 +129,32 @@ Gelesen wird das an zwei Stellen, und die verhalten sich **unterschiedlich**:
- InkNote: `ALLOWED_ORIGINS=https://os.honeyy.duckdns.org`
- Aether: CORS mit `Access-Control-Allow-Credentials` — noch nicht vorhanden
Fehlt das, bleibt die Anzeige leer und sonst passiert nichts. Die Lampe „online"
funktioniert unabhängig davon weiter.
Fehlt das, bleibt die Anzeige leer und sonst passiert nichts.
`_TOKEN_KEY` brauchen nur Apps, die einen Bearer-Token im localStorage halten statt eines
Cookies (InkNote: `inknote_token`). Der ist pro Domain sichtbar — von `os.…` aus also nicht
lesbar, für die Leiste in der App dagegen schon.
**Voraussetzung für all das:** alle Adressen liegen unter derselben Domain
(`*.honeyy.duckdns.org`). Sonst behandelt der Browser sie als fremde Site und schickt die
Sitzung gar nicht erst mit.
**Voraussetzung:** alle Adressen unter derselben Domain (`*.honeyy.duckdns.org`). Sonst
behandelt der Browser sie als fremde Site und schickt die Sitzung gar nicht erst mit.
## Entwickeln
```bash
docker compose -f docker-compose.dev.yml up -d --build # → http://localhost:8088
go test ./... # Anmeldeablauf, CSRF, Prüfungen
```
`web/` ist gemountet: Datei ändern, Seite neu laden. Weil der Mount schreibgeschützt ist,
greift dort die `.env`-Konfiguration **nicht** — es gilt die eingebackene `web/config.json`.
Zum Prüfen der `.env` das echte Image nehmen (`docker compose up -d --build`).
Der Dev-Betrieb läuft **ohne Anmeldung**. Die Oberfläche steckt per `go:embed` im Binary —
nach Änderungen an `web/` also neu bauen, ein Reload allein genügt nicht.
```
web/
index.html Desktop
style.css alle Farben in den --p*-Variablen ganz oben
os.js Boot, beide Ansichten, Fenster, Einstellungen
config.json Standardkonfiguration (wird zur Laufzeit aus der .env ersetzt)
honeybar/ die einbettbare Leiste + Vorschauseite
looks/ zwei verworfene Entwürfe (System 7, Win95) — nur Ansicht
main.go Konfiguration, Routen, statische Auslieferung
auth.go OIDC mit PKCE, Sitzungscookie, CSRF
store.go die JSON-Datei: Apps, Nutzer, Einstellungen
auth_test.go falscher OIDC-Anbieter — der ganze Anmeldeweg im Test
web/ index.html · style.css · os.js · honeybar/ · looks/
```
Neue Symbole sind 12×12-Pixelraster in `os.js` (`GLYPHS`).
Die Tests fahren einen echten OIDC-Anbieter mit eigenem Schlüssel hoch und gehen den Weg
einmal ganz durch. Wenn die grün sind, liegt ein Anmeldeproblem am Anbieter oder an der
Konfiguration, nicht am Code.
## Bauen und schieben
@@ -147,18 +165,16 @@ Neue Symbole sind 12×12-Pixelraster in `os.js` (`GLYPHS`).
## Grenzen, die man kennen sollte
- **localStorage ist pro Domain.** Betriebsart und Röhrenfarbe auf `os.…` kennt die Leiste
auf `notes.…` nicht. Der Umschalter ist ein Schalter, kein geteilter Zustand.
- **Die Lampe ist kein Health-Check.** Bei einer fremden Domain darf der Browser nur sehen,
*dass* geantwortet wurde, nicht *was*. Ein 500er sähe aus wie „online".
- **`config.json` und `honeybar.js` gehen mit `Access-Control-Allow-Origin: *` raus**, weil
die anderen Domains sie holen. Öffentliche Konfiguration — nichts Vertrauliches hinein.
- **Die Lampe an der Kachel ist kein Health-Check.** Bei einer fremden Domain darf der
Browser nur sehen, *dass* geantwortet wurde. Ein 500er sähe aus wie „online".
- **Die App-Liste ist für alle gleich.** Wer angemeldet ist, darf sie ändern — das ist für
eine Handvoll Konten gewollt und wäre für mehr Leute zu wenig.
- **Ein Angemeldeter kann jede Adresse eintragen.** Der Server prüft nur, dass es eine
http(s)-Adresse ist. Das ist zugleich die CORS-Freigabe der Leiste, also nicht
gedankenlos etwas Fremdes eintragen.
## Was als Nächstes ansteht
1. **CORS in Aether** für `os.…`, damit der Desktop den Anmeldestand sehen kann. InkNote
hat mit `ALLOWED_ORIGINS` schon den Haken dafür.
2. **Überall abmelden** in der Leiste. Braucht pro App den CSRF-Weg zum Logout — bei Aether
ein `POST` mit Token, das geht nicht nebenbei über die Domaingrenze.
3. **Fenster mit Inhalt** statt nur Verweisen: „letzte Dateien", „letzte Notizen". Fenster
zeigen dann Daten aus den APIs — die Apps selbst bleiben in ihrem eigenen Tab.
1. **CORS in Aether** für `os.…`, damit der Desktop den Anmeldestand sehen kann.
2. **Überall abmelden** in der Leiste — braucht pro App den CSRF-Weg zum Logout.
3. **Fenster mit Inhalt** statt nur Verweisen: „letzte Dateien", „letzte Notizen".
+296
View File
@@ -0,0 +1,296 @@
package main
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// Anmeldung: HoneyOS führt keine Nutzerverwaltung. Wer beim Anbieter durchkommt,
// ist drin — mit einer optionalen Namensliste als zweitem Riegel. Die Sitzung
// ist ein signiertes Cookie ohne Gegenstück auf dem Server: nichts zu putzen,
// nichts zu verlieren, ein Neustart wirft niemanden hinaus.
const (
sessionCookie = "honeyos_session"
csrfCookie = "honeyos_csrf"
stateCookie = "honeyos_state"
sessionTTL = 30 * 24 * time.Hour
)
type Identity struct {
Sub string
Name string
Picture string
}
type Auth struct {
cfg Config
secret []byte
once sync.Once
initErr error
provider *gooidc.Provider
oauth *oauth2.Config
verifier *gooidc.IDTokenVerifier
}
func NewAuth(cfg Config) *Auth {
return &Auth{cfg: cfg, secret: []byte(cfg.SessionSecret)}
}
func (a *Auth) Enabled() bool { return a.cfg.OIDCIssuer != "" }
// setup holt die Anbieterdaten beim ersten Bedarf statt beim Start. Ein
// Anbieter, der gerade neu startet, soll HoneyOS nicht mit hinunterreißen;
// beim nächsten Versuch geht es weiter.
func (a *Auth) setup(ctx context.Context) error {
a.once.Do(func() {
p, err := gooidc.NewProvider(ctx, a.cfg.OIDCIssuer)
if err != nil {
a.initErr = fmt.Errorf("OIDC-Anbieter %s: %w", a.cfg.OIDCIssuer, err)
return
}
a.provider = p
a.verifier = p.Verifier(&gooidc.Config{ClientID: a.cfg.OIDCClientID})
a.oauth = &oauth2.Config{
ClientID: a.cfg.OIDCClientID,
ClientSecret: a.cfg.OIDCClientSecret,
RedirectURL: a.cfg.RedirectURL(),
Endpoint: p.Endpoint(),
Scopes: []string{gooidc.ScopeOpenID, "profile"},
}
})
if a.initErr != nil {
// Nächster Aufruf darf es erneut versuchen.
a.once = sync.Once{}
err := a.initErr
a.initErr = nil
return err
}
return nil
}
/* ── Sitzungscookie ──────────────────────────────────────────────────────── */
func (a *Auth) sign(payload string) string {
m := hmac.New(sha256.New, a.secret)
m.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
func (a *Auth) issueSession(w http.ResponseWriter, r *http.Request, sub string) {
payload := base64.RawURLEncoding.EncodeToString([]byte(sub)) + "." +
strconv.FormatInt(time.Now().Add(sessionTTL).Unix(), 10)
setCookie(w, r, sessionCookie, payload+"."+a.sign(payload), int(sessionTTL.Seconds()), true)
// Zweites Cookie, absichtlich lesbar: der Browser schickt es als Kopfzeile
// zurück, und nur wer die Seite wirklich geöffnet hat, kann das.
tok := randomString(24)
setCookie(w, r, csrfCookie, tok, int(sessionTTL.Seconds()), false)
}
func (a *Auth) clearSession(w http.ResponseWriter, r *http.Request) {
setCookie(w, r, sessionCookie, "", -1, true)
setCookie(w, r, csrfCookie, "", -1, false)
}
// SubFromRequest liest die Sitzung. Zweiter Rückgabewert sagt, ob sie gilt.
func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
if !a.Enabled() {
return localSub, true // offener Betrieb, siehe Config.Open
}
c, err := r.Cookie(sessionCookie)
if err != nil {
return "", false
}
parts := strings.Split(c.Value, ".")
if len(parts) != 3 {
return "", false
}
payload := parts[0] + "." + parts[1]
if !hmac.Equal([]byte(a.sign(payload)), []byte(parts[2])) {
return "", false
}
exp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || time.Now().Unix() > exp {
return "", false
}
sub, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
return string(sub), true
}
func (a *Auth) checkCSRF(r *http.Request) error {
if !a.Enabled() {
return nil
}
c, err := r.Cookie(csrfCookie)
if err != nil || c.Value == "" {
return errors.New("kein CSRF-Cookie")
}
if r.Header.Get("X-CSRF-Token") != c.Value {
return errors.New("CSRF-Kopfzeile passt nicht")
}
return nil
}
/* ── Ablauf ──────────────────────────────────────────────────────────────── */
func (a *Auth) Login(w http.ResponseWriter, r *http.Request) {
if err := a.setup(r.Context()); err != nil {
log.Printf("anmeldung: %v", err)
http.Redirect(w, r, "/?fehler=anbieter", http.StatusFound)
return
}
state := randomString(16)
verifier := oauth2.GenerateVerifier()
// State und Prüfwort reisen zusammen im selben Cookie — sie gehören zum
// selben Versuch und laufen zusammen ab.
setCookie(w, r, stateCookie, state+"|"+verifier, 600, true)
http.Redirect(w, r, a.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound)
}
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
fail := func(reason string) {
setCookie(w, r, stateCookie, "", -1, true)
http.Redirect(w, r, "/?fehler="+reason, http.StatusFound)
}
if err := a.setup(r.Context()); err != nil {
log.Printf("rückruf: %v", err)
fail("anbieter")
return
}
c, err := r.Cookie(stateCookie)
if err != nil {
fail("abgelaufen")
return
}
saved := strings.SplitN(c.Value, "|", 2)
if len(saved) != 2 || r.URL.Query().Get("state") != saved[0] {
fail("state")
return
}
setCookie(w, r, stateCookie, "", -1, true)
code := r.URL.Query().Get("code")
if code == "" {
fail("kein_code")
return
}
token, err := a.oauth.Exchange(r.Context(), code, oauth2.VerifierOption(saved[1]))
if err != nil {
log.Printf("tausch fehlgeschlagen: %v", err)
fail("tausch")
return
}
rawID, ok := token.Extra("id_token").(string)
if !ok {
fail("kein_id_token")
return
}
idToken, err := a.verifier.Verify(r.Context(), rawID)
if err != nil {
log.Printf("id-token ungültig: %v", err)
fail("token")
return
}
var claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Picture string `json:"picture"`
}
if err := idToken.Claims(&claims); err != nil || claims.Sub == "" {
fail("claims")
return
}
name := firstNonEmpty(claims.Name, claims.PreferredUsername, claims.Sub)
if !a.allowed(claims.PreferredUsername, claims.Sub) {
log.Printf("anmeldung abgelehnt: %q steht nicht in HONEYOS_ALLOWED_USERS", name)
fail("nicht_erlaubt")
return
}
if err := store.SeenUser(claims.Sub, name, claims.Picture); err != nil {
log.Printf("konnte Nutzer nicht merken: %v", err)
}
a.issueSession(w, r, claims.Sub)
log.Printf("angemeldet: %s", name)
http.Redirect(w, r, "/", http.StatusFound)
}
func (a *Auth) allowed(username, sub string) bool {
if len(a.cfg.AllowedUsers) == 0 {
return true
}
for _, u := range a.cfg.AllowedUsers {
if strings.EqualFold(u, username) || u == sub {
return true
}
}
return false
}
/* ── Kleinkram ───────────────────────────────────────────────────────────── */
func setCookie(w http.ResponseWriter, r *http.Request, name, value string, maxAge int, httpOnly bool) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: httpOnly,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
}
// isHTTPS glaubt der Kopfzeile des Reverse Proxy — ohne sie steht hier immer
// http, und ein Secure-Cookie käme nie an.
func isHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func randomString(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// Ohne Zufall ist jede Sitzung wertlos — dann lieber laut sterben.
panic("kein Zufall verfügbar: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+388
View File
@@ -0,0 +1,388 @@
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// Ein Anbieter zum Anfassen: signiert echte RS256-Token und prüft PKCE. Damit
// lässt sich der ganze Anmeldeweg hier durchspielen, statt ihn erst in der
// Produktion gegen PocketID zu debuggen.
type fakeIdP struct {
*httptest.Server
key *rsa.PrivateKey
clientID string
lastChallenge string // was der Client als code_challenge geschickt hat
verifierSeen string // was er beim Einlösen als code_verifier nachreicht
}
func newFakeIdP(t *testing.T, clientID string) *fakeIdP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
f := &fakeIdP{key: key, clientID: clientID}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"issuer": f.URL,
"authorization_endpoint": f.URL + "/authorize",
"token_endpoint": f.URL + "/token",
"jwks_uri": f.URL + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
n := base64.RawURLEncoding.EncodeToString(key.N.Bytes())
e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537
writeJSON(w, 200, map[string]any{"keys": []map[string]string{
{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test", "n": n, "e": e},
}})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
f.verifierSeen = r.Form.Get("code_verifier")
writeJSON(w, 200, map[string]any{
"access_token": "at",
"token_type": "Bearer",
"id_token": f.idToken(t),
})
})
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Close)
return f
}
func (f *fakeIdP) idToken(t *testing.T) string {
t.Helper()
header := map[string]string{"alg": "RS256", "typ": "JWT", "kid": "test"}
claims := map[string]any{
"iss": f.URL,
"sub": "sub-123",
"aud": f.clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"name": "Marc Testfall",
"preferred_username": "marc",
"picture": "https://example.invalid/avatar.png",
}
seg := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
signing := seg(header) + "." + seg(claims)
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:])
if err != nil {
t.Fatal(err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
func newTestServer(t *testing.T, issuer string) *Server {
t.Helper()
store, err := OpenStore(dataPath(t.TempDir()))
if err != nil {
t.Fatal(err)
}
cfg := Config{
PublicURL: "https://os.example.test",
SessionSecret: strings.Repeat("k", 40),
OIDCIssuer: issuer,
OIDCClientID: "honeyos",
OIDCClientSecret: "geheim",
}
return &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
}
// TestAnmeldeablauf geht den Weg einmal ganz durch: Umleitung zum Anbieter,
// Rückruf mit Code, Sitzungscookie, geschützter Endpunkt.
func TestAnmeldeablauf(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
// 1. Umleitung zum Anbieter — mit State und PKCE
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/auth/login", nil))
if rec.Code != http.StatusFound {
t.Fatalf("Umleitung erwartet, bekam %d: %s", rec.Code, rec.Body)
}
loc, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
q := loc.Query()
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Errorf("PKCE fehlt: %v", q)
}
if q.Get("redirect_uri") != "https://os.example.test/api/auth/callback" {
t.Errorf("falsche Rückrufadresse: %q", q.Get("redirect_uri"))
}
state := q.Get("state")
var stateCk *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == stateCookie {
stateCk = c
}
}
if stateCk == nil {
t.Fatal("kein State-Cookie gesetzt")
}
// 2. Rückruf mit Code
req := httptest.NewRequest("GET", "/api/auth/callback?code=abc&state="+state, nil)
req.AddCookie(stateCk)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Fatalf("Rückruf scheiterte: %d → %s", rec.Code, rec.Header().Get("Location"))
}
if idp.verifierSeen == "" {
t.Error("der Client hat kein code_verifier nachgereicht")
}
var sess, csrf *http.Cookie
for _, c := range rec.Result().Cookies() {
switch c.Name {
case sessionCookie:
sess = c
case csrfCookie:
csrf = c
}
}
if sess == nil || csrf == nil {
t.Fatal("Sitzungs- oder CSRF-Cookie fehlt")
}
if !sess.HttpOnly {
t.Error("Sitzungscookie muss HttpOnly sein")
}
if csrf.HttpOnly {
t.Error("CSRF-Cookie muss lesbar sein, sonst kann die Seite es nicht mitschicken")
}
// 3. Geschützter Endpunkt kennt den Nutzer
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(sess)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("/api/me: %d", rec.Code)
}
var me struct{ Sub, Name, Picture string }
_ = json.Unmarshal(rec.Body.Bytes(), &me)
if me.Sub != "sub-123" || me.Name != "Marc Testfall" {
t.Errorf("falscher Nutzer: %+v", me)
}
if me.Picture == "" {
t.Error("Bild aus dem Token wurde nicht übernommen")
}
}
func TestOhneAnmeldungKeineDaten(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
h := newTestServer(t, idp.URL).routes()
for _, path := range []string{"/api/me", "/api/apps"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s ohne Anmeldung: %d, erwartet 401", path, rec.Code)
}
}
}
func TestGefälschtesCookieZähltNicht(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
h := newTestServer(t, idp.URL).routes()
req := httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "c3ViLTk5OQ.9999999999.gefälscht"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("selbstgebautes Cookie wurde akzeptiert: %d", rec.Code)
}
}
// TestSchreibenBrauchtCSRF: das Sitzungscookie allein darf nicht reichen,
// sonst könnte eine fremde Seite im Namen des Angemeldeten schreiben.
func TestSchreibenBrauchtCSRF(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
sess, csrf := signIn(t, h)
body := `{"mode":"desktop"}`
req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body))
req.AddCookie(sess)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("ohne CSRF-Kopfzeile: %d, erwartet 403", rec.Code)
}
req = httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body))
req.AddCookie(sess)
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("mit CSRF-Kopfzeile: %d — %s", rec.Code, rec.Body)
}
// und die Einstellung überlebt
if got := s.store.User("sub-123").Settings["mode"]; got != "desktop" {
t.Errorf("Einstellung nicht gespeichert: %q", got)
}
}
func TestAppsAnlegenUndPrüfen(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
sess, csrf := signIn(t, h)
put := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(body))
req.AddCookie(sess)
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
if rec := put(`[{"id":"aether","name":"AETHER","url":"https://cloud.example.test"}]`); rec.Code != 200 {
t.Fatalf("gültige App abgelehnt: %d — %s", rec.Code, rec.Body)
}
if apps := s.store.Apps(); len(apps) != 1 || apps[0].ID != "aether" {
t.Fatalf("nicht gespeichert: %+v", apps)
}
schlecht := []struct{ name, body string }{
{"leere Adresse", `[{"id":"x","url":""}]`},
{"Adresse ohne Schema", `[{"id":"x","url":"cloud.example.test"}]`},
{"javascript:", `[{"id":"x","url":"javascript:alert(1)"}]`},
{"Kennung mit Leerzeichen", `[{"id":"mein app","url":"https://a.test"}]`},
{"Kennung doppelt", `[{"id":"a","url":"https://a.test"},{"id":"a","url":"https://b.test"}]`},
}
for _, c := range schlecht {
if rec := put(c.body); rec.Code != http.StatusBadRequest {
t.Errorf("%s wurde angenommen (%d)", c.name, rec.Code)
}
}
// nach allen Fehlversuchen steht die gültige Liste unverändert da
if apps := s.store.Apps(); len(apps) != 1 {
t.Errorf("Liste beschädigt: %+v", apps)
}
}
// TestOffenerBetrieb: ohne OIDC_ISSUER läuft alles ohne Anmeldung — das ist
// gewollt, muss aber auch wirklich funktionieren.
func TestOffenerBetrieb(t *testing.T) {
s := newTestServer(t, "")
h := s.routes()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil))
if rec.Code != 200 {
t.Fatalf("offener Betrieb verweigert: %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("PUT", "/api/settings", strings.NewReader(`{"phosphor":"green"}`)))
if rec.Code != http.StatusNoContent {
t.Fatalf("Schreiben im offenen Betrieb: %d", rec.Code)
}
if s.store.User(localSub).Settings["phosphor"] != "green" {
t.Error("Einstellung im offenen Betrieb nicht gespeichert")
}
}
// TestLeisteNurFürEingetrageneApps: die App-Liste ist zugleich die
// CORS-Freigabe. Wer nicht drinsteht, bekommt keine Kopfzeile.
func TestLeisteNurFürEingetrageneApps(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
if err := s.store.SetApps([]App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test/"}}); err != nil {
t.Fatal(err)
}
for origin, want := range map[string]string{
"https://cloud.example.test": "https://cloud.example.test",
"https://boese.example.test": "",
} {
req := httptest.NewRequest("GET", "/api/apps", nil)
req.Header.Set("Origin", origin)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != want {
t.Errorf("Origin %s: Freigabe %q, erwartet %q", origin, got, want)
}
}
}
func TestStatischeOberflächeWirdAusgeliefert(t *testing.T) {
h := newTestServer(t, "").routes()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "HoneyOS") {
t.Fatalf("Startseite fehlt: %d", rec.Code)
}
}
// signIn spielt eine Anmeldung durch und gibt die beiden Cookies zurück.
func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/auth/login", nil))
loc, _ := url.Parse(rec.Header().Get("Location"))
var stateCk *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == stateCookie {
stateCk = c
}
}
if stateCk == nil {
t.Fatal("kein State-Cookie")
}
req := httptest.NewRequest("GET", fmt.Sprintf("/api/auth/callback?code=abc&state=%s", loc.Query().Get("state")), nil)
req.AddCookie(stateCk)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
for _, c := range rec.Result().Cookies() {
switch c.Name {
case sessionCookie:
sess = c
case csrfCookie:
csrf = c
}
}
if sess == nil || csrf == nil {
t.Fatalf("Anmeldung fehlgeschlagen: %d", rec.Code)
}
return sess, csrf
}
+10 -4
View File
@@ -1,11 +1,17 @@
# Entwicklung: web/ wird hineingemountet, ein Reload im Browser reicht.
# Entwicklung: baut aus dem Quellstand und läuft OHNE Anmeldung.
#
# docker compose -f docker-compose.dev.yml up -d --build → http://localhost:8088
#
# Die Oberfläche steckt im Binary (go:embed), ein Reload im Browser reicht
# also nicht — nach Änderungen an web/ neu bauen.
services:
honeyos:
build: .
image: honeyos:dev
container_name: honeyos-dev
ports:
- "${HONEYOS_PORT:-8088}:80"
- "8088:8080"
volumes:
- ./web:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./devdata:/data
environment:
- HONEYOS_ADDR=:8080
+17 -44
View File
@@ -1,13 +1,14 @@
# ─────────────────────────────────────────────
# HoneyOS — Betrieb aus der Gitea-Registry
#
# cp .env.example .env && anpassen
# mkdir -p /srv/honeyos && chown 1000:1000 /srv/honeyos
# cp .env.example .env && ausfüllen
# docker login gitea.honeyy.duckdns.org
# docker compose pull && docker compose up -d
#
# Es gibt nichts zu sichern: HoneyOS hat keine Datenbank und kein Volume.
# Der ganze Zustand ist die .env — und was der Browser sich pro Gerät in
# localStorage merkt (Betriebsart, Röhre).
# Zu sichern ist genau eine Datei: ${HONEYOS_DATA_DIR}/honeyos.json — darin
# stehen die Apps und die Einstellungen je Nutzer. Sichere sie zusammen mit
# der .env; ohne HONEYOS_SESSION_SECRET müssen sich alle neu anmelden.
# ─────────────────────────────────────────────
services:
@@ -15,45 +16,17 @@ services:
image: gitea.honeyy.duckdns.org/docker/honeyos:latest
container_name: honeyos
restart: unless-stopped
user: "1000:1000"
ports:
- "${HONEYOS_PORT:-8088}:80"
- "${HONEYOS_PORT:-8088}:8080"
volumes:
- ${HONEYOS_DATA_DIR:-/srv/honeyos}:/data
environment:
- HONEYOS_USER=${HONEYOS_USER:-}
- HONEYOS_HOSTNAME=${HONEYOS_HOSTNAME:-honeyos}
- HONEYOS_VERSION=${HONEYOS_VERSION:-0.1}
- HONEYOS_APP_1_ID=${HONEYOS_APP_1_ID:-}
- HONEYOS_APP_1_NAME=${HONEYOS_APP_1_NAME:-}
- HONEYOS_APP_1_SUBTITLE=${HONEYOS_APP_1_SUBTITLE:-}
- HONEYOS_APP_1_URL=${HONEYOS_APP_1_URL:-}
- HONEYOS_APP_1_GLYPH=${HONEYOS_APP_1_GLYPH:-}
- HONEYOS_APP_1_KEY=${HONEYOS_APP_1_KEY:-}
- HONEYOS_APP_1_ME=${HONEYOS_APP_1_ME:-}
- HONEYOS_APP_1_TOKEN_KEY=${HONEYOS_APP_1_TOKEN_KEY:-}
- HONEYOS_APP_2_ID=${HONEYOS_APP_2_ID:-}
- HONEYOS_APP_2_NAME=${HONEYOS_APP_2_NAME:-}
- HONEYOS_APP_2_SUBTITLE=${HONEYOS_APP_2_SUBTITLE:-}
- HONEYOS_APP_2_URL=${HONEYOS_APP_2_URL:-}
- HONEYOS_APP_2_GLYPH=${HONEYOS_APP_2_GLYPH:-}
- HONEYOS_APP_2_KEY=${HONEYOS_APP_2_KEY:-}
- HONEYOS_APP_2_ME=${HONEYOS_APP_2_ME:-}
- HONEYOS_APP_2_TOKEN_KEY=${HONEYOS_APP_2_TOKEN_KEY:-}
- HONEYOS_APP_3_ID=${HONEYOS_APP_3_ID:-}
- HONEYOS_APP_3_NAME=${HONEYOS_APP_3_NAME:-}
- HONEYOS_APP_3_SUBTITLE=${HONEYOS_APP_3_SUBTITLE:-}
- HONEYOS_APP_3_URL=${HONEYOS_APP_3_URL:-}
- HONEYOS_APP_3_GLYPH=${HONEYOS_APP_3_GLYPH:-}
- HONEYOS_APP_3_KEY=${HONEYOS_APP_3_KEY:-}
- HONEYOS_APP_3_ME=${HONEYOS_APP_3_ME:-}
- HONEYOS_APP_3_TOKEN_KEY=${HONEYOS_APP_3_TOKEN_KEY:-}
- HONEYOS_APP_4_ID=${HONEYOS_APP_4_ID:-}
- HONEYOS_APP_4_NAME=${HONEYOS_APP_4_NAME:-}
- HONEYOS_APP_4_SUBTITLE=${HONEYOS_APP_4_SUBTITLE:-}
- HONEYOS_APP_4_URL=${HONEYOS_APP_4_URL:-}
- HONEYOS_APP_4_GLYPH=${HONEYOS_APP_4_GLYPH:-}
- HONEYOS_APP_4_KEY=${HONEYOS_APP_4_KEY:-}
- HONEYOS_APP_4_ME=${HONEYOS_APP_4_ME:-}
- HONEYOS_APP_4_TOKEN_KEY=${HONEYOS_APP_4_TOKEN_KEY:-}
- HONEYOS_PUBLIC_URL=${HONEYOS_PUBLIC_URL:-}
- HONEYOS_SESSION_SECRET=${HONEYOS_SESSION_SECRET:-}
- HONEYOS_OIDC_ISSUER=${HONEYOS_OIDC_ISSUER:-}
- HONEYOS_OIDC_CLIENT_ID=${HONEYOS_OIDC_CLIENT_ID:-}
- HONEYOS_OIDC_CLIENT_SECRET=${HONEYOS_OIDC_CLIENT_SECRET:-}
- HONEYOS_OIDC_REDIRECT_URL=${HONEYOS_OIDC_REDIRECT_URL:-}
- HONEYOS_BUTTON_LABEL=${HONEYOS_BUTTON_LABEL:-Anmelden}
- HONEYOS_ALLOWED_USERS=${HONEYOS_ALLOWED_USERS:-}
-79
View File
@@ -1,79 +0,0 @@
#!/bin/sh
# Schreibt config.json aus den Umgebungsvariablen — läuft vor nginx.
#
# Das nginx-Image führt alles in /docker-entrypoint.d/ beim Start aus. Damit
# braucht HoneyOS keinen eigenen Entrypoint und kein Backend: die Konfiguration
# entsteht einmal beim Hochfahren, danach ist es wieder reines Ausliefern.
#
# Ohne HONEYOS_APP_1_URL bleibt die eingebackene config.json unangetastet —
# ein `docker run` ohne .env zeigt also weiter die Standard-Apps.
set -eu
TARGET=/usr/share/nginx/html/config.json
MAX_APPS=12
# Wert einer Variablen holen, deren Name erst zur Laufzeit feststeht.
get() { eval "printf '%s' \"\${$1:-}\""; }
# Für JSON entschärfen: Backslash und Anführungszeichen. Zeilenumbrüche haben
# in diesen Feldern nichts zu suchen und fliegen raus.
esc() { printf '%s' "$1" | tr -d '\n\r' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }
# Ein Feld nur schreiben, wenn es gesetzt ist — leere Schlüssel wären Lärm.
field() {
[ -n "$2" ] || return 0
printf ',"%s":"%s"' "$1" "$(esc "$2")"
}
if [ -z "$(get HONEYOS_APP_1_URL)" ]; then
echo "honeyos: keine HONEYOS_APP_*-Variablen gesetzt — eingebackene config.json bleibt"
exit 0
fi
if [ ! -w "$(dirname "$TARGET")" ]; then
echo "honeyos: $(dirname "$TARGET") ist nicht beschreibbar (read-only mount?) — config.json bleibt wie sie ist"
exit 0
fi
tmp="${TARGET}.tmp"
{
printf '{'
printf '"user":"%s"' "$(esc "$(get HONEYOS_USER)")"
printf ',"hostname":"%s"' "$(esc "${HONEYOS_HOSTNAME:-honeyos}")"
printf ',"version":"%s"' "$(esc "${HONEYOS_VERSION:-0.1}")"
printf ',"apps":['
n=1
first=1
while [ "$n" -le "$MAX_APPS" ]; do
url=$(get "HONEYOS_APP_${n}_URL")
if [ -z "$url" ]; then
n=$((n + 1))
continue
fi
id=$(get "HONEYOS_APP_${n}_ID")
[ -n "$id" ] || id="app${n}"
name=$(get "HONEYOS_APP_${n}_NAME")
[ -n "$name" ] || name=$(printf '%s' "$id" | tr '[:lower:]' '[:upper:]')
[ "$first" -eq 1 ] || printf ','
first=0
printf '{"id":"%s","name":"%s","url":"%s"' "$(esc "$id")" "$(esc "$name")" "$(esc "$url")"
field subtitle "$(get "HONEYOS_APP_${n}_SUBTITLE")"
field glyph "$(get "HONEYOS_APP_${n}_GLYPH")"
field key "$(get "HONEYOS_APP_${n}_KEY")"
field me "$(get "HONEYOS_APP_${n}_ME")"
field tokenKey "$(get "HONEYOS_APP_${n}_TOKEN_KEY")"
printf '}'
n=$((n + 1))
done
printf ']}'
} > "$tmp"
mv "$tmp" "$TARGET"
count=$(tr -cd '{' < "$TARGET" | wc -c)
echo "honeyos: config.json geschrieben ($((count - 1)) App(s))"
+13
View File
@@ -0,0 +1,13 @@
module honeyos
go 1.23
require (
github.com/coreos/go-oidc/v3 v3.11.0
golang.org/x/oauth2 v0.23.0
)
require (
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
golang.org/x/crypto v0.25.0 // indirect
)
+18
View File
@@ -0,0 +1,18 @@
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+269
View File
@@ -0,0 +1,269 @@
// HoneyOS — Schreibtisch und Umschalter für die eigenen Apps.
//
// Ein Binary: der Server hält Apps und Einstellungen, die Oberfläche steckt
// per embed mit drin. Angemeldet wird über den eigenen OIDC-Anbieter; ohne
// OIDC_ISSUER läuft alles offen — bequem zum Entwickeln, nichts für außen.
package main
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
)
//go:embed all:web
var webFS embed.FS
// localSub ist die Kennung im offenen Betrieb — dann gibt es genau einen Nutzer.
const localSub = "local"
type Config struct {
Addr string
PublicURL string
DataDir string
SessionSecret string
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
OIDCRedirect string
ButtonLabel string
AllowedUsers []string
}
func (c Config) RedirectURL() string {
if c.OIDCRedirect != "" {
return c.OIDCRedirect
}
return strings.TrimRight(c.PublicURL, "/") + "/api/auth/callback"
}
type Server struct {
cfg Config
auth *Auth
store *Store
}
func main() {
log.SetFlags(log.LstdFlags)
cfg := Config{
Addr: env("HONEYOS_ADDR", ":80"),
PublicURL: env("HONEYOS_PUBLIC_URL", ""),
DataDir: env("HONEYOS_DATA_DIR", "/data"),
SessionSecret: env("HONEYOS_SESSION_SECRET", ""),
OIDCIssuer: strings.TrimRight(env("HONEYOS_OIDC_ISSUER", ""), "/"),
OIDCClientID: env("HONEYOS_OIDC_CLIENT_ID", ""),
OIDCClientSecret: env("HONEYOS_OIDC_CLIENT_SECRET", ""),
OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""),
ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"),
AllowedUsers: splitList(env("HONEYOS_ALLOWED_USERS", "")),
}
if cfg.OIDCIssuer != "" {
switch {
case cfg.SessionSecret == "":
log.Fatal("HONEYOS_SESSION_SECRET fehlt — ohne Schlüssel keine Sitzung (openssl rand -hex 32)")
case len(cfg.SessionSecret) < 32:
log.Fatal("HONEYOS_SESSION_SECRET ist zu kurz — mindestens 32 Zeichen")
case cfg.OIDCClientID == "":
log.Fatal("HONEYOS_OIDC_CLIENT_ID fehlt")
case cfg.PublicURL == "" && cfg.OIDCRedirect == "":
log.Fatal("HONEYOS_PUBLIC_URL fehlt — daraus entsteht die Rückrufadresse")
}
} else {
log.Println("⚠ kein HONEYOS_OIDC_ISSUER: HoneyOS läuft OHNE Anmeldung.")
log.Println("⚠ Das ist für die Entwicklung gedacht. Nicht ins offene Netz stellen.")
}
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err)
}
store, err := OpenStore(dataPath(cfg.DataDir))
if err != nil {
log.Fatal(err)
}
s := &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
srv := &http.Server{
Addr: cfg.Addr,
Handler: s.routes(),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("HoneyOS lauscht auf %s (Daten: %s, Anmeldung: %s)",
cfg.Addr, dataPath(cfg.DataDir), map[bool]string{true: cfg.OIDCIssuer, false: "aus"}[s.auth.Enabled()])
log.Fatal(srv.ListenAndServe())
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok\n"))
})
// Ohne Anmeldung lesbar: nur was der Anmeldeschirm braucht.
mux.HandleFunc("GET /api/public", s.handlePublic)
mux.HandleFunc("GET /api/auth/login", s.auth.Login)
mux.HandleFunc("GET /api/auth/callback", func(w http.ResponseWriter, r *http.Request) {
s.auth.Callback(w, r, s.store)
})
mux.HandleFunc("POST /api/auth/logout", s.handleLogout)
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
mux.HandleFunc("GET /api/apps", s.requireAuth(s.handleApps))
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
web, err := fs.Sub(webFS, "web")
if err != nil {
log.Fatal(err)
}
files := http.FileServer(http.FS(web))
mux.Handle("GET /", noCache(files))
return s.cors(mux)
}
/* ── Zwischenschichten ───────────────────────────────────────────────────── */
// cors öffnet die Leiste für die eingetragenen Apps — und nur für die. Die
// App-Liste ist damit zugleich die Freigabeliste; wer nicht darin steht, kommt
// auch nicht an die Sitzung.
func (s *Server) cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && s.store.AppOrigins()[origin] {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Credentials", "true")
h.Set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
h.Set("Access-Control-Allow-Methods", "GET, PUT, POST")
h.Add("Vary", "Origin")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
}
func noCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
next.ServeHTTP(w, r)
})
}
func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sub, ok := s.auth.SubFromRequest(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "nicht angemeldet"})
return
}
if r.Method != http.MethodGet {
if err := s.auth.checkCSRF(r); err != nil {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
}
next(w, r, sub)
}
}
/* ── Handler ─────────────────────────────────────────────────────────────── */
func (s *Server) handlePublic(w http.ResponseWriter, r *http.Request) {
_, signedIn := s.auth.SubFromRequest(r)
writeJSON(w, http.StatusOK, map[string]any{
"oidc": s.auth.Enabled(),
"buttonLabel": s.cfg.ButtonLabel,
"signedIn": signedIn,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.auth.clearSession(w, r)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, sub string) {
u := s.store.User(sub)
writeJSON(w, http.StatusOK, map[string]any{
"sub": sub,
"name": firstNonEmpty(u.Name, "marc"),
"picture": u.Picture,
"settings": u.Settings,
"oidc": s.auth.Enabled(),
})
}
func (s *Server) handleSetSettings(w http.ResponseWriter, r *http.Request, sub string) {
var in map[string]string
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetSettings(sub, in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, _ string) {
writeJSON(w, http.StatusOK, s.store.Apps())
}
func (s *Server) handleSetApps(w http.ResponseWriter, r *http.Request, _ string) {
var in []App
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetApps(in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.Apps())
}
/* ── Kleinkram ───────────────────────────────────────────────────────────── */
func decode(r *http.Request, into any) error {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10))
return dec.Decode(into)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func splitList(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
-30
View File
@@ -1,30 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Für den Healthcheck im Dockerfile und für den Reverse Proxy.
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
# Das statische Blech: alles kurz cachen, nichts endlos.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
# config.json und honeybar.js werden von den anderen Domains geholt.
# Beides ist öffentliche Konfiguration ohne Geheimnisse — CORS offen ist
# hier die richtige Antwort und keine Bequemlichkeit.
location ~* \.(json|js)$ {
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
+225
View File
@@ -0,0 +1,225 @@
package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps
// und pro Nutzer ein paar kosmetische Einstellungen. Eine Datenbank wäre hier
// mehr Betrieb als Nutzen — die Datei lässt sich lesen, sichern und im Zweifel
// mit einem Editor reparieren. Geschrieben wird atomar (tmp + rename), damit
// ein Absturz mitten im Schreiben keine halbe Datei hinterlässt.
type App struct {
ID string `json:"id"`
Name string `json:"name"`
Subtitle string `json:"subtitle,omitempty"`
URL string `json:"url"`
Glyph string `json:"glyph,omitempty"`
Key string `json:"key,omitempty"`
Me string `json:"me,omitempty"`
TokenKey string `json:"tokenKey,omitempty"`
}
type User struct {
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
}
type data struct {
Apps []App `json:"apps"`
Users map[string]*User `json:"users"`
}
type Store struct {
mu sync.RWMutex
path string
d data
}
const (
maxApps = 24
maxSettings = 40
maxFieldLen = 200
)
var (
idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
knownGlyphs = map[string]bool{"cloud": true, "note": true, "box": true, "chip": true, "folder": true, "gear": true, "globe": true, "disk": true}
settingKeyRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,31}$`)
)
func OpenStore(path string) (*Store, error) {
s := &Store{path: path, d: data{Apps: []App{}, Users: map[string]*User{}}}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, s.save() // gleich anlegen, damit ein Rechteproblem sofort auffällt
}
if err != nil {
return nil, fmt.Errorf("lese %s: %w", path, err)
}
if err := json.Unmarshal(raw, &s.d); err != nil {
return nil, fmt.Errorf("%s ist kein gültiges JSON: %w", path, err)
}
if s.d.Apps == nil {
s.d.Apps = []App{}
}
if s.d.Users == nil {
s.d.Users = map[string]*User{}
}
return s, nil
}
// save schreibt die Datei. Aufrufer hält die Sperre.
func (s *Store) save() error {
raw, err := json.MarshalIndent(s.d, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *Store) Apps() []App {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]App, len(s.d.Apps))
copy(out, s.d.Apps)
return out
}
// SetApps ersetzt die ganze Liste. Die Oberfläche schickt sie immer vollständig
// — für ein Dutzend Einträge ist das einfacher und weniger fehleranfällig als
// einzelne Endpunkte für Anlegen, Ändern, Löschen und Umsortieren.
func (s *Store) SetApps(apps []App) error {
if len(apps) > maxApps {
return fmt.Errorf("höchstens %d Apps", maxApps)
}
seen := map[string]bool{}
cleaned := make([]App, 0, len(apps))
for i, a := range apps {
a.ID = strings.ToLower(strings.TrimSpace(a.ID))
a.Name = strings.TrimSpace(a.Name)
a.Subtitle = strings.TrimSpace(a.Subtitle)
a.URL = strings.TrimSpace(a.URL)
a.Glyph = strings.TrimSpace(a.Glyph)
a.Key = strings.TrimSpace(a.Key)
a.Me = strings.TrimSpace(a.Me)
a.TokenKey = strings.TrimSpace(a.TokenKey)
if !idPattern.MatchString(a.ID) {
return fmt.Errorf("App %d: Kennung muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1)
}
if seen[a.ID] {
return fmt.Errorf("Kennung %q kommt doppelt vor", a.ID)
}
seen[a.ID] = true
if a.Name == "" {
a.Name = strings.ToUpper(a.ID)
}
u, err := url.Parse(a.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("App %q: Adresse muss mit http:// oder https:// beginnen", a.ID)
}
if a.Glyph != "" && !knownGlyphs[a.Glyph] {
a.Glyph = ""
}
if len([]rune(a.Key)) > 1 {
return fmt.Errorf("App %q: Taste ist ein einzelnes Zeichen", a.ID)
}
for _, f := range []string{a.Name, a.Subtitle, a.URL, a.Me, a.TokenKey} {
if len(f) > maxFieldLen {
return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen)
}
}
cleaned = append(cleaned, a)
}
s.mu.Lock()
defer s.mu.Unlock()
s.d.Apps = cleaned
return s.save()
}
func (s *Store) User(sub string) User {
s.mu.RLock()
defer s.mu.RUnlock()
if u, ok := s.d.Users[sub]; ok {
cp := *u
cp.Settings = map[string]string{}
for k, v := range u.Settings {
cp.Settings[k] = v
}
return cp
}
return User{Settings: map[string]string{}}
}
// SeenUser hält Name und Bild aus dem Anbieter nach, ohne Einstellungen anzufassen.
func (s *Store) SeenUser(sub, name, picture string) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.d.Users[sub]
if !ok {
u = &User{Settings: map[string]string{}}
s.d.Users[sub] = u
}
if u.Name == name && u.Picture == picture {
return nil // nichts zu schreiben
}
u.Name, u.Picture = name, picture
return s.save()
}
func (s *Store) SetSettings(sub string, in map[string]string) error {
if len(in) > maxSettings {
return fmt.Errorf("zu viele Einstellungen")
}
clean := map[string]string{}
for k, v := range in {
if !settingKeyRe.MatchString(k) || len(v) > maxFieldLen {
continue // stillschweigend verwerfen: die Oberfläche schickt nur Bekanntes
}
clean[k] = v
}
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.d.Users[sub]
if !ok {
u = &User{}
s.d.Users[sub] = u
}
u.Settings = clean
return s.save()
}
// AppOrigins nennt die Ursprünge der eingetragenen Apps. Nur sie dürfen die
// Leiste mit Anmeldedaten abfragen — die Liste ist damit ihre eigene Freigabe.
func (s *Store) AppOrigins() map[string]bool {
s.mu.RLock()
defer s.mu.RUnlock()
out := map[string]bool{}
for _, a := range s.d.Apps {
if u, err := url.Parse(a.URL); err == nil && u.Host != "" {
out[u.Scheme+"://"+u.Host] = true
}
}
return out
}
func dataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
-26
View File
@@ -1,26 +0,0 @@
{
"user": "marc",
"hostname": "honeyos",
"version": "0.1",
"apps": [
{
"id": "aether",
"name": "AETHER",
"subtitle": "Cloud & Dateien",
"url": "https://cloud.honeyy.duckdns.org",
"glyph": "cloud",
"key": "1",
"me": "/api/v1/auth/me"
},
{
"id": "inknote",
"name": "INKNOTE",
"subtitle": "Notizen & Zeichnen",
"url": "https://notes.honeyy.duckdns.org",
"glyph": "note",
"key": "2",
"me": "/api/auth/me",
"tokenKey": "inknote_token"
}
]
}
+1 -1
View File
@@ -36,6 +36,6 @@
<p>Eine App, die mit <code>100vh</code> rechnet, nimmt <code>--honeybar-h</code> ab.</p>
</main>
<script src="./honeybar.js" data-app="inknote" data-home="../index.html"></script>
<script src="./honeybar.js" data-app="inknote" data-home="/"></script>
</body>
</html>
+18 -15
View File
@@ -12,7 +12,7 @@
*
* data-Attribute am <script>:
* data-app (Pflicht) id der App, in der die Leiste steckt
* data-apps URL zu einer config.json; ohne das gilt die Liste unten
* data-apps URL der App-Liste; ohne das /api/apps neben dem Skript
* data-home URL des HoneyOS-Desktops
* data-push "body" (Standard) schiebt den Seiteninhalt nach unten,
* "none" legt die Leiste darüber
@@ -34,18 +34,18 @@
const BAR_H = 32;
const LS_HIDDEN = "honeybar.hidden";
const DEFAULT_APPS = [
{ id: "honeyos", name: "DESKTOP", url: "https://os.honeyy.duckdns.org" },
{ id: "aether", name: "AETHER", url: "https://cloud.honeyy.duckdns.org" },
{ id: "inknote", name: "INKNOTE", url: "https://notes.honeyy.duckdns.org" },
];
// Notliste, falls der Server nichts herausgibt: nur der Weg zurück zum
// Schreibtisch. Erfundene App-Adressen stünden hier sonst ewig fest.
const DEFAULT_APPS = [{ id: "honeyos", name: "DESKTOP", url: "" }];
const current = d("app", "");
const homeUrl = d("home", "https://os.honeyy.duckdns.org");
// Ohne data-home führt der Weg zurück dorthin, wo dieses Skript herkommt.
const homeUrl = d("home", script ? new URL("/", script.src).href : "/");
const pushMode = d("push", "body");
// Ohne data-apps holt die Leiste die Konfiguration neben ihrem eigenen
// Skript — dieselbe Datei, die der Desktop liest.
const appsUrl = d("apps", script ? new URL("../config.json", script.src).href : "");
// Ohne data-apps fragt die Leiste den HoneyOS-Server, von dem sie selbst
// geladen wurde. Er gibt die Liste nur an angemeldete Browser heraus und
// nur an Ursprünge, die selbst als App eingetragen sind.
const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : "");
let apps = DEFAULT_APPS;
@@ -268,15 +268,18 @@
if (appsUrl) {
try {
const res = await fetch(appsUrl, { cache: "no-store" });
// credentials: die Sitzung liegt auf der HoneyOS-Domain, und die ist
// dieselbe Site wie diese hier — sonst käme das Cookie nicht mit.
const res = await fetch(appsUrl, { cache: "no-store", credentials: "include" });
if (res.ok) {
const cfg = await res.json();
if (Array.isArray(cfg.apps) && cfg.apps.length) {
apps = [{ id: "honeyos", name: "DESKTOP", url: homeUrl }].concat(cfg.apps);
const list = await res.json();
if (Array.isArray(list) && list.length) {
apps = [{ id: "honeyos", name: "DESKTOP", url: homeUrl }].concat(list);
}
}
} catch {
/* Liste nicht erreichbar → eingebaute Liste, Leiste funktioniert trotzdem */
/* nicht angemeldet, kein CORS, kein Netz → eingebaute Liste;
die Leiste bleibt ein Umschalter, nur ohne frische Namen */
}
}
+12 -1
View File
@@ -17,6 +17,15 @@
<p class="boot-hint">[ beliebige Taste = überspringen ]</p>
</div>
<!-- ── Anmeldung ──────────────────────────────────────────────────── -->
<div class="login" id="login" hidden>
<pre class="boot-log" id="loginLog"></pre>
<div class="login-box">
<button class="login-btn" id="btnLogin">&#9656; <span id="loginLabel">Anmelden</span></button>
<p class="login-note" id="loginNote"></p>
</div>
</div>
<!-- ── Desktop ────────────────────────────────────────────────────── -->
<div class="desktop" id="desktop" hidden>
<!-- Menüleiste: dieselbe Anatomie wie honeybar.js in den Apps -->
@@ -26,9 +35,11 @@
</button>
<nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav>
<div class="mb-right">
<span class="mb-user" id="mbUser">marc</span>
<img class="mb-avatar" id="mbAvatar" alt="" hidden />
<span class="mb-user" id="mbUser">&nbsp;</span>
<span class="mb-clock" id="clock">--:--</span>
<button class="mb-btn" id="btnSettings" title="Einstellungen (S)">&#9881;</button>
<button class="mb-btn" id="btnLogout" title="Abmelden" hidden>&#9211;</button>
</div>
</header>
+293 -76
View File
@@ -17,8 +17,8 @@
*/
const LS_SETTINGS = "honeyos.settings";
const LS_CFG = "honeyos.config";
const SS_BOOTED = "honeyos.booted";
const VERSION = "0.2";
const DEFAULT_SETTINGS = {
mode: "terminal", // terminal | desktop
@@ -30,7 +30,9 @@ const DEFAULT_SETTINGS = {
};
const state = {
cfg: { user: "marc", hostname: "honeyos", version: "0.1", apps: [] },
cfg: { user: "", hostname: "honeyos", version: VERSION, apps: [] },
me: null, // {sub, name, picture} sobald angemeldet
oidc: false,
settings: { ...DEFAULT_SETTINGS },
probes: {}, // appId -> "up" | "down" | "unknown"
auth: {}, // appId -> {status: "in"|"out"|"blind", name}
@@ -116,6 +118,34 @@ const GLYPHS = {
"..#.####.#..",
"....####....",
],
globe: [
"............",
"...######...",
"..#..#...#..",
".#...#....#.",
"############",
"#....#.....#",
"#....#.....#",
"############",
".#...#....#.",
"..#..#...#..",
"...######...",
"............",
],
disk: [
"............",
"############",
"#..######..#",
"#..#....#..#",
"#..#....#..#",
"#..######..#",
"#..........#",
"#.########.#",
"#.#......#.#",
"#.#......#.#",
"############",
"............",
],
folder: [
"............",
".####.......",
@@ -150,27 +180,49 @@ function glyphSvg(name) {
return `<svg viewBox="0 0 12 12" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${rects.join("")}</svg>`;
}
/* ── Server ────────────────────────────────────────────────────────────────
* Ein winziger Aufrufhelfer. Schreibende Aufrufe brauchen die CSRF-Kopfzeile:
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
* die Seite wirklich geöffnet hat.
*/
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (opts.method && opts.method !== "GET") {
headers["Content-Type"] = "application/json";
const tok = cookie("honeyos_csrf");
if (tok) headers["X-CSRF-Token"] = tok;
}
const res = await fetch(path, { credentials: "same-origin", cache: "no-store", ...opts, headers });
if (res.status === 204) return null;
const body = await res.json().catch(() => null);
if (!res.ok) throw new Error((body && body.error) || `${res.status} ${res.statusText}`);
return body;
}
function cookie(name) {
return document.cookie
.split(";")
.map((c) => c.trim().split("="))
.find(([k]) => k === name)?.[1] || "";
}
/* ── Laden & Speichern ─────────────────────────────────────────────────── */
async function loadConfig() {
let base = null;
try {
const res = await fetch("./config.json", { cache: "no-store" });
if (res.ok) base = await res.json();
} catch {
/* offline oder file:// — dann zählt allein der lokale Stand */
}
if (base) state.cfg = { ...state.cfg, ...base };
/** Holt Nutzer, Einstellungen und Apps. Wirft bei fehlender Anmeldung. */
async function loadFromServer() {
const me = await api("/api/me");
state.me = { sub: me.sub, name: me.name, picture: me.picture };
state.oidc = !!me.oidc;
state.cfg.user = me.name || "";
const saved = readJson(LS_CFG);
if (saved) {
// Gespeichert wird nur, was der Nutzer wirklich geändert hat: Name und URLs.
if (saved.user) state.cfg.user = saved.user;
if (saved.urls) {
state.cfg.apps = state.cfg.apps.map((a) => (saved.urls[a.id] ? { ...a, url: saved.urls[a.id] } : a));
}
}
// Der Server hat das letzte Wort; localStorage war nur der Vorschuss.
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}), ...(me.settings || {}) };
state.cfg.apps = await api("/api/apps");
}
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
* aufblitzt und dann umspringt. */
function loadLocalSettings() {
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
}
@@ -182,23 +234,24 @@ function readJson(key) {
}
}
let syncTimer = null;
function saveSettings() {
try {
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
} catch {
/* Privatmodus: Einstellungen gelten dann nur für diese Sitzung */
/* Privatmodus: dann gilt es nur für diese Sitzung — der Server merkt es trotzdem */
}
applySettings();
}
function saveConfig() {
const urls = {};
state.cfg.apps.forEach((a) => (urls[a.id] = a.url));
try {
localStorage.setItem(LS_CFG, JSON.stringify({ user: state.cfg.user, urls }));
} catch {
/* siehe oben */
}
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
// soll nicht dreimal schreiben lassen.
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
console.warn("Einstellungen nicht gespeichert:", e.message)
);
}, 600);
}
function applySettings() {
@@ -218,7 +271,7 @@ function bootLines() {
{ t: `HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`, d: 90 },
{ t: "", d: 40 },
{ t: "prüfe speicher".padEnd(28, ".") + " 640K ok", d: 120 },
{ t: "lade benutzerprofil".padEnd(28, ".") + " " + state.cfg.user, d: 120 },
{ t: "prüfe anmeldung".padEnd(28, ".") + " " + (state.cfg.user || "—"), d: 120 },
...apps.map((a) => ({ t: `binde ${a.id} ein`.padEnd(28, ".") + " [ ok ]", d: 140 })),
{ t: "", d: 60 },
{ t: `starte ${state.settings.mode}-modus`.padEnd(28, ".") + " bereit.", d: 200 },
@@ -280,6 +333,11 @@ function greeting() {
function renderShell() {
$("desktop").hidden = false;
$("mbUser").textContent = state.cfg.user;
if (state.me && state.me.picture) {
$("mbAvatar").src = state.me.picture;
$("mbAvatar").hidden = false;
}
$("btnLogout").hidden = !state.oidc;
renderMenubarApps();
renderMode();
tickClock();
@@ -525,8 +583,10 @@ function renderTiles() {
const add = document.createElement("button");
add.className = "tile tile-add";
add.innerHTML = "<span>+ app hinzufügen</span>";
add.addEventListener("click", openSettings);
add.innerHTML = state.cfg.apps.length
? "<span>+ app hinzufügen</span>"
: "<span>Noch keine App.<br />Hier die erste anlegen.</span>";
add.addEventListener("click", openApps);
wrap.appendChild(add);
}
@@ -744,7 +804,7 @@ function openProgramsWindow() {
title: "programme",
w: 360,
body: `<div class="applist">${rows}
<button data-settings="1"><span class="al-glyph">${glyphSvg("gear")}</span><span>app hinzufügen …</span></button>
<button data-settings="1"><span class="al-glyph">${glyphSvg("gear")}</span><span>programme verwalten …</span></button>
</div>`,
onMount: (root) => {
root.querySelectorAll("[data-launch]").forEach((b) =>
@@ -753,7 +813,7 @@ function openProgramsWindow() {
if (app) launch(app);
})
);
root.querySelector("[data-settings]").addEventListener("click", openSettings);
root.querySelector("[data-settings]").addEventListener("click", openApps);
},
});
}
@@ -791,11 +851,6 @@ function openSettings() {
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
</div>
<label class="field">
<span class="label">Name</span>
<input type="text" id="setUser" value="${escapeAttr(state.cfg.user)}" maxlength="24" />
</label>
<div class="field">
<span class="label">Röhre</span>
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
@@ -816,20 +871,14 @@ function openSettings() {
</div>
<div class="field">
<span class="label">Adressen</span>
${state.cfg.apps
.map(
(a) =>
`<div class="url-row"><span>${escapeHtml(a.name)}</span>
<input type="url" data-url="${a.id}" value="${escapeAttr(a.url)}" spellcheck="false" /></div>`
)
.join("")}
<span class="label">Programme</span>
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
</div>
<div class="modal-actions">
<button class="btn" id="btnReset">zurücksetzen</button>
<button class="btn btn-primary" id="btnSave">speichern</button>
</div>`,
<p class="login-note" style="margin-top:4px">
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
</p>`,
(root) => {
root.querySelectorAll(".seg").forEach((segEl) => {
segEl.addEventListener("click", (e) => {
@@ -852,33 +901,117 @@ function openSettings() {
});
});
root.querySelector("#btnSave").addEventListener("click", () => {
const user = root.querySelector("#setUser").value.trim();
if (user) state.cfg.user = user;
root.querySelectorAll("input[data-url]").forEach((inp) => {
const app = state.cfg.apps.find((a) => a.id === inp.dataset.url);
const val = inp.value.trim();
if (app && val) app.url = val;
});
saveConfig();
if (state.settings.mode === "desktop") closeWindow("settings");
else closeModal();
$("mbUser").textContent = state.cfg.user;
renderMenubarApps();
renderMode();
probeApps();
});
root.querySelector("#btnReset").addEventListener("click", () => {
localStorage.removeItem(LS_SETTINGS);
localStorage.removeItem(LS_CFG);
location.reload();
});
root.querySelector("#btnApps").addEventListener("click", openApps);
},
420
);
}
/* ── App-Verwaltung ────────────────────────────────────────────────────────
* Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als
* Ganzes zum Server. Der prüft sie noch einmal und antwortet mit dem, was er
* wirklich behalten hat — das ist danach der Stand, nicht das Formular.
*/
function openApps() {
let draft = state.cfg.apps.map((a) => ({ ...a }));
const GLYPHS_LIST = ["cloud", "note", "box", "chip", "folder", "gear", "globe", "disk"];
const rowHtml = (a, i) => `
<details class="approw" ${a.__neu ? "open" : ""}>
<summary>
<span class="ae-glyph">${glyphSvg(a.glyph)}</span>
<span>${escapeHtml(a.name || a.id || "neue app")}</span>
<span class="ae-url">${escapeHtml(a.url || "")}</span>
</summary>
<div class="ae-body">
<label class="ae-field"><span>Kennung</span>
<input data-i="${i}" data-f="id" value="${escapeAttr(a.id || "")}" placeholder="aether" spellcheck="false" /></label>
<p class="ae-hint">klein, ohne Leerzeichen — muss zu <code>data-app</code> der Leiste passen</p>
<label class="ae-field"><span>Name</span>
<input data-i="${i}" data-f="name" value="${escapeAttr(a.name || "")}" placeholder="AETHER" /></label>
<label class="ae-field"><span>Untertitel</span>
<input data-i="${i}" data-f="subtitle" value="${escapeAttr(a.subtitle || "")}" placeholder="Cloud &amp; Dateien" /></label>
<label class="ae-field"><span>Adresse</span>
<input data-i="${i}" data-f="url" value="${escapeAttr(a.url || "")}" placeholder="https://…" spellcheck="false" /></label>
<label class="ae-field"><span>Symbol</span>
<select data-i="${i}" data-f="glyph">
${GLYPHS_LIST.map((g) => `<option value="${g}" ${a.glyph === g ? "selected" : ""}>${g}</option>`).join("")}
</select></label>
<label class="ae-field"><span>Taste</span>
<input data-i="${i}" data-f="key" value="${escapeAttr(a.key || "")}" maxlength="1" placeholder="1" /></label>
<label class="ae-field"><span>Wer-bin-ich</span>
<input data-i="${i}" data-f="me" value="${escapeAttr(a.me || "")}" placeholder="/api/v1/auth/me" spellcheck="false" /></label>
<p class="ae-hint">optional — zeigt an, ob du dort angemeldet bist</p>
<label class="ae-field"><span>Token-Schlüssel</span>
<input data-i="${i}" data-f="tokenKey" value="${escapeAttr(a.tokenKey || "")}" placeholder="leer lassen" spellcheck="false" /></label>
<p class="ae-hint">nur für Apps mit Bearer-Token im localStorage statt Cookie</p>
<div class="ae-actions">
<button class="btn btn-danger" data-del="${i}">löschen</button>
</div>
</div>
</details>`;
const render = (root) => {
const list = root.querySelector("#appList");
list.innerHTML = draft.length
? draft.map(rowHtml).join("")
: `<div class="apped-empty">Noch keine App eingetragen.<br />Mit „+ neue app" die erste anlegen.</div>`;
list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
inp.addEventListener("input", () => {
draft[Number(inp.dataset.i)][inp.dataset.f] = inp.value;
});
});
list.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => {
draft.splice(Number(b.dataset.del), 1);
render(root);
})
);
};
openPanel(
"apps",
"programme verwalten",
`<div class="apped"><div id="appList"></div>
<div class="modal-actions">
<span class="saveline" id="appMsg"></span>
<button class="btn" id="btnAdd">+ neue app</button>
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
</div>
</div>`,
(root) => {
render(root);
root.querySelector("#btnAdd").addEventListener("click", () => {
draft.push({ id: "", name: "", url: "", glyph: "box", key: String(draft.length + 1), __neu: true });
render(root);
});
root.querySelector("#btnSaveApps").addEventListener("click", async () => {
const msg = root.querySelector("#appMsg");
msg.dataset.kind = "";
msg.textContent = "speichere …";
try {
const clean = draft.map(({ __neu, ...rest }) => rest);
state.cfg.apps = await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) });
draft = state.cfg.apps.map((a) => ({ ...a }));
msg.textContent = `${state.cfg.apps.length} gespeichert.`;
render(root);
renderMenubarApps();
renderMode();
probeApps();
} catch (e) {
msg.dataset.kind = "err";
msg.textContent = e.message;
}
});
},
460
);
}
function openAbout() {
openPanel(
"about",
@@ -930,7 +1063,8 @@ function buildStartMenu() {
<button data-win="system">System</button>
<button data-win="settings">Einstellungen</button>
<button data-win="about">Über HoneyOS</button>
<hr /><button data-mode="terminal">↩ Terminal-Modus</button>`;
<hr /><button data-mode="terminal">↩ Terminal-Modus</button>
<button data-logout="1">⏻ Abmelden</button>`;
menu.querySelectorAll("[data-app]").forEach((b) =>
b.addEventListener("click", () => {
@@ -951,6 +1085,7 @@ function buildStartMenu() {
closeStartMenu();
setMode("terminal");
});
menu.querySelector("[data-logout]").addEventListener("click", logout);
menu.dataset.open = "1";
btn.setAttribute("aria-expanded", "true");
@@ -968,10 +1103,91 @@ function closeStartMenu() {
/* ── Start ─────────────────────────────────────────────────────────────── */
/* ── Anmeldeschirm ─────────────────────────────────────────────────────────
* Kein Formular: HoneyOS kennt keine Passwörter. Es gibt genau einen Knopf,
* und der schickt zum Anbieter. Der Rest ist Kulisse — aber Kulisse, die sagt,
* was schiefging, wenn der Rückweg gescheitert ist.
*/
const LOGIN_ERRORS = {
anbieter: "Der Anmeldedienst war nicht erreichbar.",
abgelaufen: "Der Versuch hat zu lange gedauert. Noch einmal.",
state: "Die Antwort passte nicht zur Anfrage. Noch einmal.",
kein_code: "Der Anbieter hat keinen Code geschickt.",
tausch: "Der Code ließ sich nicht einlösen — Client-Kennung oder Geheimnis prüfen.",
kein_id_token: "Der Anbieter hat kein ID-Token geschickt.",
token: "Das ID-Token war ungültig.",
claims: "Das ID-Token nannte keinen Nutzer.",
nicht_erlaubt: "Dieses Konto steht nicht auf der erlaubten Liste.",
};
function showLogin(pub) {
const el = $("login");
const log = $("loginLog");
el.hidden = false;
const fehler = new URLSearchParams(location.search).get("fehler");
const lines = [
`HoneyOS ${VERSION} — ${state.cfg.hostname}`,
"",
"prüfe sitzung".padEnd(28, ".") + " keine",
"anmeldung erforderlich",
];
log.textContent = lines.join("\n") + "\n";
const note = $("loginNote");
if (fehler) {
note.innerHTML = `<b>${escapeHtml(LOGIN_ERRORS[fehler] || "Die Anmeldung ist gescheitert (" + fehler + ").")}</b>`;
} else {
note.textContent = "Die Anmeldung läuft über deinen Anbieter. HoneyOS speichert kein Passwort.";
}
const btn = $("btnLogin");
$("loginLabel").textContent = pub.buttonLabel || "Anmelden";
btn.addEventListener("click", () => {
btn.disabled = true;
$("loginLabel").textContent = "weiterleiten …";
location.href = "/api/auth/login";
});
btn.focus();
}
async function logout() {
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* egal — die Sitzung ist so oder so nichts mehr wert */
}
try {
sessionStorage.removeItem(SS_BOOTED);
} catch {
/* dann bootet es beim nächsten Mal eben neu */
}
location.href = "/";
}
/* ── Start ─────────────────────────────────────────────────────────────── */
async function main() {
await loadConfig();
// Einstellungen aus dem letzten Besuch gelten sofort, damit die Röhre beim
// Laden nicht in der falschen Farbe aufflackert.
loadLocalSettings();
applySettings();
let pub = { oidc: false, buttonLabel: "Anmelden" };
try {
pub = await api("/api/public");
} catch {
/* Server nicht erreichbar — der Anmeldeschirm sagt es gleich selbst */
}
try {
await loadFromServer();
} catch {
return showLogin(pub); // nicht angemeldet: hier ist Schluss
}
applySettings(); // jetzt gilt, was der Server gespeichert hat
let skipBoot = false;
try {
skipBoot = sessionStorage.getItem(SS_BOOTED) === "1";
@@ -988,6 +1204,7 @@ async function main() {
$("btnSettings").addEventListener("click", openSettings);
$("btnAbout").addEventListener("click", openAbout);
$("btnLogout").addEventListener("click", logout);
$("btnClose").addEventListener("click", closeModal);
$("modal").addEventListener("click", (e) => {
if (e.target === $("modal")) closeModal();
@@ -995,7 +1212,7 @@ async function main() {
window.addEventListener("keydown", (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const typing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
if (e.key === "Escape") {
// Bei offenem Startmenü schließt Esc nur das Menü — sonst nähme es
// einem gleichzeitig das oberste Fenster weg.
+123
View File
@@ -762,3 +762,126 @@ body {
.task-btn { min-width: 0; }
.task-date { display: none; }
}
/* ── Anmeldung ─────────────────────────────────────────────────────────────
* Derselbe Bildschirm wie der Boot, nur endet er nicht von selbst. Wer nicht
* angemeldet ist, sieht vom Schreibtisch nichts — auch nicht die App-Liste.
*/
.login {
flex: 1;
display: flex;
flex-direction: column;
padding: clamp(16px, 5vw, 56px);
overflow: hidden;
}
.login-box {
margin-top: 26px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.login-btn {
background: none;
border: 1px solid var(--p);
color: var(--p);
font: inherit;
font-size: 15px;
letter-spacing: 0.14em;
padding: 10px 22px;
cursor: pointer;
animation: pulse 2.4s ease-in-out infinite;
}
.login-btn:hover, .login-btn:focus-visible {
background: var(--p);
color: var(--bg);
text-shadow: none;
outline: none;
animation: none;
}
@keyframes pulse {
50% { box-shadow: 0 0 14px color-mix(in srgb, var(--p) 45%, transparent); }
}
.login-note {
margin: 0;
color: var(--p-dim);
font-size: 12px;
max-width: 52ch;
line-height: 1.6;
}
.login-note b { color: var(--p-mid); font-weight: 400; }
/* Nutzer in der Leiste */
.mb-avatar {
width: 18px;
height: 18px;
object-fit: cover;
border: 1px solid var(--p-dim);
}
.mb-avatar[hidden] { display: none; }
/* ── App-Verwaltung ────────────────────────────────────────────────────── */
.apped { display: flex; flex-direction: column; gap: 10px; }
.approw {
border: 1px solid var(--p-dim);
background: var(--bg);
}
.approw > summary {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 9px;
cursor: pointer;
list-style: none;
font-size: 13px;
}
.approw > summary::-webkit-details-marker { display: none; }
.approw[open] > summary { border-bottom: 1px solid var(--p-dim); background: var(--p-faint); }
.approw .ae-glyph { width: 18px; height: 18px; flex: none; }
.approw .ae-glyph svg { display: block; width: 100%; height: 100%; }
.approw .ae-url { margin-left: auto; color: var(--p-dim); font-size: 11px; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.approw .ae-body { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.ae-field { display: grid; grid-template-columns: 92px 1fr; gap: 8px; align-items: center; }
.ae-field > span { font-size: 11px; color: var(--p-dim); letter-spacing: 0.1em; }
.ae-field input, .ae-field select {
background: var(--bg-raised);
border: 1px solid var(--p-dim);
color: var(--p);
font: inherit;
font-size: 12.5px;
padding: 5px 7px;
min-width: 0;
}
.ae-field input:focus, .ae-field select:focus { outline: none; border-color: var(--p); }
/* Hinweise stehen unter ihrem Feld, bündig mit der Eingabe — nicht daneben. */
.ae-hint { margin: -4px 0 0 100px; color: var(--p-dim); font-size: 11px; }
@media (max-width: 520px) {
.ae-field { grid-template-columns: 1fr; gap: 3px; }
.ae-hint { margin-left: 0; }
}
.ae-actions { display: flex; gap: 6px; justify-content: flex-end; padding-top: 2px; }
.btn-danger { border-color: var(--p-dim); color: var(--p-mid); }
.btn-danger:hover { border-color: #d0563a; color: #ff7a5c; background: none; }
.apped-empty {
border: 1px dashed var(--p-dim);
padding: 18px;
text-align: center;
color: var(--p-dim);
font-size: 12.5px;
}
.saveline {
min-height: 16px;
font-size: 11.5px;
color: var(--p-mid);
}
.saveline[data-kind="err"] { color: #ff7a5c; }