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:
1 parent
84cc5d619e
commit
283f9a1850
20 files changed
+1831
-438
No files matched your search
+33
-75
@@ -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,3 +1,4 @@
|
||||
devdata/
|
||||
.env
|
||||
*.tmp
|
||||
shots/
|
||||
+22
-11
@@ -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"]
|
||||
@@ -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".
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:-}
|
||||
@@ -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))"
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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">▸ <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"> </span>
|
||||
<span class="mb-clock" id="clock">--:--</span>
|
||||
<button class="mb-btn" id="btnSettings" title="Einstellungen (S)">⚙</button>
|
||||
<button class="mb-btn" id="btnLogout" title="Abmelden" hidden>⏻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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 & 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
@@ -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; }
|
||||
Reference in new issue
Block a user