diff options
| author | Rose Hogenson <rosehogenson@posteo.net> | 2025-10-05 21:58:44 -0700 |
|---|---|---|
| committer | Rose Hogenson <rosehogenson@posteo.net> | 2025-10-05 21:58:44 -0700 |
| commit | bcbb629db87b6bc0e9a7880ec4ee1c90d284a6c7 (patch) | |
| tree | 7372c59802fb589f798a9e849d621dac158dc04b /roseh.moe.go | |
| parent | Games first (diff) | |
| download | roseh.moe-bcbb629db87b6bc0e9a7880ec4ee1c90d284a6c7.tar.zst | |
Bring back the notepad
Diffstat (limited to 'roseh.moe.go')
| -rw-r--r-- | roseh.moe.go | 253 |
1 files changed, 251 insertions, 2 deletions
diff --git a/roseh.moe.go b/roseh.moe.go index bf0a90b..78c0625 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -1,8 +1,13 @@ package main import ( + "bytes" + "crypto/hmac" "crypto/rand" + "crypto/sha256" + "crypto/subtle" "embed" + "encoding/base64" "encoding/binary" "flag" "fmt" @@ -10,18 +15,45 @@ import ( "io" "log" "net/http" + "os" "strings" "sync" "time" + + "gitlab.com/rhogenson/roseh.moe/internal/pwhash" ) var ( - port = flag.Int("port", 42069, "port to listen on") - selfURL = flag.String("self-url", "http://localhost:42069", "base URL of the server") + port = flag.Int("port", 42069, "port to listen on") + selfURL = flag.String("self-url", "http://localhost:42069", "base URL of the server") + secretsFile = flag.String("secrets", "secrets", "secrets file") serverStartTime = time.Now() ) +var notepadPassword, secretKey []byte + +func loadSecrets() error { + secrets, err := os.ReadFile(*secretsFile) + if err != nil { + return err + } + for _, line := range bytes.Split(bytes.TrimSuffix(secrets, []byte("\n")), []byte("\n")) { + if pw, ok := bytes.CutPrefix(line, []byte("notepad-password=")); ok { + notepadPassword, err = base64.RawURLEncoding.AppendDecode(nil, pw) + if err != nil { + return err + } + } else if key, ok := bytes.CutPrefix(line, []byte("secret-key=")); ok { + secretKey, err = base64.RawURLEncoding.AppendDecode(nil, key) + if err != nil { + return err + } + } + } + return nil +} + var ( //go:embed templates/404.html.template notFoundString string @@ -215,14 +247,231 @@ func wormholeReady(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "event: ready\ndata:\n\n") } +func sign(msg []byte, info string) []byte { + mac := hmac.New(sha256.New, secretKey) + mac.Write(binary.AppendVarint(nil, int64(len(info)))) + io.WriteString(mac, info) + mac.Write(msg) + return mac.Sum(msg) +} + +func verify(msg []byte, info string) ([]byte, bool) { + if len(msg) < sha256.Size { + return nil, false + } + msg, messageMAC := msg[:len(msg)-sha256.Size], msg[len(msg)-sha256.Size:] + mac := hmac.New(sha256.New, secretKey) + mac.Write(binary.AppendVarint(nil, int64(len(info)))) + io.WriteString(mac, info) + mac.Write(msg) + expectedMAC := mac.Sum(nil) + if !hmac.Equal(messageMAC, expectedMAC) { + return nil, false + } + return msg, true +} + +func makeToken() (string, error) { + b, err := time.Now().MarshalBinary() + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(sign(b, "auth")), nil +} + +const cookieExpiration = 180 * 24 * time.Hour + +func checkToken(token string) bool { + authCookie, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return false + } + msg, ok := verify(authCookie, "auth") + if !ok { + return false + } + var t time.Time + if err := t.UnmarshalBinary(msg); err != nil { + return false + } + return time.Since(t) < cookieExpiration +} + +func attachCookie(w http.ResponseWriter) error { + token, err := makeToken() + if err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: "auth", + Value: token, + Path: "/", + Expires: time.Now().Add(cookieExpiration), + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Partitioned: true, + }) + return nil +} + +func cookieAuth(w http.ResponseWriter, r *http.Request) (string, bool) { + const csrfTokenLen = 32 + var csrfToken string + if csrfCookie, err := r.Cookie("csrf-token"); err == nil { + csrfToken = csrfCookie.Value + } else { + buf := make([]byte, csrfTokenLen) + rand.Read(buf) + csrfToken = base64.RawURLEncoding.EncodeToString(buf) + http.SetCookie(w, &http.Cookie{ + Name: "csrf-token", + Value: csrfToken, + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Partitioned: true, + }) + } + + cookie, err := r.Cookie("auth") + if err != nil { + return csrfToken, false + } + if !checkToken(cookie.Value) { + return csrfToken, false + } + attachCookie(w) + return csrfToken, true +} + +func checkCSRFToken(r *http.Request) error { + cookie, err := r.Cookie("csrf-token") + if err != nil { + return err + } + cookieHash := sha256.Sum256([]byte(cookie.Value)) + formValueHash := sha256.Sum256([]byte(r.FormValue("csrf-token"))) + if subtle.ConstantTimeCompare(cookieHash[:], formValueHash[:]) == 0 { + return fmt.Errorf("bad CSRF token") + } + return nil +} + +var ( + //go:embed templates/login.html.template + loginTemplateString string + loginTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(loginTemplateString)).Lookup("outline") +) + +type loginTemplateArgs struct { + CSRFToken string + Redirect string + Error bool +} + +func executeLoginTemplate(w io.Writer, csrfToken, redirect string) { + if err := loginTemplate.Execute(w, loginTemplateArgs{ + Redirect: base64.RawURLEncoding.EncodeToString(sign([]byte(redirect), "redirect")), + CSRFToken: csrfToken, + }); err != nil { + log.Printf("Warning: login: %s", err) + } +} + +func login(w http.ResponseWriter, r *http.Request) { + if err := checkCSRFToken(r); err != nil { + http.Error(w, "bad CSRF token", http.StatusBadRequest) + return + } + if err := pwhash.Check(notepadPassword, r.FormValue("password")); err != nil { + if err := loginTemplate.Execute(w, loginTemplateArgs{ + Error: true, + Redirect: r.FormValue("redirect"), + CSRFToken: r.FormValue("csrf-token"), + }); err != nil { + log.Printf("Warning: login: %s", err) + } + return + } + attachCookie(w) + redirect := "/" + if b, err := base64.RawURLEncoding.DecodeString(r.FormValue("redirect")); err == nil { + if r, ok := verify(b, "redirect"); ok { + redirect = string(r) + } + } + http.Redirect(w, r, redirect, http.StatusSeeOther) +} + +var ( + //go:embed templates/note.html.template + notepadString string + notepadTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(notepadString)).Lookup("outline") + + notepadContentsMu sync.Mutex + notepadContents string +) + +type notepadTemplateArgs struct { + Content string + CSRFToken string +} + +func notepad(w http.ResponseWriter, r *http.Request) { + csrfToken, ok := cookieAuth(w, r) + if !ok { + executeLoginTemplate(w, csrfToken, "/notepad") + return + } + notepadContentsMu.Lock() + currentContent := notepadContents + notepadContentsMu.Unlock() + if err := notepadTemplate.Execute(w, notepadTemplateArgs{Content: currentContent, CSRFToken: csrfToken}); err != nil { + log.Printf("Warning: notepad: %s", err) + } +} + +func saveNote(w http.ResponseWriter, r *http.Request) error { + if err := checkCSRFToken(r); err != nil { + return err + } + if _, ok := cookieAuth(w, r); !ok { + return fmt.Errorf("not logged in") + } + newContent := r.FormValue("content") + notepadContentsMu.Lock() + notepadContents = newContent + notepadContentsMu.Unlock() + return nil +} + +func autosave(w http.ResponseWriter, r *http.Request) { + msg := "Saved ✓" + if err := saveNote(w, r); err != nil { + msg = fmt.Sprintf("Failed to save: %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 { + log.Fatal(err) + } + http.HandleFunc("GET /pong", pong) http.HandleFunc("GET /wormhole", wormhole) http.HandleFunc("POST /wormhole/{hole}", wormholeSend) http.HandleFunc("GET /wormhole/{hole}", wormholeRecv) http.HandleFunc("GET /wormhole/{hole}/ready", wormholeReady) + http.HandleFunc("POST /login", login) + http.HandleFunc("GET /notepad", notepad) + http.HandleFunc("POST /notepad", autosave) http.HandleFunc("GET /static/", static) http.HandleFunc("GET /favicon.ico", favicon) http.HandleFunc("GET /{$}", index) |
