package main import ( "encoding/json" "io/fs" "log" "net/http" "os" "regexp" "sort" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) // note.wxch.org: the plans and design notes of the project (pages in web/private, behind the web password) and a box for // the owner's suggestions. The notepad (notepad.wxch.org) is a different thing: a clipboard between devices. // docNameRe: the names of the pages in web/private that /doc/ may serve. var docNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,40}$`) const ( maxFeedback = 500 maxFeedbackText = 4000 ) type Feedback struct { ID string `json:"id"` Doc string `json:"doc"` Topic string `json:"topic"` Text string `json:"text"` At int64 `json:"at"` } type feedbackStore struct { mu sync.Mutex path string items []Feedback } func newFeedbackStore(path string) *feedbackStore { fb := &feedbackStore{path: path} if path != "" { if b, err := os.ReadFile(path); err == nil { json.Unmarshal(b, &fb.items) } } return fb } func (fb *feedbackStore) saveLocked() { if fb.path == "" { return } b, _ := json.Marshal(fb.items) if os.WriteFile(fb.path+".tmp", b, 0640) == nil { os.Rename(fb.path+".tmp", fb.path) } } func (fb *feedbackStore) add(f Feedback) { fb.mu.Lock() defer fb.mu.Unlock() fb.items = append(fb.items, f) if len(fb.items) > maxFeedback { fb.items = fb.items[len(fb.items)-maxFeedback:] } fb.saveLocked() } func (fb *feedbackStore) remove(id string) bool { fb.mu.Lock() defer fb.mu.Unlock() for i, f := range fb.items { if f.ID == id { fb.items = append(fb.items[:i:i], fb.items[i+1:]...) fb.saveLocked() return true } } return false } // list returns the suggestions of one document ("" = all), newest first. func (fb *feedbackStore) list(doc string) []Feedback { fb.mu.Lock() defer fb.mu.Unlock() out := []Feedback{} for i := len(fb.items) - 1; i >= 0; i-- { // newest first; ones saved in the same second keep their order if f := fb.items[i]; doc == "" || f.Doc == doc { out = append(out, f) } } return out } func (s *Server) noteAuthed(r *http.Request) bool { c, err := r.Cookie("wxch_note") if err != nil { return false } return s.noteSess.valid(c.Value) } var titleRe = regexp.MustCompile(`(?is)(.*?)`) func (s *Server) noteMux(sub, private 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, "note.html") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Write(b) }) mux.HandleFunc("/note/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("note login failed from %s", ip) http.Error(w, "invalid", http.StatusUnauthorized) return } tok := randHex(32) s.noteSess.add(tok, 30*24*time.Hour) http.SetCookie(w, &http.Cookie{Name: "wxch_note", Value: tok, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: 30 * 24 * 3600}) w.Write([]byte("ok")) }) // design notes and other pages for the owner: without the login the visitor is sent to the login page, which brings // them back here afterwards mux.HandleFunc("/doc/", func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/doc/") if !docNameRe.MatchString(name) { http.NotFound(w, r) return } if !s.noteAuthed(r) { http.Redirect(w, r, "/?next=/doc/"+name, http.StatusFound) return } b, err := fs.ReadFile(private, name+".html") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Robots-Tag", "noindex") w.Write(b) }) guard := func(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !s.noteAuthed(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } h(w, r) } } mux.HandleFunc("/note/api/docs", guard(func(w http.ResponseWriter, r *http.Request) { type docInfo struct{ Name, Title string } docs := []docInfo{} entries, _ := fs.ReadDir(private, ".") for _, e := range entries { n := strings.TrimSuffix(e.Name(), ".html") if e.IsDir() || n == e.Name() || !docNameRe.MatchString(n) { continue } title := n if b, err := fs.ReadFile(private, e.Name()); err == nil { if m := titleRe.FindSubmatch(b); m != nil { title = strings.TrimSpace(string(m[1])) } } docs = append(docs, docInfo{n, title}) } sort.Slice(docs, func(i, j int) bool { return docs[i].Name < docs[j].Name }) writeJSON(w, map[string]any{"docs": docs}) })) mux.HandleFunc("/note/api/feedback", guard(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, map[string]any{"items": s.feedback.list(r.URL.Query().Get("doc"))}) case http.MethodPost: var req struct{ Doc, Topic, Text string } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req) != nil { http.Error(w, "bad request", http.StatusBadRequest) return } req.Text = strings.TrimSpace(req.Text) if req.Text == "" || len([]rune(req.Text)) > maxFeedbackText { http.Error(w, "write between 1 and 4000 characters", http.StatusBadRequest) return } if !docNameRe.MatchString(req.Doc) { req.Doc = "general" } topic := req.Topic if !docNameRe.MatchString(topic) { topic = "general" } s.feedback.add(Feedback{ID: randHex(6), Doc: req.Doc, Topic: topic, Text: req.Text, At: time.Now().Unix()}) w.Write([]byte("ok")) case http.MethodDelete: if !s.feedback.remove(r.URL.Query().Get("id")) { http.NotFound(w, r) return } w.Write([]byte("ok")) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } })) return mux }