package directory import ( "fmt" "io" "log" "net/http" "strings" "time" "heliosian/internal/auth" "heliosian/internal/blob" "heliosian/internal/data" ) const changeLogTable = "Change Log" var changeLogHeader = append([]string{"Timestamp", "Actor"}, overrideColumns...) func changeLogRow(actor, email string, previous map[string]string) []string { row := make([]string, len(changeLogHeader)) row[0] = time.Now().UTC().Format(time.RFC3339) row[1] = actor for i, column := range changeLogHeader { if column == "Email" { row[i] = email } else if value, ok := previous[column]; ok { if value == "" { value = "-" } row[i] = value } } return row } var photoExtensions = map[string]string{ "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", } var audioExtensions = map[string]string{ "audio/webm": "webm", "video/webm": "webm", "audio/mp4": "m4a", "video/mp4": "m4a", "audio/x-m4a": "m4a", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav", } type uploader struct { cache *Cache sheet *data.Sheet store *blob.Store } func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *blob.Store) { u := uploader{cache: cache, sheet: sheet, store: store} mux.HandleFunc("POST /api/directory/upload", u.upload) mux.HandleFunc("POST /api/directory/facts", u.facts) mux.HandleFunc("POST /api/directory/optout", u.optOut) mux.HandleFunc("POST /api/directory/edit", u.edit) } func clearable(value string) string { if value == "" { return "-" } return value } func (u uploader) edit(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) field := r.FormValue("field") value := strings.TrimSpace(r.FormValue("value")) me := auth.Email(r) model := u.cache.Model() var person *Person for i := range model.People { if model.People[i].Email == key { person = &model.People[i] break } } if person == nil { http.Error(w, "no such person", http.StatusBadRequest) return } cells := map[string]string{} previous := map[string]string{} switch field { case "preferred-name": if !mayEdit(model, me, "person", key) { http.Error(w, "not allowed to edit this record", http.StatusForbidden) return } if value == "" || len(value) > 80 { http.Error(w, "bad preferred name", http.StatusBadRequest) return } base := person.LegalName if base == "" { base = person.FullName } cells["Preferred Name"] = value cells["Full Name"] = value + " " + surname(base) previous["Preferred Name"] = person.PreferredName previous["Full Name"] = person.FullName case "phone": if !mayEdit(model, me, "person", key) { http.Error(w, "not allowed to edit this record", http.StatusForbidden) return } if len(value) > 40 { http.Error(w, "bad phone number", http.StatusBadRequest) return } cells["Phone"] = clearable(value) previous["Phone"] = person.Phone case "address": if key != strings.ToLower(me) || !person.IsParent { http.Error(w, "not allowed to edit this record", http.StatusForbidden) return } family, ok := model.Families[person.FamilyKey] if !ok { http.Error(w, "no family record", http.StatusBadRequest) return } if len(value) > 200 { http.Error(w, "bad address", http.StatusBadRequest) return } cells["Address"] = clearable(value) previous["Address"] = family.Address default: http.Error(w, "bad field", http.StatusBadRequest) return } for column, cell := range cells { if err := u.sheet.Upsert(appName, "Overrides", "Email", key, column, cell); err != nil { serverError(w, fmt.Errorf("set %s for %s: %w", column, key, err)) return } } logRow := changeLogRow(me, key, previous) if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { serverError(w, fmt.Errorf("append change log after %s edit for %s: %w", field, key, err)) return } if err := u.cache.Refresh(); err != nil { serverError(w, fmt.Errorf("refresh model after %s edit: %w", field, err)) return } log.Printf("edit: %s set %s on %s", me, field, key) w.WriteHeader(http.StatusNoContent) } func (u uploader) optOut(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) me := auth.Email(r) key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) if key == "" { http.Error(w, "bad opt out request", http.StatusBadRequest) return } if !mayEdit(u.cache.Model(), me, "person", key) { http.Error(w, "not allowed to edit this record", http.StatusForbidden) return } if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Opted Out", "TRUE"); err != nil { serverError(w, fmt.Errorf("opt out %s: %w", key, err)) return } logRow := changeLogRow(me, key, map[string]string{"Opted Out": ""}) if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { serverError(w, fmt.Errorf("append change log after opt out of %s: %w", key, err)) return } if err := u.cache.Refresh(); err != nil { serverError(w, fmt.Errorf("refresh model after opt out: %w", err)) return } log.Printf("optout: %s removed %s from the directory", me, key) w.WriteHeader(http.StatusNoContent) } func (u uploader) facts(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) facts := strings.TrimSpace(r.FormValue("facts")) if key == "" || len(facts) > 4000 { http.Error(w, "bad facts request", http.StatusBadRequest) return } me := auth.Email(r) model := u.cache.Model() if !mayEdit(model, me, "person", key) { http.Error(w, "not allowed to edit this record", http.StatusForbidden) return } old := "" for _, p := range model.People { if p.Email == key { old = p.Facts break } } if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Facts", facts); err != nil { serverError(w, fmt.Errorf("update facts for %s: %w", key, err)) return } logRow := changeLogRow(me, key, map[string]string{"Facts": old}) if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { serverError(w, fmt.Errorf("append change log after facts update for %s: %w", key, err)) return } if err := u.cache.Refresh(); err != nil { serverError(w, fmt.Errorf("refresh model after facts update: %w", err)) return } log.Printf("facts: %s set %s (%d chars)", me, key, len(facts)) w.WriteHeader(http.StatusNoContent) } func (u uploader) upload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 30<<20) if err := r.ParseMultipartForm(30 << 20); err != nil { http.Error(w, "upload too large or malformed", http.StatusBadRequest) return } target := r.FormValue("target") key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) kind := r.FormValue("kind") if (target != "person" && target != "family") || (kind != "photo" && kind != "pronunciation") || key == "" { http.Error(w, "bad upload request", http.StatusBadRequest) return } me := auth.Email(r) model := u.cache.Model() if !mayEdit(model, me, target, key) { http.Error(w, "not allowed to edit this record", http.StatusForbidden) return } file, header, err := r.FormFile("file") if err != nil { http.Error(w, "missing file", http.StatusBadRequest) return } defer file.Close() content, err := io.ReadAll(file) if err != nil || len(content) == 0 { http.Error(w, "unreadable file", http.StatusBadRequest) return } mimeType, ext, err := mediaType(kind, content, header.Header.Get("Content-Type")) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } folder := "people" if target == "family" { folder = "families" } local, _, _ := strings.Cut(key, "@") base := local + "-" + kind name := base + "." + ext archived, err := u.store.Upload(folder, base, ext, mimeType, content) if err != nil { serverError(w, err) return } if err := u.cache.Refresh(); err != nil { serverError(w, fmt.Errorf("refresh model after upload: %w", err)) return } log.Printf("upload: %s set %s %s %s (archived %q)", me, target, key, name, archived) w.WriteHeader(http.StatusNoContent) } func mayEdit(model *Model, me, target, key string) bool { var mine *Person for i := range model.People { if model.People[i].Email == me { mine = &model.People[i] break } } if mine == nil { return false } if target == "family" { return mine.FamilyKey != "" && mine.FamilyKey == key } if key == me { return true } family, ok := model.Families[mine.FamilyKey] if !ok { return false } for _, kid := range family.KidEmails { if kid == key { return true } } return false } func mediaType(kind string, content []byte, declared string) (string, string, error) { if kind == "photo" { sniffed := http.DetectContentType(content) ext, ok := photoExtensions[sniffed] if !ok { return "", "", fmt.Errorf("unsupported photo type %s", sniffed) } return sniffed, ext, nil } base, _, _ := strings.Cut(declared, ";") base = strings.TrimSpace(strings.ToLower(base)) ext, ok := audioExtensions[base] if !ok { return "", "", fmt.Errorf("unsupported audio type %s", declared) } if strings.HasPrefix(base, "video/") { base = "audio/" + strings.TrimPrefix(base, "video/") } return base, ext, nil }