311 lines
11 KiB
Go
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))
|
|
})
|
|
}
|