tokidoki/storage/filesystem.go
2022-03-16 15:11:06 +01:00

327 lines
7.2 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: 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.Create(localPath)
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
}