diff --git a/server/cli.go b/server/cli.go new file mode 100644 index 0000000..2d39c26 --- /dev/null +++ b/server/cli.go @@ -0,0 +1,171 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "wedding-server/internal/store" +) + +// runCLI handles admin subcommands (listing/removing bad RSVPs or photo +// uploads) so fixing a mistake doesn't require touching the database by hand. +// Returns the process exit code. +func runCLI(dataDir string, st *store.Store, args []string) int { + if len(args) == 0 { + printCLIUsage() + return 1 + } + + switch args[0] { + case "rsvp": + return runRSVPCLI(st, args[1:]) + case "photos": + return runPhotosCLI(dataDir, st, args[1:]) + case "help", "-h", "--help": + printCLIUsage() + return 0 + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n\n", args[0]) + printCLIUsage() + return 1 + } +} + +func printCLIUsage() { + fmt.Println(`Usage: + wedding-server start the web server + wedding-server rsvp list list all RSVPs + wedding-server rsvp delete delete an RSVP and its guests + wedding-server photos list list all photo uploads + wedding-server photos delete delete a photo upload and its files`) +} + +func runRSVPCLI(st *store.Store, args []string) int { + if len(args) == 0 { + printCLIUsage() + return 1 + } + + switch args[0] { + case "list": + rsvps, err := st.ListRSVPs() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if len(rsvps) == 0 { + fmt.Println("no RSVPs yet") + return 0 + } + for _, r := range rsvps { + names := make([]string, len(r.Guests)) + for i, g := range r.Guests { + names[i] = g.Name + if g.IsChild { + names[i] += " (child)" + } + } + fmt.Printf("#%d %s %s\n", r.ID, r.CreatedAt.Local().Format("2006-01-02 15:04"), strings.Join(names, ", ")) + if r.Message != "" { + fmt.Printf(" message: %s\n", r.Message) + } + } + return 0 + + case "delete": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: wedding-server rsvp delete ") + return 1 + } + id, err := strconv.ParseInt(args[1], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid id %q\n", args[1]) + return 1 + } + if !confirm(fmt.Sprintf("Delete RSVP #%d? This cannot be undone.", id)) { + fmt.Println("cancelled") + return 0 + } + if err := st.DeleteRSVP(id); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("deleted RSVP #%d\n", id) + return 0 + + default: + printCLIUsage() + return 1 + } +} + +func runPhotosCLI(dataDir string, st *store.Store, args []string) int { + if len(args) == 0 { + printCLIUsage() + return 1 + } + + switch args[0] { + case "list": + uploads, err := st.ListPhotoUploads() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if len(uploads) == 0 { + fmt.Println("no photo uploads yet") + return 0 + } + for _, u := range uploads { + fmt.Printf("#%d %s %s (%d photo(s))\n", u.ID, u.CreatedAt.Local().Format("2006-01-02 15:04"), u.Name, len(u.Files)) + for _, f := range u.Files { + fmt.Printf(" %s\n", f.URL) + } + } + return 0 + + case "delete": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: wedding-server photos delete ") + return 1 + } + id, err := strconv.ParseInt(args[1], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid id %q\n", args[1]) + return 1 + } + if !confirm(fmt.Sprintf("Delete photo upload #%d and its files? This cannot be undone.", id)) { + fmt.Println("cancelled") + return 0 + } + files, err := st.DeletePhotoUpload(id) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + for _, f := range files { + if err := os.Remove(f.Path); err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "warning: could not remove %s: %v\n", f.Path, err) + } + } + // Best-effort: only removes the per-upload directory if now empty. + _ = os.Remove(filepath.Join(dataDir, "photos", strconv.FormatInt(id, 10))) + fmt.Printf("deleted photo upload #%d (%d file(s))\n", id, len(files)) + return 0 + + default: + printCLIUsage() + return 1 + } +} + +func confirm(prompt string) bool { + fmt.Printf("%s [y/N]: ", prompt) + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + line = strings.ToLower(strings.TrimSpace(line)) + return line == "y" || line == "yes" +} diff --git a/server/internal/mail/mail.go b/server/internal/mail/mail.go index 91f75e0..eee7d6c 100644 --- a/server/internal/mail/mail.go +++ b/server/internal/mail/mail.go @@ -49,7 +49,7 @@ func (m *Mailer) send(subject, body string) error { ) recipients := []string{m.cfg.To} - for _, bcc := range strings.Split(m.cfg.Bcc, ",") { + for bcc := range strings.SplitSeq(m.cfg.Bcc, ",") { if bcc = strings.TrimSpace(bcc); bcc != "" { recipients = append(recipients, bcc) } diff --git a/server/internal/store/store.go b/server/internal/store/store.go index aceacb8..c5e451c 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -102,6 +102,76 @@ func (s *Store) ListGuests() ([]Guest, error) { return guests, rows.Err() } +type RSVPRecord struct { + ID int64 + Message string + CreatedAt time.Time + Guests []Guest +} + +// ListRSVPs returns every RSVP submission (not flattened per-guest), oldest first. +func (s *Store) ListRSVPs() ([]RSVPRecord, error) { + rows, err := s.db.Query( + `SELECT r.id, r.message, r.created_at, g.name, g.is_child + FROM rsvps r + LEFT JOIN rsvp_guests g ON g.rsvp_id = r.id + ORDER BY r.created_at ASC, r.id ASC, g.id ASC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []RSVPRecord + index := map[int64]int{} + for rows.Next() { + var id int64 + var message string + var createdAt time.Time + var name sql.NullString + var isChild sql.NullBool + if err := rows.Scan(&id, &message, &createdAt, &name, &isChild); err != nil { + return nil, err + } + + i, ok := index[id] + if !ok { + out = append(out, RSVPRecord{ID: id, Message: message, CreatedAt: createdAt}) + i = len(out) - 1 + index[id] = i + } + if name.Valid { + out[i].Guests = append(out[i].Guests, Guest{Name: name.String, IsChild: isChild.Bool}) + } + } + return out, rows.Err() +} + +// DeleteRSVP removes an RSVP submission and its guests. +func (s *Store) DeleteRSVP(id int64) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM rsvp_guests WHERE rsvp_id = ?`, id); err != nil { + return err + } + res, err := tx.Exec(`DELETE FROM rsvps WHERE id = ?`, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("rsvp %d not found", id) + } + return tx.Commit() +} + func (s *Store) InsertRSVP(r RSVP) error { tx, err := s.db.Begin() if err != nil { @@ -189,6 +259,98 @@ func (s *Store) ListPhotoFiles() ([]PhotoFileRecord, error) { return out, rows.Err() } +type PhotoUploadRecord struct { + ID int64 + Name string + Message string + CreatedAt time.Time + Files []PhotoFile +} + +// ListPhotoUploads returns every upload submission (not flattened per-file), oldest first. +func (s *Store) ListPhotoUploads() ([]PhotoUploadRecord, error) { + rows, err := s.db.Query( + `SELECT u.id, u.name, u.message, u.created_at, f.filename, f.path, f.url + FROM photo_uploads u + LEFT JOIN photo_files f ON f.upload_id = u.id + ORDER BY u.created_at ASC, u.id ASC, f.id ASC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []PhotoUploadRecord + index := map[int64]int{} + for rows.Next() { + var id int64 + var name, message string + var createdAt time.Time + var filename, path, url sql.NullString + if err := rows.Scan(&id, &name, &message, &createdAt, &filename, &path, &url); err != nil { + return nil, err + } + + i, ok := index[id] + if !ok { + out = append(out, PhotoUploadRecord{ID: id, Name: name, Message: message, CreatedAt: createdAt}) + i = len(out) - 1 + index[id] = i + } + if filename.Valid { + out[i].Files = append(out[i].Files, PhotoFile{Filename: filename.String, Path: path.String, URL: url.String}) + } + } + return out, rows.Err() +} + +// DeletePhotoUpload removes an upload's DB rows and returns the files it had, +// so the caller can also remove them from disk. +func (s *Store) DeletePhotoUpload(id int64) ([]PhotoFile, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + rows, err := tx.Query(`SELECT filename, path, url FROM photo_files WHERE upload_id = ?`, id) + if err != nil { + return nil, err + } + var files []PhotoFile + for rows.Next() { + var f PhotoFile + if err := rows.Scan(&f.Filename, &f.Path, &f.URL); err != nil { + rows.Close() + return nil, err + } + files = append(files, f) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + if _, err := tx.Exec(`DELETE FROM photo_files WHERE upload_id = ?`, id); err != nil { + return nil, err + } + res, err := tx.Exec(`DELETE FROM photo_uploads WHERE id = ?`, id) + if err != nil { + return nil, err + } + n, err := res.RowsAffected() + if err != nil { + return nil, err + } + if n == 0 { + return nil, fmt.Errorf("photo upload %d not found", id) + } + if err := tx.Commit(); err != nil { + return nil, err + } + return files, nil +} + func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error { tx, err := s.db.Begin() if err != nil { diff --git a/server/main.go b/server/main.go index ebad209..779ca0b 100644 --- a/server/main.go +++ b/server/main.go @@ -37,6 +37,12 @@ func main() { if err != nil { log.Fatalf("store: %v", err) } + + if len(os.Args) > 1 { + code := runCLI(cfg.Storage.DataDir, st, os.Args[1:]) + st.Close() + os.Exit(code) + } defer st.Close() mailer := mail.New(cfg.Email) @@ -85,12 +91,16 @@ func main() { func staticHandler(fsys fs.FS) http.Handler { fileServer := http.FileServer(http.FS(fsys)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - p := strings.TrimPrefix(r.URL.Path, "/") + p := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/") if p == "" { p = "index.html" } - if _, err := fs.Stat(fsys, p); err != nil { - if _, err := fs.Stat(fsys, p+".html"); err == nil { + // A route name (e.g. "photos") can collide with a static asset + // directory of the same name; prefer the prerendered page in + // that case instead of falling through to a directory listing. + info, statErr := fs.Stat(fsys, p) + if statErr != nil || info.IsDir() { + if hinfo, err := fs.Stat(fsys, p+".html"); err == nil && !hinfo.IsDir() { r2 := *r r2.URL.Path = "/" + p + ".html" fileServer.ServeHTTP(w, &r2)