Files
arnef b543b46ad2
Docker Image bauen und veröffentlichen / docker (push) Successful in 20m27s
fix(web): prefill birthday in contact form for imported contacts
2026-09-06 19:27:28 +02:00

734 lines
22 KiB
Go

package web
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
"git.arnef.de/arnef/nidus/internal/birthdays"
"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 "<id>.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, addrValues := get("address_type"), get("address_value")
for i, v := range addrValues {
v = strings.TrimSpace(v)
if v == "" {
continue
}
in.Addresses = append(in.Addresses, templates.AddressValue{Type: formValue(addrTypes, i), Value: v})
}
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.Value}
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 <img src>. 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 ""
}
// birthdayInputValue returns birthday in the "YYYY-MM-DD" form the contact
// form's <input type="date"> renders. vCards from other clients often store
// the date in vCard 3.0's compact "YYYYMMDD" form or year-less "--MMDD"
// form; a browser date input silently ignores values it can't parse, which
// would leave the field looking empty and drop the birthday on the next
// save. A year-less value can't be shown in a year-bearing date input as-is,
// so it's pre-filled with the current year — the field still shows the
// contact's real month/day (never the blank state the bug produced), and the
// user can correct the year if they know it. Unrecognized values pass
// through unchanged (the empty string for contacts without a birthday).
func birthdayInputValue(birthday string) string {
month, day, year, ok := birthdays.ParseBirthday(birthday)
if !ok {
return birthday
}
if year == 0 {
year = time.Now().Year()
}
return fmt.Sprintf("%04d-%02d-%02d", year, int(month), day)
}
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)
}
value := strings.Join([]string{a.StreetAddress, a.Locality, a.Region, a.PostalCode, a.Country}, ", ")
out = append(out, templates.AddressValue{Type: typ, Value: value})
}
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: birthdayInputValue(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)
}