package storage import ( "context" "crypto/sha1" "encoding/base64" "encoding/json" "fmt" "io" "io/ioutil" "os" "path/filepath" "regexp" "github.com/emersion/go-vcard" "github.com/emersion/go-webdav/carddav" "git.sr.ht/~sircmpwn/tokidoki/auth" ) type filesystemBackend struct { path string } var ( nilBackend carddav.Backend = (*filesystemBackend)(nil) validFilenameRegex = regexp.MustCompile(`^/[A-Za-z0-9_-]+(.[a-zA-Z]+)?$`) ) func NewFilesystem(path string) (carddav.Backend, error) { info, err := os.Stat(path) if err != nil { return nilBackend, fmt.Errorf("failed to create filesystem backend: %s", err.Error()) } if !info.IsDir() { return nilBackend, fmt.Errorf("base path for filesystem backend must be a directory") } return &filesystemBackend{ path: path, }, nil } func (b *filesystemBackend) pathForContext(ctx context.Context) (string, error) { authCtx, ok := auth.FromContext(ctx) if !ok { panic("Invalid data in auth context!") } if authCtx == nil { return "", fmt.Errorf("unauthenticated requests are not supported") } userDir := base64.RawStdEncoding.EncodeToString([]byte(authCtx.UserName)) path := filepath.Join(b.path, userDir) _, err := os.Stat(path) if os.IsNotExist(err) { err = os.Mkdir(path, 0755) if err != nil { return "", fmt.Errorf("error creating '%s': %s", path, err.Error()) } } return path, nil } func (b *filesystemBackend) safePath(ctx context.Context, path string) (string, error) { basePath, err := b.pathForContext(ctx) if err != nil { return "", err } // We are mapping to local filesystem path, so be conservative about what to accept // TODO this changes once multiple addess books are supported if !validFilenameRegex.MatchString(path) { return "", fmt.Errorf("invalid request path") } return filepath.Join(basePath, path), nil } 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 createDefaultAddressBook(path string) error { // TODO what should the default address book look like? defaultAB := carddav.AddressBook{ Path: "/default", 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(path, 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) { path, err := b.pathForContext(ctx) if err != nil { return nil, err } path = filepath.Join(path, "default.json") data, err := ioutil.ReadFile(path) if os.IsNotExist(err) { err = createDefaultAddressBook(path) if err != nil { return nil, err } data, err = ioutil.ReadFile(path) } if err != 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, path string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { localPath, err := b.safePath(ctx, path) if err != nil { return nil, err } info, err := os.Stat(localPath) if err != nil { 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: path, ModTime: info.ModTime(), ETag: etag, Card: card, } return &obj, nil } func (b *filesystemBackend) loadAll(ctx context.Context, propFilter []string) ([]carddav.AddressObject, error) { var result []carddav.AddressObject path, err := b.pathForContext(ctx) if err != nil { return result, err } err = filepath.Walk(path, func(filename string, info os.FileInfo, err error) error { // Skip address book meta data files if !info.Mode().IsRegular() || filepath.Ext(filename) == ".json" { 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: "/" + filepath.Base(filename), ModTime: info.ModTime(), ETag: etag, Card: card, } result = append(result, obj) return nil }) return result, err } func (b *filesystemBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { var propFilter []string if req != nil && !req.AllProp { propFilter = req.Props } return b.loadAll(ctx, propFilter) } func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { var propFilter []string if query != nil && !query.DataRequest.AllProp { propFilter = query.DataRequest.Props } result, err := b.loadAll(ctx, propFilter) if err != nil { return result, err } return carddav.Filter(query, result) } func (b *filesystemBackend) hasUIDConflict(ctx context.Context, uid, path string) (bool, error) { all, err := b.loadAll(ctx, nil) if err != nil { return false, err } for _, contact := range all { if contact.Path != path && contact.Card.Value(vcard.FieldUID) == uid { return true, nil } } return false, nil } func (b *filesystemBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card) (loc string, err error) { localPath, err := b.safePath(ctx, path) if err != nil { return "", err } conflict, err := b.hasUIDConflict(ctx, card.Value(vcard.FieldUID), path) if err != nil { return "", err } if conflict { return "", carddav.NewPreconditionError(carddav.PreconditionNoUIDConflict) } f, err := os.OpenFile(localPath, os.O_RDWR|os.O_CREATE, 0644) if err != nil { return "", err } defer f.Close() enc := vcard.NewEncoder(f) err = enc.Encode(card) if err != nil { return "", err } return path, nil } func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error { localPath, err := b.safePath(ctx, path) if err != nil { return err } err = os.Remove(localPath) if err != nil { return err } return nil }