b99f38a1b7
This fixes the request that creates the address book returning an error even though it was successfully created and read.
644 lines
16 KiB
Go
644 lines
16 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"io/ioutil"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/emersion/go-ical"
|
|
"github.com/emersion/go-vcard"
|
|
"github.com/emersion/go-webdav"
|
|
"github.com/emersion/go-webdav/caldav"
|
|
"github.com/emersion/go-webdav/carddav"
|
|
|
|
"git.sr.ht/~sircmpwn/tokidoki/debug"
|
|
)
|
|
|
|
type filesystemBackend struct {
|
|
webdav.UserPrincipalBackend
|
|
path string
|
|
caldavPrefix string
|
|
carddavPrefix string
|
|
}
|
|
|
|
var (
|
|
validFilenameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]+(.[a-zA-Z]+)?$`)
|
|
)
|
|
|
|
func NewFilesystem(fsPath, caldavPrefix, carddavPrefix string, userPrincipalBackend webdav.UserPrincipalBackend) (caldav.Backend, carddav.Backend, error) {
|
|
info, err := os.Stat(fsPath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to create filesystem backend: %s", err.Error())
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, nil, fmt.Errorf("base path for filesystem backend must be a directory")
|
|
}
|
|
backend := &filesystemBackend{
|
|
UserPrincipalBackend: userPrincipalBackend,
|
|
path: fsPath,
|
|
caldavPrefix: caldavPrefix,
|
|
carddavPrefix: carddavPrefix,
|
|
}
|
|
return backend, backend, nil
|
|
}
|
|
|
|
func (b *filesystemBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) {
|
|
upPath, err := b.CurrentUserPrincipal(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return path.Join(upPath, b.carddavPrefix) + "/", nil
|
|
}
|
|
|
|
func (b *filesystemBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
|
upPath, err := b.CurrentUserPrincipal(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return path.Join(upPath, b.caldavPrefix) + "/", nil
|
|
}
|
|
|
|
func ensureLocalDir(path string) error {
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
err = os.MkdirAll(path, 0755)
|
|
if err != nil {
|
|
return fmt.Errorf("error creating '%s': %s", path, err.Error())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// don't use this directly, use localCalDAVPath or localCardDAVPath instead.
|
|
func (b *filesystemBackend) safeLocalPath(homeSetPath string, urlPath string) (string, error) {
|
|
localPath := filepath.Join(b.path, homeSetPath)
|
|
if err := ensureLocalDir(localPath); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if urlPath == "" {
|
|
return localPath, nil
|
|
}
|
|
|
|
// We are mapping to local filesystem path, so be conservative about what to accept
|
|
// TODO this changes once multiple addess books are supported
|
|
dir, file := path.Split(urlPath)
|
|
// only accept resources in prefix, no subdirs for now
|
|
if dir != homeSetPath {
|
|
if strings.HasPrefix(dir, homeSetPath+"/") {
|
|
err := fmt.Errorf("invalid request path: %s", urlPath)
|
|
return "", webdav.NewHTTPError(400, err)
|
|
} else {
|
|
err := fmt.Errorf("Access to resource outside of home set: %s", urlPath)
|
|
return "", webdav.NewHTTPError(403, err)
|
|
}
|
|
}
|
|
// only accept simple file names for now
|
|
if !validFilenameRegex.MatchString(file) {
|
|
debug.Printf("%s does not match regex!\n", file)
|
|
err := fmt.Errorf("invalid file name: %s", file)
|
|
return "", webdav.NewHTTPError(400, err)
|
|
}
|
|
|
|
// dir (= homeSetPath) is already included in path, so only file here
|
|
return filepath.Join(localPath, file), nil
|
|
}
|
|
|
|
func (b *filesystemBackend) localCalDAVPath(ctx context.Context, urlPath string) (string, error) {
|
|
homeSetPath, err := b.CalendarHomeSetPath(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return b.safeLocalPath(homeSetPath, urlPath)
|
|
}
|
|
|
|
func (b *filesystemBackend) localCardDAVPath(ctx context.Context, urlPath string) (string, error) {
|
|
homeSetPath, err := b.AddressbookHomeSetPath(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return b.safeLocalPath(homeSetPath, urlPath)
|
|
}
|
|
|
|
func etagForFile(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
h := sha1.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
csum := h.Sum(nil)
|
|
|
|
return base64.StdEncoding.EncodeToString(csum[:]), nil
|
|
}
|
|
|
|
func vcardPropFilter(card vcard.Card, props []string) vcard.Card {
|
|
if card == nil {
|
|
return nil
|
|
}
|
|
|
|
if len(props) == 0 {
|
|
return card
|
|
}
|
|
|
|
result := make(vcard.Card)
|
|
result["VERSION"] = card["VERSION"]
|
|
for _, prop := range props {
|
|
value, ok := card[prop]
|
|
if ok {
|
|
result[prop] = value
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func vcardFromFile(path string, propFilter []string) (vcard.Card, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
dec := vcard.NewDecoder(f)
|
|
card, err := dec.Decode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return vcardPropFilter(card, propFilter), nil
|
|
}
|
|
|
|
func calendarFromFile(path string, propFilter []string) (*ical.Calendar, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
dec := ical.NewDecoder(f)
|
|
cal, err := dec.Decode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cal, nil
|
|
// TODO implement
|
|
//return icalPropFilter(cal, propFilter), nil
|
|
}
|
|
|
|
func createDefaultAddressBook(path, localPath string) error {
|
|
// TODO what should the default address book look like?
|
|
defaultAB := carddav.AddressBook{
|
|
Path: path,
|
|
Name: "My contacts",
|
|
Description: "Default address book",
|
|
MaxResourceSize: 1024,
|
|
SupportedAddressData: nil,
|
|
}
|
|
blob, err := json.MarshalIndent(defaultAB, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("error creating default address book: %s", err.Error())
|
|
}
|
|
err = os.WriteFile(localPath, blob, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("error writing default address book: %s", err.Error())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *filesystemBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) {
|
|
debug.Printf("filesystem.AddressBook()")
|
|
path, err := b.localCardDAVPath(ctx, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
path = filepath.Join(path, "addressbook.json")
|
|
|
|
debug.Printf("loading addressbook from %s", path)
|
|
|
|
data, readErr := ioutil.ReadFile(path)
|
|
if os.IsNotExist(readErr) {
|
|
urlPath, err := b.AddressbookHomeSetPath(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
debug.Printf("creating default addressbook (URL:path): %s:%s", urlPath, path)
|
|
err = createDefaultAddressBook(urlPath, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, readErr = ioutil.ReadFile(path)
|
|
}
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("error opening address book: %s", err.Error())
|
|
}
|
|
var addressBook carddav.AddressBook
|
|
err = json.Unmarshal(data, &addressBook)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading address book: %s", err.Error())
|
|
}
|
|
|
|
return &addressBook, nil
|
|
}
|
|
|
|
func (b *filesystemBackend) GetAddressObject(ctx context.Context, objPath string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
|
|
debug.Printf("filesystem.GetAddressObject(%s, %v)", objPath, req)
|
|
localPath, err := b.localCardDAVPath(ctx, objPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info, err := os.Stat(localPath)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, webdav.NewHTTPError(404, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var propFilter []string
|
|
if req != nil && !req.AllProp {
|
|
propFilter = req.Props
|
|
}
|
|
|
|
card, err := vcardFromFile(localPath, propFilter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
etag, err := etagForFile(localPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
obj := carddav.AddressObject{
|
|
Path: objPath,
|
|
ModTime: info.ModTime(),
|
|
ContentLength: info.Size(),
|
|
ETag: etag,
|
|
Card: card,
|
|
}
|
|
return &obj, nil
|
|
}
|
|
|
|
func (b *filesystemBackend) loadAllContacts(ctx context.Context, propFilter []string) ([]carddav.AddressObject, error) {
|
|
var result []carddav.AddressObject
|
|
|
|
localPath, err := b.localCardDAVPath(ctx, "")
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
homeSetPath, err := b.AddressbookHomeSetPath(ctx)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
err = filepath.Walk(localPath, func(filename string, info os.FileInfo, err error) error {
|
|
if !info.Mode().IsRegular() || filepath.Ext(filename) != ".vcf" {
|
|
return nil
|
|
}
|
|
|
|
card, err := vcardFromFile(filename, propFilter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
etag, err := etagForFile(filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
obj := carddav.AddressObject{
|
|
Path: path.Join(homeSetPath, filepath.Base(filename)),
|
|
ModTime: info.ModTime(),
|
|
ContentLength: info.Size(),
|
|
ETag: etag,
|
|
Card: card,
|
|
}
|
|
result = append(result, obj)
|
|
return nil
|
|
})
|
|
|
|
debug.Printf("filesystem.loadAllContacts() returning %d results from %s", len(result), localPath)
|
|
return result, err
|
|
}
|
|
|
|
func (b *filesystemBackend) loadAllCalendars(ctx context.Context, propFilter []string) ([]caldav.CalendarObject, error) {
|
|
var result []caldav.CalendarObject
|
|
|
|
localPath, err := b.localCalDAVPath(ctx, "")
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
homeSetPath, err := b.CalendarHomeSetPath(ctx)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
err = filepath.Walk(localPath, func(filename string, info os.FileInfo, err error) error {
|
|
// Skip address book meta data files
|
|
if !info.Mode().IsRegular() || filepath.Ext(filename) != ".ics" {
|
|
return nil
|
|
}
|
|
|
|
cal, err := calendarFromFile(filename, propFilter)
|
|
if err != nil {
|
|
fmt.Printf("load calendar error for %s: %v\n", filename, err)
|
|
return err
|
|
}
|
|
|
|
etag, err := etagForFile(filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
obj := caldav.CalendarObject{
|
|
Path: path.Join(homeSetPath, filepath.Base(filename)),
|
|
ModTime: info.ModTime(),
|
|
ContentLength: info.Size(),
|
|
ETag: etag,
|
|
Data: cal,
|
|
}
|
|
result = append(result, obj)
|
|
return nil
|
|
})
|
|
|
|
debug.Printf("filesystem.loadAllCalendars() returning %d results from %s", len(result), localPath)
|
|
return result, err
|
|
}
|
|
|
|
func (b *filesystemBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
|
|
debug.Printf("filesystem.ListAddressObjects(%v)", req)
|
|
var propFilter []string
|
|
if req != nil && !req.AllProp {
|
|
propFilter = req.Props
|
|
}
|
|
|
|
return b.loadAllContacts(ctx, propFilter)
|
|
}
|
|
|
|
func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
|
|
debug.Printf("filesystem.QueryAddressObjects(%v)", query)
|
|
var propFilter []string
|
|
if query != nil && !query.DataRequest.AllProp {
|
|
propFilter = query.DataRequest.Props
|
|
}
|
|
|
|
result, err := b.loadAllContacts(ctx, propFilter)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
return carddav.Filter(query, result)
|
|
}
|
|
|
|
func (b *filesystemBackend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (loc string, err error) {
|
|
debug.Printf("filesystem.PutAddressObject(%v, %v, %v)", objPath, card, opts)
|
|
|
|
// Object always get saved as <UID>.vcf
|
|
dirname, _ := path.Split(objPath)
|
|
objPath = path.Join(dirname, card.Value(vcard.FieldUID)+".vcf")
|
|
|
|
localPath, err := b.localCardDAVPath(ctx, objPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC
|
|
if opts.IfNoneMatch {
|
|
flags |= os.O_EXCL
|
|
}
|
|
|
|
// TODO handle IfMatch
|
|
f, err := os.OpenFile(localPath, flags, 0666)
|
|
if os.IsExist(err) {
|
|
return "", carddav.NewPreconditionError(carddav.PreconditionNoUIDConflict)
|
|
} else if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
enc := vcard.NewEncoder(f)
|
|
err = enc.Encode(card)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return objPath, nil
|
|
}
|
|
|
|
func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error {
|
|
debug.Printf("filesystem.DeleteAddressObject(%s)", path)
|
|
|
|
localPath, err := b.localCardDAVPath(ctx, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = os.Remove(localPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createDefaultCalendar(path, localPath string) error {
|
|
// TODO what should the default calendar look like?
|
|
defaultC := caldav.Calendar{
|
|
Path: path,
|
|
Name: "My calendar",
|
|
Description: "Default calendar",
|
|
MaxResourceSize: 4096,
|
|
}
|
|
blob, err := json.MarshalIndent(defaultC, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("error creating default calendar: %s", err.Error())
|
|
}
|
|
err = os.WriteFile(localPath, blob, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("error writing default calendar: %s", err.Error())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *filesystemBackend) Calendar(ctx context.Context) (*caldav.Calendar, error) {
|
|
debug.Printf("filesystem.Calendar()")
|
|
|
|
path, err := b.localCalDAVPath(ctx, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
path = filepath.Join(path, "calendar.json")
|
|
|
|
debug.Printf("loading calendar from %s", path)
|
|
|
|
data, readErr := ioutil.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
homeSetPath, err := b.CalendarHomeSetPath(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
debug.Printf("creating default calendar (URL:path): %s:%s", homeSetPath, path)
|
|
err = createDefaultCalendar(homeSetPath, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, readErr = ioutil.ReadFile(path)
|
|
}
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("error opening calendar: %s", err.Error())
|
|
}
|
|
var calendar caldav.Calendar
|
|
err = json.Unmarshal(data, &calendar)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading calendar: %s", err.Error())
|
|
}
|
|
|
|
return &calendar, nil
|
|
}
|
|
|
|
func (b *filesystemBackend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
|
debug.Printf("filesystem.GetCalendarObject(%s, %v)", objPath, req)
|
|
|
|
localPath, err := b.localCalDAVPath(ctx, objPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info, err := os.Stat(localPath)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
debug.Printf("not found: %s", localPath)
|
|
return nil, webdav.NewHTTPError(404, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var propFilter []string
|
|
if req != nil && !req.AllProps {
|
|
propFilter = req.Props
|
|
}
|
|
|
|
calendar, err := calendarFromFile(localPath, propFilter)
|
|
if err != nil {
|
|
debug.Printf("error reading calendar: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
etag, err := etagForFile(localPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
obj := caldav.CalendarObject{
|
|
Path: objPath,
|
|
ModTime: info.ModTime(),
|
|
ContentLength: info.Size(),
|
|
ETag: etag,
|
|
Data: calendar,
|
|
}
|
|
return &obj, nil
|
|
return nil, fmt.Errorf("not implemented")
|
|
}
|
|
|
|
func (b *filesystemBackend) ListCalendarObjects(ctx context.Context, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
|
|
debug.Printf("filesystem.ListCalendarObjects(%v)", req)
|
|
|
|
var propFilter []string
|
|
if req != nil && !req.AllProps {
|
|
propFilter = req.Props
|
|
}
|
|
|
|
return b.loadAllCalendars(ctx, propFilter)
|
|
}
|
|
|
|
func (b *filesystemBackend) QueryCalendarObjects(ctx context.Context, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
|
debug.Printf("filesystem.QueryCalendarObjects(%v)", query)
|
|
|
|
var propFilter []string
|
|
if query != nil && !query.CompRequest.AllProps {
|
|
propFilter = query.CompRequest.Props
|
|
}
|
|
|
|
result, err := b.loadAllCalendars(ctx, propFilter)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
return caldav.Filter(query, result)
|
|
}
|
|
|
|
func (b *filesystemBackend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (loc string, err error) {
|
|
debug.Printf("filesystem.PutCalendarObject(%s, %v, %v)", objPath, calendar, opts)
|
|
|
|
_, uid, err := caldav.ValidateCalendarObject(calendar)
|
|
if err != nil {
|
|
return "", caldav.NewPreconditionError(caldav.PreconditionValidCalendarObjectResource)
|
|
}
|
|
|
|
// Object always get saved as <UID>.ics
|
|
dirname, _ := path.Split(objPath)
|
|
objPath = path.Join(dirname, uid+".ics")
|
|
|
|
localPath, err := b.localCalDAVPath(ctx, objPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC
|
|
if opts.IfNoneMatch {
|
|
flags |= os.O_EXCL
|
|
}
|
|
|
|
// TODO handle IfMatch
|
|
f, err := os.OpenFile(localPath, flags, 0666)
|
|
if os.IsExist(err) {
|
|
return "", caldav.NewPreconditionError(caldav.PreconditionNoUIDConflict)
|
|
} else if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
enc := ical.NewEncoder(f)
|
|
err = enc.Encode(calendar)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return objPath, nil
|
|
}
|
|
|
|
func (b *filesystemBackend) DeleteCalendarObject(ctx context.Context, path string) error {
|
|
debug.Printf("filesystem.DeleteCalendarObject(%s)", path)
|
|
|
|
localPath, err := b.localCalDAVPath(ctx, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = os.Remove(localPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|