refactor: aufgeraeumt — Pakete hinten, Widgets vorne
Go: store, auth, weather und config sind eigene Pakete unter internal/. Vorher lagen fuenf Dateien in einem Topf, und jede kannte jede. Web: os.js hatte 1425 Zeilen und alles darin. Jetzt core.js (Zustand, Server, Einstellungen), glyphs.js (die Pixelraster), js/widgets/ mit einem Widget je Datei, os.js nur noch die Ansichten. Stylesheet nach css/. Die HoneyBar bleibt eine einzelne Datei ohne Importe — sie wird von fremden Seiten mit einer Zeile eingebunden, da darf nichts nachladen. Nebenbei: Das gebaute Binary lag mit 9,8 MB im Repo und ist jetzt ignoriert. Und der Test gegen eine zweite Versionsnummer geht nicht mehr auf os.js, sondern durch alle .js — sonst haette ihn dieser Umbau still ausgehebelt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d08b0a229c
commit
52deffa30e
20 files changed
+780
-624
No files matched your search
@@ -1,3 +1,5 @@
|
||||
# Gebautes Binary — gehört nicht ins Repo (lag dort 9,8 MB lang)
|
||||
/honeyos
|
||||
devdata/
|
||||
.env
|
||||
*.tmp
|
||||
|
||||
@@ -5,6 +5,7 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY *.go ./
|
||||
COPY internal/ ./internal/
|
||||
COPY web/ ./web/
|
||||
RUN CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o /honeyos .
|
||||
|
||||
|
||||
@@ -402,6 +402,28 @@ Wer die Felder in seiner `honeyos.json` noch stehen hat, braucht nichts zu tun
|
||||
sie werden beim nächsten Speichern still verworfen.
|
||||
|
||||
|
||||
## Wie das Projekt liegt
|
||||
|
||||
```
|
||||
main.go Start, Routen, Handler
|
||||
internal/
|
||||
config/ was aus der Umgebung kommt
|
||||
store/ die eine JSON-Datei: Apps und Nutzer
|
||||
auth/ wer fragt da? (OIDC, Proxy, offen)
|
||||
weather/ Wetter holen und zehn Minuten behalten
|
||||
web/
|
||||
index.html
|
||||
css/style.css
|
||||
js/core.js Zustand, Server, Einstellungen, Kleinkram
|
||||
js/glyphs.js die Pixelsymbole
|
||||
js/os.js die beiden Ansichten, Fenster, Dialoge
|
||||
js/widgets/ ein Widget je Datei
|
||||
honeybar/ die HoneyBar für fremde Seiten — bewusst eine Datei
|
||||
```
|
||||
|
||||
Die HoneyBar bleibt absichtlich **eine** Datei ohne Importe: Sie wird von
|
||||
fremden Seiten mit einer Zeile eingebunden, da darf nichts nachladen.
|
||||
|
||||
## Entwickeln
|
||||
|
||||
```bash
|
||||
|
||||
+49
-34
@@ -1,6 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"honeyos/internal/auth"
|
||||
"honeyos/internal/config"
|
||||
"honeyos/internal/store"
|
||||
"io/fs"
|
||||
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
@@ -98,18 +103,18 @@ func (f *fakeIdP) idToken(t *testing.T) string {
|
||||
|
||||
func newTestServer(t *testing.T, issuer string) *Server {
|
||||
t.Helper()
|
||||
store, err := OpenStore(dataPath(t.TempDir()))
|
||||
st, err := store.Open(store.DataPath(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := Config{
|
||||
cfg := config.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}
|
||||
return &Server{cfg: cfg, auth: auth.New(cfg), store: st}
|
||||
}
|
||||
|
||||
// TestAnmeldeablauf geht den Weg einmal ganz durch: Umleitung zum Anbieter,
|
||||
@@ -140,7 +145,7 @@ func TestAnmeldeablauf(t *testing.T) {
|
||||
|
||||
var stateCk *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == stateCookie {
|
||||
if c.Name == auth.StateCookie {
|
||||
stateCk = c
|
||||
}
|
||||
}
|
||||
@@ -163,9 +168,9 @@ func TestAnmeldeablauf(t *testing.T) {
|
||||
var sess, csrf *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
switch c.Name {
|
||||
case sessionCookie:
|
||||
case auth.SessionCookie:
|
||||
sess = c
|
||||
case csrfCookie:
|
||||
case auth.CSRFCookie:
|
||||
csrf = c
|
||||
}
|
||||
}
|
||||
@@ -215,7 +220,7 @@ func TestGefälschtesCookieZähltNicht(t *testing.T) {
|
||||
h := newTestServer(t, idp.URL).routes()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "c3ViLTk5OQ.9999999999.gefälscht"})
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: "c3ViLTk5OQ.9999999999.gefälscht"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
@@ -315,7 +320,7 @@ func TestOffenerBetrieb(t *testing.T) {
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("Schreiben im offenen Betrieb: %d", rec.Code)
|
||||
}
|
||||
if s.store.User(localSub).Settings["phosphor"] != "green" {
|
||||
if s.store.User(store.LocalSub).Settings["phosphor"] != "green" {
|
||||
t.Error("Einstellung im offenen Betrieb nicht gespeichert")
|
||||
}
|
||||
}
|
||||
@@ -326,7 +331,7 @@ 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 {
|
||||
if err := s.store.SetApps([]store.App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test/"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -361,7 +366,7 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
|
||||
loc, _ := url.Parse(rec.Header().Get("Location"))
|
||||
var stateCk *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == stateCookie {
|
||||
if c.Name == auth.StateCookie {
|
||||
stateCk = c
|
||||
}
|
||||
}
|
||||
@@ -375,9 +380,9 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
|
||||
h.ServeHTTP(rec, req)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
switch c.Name {
|
||||
case sessionCookie:
|
||||
case auth.SessionCookie:
|
||||
sess = c
|
||||
case csrfCookie:
|
||||
case auth.CSRFCookie:
|
||||
csrf = c
|
||||
}
|
||||
}
|
||||
@@ -390,18 +395,18 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
|
||||
// ── Betrieb hinter einem Anmelde-Proxy ─────────────────────────────────────
|
||||
// Kein eigener Anmeldeschirm: Pangolin o. ä. hat die Tür schon zugemacht.
|
||||
|
||||
func proxyServer(t *testing.T, cfg Config) *Server {
|
||||
func proxyServer(t *testing.T, cfg config.Config) *Server {
|
||||
t.Helper()
|
||||
store, err := OpenStore(dataPath(t.TempDir()))
|
||||
st, err := store.Open(store.DataPath(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.SessionSecret = strings.Repeat("k", 40)
|
||||
return &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
|
||||
return &Server{cfg: cfg, auth: auth.New(cfg), store: st}
|
||||
}
|
||||
|
||||
func TestProxyBetriebLässtDurch(t *testing.T) {
|
||||
h := proxyServer(t, Config{ProxyAuth: true}).routes()
|
||||
h := proxyServer(t, config.Config{ProxyAuth: true}).routes()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil))
|
||||
@@ -420,7 +425,7 @@ func TestProxyBetriebLässtDurch(t *testing.T) {
|
||||
|
||||
// Nennt der Proxy einen Namen, bekommt jeder Name seine eigenen Einstellungen.
|
||||
func TestProxyKopfzeileTrenntEinstellungen(t *testing.T) {
|
||||
s := proxyServer(t, Config{ProxyAuth: true, UserHeader: "Remote-User"})
|
||||
s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User"})
|
||||
h := s.routes()
|
||||
|
||||
put := func(user, body string) {
|
||||
@@ -451,7 +456,7 @@ func TestAppListeBrauchtDenSchlüssel(t *testing.T) {
|
||||
idp := newFakeIdP(t, "honeyos")
|
||||
s := newTestServer(t, idp.URL)
|
||||
s.cfg.AppsKey = "geheim123"
|
||||
s.auth = NewAuth(s.cfg)
|
||||
s.auth = auth.New(s.cfg)
|
||||
h := s.routes()
|
||||
|
||||
get := func(mod func(*http.Request)) int {
|
||||
@@ -483,7 +488,7 @@ func TestSchlüsselDarfNichtSchreiben(t *testing.T) {
|
||||
idp := newFakeIdP(t, "honeyos")
|
||||
s := newTestServer(t, idp.URL)
|
||||
s.cfg.AppsKey = "geheim123"
|
||||
s.auth = NewAuth(s.cfg)
|
||||
s.auth = auth.New(s.cfg)
|
||||
h := s.routes()
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(`[{"id":"x","url":"https://a.test"}]`))
|
||||
@@ -497,7 +502,7 @@ func TestSchlüsselDarfNichtSchreiben(t *testing.T) {
|
||||
|
||||
// Die Oberfläche muss den Schlüssel mitbekommen, sonst fragt sie sich selbst aus.
|
||||
func TestStartseiteTrägtDenSchlüssel(t *testing.T) {
|
||||
s := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"})
|
||||
s := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||
|
||||
@@ -506,7 +511,7 @@ func TestStartseiteTrägtDenSchlüssel(t *testing.T) {
|
||||
}
|
||||
|
||||
// Ohne Schlüssel darf auch nichts drinstehen.
|
||||
s2 := proxyServer(t, Config{ProxyAuth: true})
|
||||
s2 := proxyServer(t, config.Config{ProxyAuth: true})
|
||||
rec = httptest.NewRecorder()
|
||||
s2.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||
if strings.Contains(rec.Body.String(), "honeyos-key") {
|
||||
@@ -518,7 +523,7 @@ func TestStartseiteTrägtDenSchlüssel(t *testing.T) {
|
||||
// jede Anfrage als angemeldet. Der Schlüssel darf dann NICHT auf diese Prüfung
|
||||
// zurückfallen, sonst gibt der freigegebene Pfad die Liste an jeden heraus.
|
||||
func TestSchlüsselGiltAuchHinterDemProxy(t *testing.T) {
|
||||
h := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"}).routes()
|
||||
h := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"}).routes()
|
||||
|
||||
get := func(key string) int {
|
||||
req := httptest.NewRequest("GET", "/api/apps", nil)
|
||||
@@ -543,11 +548,11 @@ func TestSchlüsselGiltAuchHinterDemProxy(t *testing.T) {
|
||||
|
||||
// Die Leiste stellt genau eine Anfrage — sie muss Liste und Aussehen bringen.
|
||||
func TestAppListeBringtDasAussehenMit(t *testing.T) {
|
||||
s := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"})
|
||||
if err := s.store.SetSettings(localSub, map[string]string{"phosphor": "green", "glow": "off"}); err != nil {
|
||||
s := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"})
|
||||
if err := s.store.SetSettings(store.LocalSub, map[string]string{"phosphor": "green", "glow": "off"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.store.SetApps([]App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test"}}); err != nil {
|
||||
if err := s.store.SetApps([]store.App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -557,7 +562,7 @@ func TestAppListeBringtDasAussehenMit(t *testing.T) {
|
||||
s.routes().ServeHTTP(rec, req)
|
||||
|
||||
var got struct {
|
||||
Apps []App
|
||||
Apps []store.App
|
||||
Settings map[string]string
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
@@ -574,7 +579,7 @@ func TestAppListeBringtDasAussehenMit(t *testing.T) {
|
||||
// Die angezeigte Version muss vom Server kommen. Vorher stand sie zusätzlich in
|
||||
// os.js und blieb bei 0.2 stehen, während die Images schon 0.4 hiessen.
|
||||
func TestVersionKommtVomServer(t *testing.T) {
|
||||
h := proxyServer(t, Config{ProxyAuth: true}).routes()
|
||||
h := proxyServer(t, config.Config{ProxyAuth: true}).routes()
|
||||
|
||||
for _, pfad := range []string{"/api/public", "/api/me"} {
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -586,14 +591,24 @@ func TestVersionKommtVomServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Und in der Oberfläche darf keine zweite fest eingetragen sein.
|
||||
raw, err := webFS.ReadFile("web/os.js")
|
||||
// Und in der Oberfläche darf keine zweite fest eingetragen sein — in keiner
|
||||
// Datei, egal wie die Oberfläche später aufgeteilt wird.
|
||||
err := fs.WalkDir(webFS, ".", func(pfad string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(pfad, ".js") {
|
||||
return err
|
||||
}
|
||||
raw, err := webFS.ReadFile(pfad)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(string(raw), `const VERSION = "`) {
|
||||
t.Errorf("%s trägt wieder eine eigene Version — die läuft mit den Image-Tags auseinander", pfad)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `const VERSION = "`) {
|
||||
t.Error("os.js trägt wieder eine eigene Version — die läuft mit den Image-Tags auseinander")
|
||||
}
|
||||
}
|
||||
|
||||
// Der Fall, nach dem Marc gefragt hat: Einstellungen am Rechner setzen, und die
|
||||
@@ -601,7 +616,7 @@ func TestVersionKommtVomServer(t *testing.T) {
|
||||
// also beim Standardnutzer — die Einstellungen können aber unter einem Namen
|
||||
// aus der Proxy-Kopfzeile liegen.
|
||||
func TestLeisteFindetDasAussehenAuchÜberDieSiteGrenze(t *testing.T) {
|
||||
s := proxyServer(t, Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"})
|
||||
s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"})
|
||||
h := s.routes()
|
||||
|
||||
// Am Rechner, durch den Proxy: grün.
|
||||
@@ -628,7 +643,7 @@ func TestLeisteFindetDasAussehenAuchÜberDieSiteGrenze(t *testing.T) {
|
||||
|
||||
// Bei mehreren Nutzern darf nicht geraten werden, wessen Farbe gilt.
|
||||
func TestBeiMehrerenNutzernWirdNichtGeraten(t *testing.T) {
|
||||
s := proxyServer(t, Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"})
|
||||
s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"})
|
||||
for name, farbe := range map[string]string{"marc": "green", "gast": "ice"} {
|
||||
if err := s.store.SetSettings("proxy:"+name, map[string]string{"phosphor": farbe}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package main
|
||||
// Package auth beantwortet eine einzige Frage: Wer fragt da?
|
||||
//
|
||||
// Drei Antworten sind möglich — der eigene OIDC-Anbieter, ein vorgelagerter
|
||||
// Proxy, oder niemand (offener Betrieb).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"honeyos/internal/config"
|
||||
"honeyos/internal/store"
|
||||
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
@@ -25,9 +32,11 @@ import (
|
||||
// nichts zu verlieren, ein Neustart wirft niemanden hinaus.
|
||||
|
||||
const (
|
||||
sessionCookie = "honeyos_session"
|
||||
csrfCookie = "honeyos_csrf"
|
||||
stateCookie = "honeyos_state"
|
||||
// Exportiert, weil die Server-Tests im Wurzelpaket dieselben Namen brauchen
|
||||
// — sonst stünden die Zeichenketten zweimal da und liefen auseinander.
|
||||
SessionCookie = "honeyos_session"
|
||||
CSRFCookie = "honeyos_csrf"
|
||||
StateCookie = "honeyos_state"
|
||||
sessionTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
@@ -38,7 +47,7 @@ type Identity struct {
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
cfg Config
|
||||
cfg config.Config
|
||||
secret []byte
|
||||
|
||||
once sync.Once
|
||||
@@ -48,7 +57,7 @@ type Auth struct {
|
||||
verifier *gooidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func NewAuth(cfg Config) *Auth {
|
||||
func New(cfg config.Config) *Auth {
|
||||
return &Auth{cfg: cfg, secret: []byte(cfg.SessionSecret)}
|
||||
}
|
||||
|
||||
@@ -95,17 +104,17 @@ func (a *Auth) sign(payload string) string {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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.
|
||||
@@ -120,9 +129,9 @@ func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
|
||||
return "proxy:" + u, true
|
||||
}
|
||||
}
|
||||
return localSub, true
|
||||
return store.LocalSub, true
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
c, err := r.Cookie(SessionCookie)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -145,11 +154,12 @@ func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
|
||||
return string(sub), true
|
||||
}
|
||||
|
||||
func (a *Auth) checkCSRF(r *http.Request) error {
|
||||
// CheckCSRF prüft das doppelt gesendete Prüfwort. Nötig bei allem, was schreibt.
|
||||
func (a *Auth) CheckCSRF(r *http.Request) error {
|
||||
if !a.Enabled() {
|
||||
return nil
|
||||
}
|
||||
c, err := r.Cookie(csrfCookie)
|
||||
c, err := r.Cookie(CSRFCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return errors.New("kein CSRF-Cookie")
|
||||
}
|
||||
@@ -172,14 +182,14 @@ func (a *Auth) Login(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
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) {
|
||||
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, st *store.Store) {
|
||||
fail := func(reason string) {
|
||||
setCookie(w, r, stateCookie, "", -1, true)
|
||||
setCookie(w, r, StateCookie, "", -1, true)
|
||||
http.Redirect(w, r, "/?fehler="+reason, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -189,7 +199,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
|
||||
return
|
||||
}
|
||||
|
||||
c, err := r.Cookie(stateCookie)
|
||||
c, err := r.Cookie(StateCookie)
|
||||
if err != nil {
|
||||
fail("abgelaufen")
|
||||
return
|
||||
@@ -199,7 +209,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
|
||||
fail("state")
|
||||
return
|
||||
}
|
||||
setCookie(w, r, stateCookie, "", -1, true)
|
||||
setCookie(w, r, StateCookie, "", -1, true)
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
@@ -243,7 +253,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SeenUser(claims.Sub, name, claims.Picture); err != nil {
|
||||
if err := st.SeenUser(claims.Sub, name, claims.Picture); err != nil {
|
||||
log.Printf("konnte Nutzer nicht merken: %v", err)
|
||||
}
|
||||
a.issueSession(w, r, claims.Sub)
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package config hält, was HoneyOS beim Start aus der Umgebung liest.
|
||||
//
|
||||
// Eigenes Paket, damit sowohl der Server als auch die Anmeldung darauf zugreifen
|
||||
// können, ohne dass eines vom anderen abhängt.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
PublicURL string
|
||||
DataDir string
|
||||
SessionSecret string
|
||||
|
||||
// Anmeldung — genau einer der beiden Wege:
|
||||
//
|
||||
// OIDCIssuer gesetzt → HoneyOS meldet selbst an, eigener Anmeldeschirm.
|
||||
// ProxyAuth gesetzt → ein vorgelagerter Proxy (Pangolin o. ä.) hat das
|
||||
// schon erledigt. HoneyOS fragt dann nicht nach.
|
||||
//
|
||||
// Keines von beiden heißt: offen. Das ist zum Entwickeln gedacht und sagt
|
||||
// beim Start deutlich, dass es so nicht ins Netz gehört.
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirect string
|
||||
ButtonLabel string
|
||||
AllowedUsers []string
|
||||
|
||||
ProxyAuth bool
|
||||
// UserHeader nennt die Kopfzeile, in die der Proxy den Nutzernamen schreibt
|
||||
// (z. B. Remote-User). Leer heißt: ein einziger Nutzer für alle.
|
||||
UserHeader string
|
||||
|
||||
// AppsKey schützt die App-Liste, wenn ihr Pfad im Proxy von der Anmeldung
|
||||
// befreit ist. Ohne Schlüssel liest sie jeder, der die Adresse kennt.
|
||||
AppsKey string
|
||||
}
|
||||
|
||||
func (c Config) RedirectURL() string {
|
||||
if c.OIDCRedirect != "" {
|
||||
return c.OIDCRedirect
|
||||
}
|
||||
return strings.TrimRight(c.PublicURL, "/") + "/api/auth/callback"
|
||||
}
|
||||
|
||||
func Env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
package main
|
||||
// Package store hält den ganzen Zustand von HoneyOS: die App-Liste und je
|
||||
// Nutzer ein paar Einstellungen.
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -17,6 +19,10 @@ import (
|
||||
// mit einem Editor reparieren. Geschrieben wird atomar (tmp + rename), damit
|
||||
// ein Absturz mitten im Schreiben keine halbe Datei hinterlässt.
|
||||
|
||||
// LocalSub ist die ID des einen Nutzers im offenen Betrieb — dann meldet sich
|
||||
// niemand an, und alles gehört ihm.
|
||||
const LocalSub = "local"
|
||||
|
||||
type App struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -55,7 +61,7 @@ var (
|
||||
settingKeyRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,31}$`)
|
||||
)
|
||||
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
func Open(path string) (*Store, error) {
|
||||
s := &Store{path: path, d: data{Apps: []App{}, Users: map[string]*User{}}}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
@@ -253,4 +259,5 @@ func (s *Store) AppOrigins() map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
func dataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
|
||||
// DataPath nennt die eine Datei, in der alles steht.
|
||||
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
|
||||
@@ -0,0 +1,37 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FakeFuerTest gibt einen Dienst, der einen erfundenen Wetterserver befragt.
|
||||
//
|
||||
// Steht bewusst nicht in der _test.go: Auch die Server-Tests im Wurzelpaket
|
||||
// brauchen ihn, und kein Test in diesem Projekt fragt je wirklich bei
|
||||
// Open-Meteo an — das wäre langsam, unzuverlässig und benutzte einen fremden
|
||||
// Dienst für unsere Testläufe.
|
||||
func FakeFuerTest(t *testing.T, code int, grad float64) *Dienst {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "search") {
|
||||
json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
|
||||
{"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
|
||||
"latitude": 50.938, "longitude": 6.96},
|
||||
}})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"current": map[string]any{
|
||||
"temperature_2m": grad, "weather_code": code,
|
||||
}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
d := Neu()
|
||||
d.wetterURL = srv.URL + "/forecast"
|
||||
d.sucheURL = srv.URL + "/search"
|
||||
return d
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
package main
|
||||
// Package weather holt das Wetter — serverseitig, damit nicht jede Seite in
|
||||
// jeder App selbst beim Wetterdienst anfragt.
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -37,13 +39,13 @@ type Wetter struct {
|
||||
}
|
||||
|
||||
type Ort struct {
|
||||
Name string `json:"name"`
|
||||
Land string `json:"land"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Name string `json:"name"`
|
||||
Land string `json:"land"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
type wetterDienst struct {
|
||||
type Dienst struct {
|
||||
mu sync.Mutex
|
||||
cache map[string]wetterEintrag
|
||||
client *http.Client
|
||||
@@ -58,8 +60,8 @@ type wetterEintrag struct {
|
||||
bis time.Time
|
||||
}
|
||||
|
||||
func neuerWetterdienst() *wetterDienst {
|
||||
return &wetterDienst{
|
||||
func Neu() *Dienst {
|
||||
return &Dienst{
|
||||
cache: map[string]wetterEintrag{},
|
||||
client: &http.Client{Timeout: wetterTimeout},
|
||||
wetterURL: "https://api.open-meteo.com/v1/forecast",
|
||||
@@ -95,7 +97,7 @@ func wmo(code int) (glyph, text string) {
|
||||
// frisch ist. Das Wetter ändert sich langsamer, als Seiten neu geladen werden;
|
||||
// ohne diesen Speicher stellte jeder Seitenaufruf in jeder App eine Anfrage
|
||||
// nach draußen.
|
||||
func (d *wetterDienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
|
||||
func (d *Dienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
|
||||
schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon)
|
||||
|
||||
d.mu.Lock()
|
||||
@@ -156,7 +158,7 @@ func (d *wetterDienst) Jetzt(ctx context.Context, ort string, lat, lon float64)
|
||||
// Suche findet Orte zu einem Namen. Nur für die Einstellungen: Der gefundene
|
||||
// Ort wird mit seinen Koordinaten gespeichert, damit die spätere Abfrage nicht
|
||||
// jedes Mal erneut sucht.
|
||||
func (d *wetterDienst) Suche(ctx context.Context, name string) ([]Ort, error) {
|
||||
func (d *Dienst) Suche(ctx context.Context, name string) ([]Ort, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("kein Suchbegriff")
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// Ein erfundener Wetterdienst. Kein Test in diesem Projekt fragt je wirklich
|
||||
// bei Open-Meteo an: Das wäre langsam, unzuverlässig und würde einen fremden
|
||||
// Dienst für unsere Testläufe benutzen.
|
||||
func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *wetterDienst {
|
||||
func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *Dienst {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if aufrufe != nil {
|
||||
@@ -32,7 +32,7 @@ func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *wetterDie
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
d := neuerWetterdienst()
|
||||
d := Neu()
|
||||
d.wetterURL = srv.URL + "/forecast"
|
||||
d.sucheURL = srv.URL + "/search"
|
||||
return d
|
||||
@@ -114,37 +114,3 @@ func TestSucheOhneBegriffFragtNichtAn(t *testing.T) {
|
||||
t.Fatalf("leerer Begriff ging trotzdem nach draußen (%d)", aufrufe)
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne eingestellten Ort ist 204 die richtige Antwort: kein Fehler, nur nichts
|
||||
// zu zeigen. Die Leiste lässt das Symbol dann weg, statt etwas zu melden.
|
||||
func TestOhneOrtGibtEsKeinWetter(t *testing.T) {
|
||||
s := newTestServer(t, "") // ohne Anbieter: offener Betrieb, kein Anmelden nötig
|
||||
s.wetter = fakeWetter(t, 0, 20, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("ohne Ort erwartet 204, bekam %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitOrtKommtDasWetter(t *testing.T) {
|
||||
s := newTestServer(t, "")
|
||||
s.wetter = fakeWetter(t, 3, 8.6, nil)
|
||||
if err := s.store.SetSettings(localSub, map[string]string{
|
||||
"weatherCity": "Köln", "weatherLat": "50.938", "weatherLon": "6.96",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("erwartet 200, bekam %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var w Wetter
|
||||
json.Unmarshal(rec.Body.Bytes(), &w)
|
||||
if w.Ort != "Köln" || w.Glyph != "wolke" {
|
||||
t.Fatalf("falsches Wetter: %+v", w)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"honeyos/internal/auth"
|
||||
"honeyos/internal/config"
|
||||
"honeyos/internal/store"
|
||||
"honeyos/internal/weather"
|
||||
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
@@ -22,76 +28,35 @@ import (
|
||||
//go:embed all:web
|
||||
var webFS embed.FS
|
||||
|
||||
// localSub ist die Kennung im offenen Betrieb — dann gibt es genau einen Nutzer.
|
||||
const localSub = "local"
|
||||
|
||||
// Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus
|
||||
// und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public.
|
||||
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
|
||||
const Version = "1.0"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
PublicURL string
|
||||
DataDir string
|
||||
SessionSecret string
|
||||
|
||||
// Anmeldung — genau einer der beiden Wege:
|
||||
//
|
||||
// OIDCIssuer gesetzt → HoneyOS meldet selbst an, eigener Anmeldeschirm.
|
||||
// ProxyAuth gesetzt → ein vorgelagerter Proxy (Pangolin o. ä.) hat das
|
||||
// schon erledigt. HoneyOS fragt dann nicht nach.
|
||||
//
|
||||
// Keines von beiden heißt: offen. Das ist zum Entwickeln gedacht und sagt
|
||||
// beim Start deutlich, dass es so nicht ins Netz gehört.
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirect string
|
||||
ButtonLabel string
|
||||
AllowedUsers []string
|
||||
|
||||
ProxyAuth bool
|
||||
// UserHeader nennt die Kopfzeile, in die der Proxy den Nutzernamen schreibt
|
||||
// (z. B. Remote-User). Leer heißt: ein einziger Nutzer für alle.
|
||||
UserHeader string
|
||||
|
||||
// AppsKey schützt die App-Liste, wenn ihr Pfad im Proxy von der Anmeldung
|
||||
// befreit ist. Ohne Schlüssel liest sie jeder, der die Adresse kennt.
|
||||
AppsKey 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
|
||||
wetter *wetterDienst
|
||||
cfg config.Config
|
||||
auth *auth.Auth
|
||||
store *store.Store
|
||||
wetter *weather.Dienst
|
||||
}
|
||||
|
||||
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", "")),
|
||||
ProxyAuth: env("HONEYOS_PROXY_AUTH", "") == "true",
|
||||
UserHeader: env("HONEYOS_USER_HEADER", ""),
|
||||
AppsKey: env("HONEYOS_APPS_KEY", ""),
|
||||
cfg := config.Config{
|
||||
Addr: config.Env("HONEYOS_ADDR", ":80"),
|
||||
PublicURL: config.Env("HONEYOS_PUBLIC_URL", ""),
|
||||
DataDir: config.Env("HONEYOS_DATA_DIR", "/data"),
|
||||
SessionSecret: config.Env("HONEYOS_SESSION_SECRET", ""),
|
||||
OIDCIssuer: strings.TrimRight(config.Env("HONEYOS_OIDC_ISSUER", ""), "/"),
|
||||
OIDCClientID: config.Env("HONEYOS_OIDC_CLIENT_ID", ""),
|
||||
OIDCClientSecret: config.Env("HONEYOS_OIDC_CLIENT_SECRET", ""),
|
||||
OIDCRedirect: config.Env("HONEYOS_OIDC_REDIRECT_URL", ""),
|
||||
ButtonLabel: config.Env("HONEYOS_BUTTON_LABEL", "Anmelden"),
|
||||
AllowedUsers: splitList(config.Env("HONEYOS_ALLOWED_USERS", "")),
|
||||
ProxyAuth: config.Env("HONEYOS_PROXY_AUTH", "") == "true",
|
||||
UserHeader: config.Env("HONEYOS_USER_HEADER", ""),
|
||||
AppsKey: config.Env("HONEYOS_APPS_KEY", ""),
|
||||
}
|
||||
|
||||
if cfg.OIDCIssuer != "" {
|
||||
@@ -125,12 +90,12 @@ func main() {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err)
|
||||
}
|
||||
store, err := OpenStore(dataPath(cfg.DataDir))
|
||||
st, err := store.Open(store.DataPath(cfg.DataDir))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
s := &Server{cfg: cfg, auth: NewAuth(cfg), store: store, wetter: neuerWetterdienst()}
|
||||
s := &Server{cfg: cfg, auth: auth.New(cfg), store: st, wetter: weather.Neu()}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
@@ -139,7 +104,7 @@ func main() {
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
log.Printf("HoneyOS %s lauscht auf %s (Daten: %s, Anmeldung: %s)",
|
||||
Version, cfg.Addr, dataPath(cfg.DataDir), map[bool]string{true: cfg.OIDCIssuer, false: "aus"}[s.auth.Enabled()])
|
||||
Version, cfg.Addr, store.DataPath(cfg.DataDir), map[bool]string{true: cfg.OIDCIssuer, false: "aus"}[s.auth.Enabled()])
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
|
||||
@@ -265,7 +230,7 @@ func (s *Server) appsReadable(next func(http.ResponseWriter, *http.Request, stri
|
||||
given = r.URL.Query().Get("key")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 {
|
||||
next(w, r, localSub)
|
||||
next(w, r, store.LocalSub)
|
||||
return
|
||||
}
|
||||
// Ohne eigene Anmeldung gibt es keine Sitzung, die den Schlüssel
|
||||
@@ -288,7 +253,7 @@ func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, strin
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
if err := s.auth.checkCSRF(r); err != nil {
|
||||
if err := s.auth.CheckCSRF(r); err != nil {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -310,7 +275,7 @@ func (s *Server) handlePublic(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.auth.clearSession(w, r)
|
||||
s.auth.ClearSession(w, r)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -318,7 +283,7 @@ 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"),
|
||||
"name": cmp.Or(u.Name, "marc"),
|
||||
"picture": u.Picture,
|
||||
"settings": u.Settings,
|
||||
"oidc": s.auth.Enabled(),
|
||||
@@ -388,7 +353,7 @@ func (s *Server) handleWeatherSearch(w http.ResponseWriter, r *http.Request, sub
|
||||
}
|
||||
|
||||
func (s *Server) handleSetApps(w http.ResponseWriter, r *http.Request, sub string) {
|
||||
var in []App
|
||||
var in []store.App
|
||||
if err := decode(r, &in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -414,13 +379,6 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
_ = 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, ",") {
|
||||
|
||||
File renamed without changes.
+2
-2
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#0a0705" />
|
||||
<title>HoneyOS</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%230a0705'/%3E%3Cpath d='M4 4h2v3h4V4h2v8h-2V9H6v3H4z' fill='%23ffb641'/%3E%3C/svg%3E" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<link rel="stylesheet" href="./css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Bildschirm: alles liegt darin, damit Scanlines/Vignette darüber passen -->
|
||||
@@ -110,6 +110,6 @@
|
||||
<div class="flicker" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<script src="./os.js"></script>
|
||||
<script type="module" src="./js/os.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/* Der gemeinsame Unterbau: Zustand, Server, Einstellungen, Kleinkram.
|
||||
*
|
||||
* Alles, was mehr als eine Datei braucht, steht hier — und nur das. Die
|
||||
* Ansichten (os.js) und die Widgets holen es sich von hier, statt sich
|
||||
* gegenseitig zu kennen.
|
||||
*/
|
||||
export const LS_SETTINGS = "honeyos.settings";
|
||||
export const SS_BOOTED = "honeyos.booted";
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
mode: "terminal", // terminal | desktop
|
||||
phosphor: "amber", // amber | green | ice
|
||||
scanlines: "on",
|
||||
flicker: "on",
|
||||
glow: "on",
|
||||
openIn: "tab", // tab | same
|
||||
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind
|
||||
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
|
||||
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
|
||||
weatherBar: "off",
|
||||
weatherDesk: "off",
|
||||
weatherCity: "",
|
||||
weatherLat: "",
|
||||
weatherLon: "",
|
||||
};
|
||||
|
||||
export const state = {
|
||||
cfg: { user: "", hostname: "honeyos", version: "…", apps: [] },
|
||||
me: null, // {sub, name, picture} sobald angemeldet
|
||||
weather: null, // zuletzt geholtes Wetter, oder null
|
||||
oidc: false,
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
probes: {}, // appId -> "up" | "down" | "unknown"
|
||||
wins: {}, // id -> {el, task, min}
|
||||
zTop: 10,
|
||||
};
|
||||
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
export const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
|
||||
|
||||
/* ── 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.
|
||||
*/
|
||||
/** Der Schlüssel für die App-Liste steht als Meta-Angabe in der Seite — der
|
||||
* Server legt ihn dort hinein, wenn einer eingerichtet ist. */
|
||||
export const APPS_KEY = document.querySelector('meta[name="honeyos-key"]')?.content || "";
|
||||
|
||||
export async function api(path, opts = {}) {
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
|
||||
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;
|
||||
}
|
||||
|
||||
export function cookie(name) {
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim().split("="))
|
||||
.find(([k]) => k === name)?.[1] || "";
|
||||
}
|
||||
|
||||
export function loadLocalSettings() {
|
||||
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
|
||||
}
|
||||
|
||||
export function readJson(key) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let syncTimer = null;
|
||||
|
||||
export function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
|
||||
} catch {
|
||||
/* Privatmodus: dann gilt es nur für diese Sitzung — der Server merkt es trotzdem */
|
||||
}
|
||||
applySettings();
|
||||
|
||||
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
|
||||
// soll nicht dreimal schreiben lassen.
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(() => {
|
||||
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
|
||||
console.warn("Einstellungen nicht gespeichert:", e.message)
|
||||
);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
export function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
||||
}
|
||||
export function escapeAttr(s) {
|
||||
return escapeHtml(s).replace(/"/g, """);
|
||||
}
|
||||
|
||||
/* Die Einstellungen schlagen als Datenattribute auf die Seite durch; das CSS
|
||||
hängt daran. Steht hier, weil saveSettings() es sofort anwenden muss. */
|
||||
export function applySettings() {
|
||||
const r = document.documentElement;
|
||||
r.dataset.phosphor = state.settings.phosphor;
|
||||
r.dataset.scanlines = state.settings.scanlines;
|
||||
r.dataset.flicker = state.settings.flicker;
|
||||
r.dataset.glow = state.settings.glow;
|
||||
r.dataset.mode = state.settings.mode;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* Die Pixelsymbole von HoneyOS.
|
||||
*
|
||||
* Eigene Datei, weil sie Daten sind und nicht Verhalten: zwölf mal zwölf
|
||||
* Zeichen je Symbol, mehr passiert hier nicht. Dieselben Raster stehen noch
|
||||
* einmal in honeybar/honeybar.js — die soll eine einzelne Datei bleiben, die
|
||||
* eine fremde App mit einer Zeile einbindet (siehe IDEAS 1.3).
|
||||
*/
|
||||
/* ── Pixel-Glyphen ─────────────────────────────────────────────────────────
|
||||
* 12×12-Raster als Zeichenketten. Ein `#` ist ein Pixel. So bleiben die Icons
|
||||
* im Retro-Raster statt aus einer Icon-Bibliothek zu kommen.
|
||||
*/
|
||||
export const GLYPHS = {
|
||||
// Wettersymbole. Dieselben Namen wie in weather.go (wmo) und in der HoneyBar —
|
||||
// solange 1.3 (geteilte Symbolquelle) nicht gebaut ist, stehen sie zweimal da.
|
||||
sonne: [
|
||||
"............",
|
||||
".....##.....",
|
||||
"..#..##..#..",
|
||||
"...#....#...",
|
||||
"....####....",
|
||||
"..##....##..",
|
||||
"..##....##..",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#..##..#..",
|
||||
".....##.....",
|
||||
"............",
|
||||
],
|
||||
heiter: [
|
||||
"............",
|
||||
"...##.......",
|
||||
"..####..#...",
|
||||
"...##..###..",
|
||||
"......#####.",
|
||||
"...####...#.",
|
||||
"..#....##.#.",
|
||||
".##........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
wolke: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
nebel: [
|
||||
"............",
|
||||
"....####....",
|
||||
"..##....##..",
|
||||
".##.......#.",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
".#########..",
|
||||
"............",
|
||||
"..#########.",
|
||||
"............",
|
||||
".#########..",
|
||||
],
|
||||
regen: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"..#..#..#...",
|
||||
".#..#..#....",
|
||||
"..#..#..#...",
|
||||
".#..#..#....",
|
||||
],
|
||||
schnee: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"..#...#...#.",
|
||||
".#.#.#.#.#..",
|
||||
"..#...#...#.",
|
||||
"............",
|
||||
],
|
||||
gewitter: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"......##....",
|
||||
".....##.....",
|
||||
"....####....",
|
||||
"......##....",
|
||||
".....##.....",
|
||||
],
|
||||
|
||||
cloud: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
note: [
|
||||
"..########..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.##...#..",
|
||||
"..#......#..",
|
||||
"..########..",
|
||||
"............",
|
||||
],
|
||||
box: [
|
||||
"............",
|
||||
".##########.",
|
||||
".#........#.",
|
||||
".#........#.",
|
||||
".#...##...#.",
|
||||
".#..####..#.",
|
||||
".#..####..#.",
|
||||
".#...##...#.",
|
||||
".#........#.",
|
||||
".#........#.",
|
||||
".##########.",
|
||||
"............",
|
||||
],
|
||||
chip: [
|
||||
"............",
|
||||
"..#.#.#.#...",
|
||||
".##########.",
|
||||
".#........#.",
|
||||
"##.######.##",
|
||||
".#.#....#.#.",
|
||||
"##.#....#.##",
|
||||
".#.######.#.",
|
||||
".#........#.",
|
||||
".##########.",
|
||||
"..#.#.#.#...",
|
||||
"............",
|
||||
],
|
||||
gear: [
|
||||
"....####....",
|
||||
"..#.####.#..",
|
||||
"..########..",
|
||||
".####..####.",
|
||||
"###......###",
|
||||
"##........##",
|
||||
"##........##",
|
||||
"###......###",
|
||||
".####..####.",
|
||||
"..########..",
|
||||
"..#.####.#..",
|
||||
"....####....",
|
||||
],
|
||||
globe: [
|
||||
"............",
|
||||
"...######...",
|
||||
"..#..#...#..",
|
||||
".#...#....#.",
|
||||
"############",
|
||||
"#....#.....#",
|
||||
"#....#.....#",
|
||||
"############",
|
||||
".#...#....#.",
|
||||
"..#..#...#..",
|
||||
"...######...",
|
||||
"............",
|
||||
],
|
||||
disk: [
|
||||
"............",
|
||||
"############",
|
||||
"#..######..#",
|
||||
"#..#....#..#",
|
||||
"#..#....#..#",
|
||||
"#..######..#",
|
||||
"#..........#",
|
||||
"#.########.#",
|
||||
"#.#......#.#",
|
||||
"#.#......#.#",
|
||||
"############",
|
||||
"............",
|
||||
],
|
||||
folder: [
|
||||
"............",
|
||||
".####.......",
|
||||
"#....#......",
|
||||
"#.....######",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"############",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
};
|
||||
|
||||
export function glyphSvg(name) {
|
||||
const grid = GLYPHS[name] || GLYPHS.box;
|
||||
const rects = [];
|
||||
grid.forEach((row, y) => {
|
||||
// Waagerechte Läufe zusammenfassen: weniger Rechtecke, gleiches Bild.
|
||||
let run = 0;
|
||||
for (let x = 0; x <= row.length; x++) {
|
||||
if (row[x] === "#") {
|
||||
run++;
|
||||
} else if (run) {
|
||||
rects.push(`<rect x="${x - run}" y="${y}" width="${run}" height="1"/>`);
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
return `<svg viewBox="0 0 12 12" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${rects.join("")}</svg>`;
|
||||
}
|
||||
+9
-443
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, isTouch,
|
||||
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
|
||||
SS_BOOTED, state,
|
||||
} from "./core.js";
|
||||
import { GLYPHS, glyphSvg } from "./glyphs.js";
|
||||
import { renderUser } from "./widgets/user.js";
|
||||
import { loadWeather, renderWeather } from "./widgets/weather.js";
|
||||
|
||||
/*
|
||||
* HoneyOS — Desktop-Logik.
|
||||
*
|
||||
@@ -16,308 +25,8 @@
|
||||
* gemeinsamen Teile hier oben und die beiden Ansichten weiter unten getrennt.
|
||||
*/
|
||||
|
||||
const LS_SETTINGS = "honeyos.settings";
|
||||
const SS_BOOTED = "honeyos.booted";
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
mode: "terminal", // terminal | desktop
|
||||
phosphor: "amber", // amber | green | ice
|
||||
scanlines: "on",
|
||||
flicker: "on",
|
||||
glow: "on",
|
||||
openIn: "tab", // tab | same
|
||||
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind
|
||||
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
|
||||
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
|
||||
weatherBar: "off",
|
||||
weatherDesk: "off",
|
||||
weatherCity: "",
|
||||
weatherLat: "",
|
||||
weatherLon: "",
|
||||
};
|
||||
|
||||
const state = {
|
||||
cfg: { user: "", hostname: "honeyos", version: "…", apps: [] },
|
||||
me: null, // {sub, name, picture} sobald angemeldet
|
||||
weather: null, // zuletzt geholtes Wetter, oder null
|
||||
oidc: false,
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
probes: {}, // appId -> "up" | "down" | "unknown"
|
||||
wins: {}, // id -> {el, task, min}
|
||||
zTop: 10,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
|
||||
|
||||
/* ── Pixel-Glyphen ─────────────────────────────────────────────────────────
|
||||
* 12×12-Raster als Zeichenketten. Ein `#` ist ein Pixel. So bleiben die Icons
|
||||
* im Retro-Raster statt aus einer Icon-Bibliothek zu kommen.
|
||||
*/
|
||||
const GLYPHS = {
|
||||
// Wettersymbole. Dieselben Namen wie in weather.go (wmo) und in der HoneyBar —
|
||||
// solange 1.3 (geteilte Symbolquelle) nicht gebaut ist, stehen sie zweimal da.
|
||||
sonne: [
|
||||
"............",
|
||||
".....##.....",
|
||||
"..#..##..#..",
|
||||
"...#....#...",
|
||||
"....####....",
|
||||
"..##....##..",
|
||||
"..##....##..",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#..##..#..",
|
||||
".....##.....",
|
||||
"............",
|
||||
],
|
||||
heiter: [
|
||||
"............",
|
||||
"...##.......",
|
||||
"..####..#...",
|
||||
"...##..###..",
|
||||
"......#####.",
|
||||
"...####...#.",
|
||||
"..#....##.#.",
|
||||
".##........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
wolke: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
nebel: [
|
||||
"............",
|
||||
"....####....",
|
||||
"..##....##..",
|
||||
".##.......#.",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
".#########..",
|
||||
"............",
|
||||
"..#########.",
|
||||
"............",
|
||||
".#########..",
|
||||
],
|
||||
regen: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"..#..#..#...",
|
||||
".#..#..#....",
|
||||
"..#..#..#...",
|
||||
".#..#..#....",
|
||||
],
|
||||
schnee: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"..#...#...#.",
|
||||
".#.#.#.#.#..",
|
||||
"..#...#...#.",
|
||||
"............",
|
||||
],
|
||||
gewitter: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"......##....",
|
||||
".....##.....",
|
||||
"....####....",
|
||||
"......##....",
|
||||
".....##.....",
|
||||
],
|
||||
|
||||
cloud: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
note: [
|
||||
"..########..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.##...#..",
|
||||
"..#......#..",
|
||||
"..########..",
|
||||
"............",
|
||||
],
|
||||
box: [
|
||||
"............",
|
||||
".##########.",
|
||||
".#........#.",
|
||||
".#........#.",
|
||||
".#...##...#.",
|
||||
".#..####..#.",
|
||||
".#..####..#.",
|
||||
".#...##...#.",
|
||||
".#........#.",
|
||||
".#........#.",
|
||||
".##########.",
|
||||
"............",
|
||||
],
|
||||
chip: [
|
||||
"............",
|
||||
"..#.#.#.#...",
|
||||
".##########.",
|
||||
".#........#.",
|
||||
"##.######.##",
|
||||
".#.#....#.#.",
|
||||
"##.#....#.##",
|
||||
".#.######.#.",
|
||||
".#........#.",
|
||||
".##########.",
|
||||
"..#.#.#.#...",
|
||||
"............",
|
||||
],
|
||||
gear: [
|
||||
"....####....",
|
||||
"..#.####.#..",
|
||||
"..########..",
|
||||
".####..####.",
|
||||
"###......###",
|
||||
"##........##",
|
||||
"##........##",
|
||||
"###......###",
|
||||
".####..####.",
|
||||
"..########..",
|
||||
"..#.####.#..",
|
||||
"....####....",
|
||||
],
|
||||
globe: [
|
||||
"............",
|
||||
"...######...",
|
||||
"..#..#...#..",
|
||||
".#...#....#.",
|
||||
"############",
|
||||
"#....#.....#",
|
||||
"#....#.....#",
|
||||
"############",
|
||||
".#...#....#.",
|
||||
"..#..#...#..",
|
||||
"...######...",
|
||||
"............",
|
||||
],
|
||||
disk: [
|
||||
"............",
|
||||
"############",
|
||||
"#..######..#",
|
||||
"#..#....#..#",
|
||||
"#..#....#..#",
|
||||
"#..######..#",
|
||||
"#..........#",
|
||||
"#.########.#",
|
||||
"#.#......#.#",
|
||||
"#.#......#.#",
|
||||
"############",
|
||||
"............",
|
||||
],
|
||||
folder: [
|
||||
"............",
|
||||
".####.......",
|
||||
"#....#......",
|
||||
"#.....######",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"############",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
};
|
||||
|
||||
function glyphSvg(name) {
|
||||
const grid = GLYPHS[name] || GLYPHS.box;
|
||||
const rects = [];
|
||||
grid.forEach((row, y) => {
|
||||
// Waagerechte Läufe zusammenfassen: weniger Rechtecke, gleiches Bild.
|
||||
let run = 0;
|
||||
for (let x = 0; x <= row.length; x++) {
|
||||
if (row[x] === "#") {
|
||||
run++;
|
||||
} else if (run) {
|
||||
rects.push(`<rect x="${x - run}" y="${y}" width="${run}" height="1"/>`);
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
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.
|
||||
*/
|
||||
/** Der Schlüssel für die App-Liste steht als Meta-Angabe in der Seite — der
|
||||
* Server legt ihn dort hinein, wenn einer eingerichtet ist. */
|
||||
const APPS_KEY = document.querySelector('meta[name="honeyos-key"]')?.content || "";
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
|
||||
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 ─────────────────────────────────────────────────── */
|
||||
|
||||
@@ -336,46 +45,8 @@ async function loadFromServer() {
|
||||
|
||||
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
|
||||
* aufblitzt und dann umspringt. */
|
||||
function loadLocalSettings() {
|
||||
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
|
||||
}
|
||||
|
||||
function readJson(key) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let syncTimer = null;
|
||||
|
||||
function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
|
||||
} catch {
|
||||
/* Privatmodus: dann gilt es nur für diese Sitzung — der Server merkt es trotzdem */
|
||||
}
|
||||
applySettings();
|
||||
|
||||
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
|
||||
// soll nicht dreimal schreiben lassen.
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(() => {
|
||||
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
|
||||
console.warn("Einstellungen nicht gespeichert:", e.message)
|
||||
);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
const r = document.documentElement;
|
||||
r.dataset.phosphor = state.settings.phosphor;
|
||||
r.dataset.scanlines = state.settings.scanlines;
|
||||
r.dataset.flicker = state.settings.flicker;
|
||||
r.dataset.glow = state.settings.glow;
|
||||
r.dataset.mode = state.settings.mode;
|
||||
}
|
||||
|
||||
/* ── Boot ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -436,13 +107,6 @@ function runBoot() {
|
||||
|
||||
/* ── Gemeinsames: Menüleiste, Uhr, Stand, Start ────────────────────────── */
|
||||
|
||||
function greeting() {
|
||||
const h = new Date().getHours();
|
||||
if (h < 5 || h >= 23) return "Noch wach?";
|
||||
if (h < 11) return "Guten Morgen.";
|
||||
if (h < 18) return "Willkommen zurück.";
|
||||
return "Guten Abend.";
|
||||
}
|
||||
|
||||
function renderShell() {
|
||||
$("desktop").hidden = false;
|
||||
@@ -497,99 +161,7 @@ function renderMode() {
|
||||
paintProbes();
|
||||
}
|
||||
|
||||
/* ── Wer angemeldet ist ────────────────────────────────────────────────────
|
||||
*
|
||||
* Stand früher rechts in der Menüleiste. Dort war es das einzige Stück, das
|
||||
* die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst
|
||||
* merkt man beim Wechsel, dass es zwei verschiedene Leisten sind. Auf dem
|
||||
* Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu.
|
||||
*/
|
||||
function userHtml() {
|
||||
const bild = state.me && state.me.picture
|
||||
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
|
||||
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
|
||||
return `
|
||||
<div class="uw">
|
||||
${bild}
|
||||
<div class="uw-text">
|
||||
<b class="uw-name">${state.cfg.user}</b>
|
||||
<span class="uw-gruss">${greeting()}</span>
|
||||
<span class="uw-host">${state.cfg.user}@${state.cfg.hostname}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderUser() {
|
||||
const tile = $("userTile");
|
||||
const desk = $("userDesk");
|
||||
const desktopMode = state.settings.mode === "desktop";
|
||||
const html = userHtml();
|
||||
|
||||
tile.hidden = desktopMode;
|
||||
desk.hidden = !desktopMode;
|
||||
tile.querySelector(".user-body").innerHTML = html;
|
||||
desk.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ── Wetter ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Der Server holt es und legt es zehn Minuten beiseite; hier wird nur gezeigt,
|
||||
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
|
||||
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
|
||||
*/
|
||||
async function loadWeather() {
|
||||
if (!state.settings.weatherCity) {
|
||||
state.weather = null;
|
||||
renderWeather();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.weather = await api("/api/weather");
|
||||
} catch {
|
||||
state.weather = null;
|
||||
}
|
||||
renderWeather();
|
||||
}
|
||||
|
||||
function weatherHtml(w) {
|
||||
return `
|
||||
<div class="wx">
|
||||
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
|
||||
<div class="wx-text">
|
||||
<b class="wx-temp">${Math.round(w.grad)}°</b>
|
||||
<span class="wx-desc">${w.text}</span>
|
||||
<span class="wx-city">${w.ort}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderWeather() {
|
||||
const w = state.weather;
|
||||
const tile = $("wxTile");
|
||||
const desk = $("wxDesk");
|
||||
const bar = $("mbWx");
|
||||
const desktopMode = state.settings.mode === "desktop";
|
||||
|
||||
// Das Widget: Fenster im Terminal-Modus, Schild auf dem Schreibtisch.
|
||||
const alsWidget = state.settings.weatherDesk === "on" && w;
|
||||
tile.hidden = !alsWidget || desktopMode;
|
||||
desk.hidden = !alsWidget || !desktopMode;
|
||||
if (alsWidget) {
|
||||
const html = weatherHtml(w);
|
||||
tile.querySelector(".wx-body").innerHTML = html;
|
||||
desk.innerHTML = html;
|
||||
}
|
||||
|
||||
// Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
|
||||
// honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
|
||||
// ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
|
||||
const inLeiste = state.settings.weatherBar === "on" && w;
|
||||
bar.hidden = !inLeiste;
|
||||
if (inLeiste) {
|
||||
bar.innerHTML = `${glyphSvg(w.glyph)}<span class="mb-wx-temp">${Math.round(w.grad)}°</span>`;
|
||||
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
if (state.settings.mode === mode) return;
|
||||
@@ -1219,12 +791,6 @@ function openAbout() {
|
||||
|
||||
/* ── Hilfsmittel ───────────────────────────────────────────────────────── */
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
||||
}
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s).replace(/"/g, """);
|
||||
}
|
||||
|
||||
/* ── Startmenü ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { $, escapeAttr, state } from "../core.js";
|
||||
import { glyphSvg } from "../glyphs.js";
|
||||
|
||||
function greeting() {
|
||||
const h = new Date().getHours();
|
||||
if (h < 5 || h >= 23) return "Noch wach?";
|
||||
if (h < 11) return "Guten Morgen.";
|
||||
if (h < 18) return "Willkommen zurück.";
|
||||
return "Guten Abend.";
|
||||
}
|
||||
|
||||
/* ── Wer angemeldet ist ────────────────────────────────────────────────────
|
||||
*
|
||||
* Stand früher rechts in der Menüleiste. Dort war es das einzige Stück, das
|
||||
* die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst
|
||||
* merkt man beim Wechsel, dass es zwei verschiedene Leisten sind. Auf dem
|
||||
* Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu.
|
||||
*/
|
||||
function userHtml() {
|
||||
const bild = state.me && state.me.picture
|
||||
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
|
||||
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
|
||||
return `
|
||||
<div class="uw">
|
||||
${bild}
|
||||
<div class="uw-text">
|
||||
<b class="uw-name">${state.cfg.user}</b>
|
||||
<span class="uw-gruss">${greeting()}</span>
|
||||
<span class="uw-host">${state.cfg.user}@${state.cfg.hostname}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderUser() {
|
||||
const tile = $("userTile");
|
||||
const desk = $("userDesk");
|
||||
const desktopMode = state.settings.mode === "desktop";
|
||||
const html = userHtml();
|
||||
|
||||
tile.hidden = desktopMode;
|
||||
desk.hidden = !desktopMode;
|
||||
tile.querySelector(".user-body").innerHTML = html;
|
||||
desk.innerHTML = html;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { $, api, state } from "../core.js";
|
||||
import { glyphSvg } from "../glyphs.js";
|
||||
|
||||
/* ── Wetter ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Der Server holt es und legt es zehn Minuten beiseite; hier wird nur gezeigt,
|
||||
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
|
||||
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
|
||||
*/
|
||||
export async function loadWeather() {
|
||||
if (!state.settings.weatherCity) {
|
||||
state.weather = null;
|
||||
renderWeather();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.weather = await api("/api/weather");
|
||||
} catch {
|
||||
state.weather = null;
|
||||
}
|
||||
renderWeather();
|
||||
}
|
||||
|
||||
function weatherHtml(w) {
|
||||
return `
|
||||
<div class="wx">
|
||||
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
|
||||
<div class="wx-text">
|
||||
<b class="wx-temp">${Math.round(w.grad)}°</b>
|
||||
<span class="wx-desc">${w.text}</span>
|
||||
<span class="wx-city">${w.ort}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderWeather() {
|
||||
const w = state.weather;
|
||||
const tile = $("wxTile");
|
||||
const desk = $("wxDesk");
|
||||
const bar = $("mbWx");
|
||||
const desktopMode = state.settings.mode === "desktop";
|
||||
|
||||
// Das Widget: Fenster im Terminal-Modus, Schild auf dem Schreibtisch.
|
||||
const alsWidget = state.settings.weatherDesk === "on" && w;
|
||||
tile.hidden = !alsWidget || desktopMode;
|
||||
desk.hidden = !alsWidget || !desktopMode;
|
||||
if (alsWidget) {
|
||||
const html = weatherHtml(w);
|
||||
tile.querySelector(".wx-body").innerHTML = html;
|
||||
desk.innerHTML = html;
|
||||
}
|
||||
|
||||
// Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
|
||||
// honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
|
||||
// ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
|
||||
const inLeiste = state.settings.weatherBar === "on" && w;
|
||||
bar.hidden = !inLeiste;
|
||||
if (inLeiste) {
|
||||
bar.innerHTML = `${glyphSvg(w.glyph)}<span class="mb-wx-temp">${Math.round(w.grad)}°</span>`;
|
||||
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"honeyos/internal/store"
|
||||
"honeyos/internal/weather"
|
||||
)
|
||||
|
||||
// Ohne eingestellten Ort ist 204 die richtige Antwort: kein Fehler, nur nichts
|
||||
// zu zeigen. Die Leiste lässt das Symbol dann weg, statt etwas zu melden.
|
||||
func TestOhneOrtGibtEsKeinWetter(t *testing.T) {
|
||||
s := newTestServer(t, "") // ohne Anbieter: offener Betrieb, kein Anmelden nötig
|
||||
s.wetter = weather.FakeFuerTest(t, 0, 20)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("ohne Ort erwartet 204, bekam %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitOrtKommtDasWetter(t *testing.T) {
|
||||
s := newTestServer(t, "")
|
||||
s.wetter = weather.FakeFuerTest(t, 3, 8.6)
|
||||
if err := s.store.SetSettings(store.LocalSub, map[string]string{
|
||||
"weatherCity": "Köln", "weatherLat": "50.938", "weatherLon": "6.96",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("erwartet 200, bekam %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var w weather.Wetter
|
||||
json.Unmarshal(rec.Body.Bytes(), &w)
|
||||
if w.Ort != "Köln" || w.Glyph != "wolke" {
|
||||
t.Fatalf("falsches Wetter: %+v", w)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user