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:
marcandClaude Opus 5 committed 2026-08-29 20:51:21 +02:00
1 parent d08b0a229c
commit 52deffa30e
20 files changed
+780 -624

No files matched your search

+315
View File
@@ -0,0 +1,315 @@
// 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"
"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 (
// 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
)
type Identity struct {
Sub string
Name string
Picture string
}
type Auth struct {
cfg config.Config
secret []byte
once sync.Once
initErr error
provider *gooidc.Provider
oauth *oauth2.Config
verifier *gooidc.IDTokenVerifier
}
func New(cfg config.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.
//
// Ohne eigene Anmeldung (Proxy davor oder offener Betrieb) gilt jede Anfrage
// als angemeldet — die Prüfung hat dann jemand anders gemacht, oder es gibt
// keine. Nennt der Proxy einen Namen, bekommt der seine eigenen Einstellungen.
func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
if !a.Enabled() {
if a.cfg.UserHeader != "" {
if u := strings.TrimSpace(r.Header.Get(a.cfg.UserHeader)); u != "" {
return "proxy:" + u, true
}
}
return store.LocalSub, true
}
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
}
// 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)
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, st *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 := st.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 ""
}
+55
View File
@@ -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
}
+263
View File
@@ -0,0 +1,263 @@
// Package store hält den ganzen Zustand von HoneyOS: die App-Liste und je
// Nutzer ein paar Einstellungen.
package store
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.
// 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"`
Subtitle string `json:"subtitle,omitempty"`
URL string `json:"url"`
Glyph string `json:"glyph,omitempty"`
Key string `json:"key,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 Open(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)
if !idPattern.MatchString(a.ID) {
return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1)
}
if seen[a.ID] {
return fmt.Errorf("ID %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} {
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()
}
// SettingsFor liefert das Aussehen für sub — und hilft nach, wenn die Frage
// über eine Site-Grenze kam.
//
// Die Leiste in einer fremden App weist sich nur mit dem Schlüssel aus, nicht
// mit einer Sitzung: der Browser schickt das Cookie dorthin nicht mit. Sie
// landet deshalb beim Standardnutzer. Sind die Einstellungen aber unter einem
// Namen aus der Proxy-Kopfzeile gespeichert, wäre der leer — und die Leiste
// bliebe bernsteinfarben, während der Schreibtisch grün leuchtet.
//
// Gibt es genau einen Nutzer mit Einstellungen, gilt dessen Aussehen. Bei
// mehreren bleibt es beim Standard: dann ist nicht zu erraten, wer da schaut.
func (s *Store) SettingsFor(sub string) map[string]string {
if own := s.User(sub).Settings; len(own) > 0 {
return own
}
s.mu.RLock()
defer s.mu.RUnlock()
var einziger map[string]string
for _, u := range s.d.Users {
if len(u.Settings) == 0 {
continue
}
if einziger != nil {
return map[string]string{} // mehrere — kein Rateweg
}
einziger = u.Settings
}
out := map[string]string{}
for k, v := range einziger {
out[k] = v
}
return out
}
// 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
}
// DataPath nennt die eine Datei, in der alles steht.
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
+37
View File
@@ -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
}
+212
View File
@@ -0,0 +1,212 @@
// Package weather holt das Wetter — serverseitig, damit nicht jede Seite in
// jeder App selbst beim Wetterdienst anfragt.
package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// Wetter kommt vom Server, nicht aus dem Browser.
//
// Die Leiste läuft auf jeder Seite jeder App. Fragte sie selbst beim
// Wetterdienst an, ginge bei jedem Seitenaufruf aus jeder App eine Anfrage samt
// IP dorthin — und die CSP jeder App müsste den fremden Ursprung erlauben. Holt
// HoneyOS es, sieht der Dienst einen Server, und die Apps bleiben unberührt.
//
// Open-Meteo braucht keinen Schlüssel und keine Anmeldung. Das ist der Grund für
// die Wahl: ein Zugangsschlüssel in der .env wäre eine Sache mehr, die jemand
// einrichten muss, bevor irgendetwas zu sehen ist.
const (
wetterFrisch = 10 * time.Minute // so lange gilt eine Antwort
wetterTimeout = 6 * time.Second // ein hängender Dienst darf nichts aufhalten
)
type Wetter struct {
Ort string `json:"ort"`
Grad float64 `json:"grad"`
Code int `json:"code"`
Text string `json:"text"`
Glyph string `json:"glyph"`
Stand string `json:"stand"`
}
type Ort struct {
Name string `json:"name"`
Land string `json:"land"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type Dienst struct {
mu sync.Mutex
cache map[string]wetterEintrag
client *http.Client
// Adressen als Feld, damit der Test einen eigenen Server unterschieben kann.
wetterURL string
sucheURL string
}
type wetterEintrag struct {
w Wetter
bis time.Time
}
func Neu() *Dienst {
return &Dienst{
cache: map[string]wetterEintrag{},
client: &http.Client{Timeout: wetterTimeout},
wetterURL: "https://api.open-meteo.com/v1/forecast",
sucheURL: "https://geocoding-api.open-meteo.com/v1/search",
}
}
// wmo übersetzt den Wettercode in ein Pixelsymbol und einen kurzen Text.
// Die Skala ist die der Weltorganisation für Meteorologie; Open-Meteo liefert
// sie so. Zusammengefasst auf sechs Symbole — feiner lässt sich in zwölf mal
// zwölf Pixeln ohnehin nichts unterscheiden.
func wmo(code int) (glyph, text string) {
switch {
case code == 0:
return "sonne", "klar"
case code <= 2:
return "heiter", "heiter"
case code == 3:
return "wolke", "bedeckt"
case code == 45 || code == 48:
return "nebel", "nebel"
case code >= 51 && code <= 67, code >= 80 && code <= 82:
return "regen", "regen"
case code >= 71 && code <= 77, code == 85 || code == 86:
return "schnee", "schnee"
case code >= 95:
return "gewitter", "gewitter"
}
return "wolke", "wolken"
}
// Jetzt liefert das Wetter für einen Ort — aus dem Zwischenspeicher, solange er
// 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 *Dienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon)
d.mu.Lock()
if e, ok := d.cache[schlüssel]; ok && time.Now().Before(e.bis) {
d.mu.Unlock()
e.w.Ort = ort // der Name gehört den Einstellungen, nicht dem Dienst
return e.w, nil
}
d.mu.Unlock()
u, _ := url.Parse(d.wetterURL)
q := u.Query()
q.Set("latitude", strconv.FormatFloat(lat, 'f', 4, 64))
q.Set("longitude", strconv.FormatFloat(lon, 'f', 4, 64))
q.Set("current", "temperature_2m,weather_code")
q.Set("timezone", "auto")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return Wetter{}, err
}
res, err := d.client.Do(req)
if err != nil {
return Wetter{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Wetter{}, fmt.Errorf("wetterdienst antwortet %d", res.StatusCode)
}
var antwort struct {
Current struct {
Temp float64 `json:"temperature_2m"`
Code int `json:"weather_code"`
} `json:"current"`
}
if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
return Wetter{}, err
}
glyph, text := wmo(antwort.Current.Code)
w := Wetter{
Ort: ort,
Grad: antwort.Current.Temp,
Code: antwort.Current.Code,
Text: text,
Glyph: glyph,
Stand: time.Now().Format(time.RFC3339),
}
d.mu.Lock()
d.cache[schlüssel] = wetterEintrag{w: w, bis: time.Now().Add(wetterFrisch)}
d.mu.Unlock()
return w, nil
}
// 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 *Dienst) Suche(ctx context.Context, name string) ([]Ort, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("kein Suchbegriff")
}
u, _ := url.Parse(d.sucheURL)
q := u.Query()
q.Set("name", name)
q.Set("count", "6")
q.Set("language", "de")
q.Set("format", "json")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
res, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ortssuche antwortet %d", res.StatusCode)
}
var antwort struct {
Results []struct {
Name string `json:"name"`
Country string `json:"country"`
Admin1 string `json:"admin1"`
Lat float64 `json:"latitude"`
Lon float64 `json:"longitude"`
} `json:"results"`
}
if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
return nil, err
}
orte := make([]Ort, 0, len(antwort.Results))
for _, r := range antwort.Results {
land := r.Country
// „Köln, Nordrhein-Westfalen" trennt zwei Treffer besser als
// „Köln, Deutschland" — davon gibt es mehrere.
if r.Admin1 != "" && r.Admin1 != r.Name {
land = r.Admin1 + ", " + r.Country
}
orte = append(orte, Ort{Name: r.Name, Land: land, Lat: r.Lat, Lon: r.Lon})
}
return orte, nil
}
+116
View File
@@ -0,0 +1,116 @@
package weather
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// 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) *Dienst {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if aufrufe != nil {
atomic.AddInt32(aufrufe, 1)
}
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
}
func TestWetterKommtAn(t *testing.T) {
d := fakeWetter(t, 61, 12.4, nil)
w, err := d.Jetzt(context.Background(), "Köln", 50.9, 6.9)
if err != nil {
t.Fatalf("unerwartet: %v", err)
}
if w.Ort != "Köln" || w.Grad != 12.4 {
t.Fatalf("falsche Werte: %+v", w)
}
if w.Glyph != "regen" || w.Text != "regen" {
t.Fatalf("Code 61 ist Regen, nicht %q/%q", w.Glyph, w.Text)
}
}
// Der Zwischenspeicher ist der Grund, warum die Leiste auf jeder Seite jeder App
// stehen darf: Ohne ihn ginge bei jedem Seitenaufruf eine Anfrage nach draußen.
func TestZweiteFrageGehtNichtNachDraussen(t *testing.T) {
var aufrufe int32
d := fakeWetter(t, 0, 21, &aufrufe)
for i := 0; i < 5; i++ {
if _, err := d.Jetzt(context.Background(), "Köln", 50.9, 6.9); err != nil {
t.Fatalf("unerwartet: %v", err)
}
}
if aufrufe != 1 {
t.Fatalf("%d Anfragen nach draußen, erwartet 1", aufrufe)
}
}
func TestAndererOrtWirdGetrenntGehalten(t *testing.T) {
var aufrufe int32
d := fakeWetter(t, 0, 21, &aufrufe)
d.Jetzt(context.Background(), "Köln", 50.9, 6.9)
d.Jetzt(context.Background(), "Wien", 48.2, 16.4)
if aufrufe != 2 {
t.Fatalf("zwei Orte, %d Anfragen", aufrufe)
}
}
func TestWettercodesWerdenZuSymbolen(t *testing.T) {
fälle := map[int]string{
0: "sonne", 2: "heiter", 3: "wolke", 45: "nebel",
61: "regen", 80: "regen", 73: "schnee", 86: "schnee", 95: "gewitter",
}
for code, will := range fälle {
if g, _ := wmo(code); g != will {
t.Errorf("Code %d → %q, erwartet %q", code, g, will)
}
}
}
func TestOrtssucheGibtKoordinatenZurueck(t *testing.T) {
d := fakeWetter(t, 0, 20, nil)
orte, err := d.Suche(context.Background(), "Köln")
if err != nil {
t.Fatalf("unerwartet: %v", err)
}
if len(orte) != 1 || orte[0].Lat == 0 || orte[0].Lon == 0 {
t.Fatalf("ohne Koordinaten müsste jede Abfrage neu suchen: %+v", orte)
}
// Bundesland statt Land: „Köln, Deutschland" gibt es mehrfach.
if !strings.Contains(orte[0].Land, "Nordrhein-Westfalen") {
t.Fatalf("Land zu grob: %q", orte[0].Land)
}
}
func TestSucheOhneBegriffFragtNichtAn(t *testing.T) {
var aufrufe int32
d := fakeWetter(t, 0, 20, &aufrufe)
if _, err := d.Suche(context.Background(), " "); err == nil {
t.Fatal("leerer Begriff müsste abgelehnt werden")
}
if aufrufe != 0 {
t.Fatalf("leerer Begriff ging trotzdem nach draußen (%d)", aufrufe)
}
}