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
+1827
-434
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
|
# docker compose pull && docker compose up -d
|
||||||
#
|
#
|
||||||
# Alles hier landet beim Containerstart in config.json und wird vom Browser
|
# Apps werden NICHT hier eingetragen — die legst du in der Oberfläche an
|
||||||
# gelesen. Das heißt: **es ist öffentlich.** Adressen und Namen gehören hier
|
# (Einstellungen → Programme). Sie liegen im Server unter /data/honeyos.json.
|
||||||
# hinein, Geheimnisse niemals — HoneyOS hat kein Backend, das eines hüten
|
|
||||||
# könnte. Nach jeder Änderung: docker compose up -d (Neustart genügt).
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
HONEYOS_PORT=8088
|
HONEYOS_PORT=8088
|
||||||
|
|
||||||
# Anzeigename, solange keine App einen besseren nennt (siehe _ME weiter unten).
|
# Wo HoneyOS von außen erreichbar ist — ohne Schrägstrich am Ende.
|
||||||
HONEYOS_USER=marc
|
# Daraus entsteht die Rückrufadresse: <PUBLIC_URL>/api/auth/callback
|
||||||
HONEYOS_HOSTNAME=honeyos
|
HONEYOS_PUBLIC_URL=https://os.honeyy.duckdns.org
|
||||||
HONEYOS_VERSION=0.1
|
|
||||||
|
|
||||||
# ── Apps ─────────────────────────────────────────────────────────────────────
|
# Unterschreibt das Sitzungscookie. Mindestens 32 Zeichen.
|
||||||
# Durchnummeriert ab 1. Eine App entsteht durch ihre _URL — fehlt die, wird die
|
# openssl rand -hex 32
|
||||||
# Nummer übersprungen. Bis zu 12 Stück.
|
# Wird der Wert getauscht, sind alle Anmeldungen weg — sonst passiert nichts.
|
||||||
#
|
HONEYOS_SESSION_SECRET=
|
||||||
# _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
|
|
||||||
|
|
||||||
HONEYOS_APP_1_ID=aether
|
# Wo die Daten liegen (Apps + Einstellungen je Nutzer, eine JSON-Datei).
|
||||||
HONEYOS_APP_1_NAME=AETHER
|
# Das ist das Einzige, was gesichert werden muss.
|
||||||
HONEYOS_APP_1_SUBTITLE=Cloud & Dateien
|
HONEYOS_DATA_DIR=/srv/honeyos
|
||||||
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
|
|
||||||
|
|
||||||
HONEYOS_APP_2_ID=inknote
|
# ── Anmeldung ────────────────────────────────────────────────────────────────
|
||||||
HONEYOS_APP_2_NAME=INKNOTE
|
# Leerer HONEYOS_OIDC_ISSUER = KEINE Anmeldung, jeder kommt rein. Das ist zum
|
||||||
HONEYOS_APP_2_SUBTITLE=Notizen & Zeichnen
|
# Entwickeln gedacht und gehört nicht ins offene Netz.
|
||||||
HONEYOS_APP_2_URL=https://notes.honeyy.duckdns.org
|
#
|
||||||
HONEYOS_APP_2_GLYPH=note
|
# In PocketID einen Client anlegen und dort als Rückrufadresse exakt
|
||||||
HONEYOS_APP_2_KEY=2
|
# https://os.honeyy.duckdns.org/api/auth/callback
|
||||||
HONEYOS_APP_2_ME=/api/auth/me
|
# eintragen. HoneyOS schickt PKCE mit, ein Client-Geheimnis braucht es
|
||||||
HONEYOS_APP_2_TOKEN_KEY=inknote_token
|
# 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
|
# Beschriftung des einen Knopfes auf dem Anmeldeschirm.
|
||||||
# HONEYOS_APP_3_URL=https://…
|
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
|
||||||
# HONEYOS_APP_3_GLYPH=box
|
|
||||||
# HONEYOS_APP_3_KEY=3
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# Zweiter Riegel neben den Gruppen in PocketID: nur diese Konten dürfen rein
|
||||||
# Anmeldung — was HoneyOS kann und was nicht
|
# (Benutzername oder sub, kommagetrennt). Leer = jeder, den der Anbieter
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# durchlässt.
|
||||||
#
|
# HONEYOS_ALLOWED_USERS=marc
|
||||||
# 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.
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
devdata/
|
||||||
.env
|
.env
|
||||||
*.tmp
|
*.tmp
|
||||||
shots/
|
shots/
|
||||||
+22
-11
@@ -1,20 +1,31 @@
|
|||||||
# HoneyOS — statisch. Kein Build, kein Backend: nginx liefert web/ aus.
|
# HoneyOS — ein Binary, die Oberfläche steckt per embed mit drin.
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
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" \
|
LABEL org.opencontainers.image.title="HoneyOS" \
|
||||||
org.opencontainers.image.description="Retro-Schreibtisch und Umschalter für die eigenen Apps" \
|
org.opencontainers.image.description="Retro-Schreibtisch und Umschalter für die eigenen Apps" \
|
||||||
org.opencontainers.image.source="https://gitea.honeyy.duckdns.org/Docker/HoneyOS"
|
org.opencontainers.image.source="https://gitea.honeyy.duckdns.org/Docker/HoneyOS"
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
# ca-certificates: ohne die scheitert die Anfrage an den OIDC-Anbieter über https.
|
||||||
COPY docker-entrypoint.d/20-honeyos-config.sh /docker-entrypoint.d/20-honeyos-config.sh
|
RUN apk add --no-cache ca-certificates wget && \
|
||||||
COPY web/ /usr/share/nginx/html/
|
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 \
|
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
|
# HoneyOS
|
||||||
|
|
||||||
Ein Retro-Schreibtisch vor den eigenen Apps. Amber-CRT, Boot-Log, zwei Betriebsarten —
|
Ein Retro-Schreibtisch vor den eigenen Apps. Amber-CRT, Boot-Log, zwei Betriebsarten,
|
||||||
und dieselbe Leiste zum Einhängen in Aether und InkNote.
|
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
|
**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
|
Domain mit ihrer eigenen Anmeldung. HoneyOS ist der Startpunkt, der Umschalter und die
|
||||||
Startpunkt, der Umschalter und die gemeinsame Leiste — **kein Rahmen, der die Apps
|
gemeinsame Leiste — **kein Rahmen, der die Apps einsperrt** (kein iframe).
|
||||||
einsperrt** (kein iframe).
|
|
||||||
|
|
||||||
**Was es nicht ist:** eine Anmeldung. HoneyOS ist eine statische Seite ohne Backend; ein
|
**Wo der Zustand liegt:** im Server. Die App-Liste gilt für alle, die Einstellungen gehören
|
||||||
OIDC-Client bräuchte ein Client-Geheimnis und eine Sitzung, und beides gehört nicht in
|
je Nutzer — deshalb sieht dein HoneyOS auf jedem Gerät gleich aus, an dem du angemeldet bist.
|
||||||
etwas, das der Browser vollständig lesen kann. Angemeldet wird bei Aether und InkNote,
|
Der Browser hält dieselben Einstellungen nur als Vorschuss, damit die Röhre beim Laden nicht
|
||||||
die beide an PocketID hängen. HoneyOS kann den Stand nur **anzeigen** — siehe unten.
|
in der falschen Farbe aufflackert.
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
```bash
|
```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 login gitea.honeyy.duckdns.org
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Es gibt nichts zu sichern: keine Datenbank, kein Volume. Der ganze Zustand ist die `.env` —
|
Zu sichern ist genau eine Datei: `${HONEYOS_DATA_DIR}/honeyos.json`. Darin stehen die Apps
|
||||||
und was sich der Browser pro Gerät merkt (Betriebsart, Röhrenfarbe).
|
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,
|
`/healthz` antwortet mit `ok`. Hinter deinem Reverse Proxy braucht es nichts Besonderes,
|
||||||
fertig. `/healthz` antwortet mit `ok`.
|
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
|
In PocketID einen Client anlegen und dort als Rückrufadresse **exakt** eintragen:
|
||||||
[`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`.
|
|
||||||
|
|
||||||
**Alles darin ist öffentlich.** Adressen und Namen gehören hinein, Geheimnisse niemals.
|
```
|
||||||
|
https://os.honeyy.duckdns.org/api/auth/callback
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Ohne `HONEYOS_APP_1_URL` bleibt die eingebackene `web/config.json` gültig — ein
|
HoneyOS schickt PKCE mit, braucht aber trotzdem ein Client-Geheimnis — also **keinen
|
||||||
`docker run` ohne `.env` zeigt also die Standard-Apps. Adressen lassen sich zusätzlich im
|
„public client"** anlegen. Danach in die `.env`:
|
||||||
Browser unter *Einstellungen* überschreiben; das gilt dann nur für dieses Gerät.
|
|
||||||
|
```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
|
## 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 |
|
| Fenster | Dialog in der Mitte | verschiebbar, minimierbar |
|
||||||
| unten | Statuszeile mit Tastenhinweisen | Taskleiste mit Startmenü |
|
| unten | Statuszeile mit Tastenhinweisen | Taskleiste mit Startmenü |
|
||||||
|
|
||||||
Umschalten mit **`M`**, über *Einstellungen → Betriebsart* oder im Startmenü.
|
Umschalten mit **`M`**, über *Einstellungen → Betriebsart* oder im Startmenü. Gleich bleiben
|
||||||
|
in beiden: Menüleiste, Palette, Einstellungen, App-Liste.
|
||||||
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.
|
|
||||||
|
|
||||||
**Tasten:** `1` `2` App starten · `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
|
**Tasten:** `1` `2` App starten · `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
|
||||||
`Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge.
|
`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="aether"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
`data-app` ist die `id` aus der Konfiguration — danach markiert sich die Leiste selbst als
|
`data-app` ist die Kennung aus der App-Liste. Die Liste holt sich die Leiste selbst von
|
||||||
aktiv. Die App-Liste holt sie von allein neben ihrem eigenen Skript.
|
`/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
|
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
|
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`.
|
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
|
Das Feld **Wer-bin-ich** ist der Pfad, unter dem eine App „wer bin ich" beantwortet
|
||||||
absolut). Aus der Antwort wird der Anzeigename gefischt — `display_name`, `displayName`,
|
(relativ zur Adresse oder absolut). Aus der Antwort wird der Anzeigename gefischt —
|
||||||
`name`, `username`, auch verschachtelt unter `user`.
|
`display_name`, `displayName`, `name`, `username`, auch verschachtelt unter `user`.
|
||||||
|
|
||||||
Gelesen wird das an zwei Stellen, und die verhalten sich **unterschiedlich**:
|
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`
|
- InkNote: `ALLOWED_ORIGINS=https://os.honeyy.duckdns.org`
|
||||||
- Aether: CORS mit `Access-Control-Allow-Credentials` — noch nicht vorhanden
|
- Aether: CORS mit `Access-Control-Allow-Credentials` — noch nicht vorhanden
|
||||||
|
|
||||||
Fehlt das, bleibt die Anzeige leer und sonst passiert nichts. Die Lampe „online"
|
Fehlt das, bleibt die Anzeige leer und sonst passiert nichts.
|
||||||
funktioniert unabhängig davon weiter.
|
|
||||||
|
|
||||||
`_TOKEN_KEY` brauchen nur Apps, die einen Bearer-Token im localStorage halten statt eines
|
**Voraussetzung:** alle Adressen unter derselben Domain (`*.honeyy.duckdns.org`). Sonst
|
||||||
Cookies (InkNote: `inknote_token`). Der ist pro Domain sichtbar — von `os.…` aus also nicht
|
behandelt der Browser sie als fremde Site und schickt die Sitzung gar nicht erst mit.
|
||||||
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.
|
|
||||||
|
|
||||||
## Entwickeln
|
## Entwickeln
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d --build # → http://localhost:8088
|
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,
|
Der Dev-Betrieb läuft **ohne Anmeldung**. Die Oberfläche steckt per `go:embed` im Binary —
|
||||||
greift dort die `.env`-Konfiguration **nicht** — es gilt die eingebackene `web/config.json`.
|
nach Änderungen an `web/` also neu bauen, ein Reload allein genügt nicht.
|
||||||
Zum Prüfen der `.env` das echte Image nehmen (`docker compose up -d --build`).
|
|
||||||
|
|
||||||
```
|
```
|
||||||
web/
|
main.go Konfiguration, Routen, statische Auslieferung
|
||||||
index.html Desktop
|
auth.go OIDC mit PKCE, Sitzungscookie, CSRF
|
||||||
style.css alle Farben in den --p*-Variablen ganz oben
|
store.go die JSON-Datei: Apps, Nutzer, Einstellungen
|
||||||
os.js Boot, beide Ansichten, Fenster, Einstellungen
|
auth_test.go falscher OIDC-Anbieter — der ganze Anmeldeweg im Test
|
||||||
config.json Standardkonfiguration (wird zur Laufzeit aus der .env ersetzt)
|
web/ index.html · style.css · os.js · honeybar/ · looks/
|
||||||
honeybar/ die einbettbare Leiste + Vorschauseite
|
|
||||||
looks/ zwei verworfene Entwürfe (System 7, Win95) — nur Ansicht
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Bauen und schieben
|
||||||
|
|
||||||
@@ -147,18 +165,16 @@ Neue Symbole sind 12×12-Pixelraster in `os.js` (`GLYPHS`).
|
|||||||
|
|
||||||
## Grenzen, die man kennen sollte
|
## Grenzen, die man kennen sollte
|
||||||
|
|
||||||
- **localStorage ist pro Domain.** Betriebsart und Röhrenfarbe auf `os.…` kennt die Leiste
|
- **Die Lampe an der Kachel ist kein Health-Check.** Bei einer fremden Domain darf der
|
||||||
auf `notes.…` nicht. Der Umschalter ist ein Schalter, kein geteilter Zustand.
|
Browser nur sehen, *dass* geantwortet wurde. Ein 500er sähe aus wie „online".
|
||||||
- **Die Lampe ist kein Health-Check.** Bei einer fremden Domain darf der Browser nur sehen,
|
- **Die App-Liste ist für alle gleich.** Wer angemeldet ist, darf sie ändern — das ist für
|
||||||
*dass* geantwortet wurde, nicht *was*. Ein 500er sähe aus wie „online".
|
eine Handvoll Konten gewollt und wäre für mehr Leute zu wenig.
|
||||||
- **`config.json` und `honeybar.js` gehen mit `Access-Control-Allow-Origin: *` raus**, weil
|
- **Ein Angemeldeter kann jede Adresse eintragen.** Der Server prüft nur, dass es eine
|
||||||
die anderen Domains sie holen. Öffentliche Konfiguration — nichts Vertrauliches hinein.
|
http(s)-Adresse ist. Das ist zugleich die CORS-Freigabe der Leiste, also nicht
|
||||||
|
gedankenlos etwas Fremdes eintragen.
|
||||||
|
|
||||||
## Was als Nächstes ansteht
|
## Was als Nächstes ansteht
|
||||||
|
|
||||||
1. **CORS in Aether** für `os.…`, damit der Desktop den Anmeldestand sehen kann. InkNote
|
1. **CORS in Aether** für `os.…`, damit der Desktop den Anmeldestand sehen kann.
|
||||||
hat mit `ALLOWED_ORIGINS` schon den Haken dafür.
|
2. **Überall abmelden** in der Leiste — braucht pro App den CSRF-Weg zum Logout.
|
||||||
2. **Überall abmelden** in der Leiste. Braucht pro App den CSRF-Weg zum Logout — bei Aether
|
3. **Fenster mit Inhalt** statt nur Verweisen: „letzte Dateien", „letzte Notizen".
|
||||||
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.
|
|
||||||
@@ -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:
|
services:
|
||||||
honeyos:
|
honeyos:
|
||||||
build: .
|
build: .
|
||||||
image: honeyos:dev
|
image: honeyos:dev
|
||||||
container_name: honeyos-dev
|
container_name: honeyos-dev
|
||||||
ports:
|
ports:
|
||||||
- "${HONEYOS_PORT:-8088}:80"
|
- "8088:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./web:/usr/share/nginx/html:ro
|
- ./devdata:/data
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
environment:
|
||||||
|
- HONEYOS_ADDR=:8080
|
||||||
+17
-44
@@ -1,13 +1,14 @@
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# HoneyOS — Betrieb aus der Gitea-Registry
|
# 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 login gitea.honeyy.duckdns.org
|
||||||
# docker compose pull && docker compose up -d
|
# docker compose pull && docker compose up -d
|
||||||
#
|
#
|
||||||
# Es gibt nichts zu sichern: HoneyOS hat keine Datenbank und kein Volume.
|
# Zu sichern ist genau eine Datei: ${HONEYOS_DATA_DIR}/honeyos.json — darin
|
||||||
# Der ganze Zustand ist die .env — und was der Browser sich pro Gerät in
|
# stehen die Apps und die Einstellungen je Nutzer. Sichere sie zusammen mit
|
||||||
# localStorage merkt (Betriebsart, Röhre).
|
# der .env; ohne HONEYOS_SESSION_SECRET müssen sich alle neu anmelden.
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -15,45 +16,17 @@ services:
|
|||||||
image: gitea.honeyy.duckdns.org/docker/honeyos:latest
|
image: gitea.honeyy.duckdns.org/docker/honeyos:latest
|
||||||
container_name: honeyos
|
container_name: honeyos
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
user: "1000:1000"
|
||||||
ports:
|
ports:
|
||||||
- "${HONEYOS_PORT:-8088}:80"
|
- "${HONEYOS_PORT:-8088}:8080"
|
||||||
|
volumes:
|
||||||
|
- ${HONEYOS_DATA_DIR:-/srv/honeyos}:/data
|
||||||
environment:
|
environment:
|
||||||
- HONEYOS_USER=${HONEYOS_USER:-}
|
- HONEYOS_PUBLIC_URL=${HONEYOS_PUBLIC_URL:-}
|
||||||
- HONEYOS_HOSTNAME=${HONEYOS_HOSTNAME:-honeyos}
|
- HONEYOS_SESSION_SECRET=${HONEYOS_SESSION_SECRET:-}
|
||||||
- HONEYOS_VERSION=${HONEYOS_VERSION:-0.1}
|
- HONEYOS_OIDC_ISSUER=${HONEYOS_OIDC_ISSUER:-}
|
||||||
|
- HONEYOS_OIDC_CLIENT_ID=${HONEYOS_OIDC_CLIENT_ID:-}
|
||||||
- HONEYOS_APP_1_ID=${HONEYOS_APP_1_ID:-}
|
- HONEYOS_OIDC_CLIENT_SECRET=${HONEYOS_OIDC_CLIENT_SECRET:-}
|
||||||
- HONEYOS_APP_1_NAME=${HONEYOS_APP_1_NAME:-}
|
- HONEYOS_OIDC_REDIRECT_URL=${HONEYOS_OIDC_REDIRECT_URL:-}
|
||||||
- HONEYOS_APP_1_SUBTITLE=${HONEYOS_APP_1_SUBTITLE:-}
|
- HONEYOS_BUTTON_LABEL=${HONEYOS_BUTTON_LABEL:-Anmelden}
|
||||||
- HONEYOS_APP_1_URL=${HONEYOS_APP_1_URL:-}
|
- HONEYOS_ALLOWED_USERS=${HONEYOS_ALLOWED_USERS:-}
|
||||||
- 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:-}
|
|
||||||
@@ -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>
|
<p>Eine App, die mit <code>100vh</code> rechnet, nimmt <code>--honeybar-h</code> ab.</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="./honeybar.js" data-app="inknote" data-home="../index.html"></script>
|
<script src="./honeybar.js" data-app="inknote" data-home="/"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+18
-15
@@ -12,7 +12,7 @@
|
|||||||
*
|
*
|
||||||
* data-Attribute am <script>:
|
* data-Attribute am <script>:
|
||||||
* data-app (Pflicht) id der App, in der die Leiste steckt
|
* 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-home URL des HoneyOS-Desktops
|
||||||
* data-push "body" (Standard) schiebt den Seiteninhalt nach unten,
|
* data-push "body" (Standard) schiebt den Seiteninhalt nach unten,
|
||||||
* "none" legt die Leiste darüber
|
* "none" legt die Leiste darüber
|
||||||
@@ -34,18 +34,18 @@
|
|||||||
const BAR_H = 32;
|
const BAR_H = 32;
|
||||||
const LS_HIDDEN = "honeybar.hidden";
|
const LS_HIDDEN = "honeybar.hidden";
|
||||||
|
|
||||||
const DEFAULT_APPS = [
|
// Notliste, falls der Server nichts herausgibt: nur der Weg zurück zum
|
||||||
{ id: "honeyos", name: "DESKTOP", url: "https://os.honeyy.duckdns.org" },
|
// Schreibtisch. Erfundene App-Adressen stünden hier sonst ewig fest.
|
||||||
{ id: "aether", name: "AETHER", url: "https://cloud.honeyy.duckdns.org" },
|
const DEFAULT_APPS = [{ id: "honeyos", name: "DESKTOP", url: "" }];
|
||||||
{ id: "inknote", name: "INKNOTE", url: "https://notes.honeyy.duckdns.org" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const current = d("app", "");
|
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");
|
const pushMode = d("push", "body");
|
||||||
// Ohne data-apps holt die Leiste die Konfiguration neben ihrem eigenen
|
// Ohne data-apps fragt die Leiste den HoneyOS-Server, von dem sie selbst
|
||||||
// Skript — dieselbe Datei, die der Desktop liest.
|
// geladen wurde. Er gibt die Liste nur an angemeldete Browser heraus und
|
||||||
const appsUrl = d("apps", script ? new URL("../config.json", script.src).href : "");
|
// 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;
|
let apps = DEFAULT_APPS;
|
||||||
|
|
||||||
@@ -268,15 +268,18 @@
|
|||||||
|
|
||||||
if (appsUrl) {
|
if (appsUrl) {
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const cfg = await res.json();
|
const list = await res.json();
|
||||||
if (Array.isArray(cfg.apps) && cfg.apps.length) {
|
if (Array.isArray(list) && list.length) {
|
||||||
apps = [{ id: "honeyos", name: "DESKTOP", url: homeUrl }].concat(cfg.apps);
|
apps = [{ id: "honeyos", name: "DESKTOP", url: homeUrl }].concat(list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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>
|
<p class="boot-hint">[ beliebige Taste = überspringen ]</p>
|
||||||
</div>
|
</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 ────────────────────────────────────────────────────── -->
|
<!-- ── Desktop ────────────────────────────────────────────────────── -->
|
||||||
<div class="desktop" id="desktop" hidden>
|
<div class="desktop" id="desktop" hidden>
|
||||||
<!-- Menüleiste: dieselbe Anatomie wie honeybar.js in den Apps -->
|
<!-- Menüleiste: dieselbe Anatomie wie honeybar.js in den Apps -->
|
||||||
@@ -26,9 +35,11 @@
|
|||||||
</button>
|
</button>
|
||||||
<nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav>
|
<nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav>
|
||||||
<div class="mb-right">
|
<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>
|
<span class="mb-clock" id="clock">--:--</span>
|
||||||
<button class="mb-btn" id="btnSettings" title="Einstellungen (S)">⚙</button>
|
<button class="mb-btn" id="btnSettings" title="Einstellungen (S)">⚙</button>
|
||||||
|
<button class="mb-btn" id="btnLogout" title="Abmelden" hidden>⏻</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const LS_SETTINGS = "honeyos.settings";
|
const LS_SETTINGS = "honeyos.settings";
|
||||||
const LS_CFG = "honeyos.config";
|
|
||||||
const SS_BOOTED = "honeyos.booted";
|
const SS_BOOTED = "honeyos.booted";
|
||||||
|
const VERSION = "0.2";
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
mode: "terminal", // terminal | desktop
|
mode: "terminal", // terminal | desktop
|
||||||
@@ -30,7 +30,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const state = {
|
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 },
|
settings: { ...DEFAULT_SETTINGS },
|
||||||
probes: {}, // appId -> "up" | "down" | "unknown"
|
probes: {}, // appId -> "up" | "down" | "unknown"
|
||||||
auth: {}, // appId -> {status: "in"|"out"|"blind", name}
|
auth: {}, // appId -> {status: "in"|"out"|"blind", name}
|
||||||
@@ -116,6 +118,34 @@ const GLYPHS = {
|
|||||||
"..#.####.#..",
|
"..#.####.#..",
|
||||||
"....####....",
|
"....####....",
|
||||||
],
|
],
|
||||||
|
globe: [
|
||||||
|
"............",
|
||||||
|
"...######...",
|
||||||
|
"..#..#...#..",
|
||||||
|
".#...#....#.",
|
||||||
|
"############",
|
||||||
|
"#....#.....#",
|
||||||
|
"#....#.....#",
|
||||||
|
"############",
|
||||||
|
".#...#....#.",
|
||||||
|
"..#..#...#..",
|
||||||
|
"...######...",
|
||||||
|
"............",
|
||||||
|
],
|
||||||
|
disk: [
|
||||||
|
"............",
|
||||||
|
"############",
|
||||||
|
"#..######..#",
|
||||||
|
"#..#....#..#",
|
||||||
|
"#..#....#..#",
|
||||||
|
"#..######..#",
|
||||||
|
"#..........#",
|
||||||
|
"#.########.#",
|
||||||
|
"#.#......#.#",
|
||||||
|
"#.#......#.#",
|
||||||
|
"############",
|
||||||
|
"............",
|
||||||
|
],
|
||||||
folder: [
|
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>`;
|
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 ─────────────────────────────────────────────────── */
|
/* ── Laden & Speichern ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
async function loadConfig() {
|
/** Holt Nutzer, Einstellungen und Apps. Wirft bei fehlender Anmeldung. */
|
||||||
let base = null;
|
async function loadFromServer() {
|
||||||
try {
|
const me = await api("/api/me");
|
||||||
const res = await fetch("./config.json", { cache: "no-store" });
|
state.me = { sub: me.sub, name: me.name, picture: me.picture };
|
||||||
if (res.ok) base = await res.json();
|
state.oidc = !!me.oidc;
|
||||||
} catch {
|
state.cfg.user = me.name || "";
|
||||||
/* offline oder file:// — dann zählt allein der lokale Stand */
|
|
||||||
}
|
|
||||||
if (base) state.cfg = { ...state.cfg, ...base };
|
|
||||||
|
|
||||||
const saved = readJson(LS_CFG);
|
// Der Server hat das letzte Wort; localStorage war nur der Vorschuss.
|
||||||
if (saved) {
|
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}), ...(me.settings || {}) };
|
||||||
// Gespeichert wird nur, was der Nutzer wirklich geändert hat: Name und URLs.
|
state.cfg.apps = await api("/api/apps");
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
|
||||||
|
* aufblitzt und dann umspringt. */
|
||||||
|
function loadLocalSettings() {
|
||||||
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
|
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,23 +234,24 @@ function readJson(key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let syncTimer = null;
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
|
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
|
||||||
} catch {
|
} 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();
|
applySettings();
|
||||||
}
|
|
||||||
|
|
||||||
function saveConfig() {
|
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
|
||||||
const urls = {};
|
// soll nicht dreimal schreiben lassen.
|
||||||
state.cfg.apps.forEach((a) => (urls[a.id] = a.url));
|
clearTimeout(syncTimer);
|
||||||
try {
|
syncTimer = setTimeout(() => {
|
||||||
localStorage.setItem(LS_CFG, JSON.stringify({ user: state.cfg.user, urls }));
|
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
|
||||||
} catch {
|
console.warn("Einstellungen nicht gespeichert:", e.message)
|
||||||
/* siehe oben */
|
);
|
||||||
}
|
}, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySettings() {
|
function applySettings() {
|
||||||
@@ -218,7 +271,7 @@ function bootLines() {
|
|||||||
{ t: `HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`, d: 90 },
|
{ t: `HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`, d: 90 },
|
||||||
{ t: "", d: 40 },
|
{ t: "", d: 40 },
|
||||||
{ t: "prüfe speicher".padEnd(28, ".") + " 640K ok", d: 120 },
|
{ 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 })),
|
...apps.map((a) => ({ t: `binde ${a.id} ein`.padEnd(28, ".") + " [ ok ]", d: 140 })),
|
||||||
{ t: "", d: 60 },
|
{ t: "", d: 60 },
|
||||||
{ t: `starte ${state.settings.mode}-modus`.padEnd(28, ".") + " bereit.", d: 200 },
|
{ t: `starte ${state.settings.mode}-modus`.padEnd(28, ".") + " bereit.", d: 200 },
|
||||||
@@ -280,6 +333,11 @@ function greeting() {
|
|||||||
function renderShell() {
|
function renderShell() {
|
||||||
$("desktop").hidden = false;
|
$("desktop").hidden = false;
|
||||||
$("mbUser").textContent = state.cfg.user;
|
$("mbUser").textContent = state.cfg.user;
|
||||||
|
if (state.me && state.me.picture) {
|
||||||
|
$("mbAvatar").src = state.me.picture;
|
||||||
|
$("mbAvatar").hidden = false;
|
||||||
|
}
|
||||||
|
$("btnLogout").hidden = !state.oidc;
|
||||||
renderMenubarApps();
|
renderMenubarApps();
|
||||||
renderMode();
|
renderMode();
|
||||||
tickClock();
|
tickClock();
|
||||||
@@ -525,8 +583,10 @@ function renderTiles() {
|
|||||||
|
|
||||||
const add = document.createElement("button");
|
const add = document.createElement("button");
|
||||||
add.className = "tile tile-add";
|
add.className = "tile tile-add";
|
||||||
add.innerHTML = "<span>+ app hinzufügen</span>";
|
add.innerHTML = state.cfg.apps.length
|
||||||
add.addEventListener("click", openSettings);
|
? "<span>+ app hinzufügen</span>"
|
||||||
|
: "<span>Noch keine App.<br />Hier die erste anlegen.</span>";
|
||||||
|
add.addEventListener("click", openApps);
|
||||||
wrap.appendChild(add);
|
wrap.appendChild(add);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,7 +804,7 @@ function openProgramsWindow() {
|
|||||||
title: "programme",
|
title: "programme",
|
||||||
w: 360,
|
w: 360,
|
||||||
body: `<div class="applist">${rows}
|
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>`,
|
</div>`,
|
||||||
onMount: (root) => {
|
onMount: (root) => {
|
||||||
root.querySelectorAll("[data-launch]").forEach((b) =>
|
root.querySelectorAll("[data-launch]").forEach((b) =>
|
||||||
@@ -753,7 +813,7 @@ function openProgramsWindow() {
|
|||||||
if (app) launch(app);
|
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)}
|
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<span class="label">Röhre</span>
|
<span class="label">Röhre</span>
|
||||||
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
|
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
|
||||||
@@ -816,20 +871,14 @@ function openSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Adressen</span>
|
<span class="label">Programme</span>
|
||||||
${state.cfg.apps
|
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
|
||||||
.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("")}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<p class="login-note" style="margin-top:4px">
|
||||||
<button class="btn" id="btnReset">zurücksetzen</button>
|
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
||||||
<button class="btn btn-primary" id="btnSave">speichern</button>
|
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
|
||||||
</div>`,
|
</p>`,
|
||||||
(root) => {
|
(root) => {
|
||||||
root.querySelectorAll(".seg").forEach((segEl) => {
|
root.querySelectorAll(".seg").forEach((segEl) => {
|
||||||
segEl.addEventListener("click", (e) => {
|
segEl.addEventListener("click", (e) => {
|
||||||
@@ -852,30 +901,114 @@ function openSettings() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
root.querySelector("#btnSave").addEventListener("click", () => {
|
root.querySelector("#btnApps").addEventListener("click", openApps);
|
||||||
const user = root.querySelector("#setUser").value.trim();
|
},
|
||||||
if (user) state.cfg.user = user;
|
420
|
||||||
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;
|
/* ── 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;
|
||||||
});
|
});
|
||||||
saveConfig();
|
});
|
||||||
if (state.settings.mode === "desktop") closeWindow("settings");
|
list.querySelectorAll("[data-del]").forEach((b) =>
|
||||||
else closeModal();
|
b.addEventListener("click", () => {
|
||||||
$("mbUser").textContent = state.cfg.user;
|
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();
|
renderMenubarApps();
|
||||||
renderMode();
|
renderMode();
|
||||||
probeApps();
|
probeApps();
|
||||||
});
|
} catch (e) {
|
||||||
|
msg.dataset.kind = "err";
|
||||||
root.querySelector("#btnReset").addEventListener("click", () => {
|
msg.textContent = e.message;
|
||||||
localStorage.removeItem(LS_SETTINGS);
|
}
|
||||||
localStorage.removeItem(LS_CFG);
|
|
||||||
location.reload();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
420
|
460
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,7 +1063,8 @@ function buildStartMenu() {
|
|||||||
<button data-win="system">System</button>
|
<button data-win="system">System</button>
|
||||||
<button data-win="settings">Einstellungen</button>
|
<button data-win="settings">Einstellungen</button>
|
||||||
<button data-win="about">Über HoneyOS</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) =>
|
menu.querySelectorAll("[data-app]").forEach((b) =>
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
@@ -951,6 +1085,7 @@ function buildStartMenu() {
|
|||||||
closeStartMenu();
|
closeStartMenu();
|
||||||
setMode("terminal");
|
setMode("terminal");
|
||||||
});
|
});
|
||||||
|
menu.querySelector("[data-logout]").addEventListener("click", logout);
|
||||||
|
|
||||||
menu.dataset.open = "1";
|
menu.dataset.open = "1";
|
||||||
btn.setAttribute("aria-expanded", "true");
|
btn.setAttribute("aria-expanded", "true");
|
||||||
@@ -968,10 +1103,91 @@ function closeStartMenu() {
|
|||||||
|
|
||||||
/* ── Start ─────────────────────────────────────────────────────────────── */
|
/* ── 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() {
|
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();
|
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;
|
let skipBoot = false;
|
||||||
try {
|
try {
|
||||||
skipBoot = sessionStorage.getItem(SS_BOOTED) === "1";
|
skipBoot = sessionStorage.getItem(SS_BOOTED) === "1";
|
||||||
@@ -988,6 +1204,7 @@ async function main() {
|
|||||||
|
|
||||||
$("btnSettings").addEventListener("click", openSettings);
|
$("btnSettings").addEventListener("click", openSettings);
|
||||||
$("btnAbout").addEventListener("click", openAbout);
|
$("btnAbout").addEventListener("click", openAbout);
|
||||||
|
$("btnLogout").addEventListener("click", logout);
|
||||||
$("btnClose").addEventListener("click", closeModal);
|
$("btnClose").addEventListener("click", closeModal);
|
||||||
$("modal").addEventListener("click", (e) => {
|
$("modal").addEventListener("click", (e) => {
|
||||||
if (e.target === $("modal")) closeModal();
|
if (e.target === $("modal")) closeModal();
|
||||||
@@ -995,7 +1212,7 @@ async function main() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
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") {
|
if (e.key === "Escape") {
|
||||||
// Bei offenem Startmenü schließt Esc nur das Menü — sonst nähme es
|
// Bei offenem Startmenü schließt Esc nur das Menü — sonst nähme es
|
||||||
// einem gleichzeitig das oberste Fenster weg.
|
// einem gleichzeitig das oberste Fenster weg.
|
||||||
|
|||||||
+123
@@ -762,3 +762,126 @@ body {
|
|||||||
.task-btn { min-width: 0; }
|
.task-btn { min-width: 0; }
|
||||||
.task-date { display: none; }
|
.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