Zwei Widgets: Zettel mit kleinem Markdown, Bild je Nutzer

Zettel: ein Textfeld, mehr nicht — InkNote gibt es schon. Gelesen wird
gesetzt (Ueberschriften, Haken, Aufzaehlungen, fett/kursiv/code, Strich),
geschrieben wird roh. Haken lassen sich ohne Bearbeiten setzen und
aendern die Quelle. Gespeichert wird 800 ms nach dem letzten Anschlag,
sofort beim Verlassen und beim Schliessen des Tabs (keepalive statt
sendBeacon: der kann nur POST und traegt keine Kopfzeilen). Grenze 4000
Zeichen, vom Server durchgesetzt.

Bild: genau eines je Nutzer, hochgeladen oder verlinkt. Die erste Datei,
die HoneyOS ablegt — in bilder/ neben honeyos.json, benannt nach einem
Fingerabdruck der Nutzerkennung. Ein neues Bild ersetzt das alte samt
Datei, die Adresse traegt einen Versionsanteil gegen den Zwischen-
speicher. SVG kommt nicht herein (Text, kann Skripte tragen), und der
Typ wird an den Bytes erkannt, nicht an der Angabe des Browsers.

Der Rahmen kann jetzt Widgets mit eigenem DOM: 'stabil' haelt den Inhalt,
'montiert' verdrahtet ihn. Ohne das haette das Anklopfen bei den Apps
alle zwei Minuten den Text unter den Fingern ausgetauscht.

Nebenbei: Der Aufrufhelfer setzte bei jedem Schreibzugriff
Content-Type: application/json — auch fuer Bildbytes.
This commit is contained in:
marc committed 2026-08-31 00:44:20 +02:00
1 parent b7cc06b7c1
commit c0abfbb491
9 files changed
+857 -20

No files matched your search

+122
View File
@@ -16,6 +16,7 @@ import (
"crypto/subtle"
"embed"
"encoding/json"
"io"
"io/fs"
"log"
"net/http"
@@ -146,6 +147,19 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
// Der Zettel — nur für Angemeldete, in beide Richtungen. Er steht bewusst
// nicht in `appsReadable`: Die HoneyBar in einer fremden App weist sich mit
// dem Schlüssel aus, und der kennt keinen Nutzer. Fremde Zettel gehen
// niemanden etwas an.
mux.HandleFunc("GET /api/note", s.requireAuth(s.handleNote))
mux.HandleFunc("PUT /api/note", s.requireAuth(s.handleSetNote))
// Das Bild: nachsehen, hochladen, verlinken, ausliefern. Vier Wege, weil
// „hochgeladene Bytes" und „fremde Adresse" verschiedene Dinge sind und
// eine Abzweigung nach Inhaltstyp im selben Endpunkt nur Rätsel aufgäbe.
mux.HandleFunc("GET /api/image/info", s.requireAuth(s.handleBildInfo))
mux.HandleFunc("POST /api/image", s.requireAuth(s.handleBildHoch))
mux.HandleFunc("PUT /api/image", s.requireAuth(s.handleBildLink))
mux.HandleFunc("GET /api/image", s.requireAuth(s.handleBild))
mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps))
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
// Dieselbe Tür wie die App-Liste: Die HoneyBar steht in einer fremden App und
@@ -368,6 +382,114 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, sub string)
// Normalfall, solange niemand einen gewählt hat, und die HoneyBar soll dafür
// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn
// der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern.
func (s *Server) handleNote(w http.ResponseWriter, r *http.Request, sub string) {
writeJSON(w, http.StatusOK, map[string]string{"text": s.store.Note(sub)})
}
func (s *Server) handleSetNote(w http.ResponseWriter, r *http.Request, sub string) {
var in struct {
Text string `json:"text"`
}
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetNote(sub, in.Text); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
/* ── Das Bild ──────────────────────────────────────────────────────────────
*
* Genau eines je Nutzer, entweder hochgeladen oder verlinkt. Der Ausweis ist
* immer die Sitzung: Bilder gehen weder über den App-Schlüssel noch an fremde
* Ursprünge — es ist das persönlichste, was HoneyOS speichert.
*/
// bildTypen: was hereindarf, und mit welcher Endung es abgelegt wird.
//
// **SVG steht bewusst nicht dabei.** Es ist Text, kann Skripte enthalten und
// würde, aus derselben Adresse ausgeliefert, im Namen der Seite laufen. Ein
// Bildwidget ist diesen Weg nicht wert.
var bildTypen = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
const maxBild = 3 << 20 // 3 MB
func (s *Server) handleBildInfo(w http.ResponseWriter, r *http.Request, sub string) {
datei, url, stand := s.store.Bild(sub)
switch {
case datei != "":
writeJSON(w, http.StatusOK, map[string]string{"art": "datei", "url": "/api/image?v=" + stand})
case url != "":
writeJSON(w, http.StatusOK, map[string]string{"art": "link", "url": url})
default:
writeJSON(w, http.StatusOK, map[string]string{"art": "", "url": ""})
}
}
func (s *Server) handleBildHoch(w http.ResponseWriter, r *http.Request, sub string) {
defer r.Body.Close()
daten, err := io.ReadAll(io.LimitReader(r.Body, maxBild+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bild nicht lesbar"})
return
}
if len(daten) > maxBild {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "Das Bild ist größer als 3 MB"})
return
}
// Der eigene Blick auf die Bytes, nicht die Angabe des Browsers: Wer
// hochlädt, bestimmt sonst den Typ — und damit, was ausgeliefert wird.
typ := strings.SplitN(http.DetectContentType(daten), ";", 2)[0]
endung, ok := bildTypen[typ]
if !ok {
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{
"error": "Nur PNG, JPEG, WebP oder GIF — erkannt wurde " + typ,
})
return
}
if err := s.store.SetBildDatei(sub, endung, daten); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleBildLink(w http.ResponseWriter, r *http.Request, sub string) {
var in struct {
URL string `json:"url"`
}
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetBildURL(sub, strings.TrimSpace(in.URL)); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleBild(w http.ResponseWriter, r *http.Request, sub string) {
pfad, ok := s.store.BildPfad(sub)
if !ok {
http.NotFound(w, r)
return
}
// Der Versionsanteil steht in der Adresse, also darf der Browser es lange
// behalten: Ein neues Bild bekommt eine neue Adresse.
w.Header().Set("Cache-Control", "private, max-age=604800")
http.ServeFile(w, r, pfad)
}
func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) {
set := s.store.SettingsFor(sub)
lat, err1 := strconv.ParseFloat(set["weatherLat"], 64)