Files
mailgoserver/internal/webui/carddav.go
T

311 lines
11 KiB
Go

// CardDAV server (RFC 6352) — exposes a mailbox owner's existing webmail Contacts
// (esrv_mailbox_contacts) for sync from Apple Contacts, Thunderbird, DAVx5, etc.
// Reuses the exact same contact rows/CRUD as the webmail Contacts page
// (webmail_contacts.go) — a contact added either way shows up in both.
package webui
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/carddav"
"mailgoserver/internal/abuseguard"
"mailgoserver/internal/db"
)
const carddavPrefix = "/carddav"
func carddavPrincipalPath(email string) string {
return carddavPrefix + "/" + url.PathEscape(email) + "/"
}
func carddavHomeSetPath(email string) string {
return carddavPrincipalPath(email) + "addressbooks/"
}
// carddavAddressBookPath is the one, fixed address book every mailbox gets — no
// multi-addressbook UI exists in this app, so there's nothing for a second one to
// represent.
func carddavAddressBookPath(email string) string {
return carddavHomeSetPath(email) + "default/"
}
func carddavObjectPath(email, uid string) string {
return carddavAddressBookPath(email) + uid + ".vcf"
}
// carddavUIDFromPath extracts the uid segment from an address-object request path
// (the last path segment, minus its ".vcf" extension) — the inverse of
// carddavObjectPath, used for both client-supplied PUT paths and our own GET/DELETE.
func carddavUIDFromPath(p string) string {
return strings.TrimSuffix(path.Base(p), ".vcf")
}
// carddavBackend implements carddav.Backend against esrv_mailbox_contacts. Every
// method resolves the authenticated mailbox from ctx (set by DAVBasicAuth below) —
// never from the request path — so a forged path segment can never read or modify
// another mailbox's contacts, same discipline as every other webmail handler in this
// codebase (mailboxFromContext, not a path parameter, is the source of truth).
type carddavBackend struct {
DB *db.DB
}
// davMailboxFromCtx resolves the authenticated mailbox set by DAVBasicAuth — shared by
// both carddavBackend and caldavBackend (caldav.go), since both protocols use the
// exact same auth path (DAVBasicAuth) and context key (ctxMailboxKey).
func davMailboxFromCtx(ctx context.Context) (*db.Mailbox, error) {
mbox, _ := ctx.Value(ctxMailboxKey).(*db.Mailbox)
if mbox == nil {
return nil, webdav.NewHTTPError(http.StatusUnauthorized, errors.New("no authenticated mailbox"))
}
return mbox, nil
}
func (b *carddavBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return "", err
}
return carddavPrincipalPath(mbox.Email), nil
}
func (b *carddavBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return "", err
}
return carddavHomeSetPath(mbox.Email), nil
}
func (b *carddavBackend) addressBook(mbox *db.Mailbox) carddav.AddressBook {
return carddav.AddressBook{
Path: carddavAddressBookPath(mbox.Email),
Name: "Contacts",
Description: "Contacts for " + mbox.Email,
}
}
func (b *carddavBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
return []carddav.AddressBook{b.addressBook(mbox)}, nil
}
func (b *carddavBackend) GetAddressBook(ctx context.Context, p string) (*carddav.AddressBook, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
if p != carddavAddressBookPath(mbox.Email) {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such address book"))
}
ab := b.addressBook(mbox)
return &ab, nil
}
// CreateAddressBook/DeleteAddressBook: unsupported — every mailbox has exactly one
// fixed address book (its Contacts), matching this app's own Contacts page, which has
// no concept of multiple address books either.
func (b *carddavBackend) CreateAddressBook(ctx context.Context, addressBook *carddav.AddressBook) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("creating address books is not supported"))
}
func (b *carddavBackend) DeleteAddressBook(ctx context.Context, p string) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("deleting the address book is not supported"))
}
// contactToCard maps a stored contact to the vCard the client sees. FN falls back to
// the email when no display name was ever set, since Encode refuses a card with an
// empty FN in practice (most clients treat a blank display name as broken).
func contactToCard(c db.MailboxContact) vcard.Card {
card := make(vcard.Card)
fn := c.Name
if fn == "" {
fn = c.Email
}
card.SetValue(vcard.FieldFormattedName, fn)
if c.GivenName != "" || c.FamilyName != "" {
card.AddName(&vcard.Name{GivenName: c.GivenName, FamilyName: c.FamilyName})
}
if c.Email != "" {
card.AddValue(vcard.FieldEmail, c.Email)
}
if c.Phone != "" {
card.AddValue(vcard.FieldTelephone, c.Phone)
}
if c.Org != "" {
card.AddValue(vcard.FieldOrganization, c.Org)
}
card.SetValue(vcard.FieldUID, c.UID)
if !c.UpdatedAt.IsZero() {
card.SetRevision(c.UpdatedAt)
}
vcard.ToV4(card)
return card
}
func contactETag(c db.MailboxContact) string {
return fmt.Sprintf("%d-%d", c.ID, c.UpdatedAt.Unix())
}
func contactToAddressObject(email string, c db.MailboxContact) carddav.AddressObject {
return carddav.AddressObject{
Path: carddavObjectPath(email, c.UID),
ModTime: c.UpdatedAt,
ETag: contactETag(c),
Card: contactToCard(c),
}
}
func (b *carddavBackend) GetAddressObject(ctx context.Context, p string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
c, err := b.DB.GetContactByUID(mbox.ID, carddavUIDFromPath(p))
if err != nil {
return nil, err
}
if c == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such contact"))
}
ao := contactToAddressObject(mbox.Email, *c)
return &ao, nil
}
func (b *carddavBackend) ListAddressObjects(ctx context.Context, p string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
contacts, err := b.DB.ListContacts(mbox.ID)
if err != nil {
return nil, err
}
out := make([]carddav.AddressObject, len(contacts))
for i, c := range contacts {
out[i] = contactToAddressObject(mbox.Email, c)
}
return out, nil
}
// QueryAddressObjects: this mailbox's contact list is realistically small (a personal
// address book, not a shared directory), so server-side filtering isn't worth the
// complexity of translating carddav.AddressBookQuery's PropFilters — every client that
// issues addressbook-query already filters again locally against whatever it gets
// back, per RFC 6352. Add real filtering if a mailbox's contact count ever makes that
// matter in practice.
func (b *carddavBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
return b.ListAddressObjects(ctx, p, &query.DataRequest)
}
func (b *carddavBackend) PutAddressObject(ctx context.Context, p string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
uid := carddavUIDFromPath(p)
email := card.PreferredValue(vcard.FieldEmail)
if email == "" {
return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("vCard has no EMAIL"))
}
name := card.PreferredValue(vcard.FieldFormattedName)
phone := card.PreferredValue(vcard.FieldTelephone)
org := card.PreferredValue(vcard.FieldOrganization)
var givenName, familyName string
if n := card.Name(); n != nil {
givenName, familyName = n.GivenName, n.FamilyName
}
existing, err := b.DB.GetContactByUID(mbox.ID, uid)
if err != nil {
return nil, err
}
if existing == nil {
if _, err := b.DB.CreateContactWithUID(mbox.ID, uid, email, name, phone, givenName, familyName, org); err != nil {
return nil, webdav.NewHTTPError(http.StatusConflict, err)
}
} else if err := b.DB.UpdateContactCard(mbox.ID, existing.ID, email, name, phone, givenName, familyName, org); err != nil {
return nil, webdav.NewHTTPError(http.StatusConflict, err)
}
stored, err := b.DB.GetContactByUID(mbox.ID, uid)
if err != nil || stored == nil {
return nil, err
}
ao := contactToAddressObject(mbox.Email, *stored)
return &ao, nil
}
func (b *carddavBackend) DeleteAddressObject(ctx context.Context, p string) error {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return err
}
return b.DB.DeleteContactByUID(mbox.ID, carddavUIDFromPath(p))
}
// DAVBasicAuth authenticates a CardDAV or CalDAV request (mostly protocol-agnostic —
// shared by both mounts in webui.go) via HTTP Basic Auth against the same app-password
// credential IMAP/SMTP already use (db.VerifyMailboxAppPassword) — DAV clients don't
// carry a browser session cookie, so this is a separate auth path from
// requireMailboxAuth, not a variant of it. Failed/missing auth gets the same
// admin-visible logging and IP auto-blacklist protection SMTP/IMAP auth failures
// already get (LogAuthAttempt + abuseguard), rather than a one-off parallel mechanism.
//
// protocol ("caldav" or "carddav") additionally gates the request on that protocol's
// domain-wide admin switch and this mailbox's own opt-in — both off by default (see
// schema.go's caldav_enabled/carddav_enabled comments) — with 403, not 401, once
// credentials check out but the feature is simply turned off.
func (a *App) DAVBasicAuth(protocol string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fail := func() {
w.Header().Set("WWW-Authenticate", `Basic realm="Mail"`) // shared by both CardDAV and CalDAV (caldav.go)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
email, password, ok := r.BasicAuth()
peerIP := a.requestIP(r)
if !ok || email == "" || password == "" {
fail()
return
}
mbox, err := a.DB.VerifyMailboxAppPassword(email, password)
if err != nil || mbox == nil {
if logErr := a.DB.LogAuthAttempt("dav_login", email, peerIP, false, "invalid credentials"); logErr != nil {
a.Logger.Error("log dav auth failure: %v", logErr)
}
abuseguard.RecordFailureAndMaybeBlacklist(a.DB, a.Cfg, a.Logger, peerIP)
fail()
return
}
dom, err := a.DB.GetDomainByID(mbox.DomainID)
if err != nil || dom == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
domainOn, mailboxOn := dom.CalDAVEnabled, mbox.CalDAVEnabled
if protocol == "carddav" {
domainOn, mailboxOn = dom.CardDAVEnabled, mbox.CardDAVEnabled
}
if !domainOn || !mailboxOn {
http.Error(w, protocol+" is not enabled for this mailbox", http.StatusForbidden)
return
}
if logErr := a.DB.LogAuthAttempt("dav_login", email, peerIP, true, ""); logErr != nil {
a.Logger.Error("log dav auth success: %v", logErr)
}
ctx := context.WithValue(r.Context(), ctxMailboxKey, mbox)
next.ServeHTTP(w, r.WithContext(ctx))
})
}