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

+126
View File
@@ -3,14 +3,18 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps
@@ -35,6 +39,17 @@ type User struct {
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
// Der Zettel. Nicht in den Einstellungen, obwohl er dorthin passen würde:
// Die sind auf kurze Werte ausgelegt (200 Zeichen je Feld), und ein Zettel
// ist Text, kein Schalter.
Note string `json:"note,omitempty"`
// Das Bild — entweder eine Datei neben honeyos.json oder eine fremde
// Adresse, nie beides. `Bildstand` ist der Versionsanteil in der Adresse:
// Ohne ihn bliebe beim Austausch das alte Bild im Zwischenspeicher hängen.
Bild string `json:"bild,omitempty"` // Dateiname in bilder/
BildURL string `json:"bildUrl,omitempty"` // oder eine fremde Adresse
Bildstand string `json:"bildstand,omitempty"` // gegen den Zwischenspeicher
}
type data struct {
@@ -52,6 +67,9 @@ const (
maxApps = 24
maxSettings = 40
maxFieldLen = 200
// Ein Zettel, kein Dokument. Wer mehr braucht, hat InkNote — und der Server
// muss die Grenze durchsetzen, nicht das Formular.
maxNoteLen = 4000
)
var (
@@ -182,6 +200,27 @@ func (s *Store) SeenUser(sub, name, picture string) error {
return s.save()
}
// Note gibt den Zettel des Nutzers. Kein Rückfall auf den Standardnutzer wie
// bei den Einstellungen: Ein fremder Zettel ist kein Ersatz für den eigenen.
func (s *Store) Note(sub string) string {
return s.User(sub).Note
}
// SetNote schreibt den Zettel. Zwei offene Geräte überschreiben einander — der
// letzte Stand gewinnt. Für einen Zettel ist das vertretbar; alles darüber
// bräuchte Versionen und eine Zusammenführung, und dann wäre es keine
// Kleinigkeit mehr, sondern eine Notiz-App.
func (s *Store) SetNote(sub, text string) error {
if len([]rune(text)) > maxNoteLen {
return fmt.Errorf("der Zettel fasst %d Zeichen", maxNoteLen)
}
s.mu.Lock()
defer s.mu.Unlock()
s.userLocked(sub).Note = text
return s.save()
}
func (s *Store) SetSettings(sub string, in map[string]string) error {
if len(in) > maxSettings {
return fmt.Errorf("zu viele Einstellungen")
@@ -254,5 +293,92 @@ func (s *Store) AppOrigins() map[string]bool {
return out
}
/* ── Bilder ────────────────────────────────────────────────────────────────
*
* Das Erste, was HoneyOS als Datei ablegt. Bis hierher war alles in einer
* einzigen JSON-Datei, und das soll auch so bleiben: Die Bilder liegen daneben
* in `bilder/`, die JSON kennt nur ihre Namen. Ein Nutzer hat genau **ein**
* Bild — ein neues ersetzt das alte, samt Datei.
*
* Der Dateiname ist ein Fingerabdruck der Nutzerkennung, keine Kennung im
* Klartext: In einem Ordnernamen hätte eine OIDC-`sub` nichts zu suchen, und
* manche taugen gar nicht als Dateiname.
*/
func (s *Store) bilderDir() string { return filepath.Join(filepath.Dir(s.path), "bilder") }
func bildName(sub, endung string) string {
sum := sha256.Sum256([]byte(sub))
return hex.EncodeToString(sum[:8]) + endung
}
// Bild sagt, was der Nutzer hinterlegt hat: eine Datei, eine Adresse oder
// nichts. `stand` gehört an die Adresse der Datei.
func (s *Store) Bild(sub string) (datei, url, stand string) {
u := s.User(sub)
return u.Bild, u.BildURL, u.Bildstand
}
func (s *Store) BildPfad(sub string) (string, bool) {
datei, _, _ := s.Bild(sub)
if datei == "" {
return "", false
}
return filepath.Join(s.bilderDir(), datei), true
}
// SetBildDatei legt die Bytes ab und macht sie zum Bild des Nutzers. Ein
// vorhandenes Bild wird ersetzt — auch die alte Datei, sonst wächst der Ordner
// mit jedem Austausch.
func (s *Store) SetBildDatei(sub, endung string, daten []byte) error {
if err := os.MkdirAll(s.bilderDir(), 0o755); err != nil {
return fmt.Errorf("bilder-ordner: %w", err)
}
name := bildName(sub, endung)
if err := os.WriteFile(filepath.Join(s.bilderDir(), name), daten, 0o644); err != nil {
return fmt.Errorf("bild schreiben: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.userLocked(sub)
if u.Bild != "" && u.Bild != name {
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
}
u.Bild, u.BildURL = name, ""
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
return s.save()
}
// SetBildURL nimmt stattdessen eine fremde Adresse. Eine leere löscht alles.
func (s *Store) SetBildURL(sub, url string) error {
if len(url) > maxFieldLen*4 {
return fmt.Errorf("die Adresse ist zu lang")
}
if url != "" && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
return fmt.Errorf("die Adresse muss mit http:// oder https:// beginnen")
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.userLocked(sub)
if u.Bild != "" {
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
}
u.Bild, u.BildURL = "", url
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
return s.save()
}
// userLocked holt (oder legt an) den Nutzer. Nur mit gehaltenem Schloss.
func (s *Store) userLocked(sub string) *User {
u, ok := s.d.Users[sub]
if !ok {
u = &User{}
s.d.Users[sub] = u
}
return u
}
// DataPath nennt die eine Datei, in der alles steht.
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }