2022-02-22 11:14:19 +00:00
|
|
|
package storage
|
|
|
|
|
|
|
|
import (
|
2022-02-22 17:24:17 +00:00
|
|
|
"context"
|
2022-02-24 11:54:30 +00:00
|
|
|
"crypto/md5"
|
|
|
|
"encoding/base64"
|
2022-02-22 17:24:17 +00:00
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"io/ioutil"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
2022-03-10 15:46:56 +00:00
|
|
|
"regexp"
|
2022-02-22 17:24:17 +00:00
|
|
|
|
2022-02-22 11:14:19 +00:00
|
|
|
"github.com/emersion/go-vcard"
|
|
|
|
"github.com/emersion/go-webdav/carddav"
|
2022-02-22 17:24:17 +00:00
|
|
|
|
|
|
|
"git.sr.ht/~sircmpwn/tokidoki/auth"
|
2022-02-22 11:14:19 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type filesystemBackend struct {
|
|
|
|
path string
|
|
|
|
}
|
|
|
|
|
2022-02-22 17:24:17 +00:00
|
|
|
var nilBackend carddav.Backend = (*filesystemBackend)(nil)
|
2022-02-22 11:14:19 +00:00
|
|
|
|
2022-02-22 17:24:17 +00:00
|
|
|
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")
|
|
|
|
}
|
2022-02-22 11:14:19 +00:00
|
|
|
return &filesystemBackend{
|
|
|
|
path: path,
|
2022-02-22 17:24:17 +00:00
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *filesystemBackend) pathForContext(ctx context.Context) (string, error) {
|
|
|
|
raw := ctx.Value(auth.AuthCtxKey)
|
|
|
|
if raw == nil {
|
2022-03-10 15:46:56 +00:00
|
|
|
return "", fmt.Errorf("unauthenticated requests are not supported")
|
2022-02-22 17:24:17 +00:00
|
|
|
}
|
|
|
|
authCtx, ok := raw.(*auth.AuthContext)
|
|
|
|
if !ok {
|
|
|
|
panic("Invalid data in auth context!")
|
|
|
|
}
|
|
|
|
//TODO sanitize user name or at least check if valid dir name?
|
|
|
|
path := filepath.Join(b.path, authCtx.UserName)
|
|
|
|
|
|
|
|
_, 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
|
|
|
|
}
|
|
|
|
|
2022-03-10 15:46:56 +00:00
|
|
|
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
|
|
|
|
var valid = regexp.MustCompile(`^/[A-Za-z0-9_-]+(.[a-zA-Z]+)?$`)
|
|
|
|
if !valid.MatchString(path) {
|
|
|
|
return "", fmt.Errorf("invalid request path")
|
|
|
|
}
|
|
|
|
return filepath.Join(basePath, path), nil
|
|
|
|
}
|
|
|
|
|
2022-02-24 11:54:30 +00:00
|
|
|
func etagForFile(path string) (string, error) {
|
|
|
|
data, err := ioutil.ReadFile(path)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
csum := md5.Sum(data)
|
|
|
|
return base64.StdEncoding.EncodeToString(csum[:]), nil
|
|
|
|
}
|
|
|
|
|
2022-02-28 18:50:36 +00:00
|
|
|
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) {
|
2022-02-24 11:54:30 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-02-28 18:50:36 +00:00
|
|
|
return vcardPropFilter(&card, propFilter), nil
|
2022-02-24 11:54:30 +00:00
|
|
|
}
|
|
|
|
|
2022-02-22 17:24:17 +00:00
|
|
|
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: []carddav.AddressDataType{},
|
|
|
|
}
|
|
|
|
blob, err := json.MarshalIndent(defaultAB, "", " ")
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error creating default address book: %s", err.Error())
|
2022-02-22 11:14:19 +00:00
|
|
|
}
|
2022-02-22 17:24:17 +00:00
|
|
|
err = os.WriteFile(path, blob, 0644)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error writing default address book: %s", err.Error())
|
|
|
|
}
|
|
|
|
return nil
|
2022-02-22 11:14:19 +00:00
|
|
|
}
|
|
|
|
|
2022-02-22 17:24:17 +00:00
|
|
|
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")
|
|
|
|
_, err = os.Stat(path)
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
err = createDefaultAddressBook(path)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
} else if err != nil {
|
|
|
|
return nil, fmt.Errorf("error opening address book: %s", err.Error())
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
2022-02-22 11:14:19 +00:00
|
|
|
}
|
|
|
|
|
2022-02-24 11:54:30 +00:00
|
|
|
func (b *filesystemBackend) GetAddressObject(ctx context.Context, path string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
|
2022-03-10 15:46:56 +00:00
|
|
|
localPath, err := b.safePath(ctx, path)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-03-10 15:46:56 +00:00
|
|
|
info, err := os.Stat(localPath)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-03-10 16:22:21 +00:00
|
|
|
var propFilter []string
|
2022-02-28 18:50:36 +00:00
|
|
|
if req != nil && !req.AllProp {
|
|
|
|
propFilter = req.Props
|
|
|
|
}
|
|
|
|
|
2022-03-10 15:46:56 +00:00
|
|
|
card, err := vcardFromFile(localPath, propFilter)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-03-10 15:46:56 +00:00
|
|
|
etag, err := etagForFile(localPath)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
obj := carddav.AddressObject{
|
2022-03-10 16:22:21 +00:00
|
|
|
Path: path,
|
2022-02-24 11:54:30 +00:00
|
|
|
ModTime: info.ModTime(),
|
2022-03-10 16:22:21 +00:00
|
|
|
ETag: etag,
|
|
|
|
Card: *card,
|
2022-02-24 11:54:30 +00:00
|
|
|
}
|
|
|
|
return &obj, nil
|
2022-02-22 11:14:19 +00:00
|
|
|
}
|
|
|
|
|
2022-02-28 18:50:36 +00:00
|
|
|
func (b *filesystemBackend) loadAll(ctx context.Context, propFilter []string) ([]carddav.AddressObject, error) {
|
2022-02-24 11:54:30 +00:00
|
|
|
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 {
|
2022-02-28 18:50:36 +00:00
|
|
|
// Skip address book meta data files
|
|
|
|
if !info.Mode().IsRegular() || filepath.Ext(filename) == ".json" {
|
2022-02-24 11:54:30 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-02-28 18:50:36 +00:00
|
|
|
card, err := vcardFromFile(filename, propFilter)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
etag, err := etagForFile(filename)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
obj := carddav.AddressObject{
|
2022-03-10 16:22:21 +00:00
|
|
|
Path: "/" + filepath.Base(filename),
|
2022-02-24 11:54:30 +00:00
|
|
|
ModTime: info.ModTime(),
|
2022-03-10 16:22:21 +00:00
|
|
|
ETag: etag,
|
|
|
|
Card: *card,
|
2022-02-24 11:54:30 +00:00
|
|
|
}
|
|
|
|
result = append(result, obj)
|
|
|
|
return nil
|
|
|
|
})
|
2022-02-28 18:50:36 +00:00
|
|
|
|
|
|
|
return result, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *filesystemBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
|
2022-03-10 16:22:21 +00:00
|
|
|
var propFilter []string
|
2022-02-28 18:50:36 +00:00
|
|
|
if req != nil && !req.AllProp {
|
|
|
|
propFilter = req.Props
|
2022-02-24 11:54:30 +00:00
|
|
|
}
|
|
|
|
|
2022-02-28 18:50:36 +00:00
|
|
|
return b.loadAll(ctx, propFilter)
|
2022-02-22 11:14:19 +00:00
|
|
|
}
|
|
|
|
|
2022-02-28 18:50:36 +00:00
|
|
|
func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
|
2022-03-10 16:22:21 +00:00
|
|
|
var propFilter []string
|
2022-02-28 18:50:36 +00:00
|
|
|
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)
|
2022-02-22 11:14:19 +00:00
|
|
|
}
|
|
|
|
|
2022-03-10 16:22:21 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-02-24 11:54:30 +00:00
|
|
|
func (b *filesystemBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card) (loc string, err error) {
|
2022-03-10 15:46:56 +00:00
|
|
|
localPath, err := b.safePath(ctx, path)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
2022-03-10 16:22:21 +00:00
|
|
|
|
|
|
|
conflict, err := b.hasUIDConflict(ctx, card.Value(vcard.FieldUID), path)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
if conflict {
|
|
|
|
return "", carddav.NewPreconditionError(carddav.PreconditionNoUIDConflict)
|
|
|
|
}
|
|
|
|
|
2022-03-10 15:46:56 +00:00
|
|
|
f, err := os.OpenFile(localPath, os.O_RDWR|os.O_CREATE, 0644)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
enc := vcard.NewEncoder(f)
|
|
|
|
err = enc.Encode(card)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
return path, nil
|
2022-02-22 11:14:19 +00:00
|
|
|
}
|
|
|
|
|
2022-02-24 11:54:30 +00:00
|
|
|
func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error {
|
2022-03-10 15:46:56 +00:00
|
|
|
localPath, err := b.safePath(ctx, path)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-03-10 15:46:56 +00:00
|
|
|
err = os.Remove(localPath)
|
2022-02-24 11:54:30 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-02-22 17:24:17 +00:00
|
|
|
return nil
|
2022-02-22 11:14:19 +00:00
|
|
|
}
|