Add web UI for WebDAV files and CardDAV contacts
Files browser (web/files): - Alphabetical listing grouped folders-then-files - Upload via button or drag-and-drop, including whole folders - Folder creation - Fixed layout for long filenames without spaces (table-fixed + break-all) Contacts (web/contacts): - Full CRUD for CardDAV contacts (create/edit/delete) - VCF import (multi-card files) and export (single/all) - Photo upload with preview, birthday field - TYPE labels (private/business) for phone, email, address - Repeatable multi-input rows for phones/emails/addresses instead of textareas - Sanitizes a known malformed TYPE parameter pattern from some vCard exporters (e.g. Nextcloud Contacts) that otherwise caused phone numbers to be silently dropped on import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
vcard "github.com/emersion/go-vcard"
|
||||
"github.com/yourusername/caldav-server/internal/store"
|
||||
"github.com/yourusername/caldav-server/internal/web/templates"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
FullName 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{
|
||||
FullName: strings.TrimSpace(formValue(get("full_name"), 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.FullName == "" {
|
||||
form := templates.ContactFormData{
|
||||
Book: book,
|
||||
ID: id,
|
||||
FullName: in.FullName,
|
||||
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, "Full 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)
|
||||
}
|
||||
card.SetValue(vcard.FieldFormattedName, in.FullName)
|
||||
|
||||
name := &vcard.Name{}
|
||||
parts := strings.Fields(in.FullName)
|
||||
if len(parts) > 0 {
|
||||
name.GivenName = parts[0]
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
name.FamilyName = strings.Join(parts[1:], " ")
|
||||
}
|
||||
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 <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 ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return templates.ContactFormData{
|
||||
Book: book,
|
||||
ID: id,
|
||||
FullName: card.PreferredValue(vcard.FieldFormattedName),
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user