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>
723 lines
22 KiB
Go
723 lines
22 KiB
Go
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)
|
|
}
|