feat(ui): add wedding theme styles and layout components
* Introduced wedding-themed CSS variables for styling. * Created layout component for the wedding site with navigation and countdown. * Added RSVP and photo upload pages with form handling. * Implemented QR code page for wedding site access. * Configured TypeScript and Vite for the project. * Added robots.txt for search engine crawling.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
module wedding-server
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.56.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
@@ -0,0 +1,267 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"wedding-server/internal/config"
|
||||
"wedding-server/internal/mail"
|
||||
"wedding-server/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
mailer *mail.Mailer
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer) *Server {
|
||||
return &Server{cfg: cfg, store: st, mailer: mailer}
|
||||
}
|
||||
|
||||
func (s *Server) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /api/config", s.handleConfig)
|
||||
mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
|
||||
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
|
||||
}
|
||||
|
||||
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, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"siteUrl": s.cfg.Site.URL,
|
||||
"weddingDate": s.cfg.Wedding.Date.Format(time.RFC3339),
|
||||
"ceremonyTime": s.cfg.Wedding.CeremonyTime,
|
||||
"rsvpByDate": s.cfg.Wedding.RSVPBy.Format(time.RFC3339),
|
||||
"venueName": s.cfg.Wedding.VenueName,
|
||||
"venueAddress": s.cfg.Wedding.VenueAddress,
|
||||
"rsvpPhone": s.cfg.Wedding.RSVPPhone,
|
||||
})
|
||||
}
|
||||
|
||||
type guestRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type rsvpRequest struct {
|
||||
Guests []guestRequest `json:"guests"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
var req rsvpRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var guests []store.Guest
|
||||
for _, g := range req.Guests {
|
||||
name := strings.TrimSpace(g.Name)
|
||||
if name != "" {
|
||||
guests = append(guests, store.Guest{Name: name, IsChild: g.Type == "child"})
|
||||
}
|
||||
}
|
||||
if len(guests) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "at least one name is required")
|
||||
return
|
||||
}
|
||||
|
||||
rsvp := store.RSVP{
|
||||
Guests: guests,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
}
|
||||
if err := s.store.InsertRSVP(rsvp); err != nil {
|
||||
log.Printf("rsvp: insert failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save RSVP")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.mailer.SendRSVP(rsvp); err != nil {
|
||||
log.Printf("rsvp: email failed: %v", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
var allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/webp": true,
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
}
|
||||
|
||||
func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if time.Now().Before(s.cfg.Wedding.Date) {
|
||||
writeError(w, http.StatusForbidden, "photo uploads open on the wedding day")
|
||||
return
|
||||
}
|
||||
|
||||
maxTotalBytes := int64(s.cfg.Upload.MaxSizeMB) * int64(s.cfg.Upload.MaxFiles) * 1024 * 1024
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+1<<20)
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid upload (too large or malformed)")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(r.FormValue("message"))
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
phone := strings.TrimSpace(r.FormValue("phone"))
|
||||
|
||||
headers := r.MultipartForm.File["files"]
|
||||
if len(headers) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "at least one photo is required")
|
||||
return
|
||||
}
|
||||
if len(headers) > s.cfg.Upload.MaxFiles {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("at most %d photos per submission", s.cfg.Upload.MaxFiles))
|
||||
return
|
||||
}
|
||||
|
||||
maxFileBytes := int64(s.cfg.Upload.MaxSizeMB) * 1024 * 1024
|
||||
for _, fh := range headers {
|
||||
if fh.Size > maxFileBytes {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("%q is larger than %dMB", fh.Filename, s.cfg.Upload.MaxSizeMB))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Sniff and validate every file's actual content before creating any
|
||||
// database row or directory, so a rejected upload leaves no trace.
|
||||
for _, fh := range headers {
|
||||
file, err := fh.Open()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "could not read uploaded file")
|
||||
return
|
||||
}
|
||||
sniff := make([]byte, 512)
|
||||
n, _ := io.ReadFull(file, sniff)
|
||||
file.Close()
|
||||
sniff = sniff[:n]
|
||||
|
||||
contentType := http.DetectContentType(sniff)
|
||||
if contentType == "application/octet-stream" && isHEIC(sniff) {
|
||||
// net/http's sniffer table has no ISOBMFF/HEIC signature.
|
||||
contentType = "image/heic"
|
||||
}
|
||||
if !allowedImageTypes[contentType] {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a supported image type", fh.Filename))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
uploadID, err := s.store.CreatePhotoUpload(store.PhotoUpload{
|
||||
Name: name, Message: message, Email: email, Phone: phone,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("photos: create upload failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
uploadDir := filepath.Join(s.cfg.Storage.DataDir, "photos", strconv.FormatInt(uploadID, 10))
|
||||
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
||||
log.Printf("photos: mkdir failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
var saved []store.PhotoFile
|
||||
for _, fh := range headers {
|
||||
file, err := fh.Open()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "could not read uploaded file")
|
||||
return
|
||||
}
|
||||
|
||||
storedName := randomHex(8) + "_" + filepath.Base(fh.Filename)
|
||||
destPath := filepath.Join(uploadDir, storedName)
|
||||
dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
log.Printf("photos: create file failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(dest, file)
|
||||
dest.Close()
|
||||
file.Close()
|
||||
if err != nil {
|
||||
log.Printf("photos: write file failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
saved = append(saved, store.PhotoFile{
|
||||
Filename: fh.Filename,
|
||||
Path: destPath,
|
||||
URL: fmt.Sprintf("/uploads/%d/%s", uploadID, storedName),
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.store.AddPhotoFiles(uploadID, saved); err != nil {
|
||||
log.Printf("photos: add files failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
upload := store.PhotoUpload{Name: name, Message: message, Email: email, Phone: phone}
|
||||
if err := s.mailer.SendPhotoUpload(upload, saved); err != nil {
|
||||
log.Printf("photos: email failed: %v", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// isHEIC checks for the ISOBMFF "ftyp" box and a HEIC/HEIF brand, since
|
||||
// net/http.DetectContentType does not recognize this container format.
|
||||
func isHEIC(b []byte) bool {
|
||||
if len(b) < 12 || string(b[4:8]) != "ftyp" {
|
||||
return false
|
||||
}
|
||||
switch string(b[8:12]) {
|
||||
case "heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Site SiteConfig `yaml:"site"`
|
||||
Wedding WeddingConfig `yaml:"wedding"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Email EmailConfig `yaml:"email"`
|
||||
Upload UploadConfig `yaml:"upload"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type SiteConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
type WeddingConfig struct {
|
||||
Date time.Time `yaml:"date"`
|
||||
CeremonyTime string `yaml:"ceremony_time"`
|
||||
RSVPBy time.Time `yaml:"rsvp_by"`
|
||||
VenueName string `yaml:"venue_name"`
|
||||
VenueAddress string `yaml:"venue_address"`
|
||||
RSVPPhone string `yaml:"rsvp_phone"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
DataDir string `yaml:"data_dir"`
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
SMTPHost string `yaml:"smtp_host"`
|
||||
SMTPPort int `yaml:"smtp_port"`
|
||||
SMTPUser string `yaml:"smtp_user"`
|
||||
SMTPPass string `yaml:"smtp_pass"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
}
|
||||
|
||||
type UploadConfig struct {
|
||||
MaxSizeMB int `yaml:"max_size_mb"`
|
||||
MaxFiles int `yaml:"max_files"`
|
||||
}
|
||||
|
||||
// Path resolves the config file location: $CONFIG_PATH, or ./config.yaml.
|
||||
func Path() string {
|
||||
if p := os.Getenv("CONFIG_PATH"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "config.yaml"
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config %s: %w", path, err)
|
||||
}
|
||||
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.Storage.DataDir == "" {
|
||||
cfg.Storage.DataDir = "./data"
|
||||
}
|
||||
if cfg.Upload.MaxSizeMB == 0 {
|
||||
cfg.Upload.MaxSizeMB = 15
|
||||
}
|
||||
if cfg.Upload.MaxFiles == 0 {
|
||||
cfg.Upload.MaxFiles = 10
|
||||
}
|
||||
if cfg.Wedding.Date.IsZero() {
|
||||
return nil, fmt.Errorf("wedding.date is required in %s", path)
|
||||
}
|
||||
if cfg.Wedding.RSVPBy.IsZero() {
|
||||
return nil, fmt.Errorf("wedding.rsvp_by is required in %s", path)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"wedding-server/internal/config"
|
||||
"wedding-server/internal/store"
|
||||
)
|
||||
|
||||
type Mailer struct {
|
||||
cfg config.EmailConfig
|
||||
}
|
||||
|
||||
func New(cfg config.EmailConfig) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
func (m *Mailer) send(subject, body string) error {
|
||||
if m.cfg.SMTPHost == "" {
|
||||
log.Printf("mail: SMTP not configured, skipping email %q", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.SMTPHost, m.cfg.SMTPPort)
|
||||
auth := smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPass, m.cfg.SMTPHost)
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||
m.cfg.From, m.cfg.To, subject, body,
|
||||
)
|
||||
|
||||
return smtp.SendMail(addr, auth, m.cfg.From, []string{m.cfg.To}, []byte(msg))
|
||||
}
|
||||
|
||||
func (m *Mailer) SendRSVP(r store.RSVP) error {
|
||||
names := make([]string, len(r.Guests))
|
||||
adults, children := 0, 0
|
||||
for i, g := range r.Guests {
|
||||
if g.IsChild {
|
||||
names[i] = g.Name + " (child)"
|
||||
children++
|
||||
} else {
|
||||
names[i] = g.Name
|
||||
adults++
|
||||
}
|
||||
}
|
||||
joined := strings.Join(names, ", ")
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "New RSVP for %d guest(s) — %d adult(s), %d child(ren): %s\n", len(r.Guests), adults, children, joined)
|
||||
if r.Message != "" {
|
||||
fmt.Fprintf(&b, "\nMessage:\n%s\n", r.Message)
|
||||
}
|
||||
return m.send(fmt.Sprintf("RSVP from %s", joined), b.String())
|
||||
}
|
||||
|
||||
func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s uploaded %d photo(s)\n\n", u.Name, len(files))
|
||||
if u.Email != "" {
|
||||
fmt.Fprintf(&b, "Email: %s\n", u.Email)
|
||||
}
|
||||
if u.Phone != "" {
|
||||
fmt.Fprintf(&b, "Phone: %s\n", u.Phone)
|
||||
}
|
||||
if u.Message != "" {
|
||||
fmt.Fprintf(&b, "\nMessage:\n%s\n", u.Message)
|
||||
}
|
||||
b.WriteString("\nPhotos:\n")
|
||||
for _, f := range files {
|
||||
fmt.Fprintf(&b, "- %s\n", f.URL)
|
||||
}
|
||||
return m.send(fmt.Sprintf("%s uploaded photos", u.Name), b.String())
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS rsvps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rsvp_guests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rsvp_id INTEGER NOT NULL REFERENCES rsvps(id),
|
||||
name TEXT NOT NULL,
|
||||
is_child INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
message TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL REFERENCES photo_uploads(id),
|
||||
filename TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
dbPath := filepath.Join(dataDir, "rsvp.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
// SQLite only supports one writer at a time; a single connection avoids
|
||||
// SQLITE_BUSY errors under concurrent requests instead of retry logic.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrating schema: %w", err)
|
||||
}
|
||||
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
type Guest struct {
|
||||
Name string
|
||||
IsChild bool
|
||||
}
|
||||
|
||||
type RSVP struct {
|
||||
Guests []Guest
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s *Store) InsertRSVP(r RSVP) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO rsvps (message, created_at) VALUES (?, ?)`,
|
||||
r.Message, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsvpID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, guest := range r.Guests {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO rsvp_guests (rsvp_id, name, is_child) VALUES (?, ?, ?)`,
|
||||
rsvpID, guest.Name, guest.IsChild,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type PhotoFile struct {
|
||||
Filename string
|
||||
Path string
|
||||
URL string
|
||||
}
|
||||
|
||||
type PhotoUpload struct {
|
||||
Name string
|
||||
Message string
|
||||
Email string
|
||||
Phone string
|
||||
}
|
||||
|
||||
// CreatePhotoUpload inserts the upload record and returns its id, which the
|
||||
// caller uses as the on-disk directory name before attaching files.
|
||||
func (s *Store) CreatePhotoUpload(u PhotoUpload) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO photo_uploads (name, message, email, phone, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
u.Name, u.Message, u.Email, u.Phone, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, f := range files {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO photo_files (upload_id, filename, path, url, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
uploadID, f.Filename, f.Path, f.URL, now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"wedding-server/internal/api"
|
||||
"wedding-server/internal/config"
|
||||
"wedding-server/internal/mail"
|
||||
"wedding-server/internal/store"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
var embeddedWeb embed.FS
|
||||
|
||||
func main() {
|
||||
cfgPath := config.Path()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
|
||||
log.Fatalf("data dir: %v", err)
|
||||
}
|
||||
|
||||
st, err := store.Open(cfg.Storage.DataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("store: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
mailer := mail.New(cfg.Email)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(cfg, st, mailer).Routes(mux)
|
||||
|
||||
uploadsDir := filepath.Join(cfg.Storage.DataDir, "photos")
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsDir))))
|
||||
|
||||
webFS, err := fs.Sub(embeddedWeb, "web/dist")
|
||||
if err != nil {
|
||||
log.Fatalf("embedded web assets: %v", err)
|
||||
}
|
||||
mux.Handle("/", staticHandler(webFS))
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
log.Printf("listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// staticHandler serves the prerendered SvelteKit build, where routes are
|
||||
// written as flat files (e.g. "rsvp.html") rather than "rsvp/index.html".
|
||||
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, "/")
|
||||
if p == "" {
|
||||
p = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(fsys, p); err != nil {
|
||||
if _, err := fs.Stat(fsys, p+".html"); err == nil {
|
||||
r2 := *r
|
||||
r2.URL.Path = "/" + p + ".html"
|
||||
fileServer.ServeHTTP(w, &r2)
|
||||
return
|
||||
}
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<!-- placeholder, replaced by the ui build output before building the Go binary -->
|
||||
Reference in New Issue
Block a user