package main import ( "crypto/rand" "embed" "encoding/json" "errors" "flag" "fmt" "io/fs" "log" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" ) //go:embed static var staticFS embed.FS // ---------- Models ---------- type Option struct { ID string `json:"id"` Date string `json:"date"` // YYYY-MM-DD Time string `json:"time,omitempty"` // HH:MM, optional } type Vote struct { Name string `json:"name"` OptionIDs []string `json:"optionIds"` CreatedAt time.Time `json:"createdAt"` } type Poll struct { ID string `json:"id"` AdminToken string `json:"-"` // never serialized to clients Title string `json:"title"` Description string `json:"description,omitempty"` Options []Option `json:"options"` Votes []Vote `json:"votes"` Closed bool `json:"closed"` CreatedAt time.Time `json:"createdAt"` } // pollFile is the on-disk representation (includes the admin token). type pollFile struct { Poll AdminToken string `json:"adminToken"` } // ---------- Store ---------- type Store struct { mu sync.Mutex path string polls map[string]*Poll } func NewStore(path string) (*Store, error) { s := &Store{path: path, polls: map[string]*Poll{}} data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return s, nil } return nil, err } var list []pollFile if err := json.Unmarshal(data, &list); err != nil { return nil, fmt.Errorf("parsing %s: %w", path, err) } for i := range list { p := list[i].Poll p.AdminToken = list[i].AdminToken s.polls[p.ID] = &p } return s, nil } // persist writes all polls to disk. Caller must hold s.mu. func (s *Store) persist() error { list := make([]pollFile, 0, len(s.polls)) for _, p := range s.polls { list = append(list, pollFile{Poll: *p, AdminToken: p.AdminToken}) } sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.Before(list[j].CreatedAt) }) data, err := json.MarshalIndent(list, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, s.path) } func (s *Store) Create(p *Poll) error { s.mu.Lock() defer s.mu.Unlock() s.polls[p.ID] = p return s.persist() } func (s *Store) Get(id string) (*Poll, bool) { s.mu.Lock() defer s.mu.Unlock() p, ok := s.polls[id] if !ok { return nil, false } cp := *p // shallow copy is fine for read-only use return &cp, true } // Update runs fn on the poll under lock and persists the result. func (s *Store) Update(id string, fn func(*Poll) error) error { s.mu.Lock() defer s.mu.Unlock() p, ok := s.polls[id] if !ok { return errNotFound } if err := fn(p); err != nil { return err } return s.persist() } func (s *Store) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.polls[id]; !ok { return errNotFound } delete(s.polls, id) return s.persist() } var errNotFound = errors.New("not found") // ---------- Helpers ---------- const idAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" func randomID(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic(err) } out := make([]byte, n) for i, v := range b { out[i] = idAlphabet[int(v)%len(idAlphabet)] } return string(out) } var ( dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) timeRe = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`) ) func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func adminToken(r *http.Request) string { if t := r.Header.Get("X-Admin-Token"); t != "" { return t } return r.URL.Query().Get("admin") } // ---------- Handlers ---------- type server struct { store *Store } type createPollRequest struct { Title string `json:"title"` Description string `json:"description"` Options []struct { Date string `json:"date"` Time string `json:"time"` } `json:"options"` } func (sv *server) createPoll(w http.ResponseWriter, r *http.Request) { var req createPollRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Invalid request body.") return } req.Title = strings.TrimSpace(req.Title) req.Description = strings.TrimSpace(req.Description) if req.Title == "" { writeError(w, http.StatusBadRequest, "A title is required.") return } if len(req.Title) > 120 || len(req.Description) > 600 { writeError(w, http.StatusBadRequest, "Title or description is too long.") return } if len(req.Options) == 0 { writeError(w, http.StatusBadRequest, "Select at least one date.") return } if len(req.Options) > 60 { writeError(w, http.StatusBadRequest, "Too many dates (max 60).") return } seen := map[string]bool{} options := make([]Option, 0, len(req.Options)) for _, o := range req.Options { if !dateRe.MatchString(o.Date) { writeError(w, http.StatusBadRequest, "Invalid date: "+o.Date) return } if _, err := time.Parse("2006-01-02", o.Date); err != nil { writeError(w, http.StatusBadRequest, "Invalid date: "+o.Date) return } if o.Time != "" && !timeRe.MatchString(o.Time) { writeError(w, http.StatusBadRequest, "Invalid time for "+o.Date+" (use HH:MM).") return } key := o.Date + "T" + o.Time if seen[key] { continue } seen[key] = true options = append(options, Option{ID: randomID(8), Date: o.Date, Time: o.Time}) } sort.Slice(options, func(i, j int) bool { if options[i].Date != options[j].Date { return options[i].Date < options[j].Date } return options[i].Time < options[j].Time }) p := &Poll{ ID: randomID(10), AdminToken: randomID(24), Title: req.Title, Description: req.Description, Options: options, Votes: []Vote{}, CreatedAt: time.Now().UTC(), } if err := sv.store.Create(p); err != nil { log.Printf("create poll: %v", err) writeError(w, http.StatusInternalServerError, "Could not save the poll.") return } writeJSON(w, http.StatusCreated, map[string]string{"id": p.ID, "adminToken": p.AdminToken}) } func (sv *server) getPoll(w http.ResponseWriter, r *http.Request) { p, ok := sv.store.Get(r.PathValue("id")) if !ok { writeError(w, http.StatusNotFound, "Poll not found.") return } resp := struct { *Poll IsAdmin bool `json:"isAdmin"` }{p, adminToken(r) == p.AdminToken} writeJSON(w, http.StatusOK, resp) } type voteRequest struct { Name string `json:"name"` OptionIDs []string `json:"optionIds"` } func (sv *server) vote(w http.ResponseWriter, r *http.Request) { var req voteRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Invalid request body.") return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { writeError(w, http.StatusBadRequest, "Enter your name so friends know who answered.") return } if len(req.Name) > 60 { writeError(w, http.StatusBadRequest, "Name is too long.") return } err := sv.store.Update(r.PathValue("id"), func(p *Poll) error { if p.Closed { return errors.New("This poll is closed.") } valid := map[string]bool{} for _, o := range p.Options { valid[o.ID] = true } ids := []string{} seen := map[string]bool{} for _, id := range req.OptionIDs { if valid[id] && !seen[id] { ids = append(ids, id) seen[id] = true } } v := Vote{Name: req.Name, OptionIDs: ids, CreatedAt: time.Now().UTC()} // Same name (case-insensitive) replaces the earlier answer. for i := range p.Votes { if strings.EqualFold(p.Votes[i].Name, req.Name) { p.Votes[i] = v return nil } } p.Votes = append(p.Votes, v) return nil }) if err != nil { if errors.Is(err, errNotFound) { writeError(w, http.StatusNotFound, "Poll not found.") } else { writeError(w, http.StatusConflict, err.Error()) } return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (sv *server) withAdmin(fn func(http.ResponseWriter, *http.Request)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p, ok := sv.store.Get(r.PathValue("id")) if !ok { writeError(w, http.StatusNotFound, "Poll not found.") return } if adminToken(r) != p.AdminToken { writeError(w, http.StatusForbidden, "Only the poll creator can do this. Use your admin link.") return } fn(w, r) } } func (sv *server) closePoll(w http.ResponseWriter, r *http.Request) { err := sv.store.Update(r.PathValue("id"), func(p *Poll) error { p.Closed = true return nil }) if err != nil { writeError(w, http.StatusNotFound, "Poll not found.") return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (sv *server) deletePoll(w http.ResponseWriter, r *http.Request) { if err := sv.store.Delete(r.PathValue("id")); err != nil { writeError(w, http.StatusNotFound, "Poll not found.") return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } // ---------- main ---------- func main() { addr := flag.String("addr", ":8080", "listen address") dataDir := flag.String("data", "data", "directory for poll storage") flag.Parse() if err := os.MkdirAll(*dataDir, 0o755); err != nil { log.Fatal(err) } store, err := NewStore(filepath.Join(*dataDir, "polls.json")) if err != nil { log.Fatal(err) } sv := &server{store: store} static, _ := fs.Sub(staticFS, "static") mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, static, "index.html") }) mux.HandleFunc("GET /p/{id}", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, static, "poll.html") }) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static))) mux.HandleFunc("POST /api/polls", sv.createPoll) mux.HandleFunc("GET /api/polls/{id}", sv.getPoll) mux.HandleFunc("POST /api/polls/{id}/votes", sv.vote) mux.HandleFunc("POST /api/polls/{id}/close", sv.withAdmin(sv.closePoll)) mux.HandleFunc("DELETE /api/polls/{id}", sv.withAdmin(sv.deletePoll)) log.Printf("mediator running on http://localhost%s (data in %s)", *addr, *dataDir) log.Fatal(http.ListenAndServe(*addr, mux)) }