package main import ( "crypto/subtle" "encoding/json" "io" "io/fs" "log" "net/http" "os" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) // Shared clipboard on notepad.wxch.org: a list of text items that any logged-in // browser, or any agent (Bearer token), can add to, read and delete. const ( notesPath = "notepad.json" oldNotePath = "notepad.txt" maxItems = 100 maxItemSize = 64 * 1024 ) type NoteItem struct { ID string `json:"id"` Text string `json:"text"` From string `json:"from"` TS int64 `json:"ts"` } var notesMu sync.Mutex func loadNotes() []NoteItem { var items []NoteItem if b, err := os.ReadFile(notesPath); err == nil { json.Unmarshal(b, &items) return items } // migrate the old single-note file if b, err := os.ReadFile(oldNotePath); err == nil && len(strings.TrimSpace(string(b))) > 0 { items = []NoteItem{{ID: randHex(6), Text: string(b), From: "system", TS: time.Now().Unix()}} } return items } func saveNotes(items []NoteItem) error { b, _ := json.Marshal(items) tmp := notesPath + ".tmp" if err := os.WriteFile(tmp, b, 0600); err != nil { return err } return os.Rename(tmp, notesPath) } func (s *Server) npAuthed(r *http.Request) bool { c, err := r.Cookie("wxch_np") if err != nil { return false } return s.npSess.valid(c.Value) } // npAuth returns the caller's name ("" if not authorized): a browser session or an agent token. func (s *Server) npAuth(r *http.Request) (string, bool) { if s.npAuthed(r) { return "", true } tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if tok == "" { return "", false } tokens := s.cfg.AgentTokens if b, err := os.ReadFile(s.cfgPath); err == nil { var c Config if json.Unmarshal(b, &c) == nil { tokens = c.AgentTokens } } for n, t := range tokens { if subtle.ConstantTimeCompare([]byte(t), []byte(tok)) == 1 { return n, true } } return "", false } func (s *Server) notepadMux(sub fs.FS) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } b, _ := fs.ReadFile(sub, "notepad.html") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Write(b) }) mux.HandleFunc("/np/login", func(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if s.locked(ip) { http.Error(w, "too many attempts", http.StatusTooManyRequests) return } var req struct{ Password string } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req) != nil || bcrypt.CompareHashAndPassword([]byte(s.pwHash()), []byte(req.Password)) != nil { s.fail(ip) log.Printf("notepad login failed from %s", ip) http.Error(w, "invalid", http.StatusUnauthorized) return } tok := randHex(32) s.npSess.add(tok, 30*24*time.Hour) http.SetCookie(w, &http.Cookie{Name: "wxch_np", Value: tok, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: 30 * 24 * 3600}) w.Write([]byte("ok")) }) // list / add / delete-all mux.HandleFunc("/np/items", func(w http.ResponseWriter, r *http.Request) { caller, ok := s.npAuth(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } notesMu.Lock() defer notesMu.Unlock() items := loadNotes() switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") if items == nil { items = []NoteItem{} } json.NewEncoder(w).Encode(items) case http.MethodPost: var req struct{ Text, From string } if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxItemSize+1024)).Decode(&req) != nil || req.Text == "" { http.Error(w, "bad request", http.StatusBadRequest) return } s.addNote(&items, req.Text, req.From, caller) if err := saveNotes(items); err != nil { http.Error(w, "write failed", http.StatusInternalServerError) return } w.Write([]byte("ok")) case http.MethodDelete: if err := saveNotes([]NoteItem{}); err != nil { http.Error(w, "write failed", http.StatusInternalServerError) return } w.Write([]byte("ok")) default: http.Error(w, "method", http.StatusMethodNotAllowed) } }) // delete one: DELETE /np/items/ mux.HandleFunc("/np/items/", func(w http.ResponseWriter, r *http.Request) { if _, ok := s.npAuth(r); !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodDelete { http.Error(w, "method", http.StatusMethodNotAllowed) return } id := strings.TrimPrefix(r.URL.Path, "/np/items/") notesMu.Lock() defer notesMu.Unlock() items := loadNotes() out := items[:0] for _, it := range items { if it.ID != id { out = append(out, it) } } if err := saveNotes(out); err != nil { http.Error(w, "write failed", http.StatusInternalServerError) return } w.Write([]byte("ok")) }) // terminal-friendly: POST raw text to push, GET to read the newest item as plain text. // curl -H "Authorization: Bearer $TOKEN" --data-binary @file https://notepad.wxch.org/np/push mux.HandleFunc("/np/push", func(w http.ResponseWriter, r *http.Request) { caller, ok := s.npAuth(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxItemSize)) if err != nil || len(b) == 0 { http.Error(w, "bad request", http.StatusBadRequest) return } notesMu.Lock() defer notesMu.Unlock() items := loadNotes() s.addNote(&items, string(b), "", caller) if err := saveNotes(items); err != nil { http.Error(w, "write failed", http.StatusInternalServerError) return } w.Write([]byte("ok\n")) }) mux.HandleFunc("/np/latest", func(w http.ResponseWriter, r *http.Request) { if _, ok := s.npAuth(r); !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } notesMu.Lock() items := loadNotes() notesMu.Unlock() if len(items) == 0 { http.Error(w, "empty", http.StatusNotFound) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte(items[0].Text)) }) return mux } // addNote prepends an item (newest first) and trims to maxItems. caller is the agent name, if any. func (s *Server) addNote(items *[]NoteItem, text, from, caller string) { if len(text) > maxItemSize { text = text[:maxItemSize] } if caller != "" { from = caller } from = strings.TrimSpace(from) if len([]rune(from)) > 32 { from = string([]rune(from)[:32]) } if from == "" { from = "web" } it := NoteItem{ID: randHex(6), Text: text, From: from, TS: time.Now().Unix()} *items = append([]NoteItem{it}, *items...) if len(*items) > maxItems { *items = (*items)[:maxItems] } }