e5db18e289
Just try to read the file, use it if it works. Only if the file does not exist, create default address book and try again.
327 lines
7.3 KiB
Go
327 lines
7.3 KiB
Go
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: []carddav.AddressDataType{},
|
|
}
|
|
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) {
|
|
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
|
|
}
|