From b8aec40bdc5cb5a5703113a6a89298d20a2cd896 Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Sat, 20 Sep 2025 12:15:57 -0700 Subject: Add support for a private notepad --- roseh.moe.go | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) (limited to 'roseh.moe.go') diff --git a/roseh.moe.go b/roseh.moe.go index 5d6d2d5..681ab8e 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -1,23 +1,63 @@ package main import ( + "bytes" + "crypto/ed25519" + "crypto/sha512" + "crypto/subtle" "embed" + "encoding/base64" + "encoding/hex" "flag" "fmt" "html/template" "io" "log" "net/http" + "os" + "path/filepath" "strings" "time" ) var ( - port = flag.Int("port", 42069, "port to listen on") + port = flag.Int("port", 42069, "port to listen on") + secretsFile = flag.String("secrets", "secrets", "path to the secrets file") + notepadFile = flag.String("notepad", "notepad", "path to save user notes") serverStartTime = time.Now() ) +var ( + notepadPassword []byte + secretKey ed25519.PrivateKey +) + +func loadSecrets() error { + secrets, err := os.ReadFile(*secretsFile) + if err != nil { + return err + } + for _, line := range bytes.Split(bytes.TrimSpace(secrets), []byte("\n")) { + if pw, ok := bytes.CutPrefix(line, []byte("notepad-password=")); ok { + notepadPassword = make([]byte, hex.DecodedLen(len(pw))) + if _, err := hex.Decode(notepadPassword, pw); err != nil { + return err + } + } else if key, ok := bytes.CutPrefix(line, []byte("secret-key=")); ok { + seed := make([]byte, hex.DecodedLen(len(key))) + if _, err := hex.Decode(seed, key); err != nil { + return err + } + if len(seed) != ed25519.SeedSize { + return fmt.Errorf("invalid ed25519 key") + } + secretKey = ed25519.NewKeyFromSeed(seed) + } + } + return nil +} + var ( //go:embed templates/404.html.template notFoundString string @@ -83,10 +123,127 @@ func favicon(w http.ResponseWriter, r *http.Request) { serveStaticFile(w, r, "static/favicon.ico") } +const cookieExpiration = 400 * 24 * time.Hour + +func attachCookie(w http.ResponseWriter) error { + nowBytes, err := time.Now().MarshalBinary() + if err != nil { + return err + } + sig := ed25519.Sign(secretKey, nowBytes) + http.SetCookie(w, &http.Cookie{ + Name: "auth", + Value: base64.RawStdEncoding.EncodeToString(append(nowBytes, sig...)), + Path: "/notepad", + Expires: time.Now().Add(cookieExpiration), + // Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + // Partitioned: true, + }) + return nil +} + +func cookieOK(cookie string) bool { + bytes, err := base64.RawStdEncoding.DecodeString(cookie) + if err != nil { + return false + } + if len(bytes) < ed25519.SignatureSize { + return false + } + msg, sig := bytes[:len(bytes)-ed25519.SignatureSize], bytes[len(bytes)-ed25519.SignatureSize:] + if !ed25519.Verify(secretKey.Public().(ed25519.PublicKey), msg, sig) { + return false + } + var t time.Time + if err := t.UnmarshalBinary(msg); err != nil { + return false + } + return time.Since(t) < cookieExpiration +} + +func authenticate(next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("auth"); err == nil && cookieOK(cookie.Value) { + attachCookie(w) + next.ServeHTTP(w, r) + return + } + _, password, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="notepad"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + pwHash := sha512.Sum512([]byte(password)) + if subtle.ConstantTimeCompare(pwHash[:], notepadPassword) == 0 { + w.Header().Set("WWW-Authenticate", `Basic realm="notepad"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + attachCookie(w) + next.ServeHTTP(w, r) + } +} + +var ( + //go:embed templates/note.html.template + notepadString string + notepadTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(notepadString)).Lookup("outline") +) + +func notepad(w http.ResponseWriter, r *http.Request) { + var currentContent string + if currentContentBytes, err := os.ReadFile(*notepadFile); err != nil { + currentContent = fmt.Sprintf("Error reading notepad file: %s", err) + } else { + currentContent = string(currentContentBytes) + } + type args struct { + Content string + } + if err := notepadTemplate.Execute(w, args{Content: currentContent}); err != nil { + log.Printf("Warning: notepad: %s", err) + } +} + +func saveNote(content []byte) error { + f, err := os.CreateTemp(filepath.Dir(*notepadFile), "notepad") + if err != nil { + return err + } + defer f.Close() + if _, err = f.Write(content); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), *notepadFile) +} + +func autosave(w http.ResponseWriter, r *http.Request) { + msg := "Saved ✓" + if err := saveNote([]byte(r.FormValue("content"))); err != nil { + msg = fmt.Sprintf("Error: %s", err) + } + if err := notepadTemplate.ExecuteTemplate(w, "saveIndicator", msg); err != nil { + log.Printf("Warning: autosave: %s", err) + } +} + func main() { flag.Parse() + if err := loadSecrets(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + http.HandleFunc("GET /pong", pong) + http.Handle("GET /notepad", authenticate(http.HandlerFunc(notepad))) + http.HandleFunc("POST /notepad/autosave", autosave) http.HandleFunc("GET /static/", static) http.HandleFunc("GET /favicon.ico", favicon) http.HandleFunc("GET /{$}", index) -- cgit v1.3.1