package storage import ( "context" "crypto/sha1" "encoding/base64" "encoding/json" "errors" "fmt" "io" "io/fs" "io/ioutil" "net/http" "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, "default") 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 default calendar/adress book for now if path.Clean(dir) != path.Join(homeSetPath, "default") { if strings.HasPrefix(dir, homeSetPath) { err := fmt.Errorf("invalid request path: %s (%s is not %s)", urlPath, dir, path.Join(homeSetPath, "default")) 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()") localPath, err := b.localCardDAVPath(ctx, "") if err != nil { return nil, err } localPath = filepath.Join(localPath, "addressbook.json") debug.Printf("loading addressbook from %s", localPath) data, readErr := ioutil.ReadFile(localPath) if os.IsNotExist(readErr) { urlPath, err := b.AddressbookHomeSetPath(ctx) if err != nil { return nil, err } urlPath = path.Join(urlPath, "default") + "/" debug.Printf("creating default addressbook (URL:path): %s:%s", urlPath, localPath) err = createDefaultAddressBook(urlPath, localPath) if err != nil { return nil, err } data, readErr = ioutil.ReadFile(localPath) } if readErr != nil { return nil, fmt.Errorf("error opening address book: %s", readErr.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, "default", 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, "default", 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 .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 // TODO handle IfNoneMatch == ETag if opts.IfNoneMatch.IsWildcard() { // Make sure we're not overwriting an existing file flags |= os.O_EXCL } else if opts.IfMatch.IsWildcard() { // Make sure we _are_ overwriting an existing file flags &= ^os.O_CREATE } else if opts.IfMatch.IsSet() { // Make sure we overwrite the _right_ file etag, err := etagForFile(localPath) if err != nil { return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) } want, err := opts.IfMatch.ETag() if err != nil { return "", webdav.NewHTTPError(http.StatusBadRequest, err) } if want != etag { err = fmt.Errorf("If-Match does not match current ETag (%s/%s)", want, etag) return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) } } 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()") localPath, err := b.localCalDAVPath(ctx, "") if err != nil { return nil, err } localPath = filepath.Join(localPath, "calendar.json") debug.Printf("loading calendar from %s", localPath) data, readErr := ioutil.ReadFile(localPath) if os.IsNotExist(readErr) { urlPath, err := b.CalendarHomeSetPath(ctx) if err != nil { return nil, err } urlPath = path.Join(urlPath, "default") + "/" debug.Printf("creating default calendar (URL:path): %s:%s", urlPath, localPath) err = createDefaultCalendar(urlPath, localPath) if err != nil { return nil, err } data, readErr = ioutil.ReadFile(localPath) } if readErr != nil { return nil, fmt.Errorf("error opening calendar: %s", readErr.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 .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 // TODO handle IfNoneMatch == ETag if opts.IfNoneMatch.IsWildcard() { // Make sure we're not overwriting an existing file flags |= os.O_EXCL } else if opts.IfMatch.IsWildcard() { // Make sure we _are_ overwriting an existing file flags &= ^os.O_CREATE } else if opts.IfMatch.IsSet() { // Make sure we overwrite the _right_ file etag, err := etagForFile(localPath) if err != nil { return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) } want, err := opts.IfMatch.ETag() if err != nil { return "", webdav.NewHTTPError(http.StatusBadRequest, err) } if want != etag { err = fmt.Errorf("If-Match does not match current ETag (%s/%s)", want, etag) return "", webdav.NewHTTPError(http.StatusPreconditionFailed, err) } } 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 }