From d3b839e35b29657fa5267e924c061d144c27a200 Mon Sep 17 00:00:00 2001 From: luxick Date: Wed, 10 Jun 2026 08:31:59 +0200 Subject: [PATCH] Add Readme and code --- README.md | 84 +++++++++++ main.go | 413 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 497 insertions(+) create mode 100644 README.md create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..8134508 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Group Dates + +A tiny self-hosted web app for scheduling get-togethers with your friend group. +You circle dates on a calendar, optionally add a time per day, and share one +link. Friends open the same calendar, circle the days that work for them, and +the best date floats to the top. No accounts, no sign-up — the link is the key. + +Built with **Go standard library only**: no external dependencies, one static +binary, polls stored in a plain JSON file. + +## Run it + +Requires Go 1.22+. + +```sh +go build -o groupdates . +./groupdates +``` + +Open http://localhost:8080 — that's the page for creating a poll. +þ +Flags: + +| Flag | Default | Meaning | +|---------|---------|----------------------------------| +| `-addr` | `:8080` | Listen address | +| `-data` | `data` | Directory for `polls.json` | + +To let friends reach it, run it on a machine they can access (a small VPS, a +Raspberry Pi behind a port forward, etc.) and put it behind HTTPS — e.g. a +Caddy or nginx reverse proxy. The share links use whatever host your friends +open the page on, so no configuration is needed. + +## How it works + +1. **Create a poll** — give it a title, click every date you want to offer on + the calendar (only today and future dates are clickable), and optionally + set a time for each date in the list below the calendar. +2. **Share** — after creating you get two links: + - **Share link** (`/p/`) — send this to the group. + - **Admin link** (`/p/?admin=`) — keep this. It's the only way + to close or delete the poll. +3. **Friends answer** — they open the share link, see the same calendar with + the offered days highlighted, circle the ones that work, enter their name, + and send. Answering again with the same name (case-insensitive) replaces + the earlier answer. +4. **Close or delete** — polls can never be edited after creation. From the + admin link you can *close* the poll (answers freeze, results stay visible) + or *delete* it entirely. + +The poll page refreshes results every 30 seconds while open. + +## API (if you're curious) + +| Method | Path | What it does | +|----------|------------------------|---------------------------------------| +| `POST` | `/api/polls` | Create a poll, returns id + admin token | +| `GET` | `/api/polls/{id}` | Poll data (admin token never included) | +| `POST` | `/api/polls/{id}/votes`| Submit / replace an answer | +| `POST` | `/api/polls/{id}/close`| Close (admin token required) | +| `DELETE` | `/api/polls/{id}` | Delete (admin token required) | + +Admin token goes in the `X-Admin-Token` header or `?admin=` query parameter. + +## Storage + +Everything lives in `/polls.json`, written atomically on every +change. Back it up by copying that one file. Deleting a poll removes it from +the file immediately. + +## Project layout + +``` +main.go server, storage, API handlers (stdlib only) +static/index.html create-a-poll page +static/poll.html voting + results page +static/calendar.js the shared calendar widget both pages use +static/create.js create-page logic +static/poll.js poll-page logic +static/style.css styles +``` + +Static files are embedded into the binary with `go:embed`, so the compiled +`groupdates` binary is all you need to deploy. diff --git a/main.go b/main.go new file mode 100644 index 0000000..0ecc570 --- /dev/null +++ b/main.go @@ -0,0 +1,413 @@ +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("Group Dates running on http://localhost%s (data in %s)", *addr, *dataDir) + log.Fatal(http.ListenAndServe(*addr, mux)) +}