package web import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/hex" "errors" "fmt" "io" "net/http" "regexp" "sort" "strconv" "strings" "git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/web/templates" vcard "github.com/emersion/go-vcard" ) // contactIDRe validates a contact's object ID as it appears in a URL path // segment: a filename like "3f9a2b8c1d4e5f60ab12cd34ef56ab78.vcf" produced // by newContactID. var contactIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}\.vcf$`) // brokenMultiTypeRe matches a malformed TYPE parameter value seen in some // exported vCards (e.g. from certain Nextcloud Contacts versions): a // comma-separated pair of quoted strings where the quote characters // themselves were mistakenly emitted as RFC 6868 caret-encoded quotes // (^') rather than actually delimiting the two values, e.g. // `TYPE="WORK^'","^'VOICE"`. go-vcard's decoder can't parse this as a // valid parameter and silently drops the whole field (no error), which is // why phone numbers using this pattern vanish on import. The intended // value is simply the two types joined by a comma, so it's rewritten to // the unambiguous, valid form TYPE=WORK,VOICE before decoding. var brokenMultiTypeRe = regexp.MustCompile(`TYPE="([A-Za-z0-9]+)\^'","\^'([A-Za-z0-9]+)"`) // sanitizeVCardBytes repairs known-malformed-but-recoverable syntax // produced by some real-world vCard exporters before handing the data to // go-vcard, so fields that would otherwise be silently dropped survive // import. func sanitizeVCardBytes(data []byte) []byte { return brokenMultiTypeRe.ReplaceAll(data, []byte(`TYPE=$1,$2`)) } // newContactID generates a random filename for a new contact object, // mirroring the ".vcf" convention used by the CardDAV backend // (internal/carddav). func newContactID() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf) + ".vcf", nil } // ownsAddressBook reports whether book is one of username's own address // books (never a book shared with them by another user — the web contacts // UI only manages a user's own books, consistent with the file browser and // dashboard resource cards). func (s *Server) ownsAddressBook(username, book string) (bool, error) { if !resourceNameRe.MatchString(book) { return false, nil } names, err := s.dbase.ListAddressBooks(username) if err != nil { return false, err } for _, n := range names { if n == book { return true, nil } } return false, nil } func (s *Server) handleContactsHome(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) names, err := s.dbase.ListAddressBooks(username) if err != nil { s.logger.Error("listing address books", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } sort.Strings(names) var books []templates.AddressBookSummary for _, name := range names { ids, err := s.store.ListObjects(username, "card-"+name) if err != nil { s.logger.Warn("listing contacts", "book", name, "error", err) } books = append(books, templates.AddressBookSummary{Name: name, Count: len(ids)}) } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ContactsHome(username, books).Render(context.Background(), w) } func (s *Server) handleContactsList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) book := r.PathValue("book") ok, err := s.ownsAddressBook(username, book) if err != nil { s.logger.Error("checking address book ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } ids, err := s.store.ListObjects(username, "card-"+book) if err != nil { s.logger.Error("listing contacts", "book", book, "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } var contacts []templates.ContactSummary for _, id := range ids { data, err := s.store.GetObject(username, "card-"+book, id) if err != nil { continue } contacts = append(contacts, summarizeCard(id, data)) } sort.Slice(contacts, func(i, j int) bool { return strings.ToLower(contacts[i].FullName) < strings.ToLower(contacts[j].FullName) }) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ContactsList(username, book, contacts).Render(context.Background(), w) } func summarizeCard(id string, data []byte) templates.ContactSummary { dec := vcard.NewDecoder(bytes.NewReader(data)) card, err := dec.Decode() if err != nil { return templates.ContactSummary{ID: id, FullName: id} } fullName := card.PreferredValue(vcard.FieldFormattedName) if fullName == "" { fullName = id } return templates.ContactSummary{ ID: id, FullName: fullName, Organization: card.PreferredValue(vcard.FieldOrganization), Phone: card.PreferredValue(vcard.FieldTelephone), Email: card.PreferredValue(vcard.FieldEmail), PhotoDataURL: photoDataURL(card), } } func (s *Server) handleContactNew(w http.ResponseWriter, r *http.Request) { username := userFromContext(r.Context()) book := r.PathValue("book") ok, err := s.ownsAddressBook(username, book) if err != nil { s.logger.Error("checking address book ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: form := templates.ContactFormData{ Book: book, Phones: ensureAtLeastOne[templates.LabeledValue](nil), Emails: ensureAtLeastOne[templates.LabeledValue](nil), Addresses: ensureAtLeastOne[templates.AddressValue](nil), } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ContactForm(username, form, "").Render(context.Background(), w) case http.MethodPost: s.saveContactFromForm(w, r, username, book, "") default: w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleContactEdit(w http.ResponseWriter, r *http.Request) { username := userFromContext(r.Context()) book := r.PathValue("book") id := r.PathValue("id") ok, err := s.ownsAddressBook(username, book) if err != nil { s.logger.Error("checking address book ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok || !contactIDRe.MatchString(id) { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: data, err := s.store.GetObject(username, "card-"+book, id) if err != nil { http.NotFound(w, r) return } form, err := contactFormFromCard(book, id, data) if err != nil { s.logger.Error("decoding contact", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ContactForm(username, form, "").Render(context.Background(), w) case http.MethodPost: s.saveContactFromForm(w, r, username, book, id) default: w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // contactFormInput holds the parsed, validated-ish values submitted by the // contact form, before they're translated into vCard fields. type contactFormInput struct { Forename string Surname string Organization string Note string Birthday string Phones []templates.LabeledValue Emails []templates.LabeledValue Addresses []templates.AddressValue PhotoData []byte PhotoMime string RemovePhoto bool } // ensureAtLeastOne returns items unchanged if non-empty, or a single // zero-value element otherwise, so the repeatable-row UI always renders at // least one row per section. func ensureAtLeastOne[T any](items []T) []T { if len(items) > 0 { return items } var zero T return []T{zero} } func formValue(values []string, i int) string { if i < 0 || i >= len(values) { return "" } return values[i] } // parseContactForm reads the multipart contact form (required because it // may include a photo upload) into a contactFormInput, zipping the // parallel type/value fields for phones, emails, and addresses by index. func parseContactForm(r *http.Request) (contactFormInput, error) { if err := r.ParseMultipartForm(maxUploadMemory); err != nil { return contactFormInput{}, err } var form map[string][]string if r.MultipartForm != nil { form = r.MultipartForm.Value } get := func(key string) []string { return form[key] } in := contactFormInput{ Forename: strings.TrimSpace(formValue(get("forename"), 0)), Surname: strings.TrimSpace(formValue(get("surname"), 0)), Organization: strings.TrimSpace(formValue(get("organization"), 0)), Note: strings.TrimSpace(formValue(get("note"), 0)), Birthday: strings.TrimSpace(formValue(get("birthday"), 0)), RemovePhoto: formValue(get("remove_photo"), 0) != "", } phoneTypes, phoneValues := get("phone_type"), get("phone_value") for i, v := range phoneValues { v = strings.TrimSpace(v) if v == "" { continue } in.Phones = append(in.Phones, templates.LabeledValue{Type: formValue(phoneTypes, i), Value: v}) } emailTypes, emailValues := get("email_type"), get("email_value") for i, v := range emailValues { v = strings.TrimSpace(v) if v == "" { continue } in.Emails = append(in.Emails, templates.LabeledValue{Type: formValue(emailTypes, i), Value: v}) } addrTypes := get("address_type") streets := get("address_street") cities := get("address_city") postalCodes := get("address_postal_code") regions := get("address_region") countries := get("address_country") for i := range streets { street := strings.TrimSpace(formValue(streets, i)) city := strings.TrimSpace(formValue(cities, i)) postalCode := strings.TrimSpace(formValue(postalCodes, i)) region := strings.TrimSpace(formValue(regions, i)) country := strings.TrimSpace(formValue(countries, i)) if street == "" && city == "" && postalCode == "" && region == "" && country == "" { continue } in.Addresses = append(in.Addresses, templates.AddressValue{ Type: formValue(addrTypes, i), Street: street, City: city, Region: region, PostalCode: postalCode, Country: country, }) } if r.MultipartForm != nil { if files := r.MultipartForm.File["photo"]; len(files) > 0 { fh := files[0] f, err := fh.Open() if err == nil { defer f.Close() data, err := io.ReadAll(io.LimitReader(f, 10<<20)) if err == nil && len(data) > 0 { in.PhotoData = data in.PhotoMime = fh.Header.Get("Content-Type") } } } } return in, nil } // saveContactFromForm handles both contact creation (id == "") and // editing (id preserves the existing contact's ID and any vCard fields // not set from the form, such as UID and an unchanged photo). func (s *Server) saveContactFromForm(w http.ResponseWriter, r *http.Request, username, book, id string) { in, err := parseContactForm(r) if err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } if in.Forename == "" && in.Surname == "" { form := templates.ContactFormData{ Book: book, ID: id, Forename: in.Forename, Surname: in.Surname, Organization: in.Organization, Note: in.Note, Birthday: in.Birthday, Phones: ensureAtLeastOne(in.Phones), Emails: ensureAtLeastOne(in.Emails), Addresses: ensureAtLeastOne(in.Addresses), } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.ContactForm(username, form, "Name is required").Render(context.Background(), w) return } var uid, existingPhoto string if id == "" { newID, err := newContactID() if err != nil { s.logger.Error("generating contact id", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } id = newID uid = strings.TrimSuffix(id, ".vcf") } else { data, err := s.store.GetObject(username, "card-"+book, id) if err != nil { http.NotFound(w, r) return } dec := vcard.NewDecoder(bytes.NewReader(data)) existing, err := dec.Decode() if err != nil { s.logger.Error("decoding existing contact", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } uid = existing.Value(vcard.FieldUID) existingPhoto = existing.Value(vcard.FieldPhoto) } card := buildCard(uid, in, existingPhoto) if err := s.saveCard(username, book, id, card); err != nil { s.logger.Error("saving contact", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/web/contacts/"+book, http.StatusSeeOther) } // buildCard builds a fresh vCard from the submitted form input. uid and // existingPhoto (both optional) are carried over from an existing contact // when editing, so re-saving doesn't drop its identity or a previously // uploaded photo the user didn't change. func buildCard(uid string, in contactFormInput, existingPhoto string) vcard.Card { card := make(vcard.Card) card.SetValue(vcard.FieldVersion, "4.0") if uid != "" { card.SetValue(vcard.FieldUID, uid) } if fn := strings.TrimSpace(in.Forename + " " + in.Surname); fn != "" { card.SetValue(vcard.FieldFormattedName, fn) } name := &vcard.Name{GivenName: in.Forename, FamilyName: in.Surname} card.SetName(name) if in.Organization != "" { card.SetValue(vcard.FieldOrganization, in.Organization) } if in.Note != "" { card.SetValue(vcard.FieldNote, in.Note) } if in.Birthday != "" { card.SetValue(vcard.FieldBirthday, in.Birthday) } for _, p := range in.Phones { f := &vcard.Field{Value: p.Value} if p.Type != "" { f.Params = vcard.Params{vcard.ParamType: {p.Type}} } card.Add(vcard.FieldTelephone, f) } for _, e := range in.Emails { f := &vcard.Field{Value: e.Value} if e.Type != "" { f.Params = vcard.Params{vcard.ParamType: {e.Type}} } card.Add(vcard.FieldEmail, f) } for _, a := range in.Addresses { addr := &vcard.Address{ Field: &vcard.Field{}, StreetAddress: a.Street, Locality: a.City, Region: a.Region, PostalCode: a.PostalCode, Country: a.Country, } if a.Type != "" { addr.Params = vcard.Params{vcard.ParamType: {a.Type}} } card.AddAddress(addr) } switch { case in.RemovePhoto: // Leave PHOTO unset. case len(in.PhotoData) > 0: mime := in.PhotoMime if mime == "" { mime = http.DetectContentType(in.PhotoData) } encoded := base64.StdEncoding.EncodeToString(in.PhotoData) card.SetValue(vcard.FieldPhoto, "data:"+mime+";base64,"+encoded) case existingPhoto != "": card.SetValue(vcard.FieldPhoto, existingPhoto) } return card } func (s *Server) saveCard(username, book, id string, card vcard.Card) error { var buf strings.Builder enc := vcard.NewEncoder(&buf) if err := enc.Encode(card); err != nil { return fmt.Errorf("encoding vcard: %w", err) } return s.store.PutObject(username, "card-"+book, id, []byte(buf.String())) } // photoDataURL returns card's PHOTO value if it's already a data: URI // (how we always store newly uploaded photos), so it can be used directly // as an . Legacy vCards may store photos differently (e.g. a bare // base64 blob with an ENCODING param, or a remote http(s) URL); those are // preserved on save but not rendered as a preview. func photoDataURL(card vcard.Card) string { v := card.Value(vcard.FieldPhoto) if strings.HasPrefix(v, "data:") { return v } return "" } func labeledValues(card vcard.Card, key string) []templates.LabeledValue { fields := card[key] out := make([]templates.LabeledValue, 0, len(fields)) for _, f := range fields { typ := "" if f.Params != nil { typ = f.Params.Get(vcard.ParamType) } out = append(out, templates.LabeledValue{Type: typ, Value: f.Value}) } return out } func addressValues(card vcard.Card) []templates.AddressValue { addrs := card.Addresses() out := make([]templates.AddressValue, 0, len(addrs)) for _, a := range addrs { typ := "" if a.Params != nil { typ = a.Params.Get(vcard.ParamType) } out = append(out, templates.AddressValue{ Type: typ, Street: a.StreetAddress, City: a.Locality, Region: a.Region, PostalCode: a.PostalCode, Country: a.Country, }) } return out } func contactFormFromCard(book, id string, data []byte) (templates.ContactFormData, error) { dec := vcard.NewDecoder(bytes.NewReader(data)) card, err := dec.Decode() if err != nil { return templates.ContactFormData{}, err } var forename, surname string if n := card.Name(); n != nil && (n.GivenName != "" || n.FamilyName != "") { forename, surname = n.GivenName, n.FamilyName } else if fn := strings.TrimSpace(card.PreferredValue(vcard.FieldFormattedName)); fn != "" { parts := strings.Fields(fn) if len(parts) > 1 { forename, surname = parts[0], strings.Join(parts[1:], " ") } else { forename = parts[0] } } return templates.ContactFormData{ Book: book, ID: id, Forename: forename, Surname: surname, Organization: card.PreferredValue(vcard.FieldOrganization), Note: card.PreferredValue(vcard.FieldNote), Birthday: card.PreferredValue(vcard.FieldBirthday), PhotoDataURL: photoDataURL(card), Phones: ensureAtLeastOne(labeledValues(card, vcard.FieldTelephone)), Emails: ensureAtLeastOne(labeledValues(card, vcard.FieldEmail)), Addresses: ensureAtLeastOne(addressValues(card)), }, nil } func (s *Server) handleContactDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) book := r.PathValue("book") id := r.PathValue("id") ok, err := s.ownsAddressBook(username, book) if err != nil { s.logger.Error("checking address book ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok || !contactIDRe.MatchString(id) { http.NotFound(w, r) return } if err := s.store.DeleteObject(username, "card-"+book, id); err != nil && !errors.Is(err, store.ErrNotFound) { s.logger.Error("deleting contact", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/web/contacts/"+book, http.StatusSeeOther) } func (s *Server) handleContactExportOne(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) book := r.PathValue("book") id := r.PathValue("id") ok, err := s.ownsAddressBook(username, book) if err != nil { s.logger.Error("checking address book ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok || !contactIDRe.MatchString(id) { http.NotFound(w, r) return } data, err := s.store.GetObject(username, "card-"+book, id) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/vcard; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="`+id+`"`) _, _ = w.Write(data) } func (s *Server) handleContactExportAll(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) book := r.PathValue("book") ok, err := s.ownsAddressBook(username, book) if err != nil { s.logger.Error("checking address book ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } ids, err := s.store.ListObjects(username, "card-"+book) if err != nil { s.logger.Error("listing contacts", "book", book, "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/vcard; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="`+book+`.vcf"`) for _, id := range ids { data, err := s.store.GetObject(username, "card-"+book, id) if err != nil { continue } // Each stored object is already a complete, self-contained vCard // (ending in "END:VCARD"), so concatenating their raw bytes // produces a valid multi-card .vcf file. _, _ = w.Write(data) } } func (s *Server) handleContactImport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username := userFromContext(r.Context()) book := r.PathValue("book") ok, err := s.ownsAddressBook(username, book) if err != nil { s.logger.Error("checking address book ownership", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } if err := r.ParseMultipartForm(maxUploadMemory); err != nil { http.Error(w, "invalid upload", http.StatusBadRequest) return } file, _, err := r.FormFile("file") if err != nil { http.Error(w, "no file provided", http.StatusBadRequest) return } defer file.Close() body, err := io.ReadAll(file) if err != nil { s.logger.Error("reading import file", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } body = sanitizeVCardBytes(body) dec := vcard.NewDecoder(bytes.NewReader(body)) imported := 0 for { card, err := dec.Decode() if errors.Is(err, io.EOF) { break } if err != nil { s.logger.Warn("stopping vcard import on parse error", "book", book, "error", err) break } id, err := newContactID() if err != nil { s.logger.Error("generating contact id", "error", err) continue } if err := s.saveCard(username, book, id, card); err != nil { s.logger.Warn("saving imported contact", "error", err) continue } imported++ } http.Redirect(w, r, "/web/contacts/"+book+"?imported="+strconv.Itoa(imported), http.StatusSeeOther) }