Add CalDAV support, refactor
The filesystem storage backend now implements the required functions to act as a basic CalDAV server. Some refactoring was done based on the go-webdav development: introduce a UserPrincipalBackend, a new function to serve the user principal URL, and more. See this PR for lots of details: https://github.com/emersion/go-webdav/pull/62 Also adds a simple facility for debug output.
This commit is contained in:
parent
5728f1ee27
commit
001917295d
@ -1,19 +1,83 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/emersion/go-webdav"
|
||||||
|
"github.com/emersion/go-webdav/caldav"
|
||||||
"github.com/emersion/go-webdav/carddav"
|
"github.com/emersion/go-webdav/carddav"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"git.sr.ht/~sircmpwn/tokidoki/auth"
|
"git.sr.ht/~sircmpwn/tokidoki/auth"
|
||||||
|
"git.sr.ht/~sircmpwn/tokidoki/debug"
|
||||||
"git.sr.ht/~sircmpwn/tokidoki/storage"
|
"git.sr.ht/~sircmpwn/tokidoki/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type userPrincipalBackend struct{}
|
||||||
|
|
||||||
|
func (u *userPrincipalBackend) CurrentUserPrincipal(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))
|
||||||
|
return "/" + userDir + "/", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokidokiHandler struct {
|
||||||
|
upBackend webdav.UserPrincipalBackend
|
||||||
|
authBackend auth.AuthProvider
|
||||||
|
caldavBackend caldav.Backend
|
||||||
|
carddavBackend carddav.Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *tokidokiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userPrincipalPath, err := u.upBackend.CurrentUserPrincipal(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
var homeSets []webdav.BackendSuppliedHomeSet
|
||||||
|
if u.caldavBackend != nil {
|
||||||
|
path, err := u.caldavBackend.CalendarHomeSetPath(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
homeSets = append(homeSets, caldav.NewCalendarHomeSet(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.carddavBackend != nil {
|
||||||
|
path, err := u.carddavBackend.AddressbookHomeSetPath(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
homeSets = append(homeSets, carddav.NewAddressBookHomeSet(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := webdav.ServeUserPrincipalOptions{
|
||||||
|
UserPrincipalPath: userPrincipalPath,
|
||||||
|
HomeSets: homeSets,
|
||||||
|
}
|
||||||
|
|
||||||
|
if webdav.ServeUserPrincipal(w, r, opts) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
addr string
|
addr string
|
||||||
@ -23,6 +87,7 @@ func main() {
|
|||||||
flag.StringVar(&addr, "addr", ":8080", "listening address")
|
flag.StringVar(&addr, "addr", ":8080", "listening address")
|
||||||
flag.StringVar(&authURL, "auth.url", "", "auth backend URL (required)")
|
flag.StringVar(&authURL, "auth.url", "", "auth backend URL (required)")
|
||||||
flag.StringVar(&storageURL, "storage.url", "", "storage backend URL (required)")
|
flag.StringVar(&storageURL, "storage.url", "", "storage backend URL (required)")
|
||||||
|
flag.BoolVar(&debug.Enable, "debug", false, "enable debug output")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if len(flag.Args()) != 0 || authURL == "" || storageURL == "" {
|
if len(flag.Args()) != 0 || authURL == "" || storageURL == "" {
|
||||||
@ -48,17 +113,36 @@ func main() {
|
|||||||
}
|
}
|
||||||
mux.Use(authProvider.Middleware())
|
mux.Use(authProvider.Middleware())
|
||||||
|
|
||||||
backend, err := storage.NewFromURL(storageURL)
|
upBackend := &userPrincipalBackend{}
|
||||||
|
|
||||||
|
caldavBackend, carddavBackend, err := storage.NewFromURL(storageURL, "/calendar/", "/contacts/", upBackend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to load storage backend: %s", err.Error())
|
log.Fatalf("failed to load storage backend: %s", err.Error())
|
||||||
}
|
}
|
||||||
mux.Mount("/", &carddav.Handler{Backend: backend})
|
|
||||||
|
carddavHandler := carddav.Handler{Backend: carddavBackend}
|
||||||
|
caldavHandler := caldav.Handler{Backend: caldavBackend}
|
||||||
|
handler := tokidokiHandler{
|
||||||
|
upBackend: upBackend,
|
||||||
|
authBackend: authProvider,
|
||||||
|
caldavBackend: caldavBackend,
|
||||||
|
carddavBackend: carddavBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.Mount("/", &handler)
|
||||||
|
mux.Mount("/.well-known/caldav", &caldavHandler)
|
||||||
|
mux.Mount("/.well-known/carddav", &carddavHandler)
|
||||||
|
mux.Mount("/{user}/contacts", &carddavHandler)
|
||||||
|
mux.Mount("/{user}/calendar", &caldavHandler)
|
||||||
|
|
||||||
server := http.Server{
|
server := http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Server running on %s", addr)
|
log.Printf("Server running on %s", addr)
|
||||||
|
debug.Printf("Debug output enabled")
|
||||||
|
|
||||||
err = server.ListenAndServe()
|
err = server.ListenAndServe()
|
||||||
if err != http.ErrServerClosed {
|
if err != http.ErrServerClosed {
|
||||||
log.Fatalf("ListenAndServe: %s", err.Error())
|
log.Fatalf("ListenAndServe: %s", err.Error())
|
||||||
|
15
debug/debug.go
Normal file
15
debug/debug.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package debug
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Enable = false
|
||||||
|
)
|
||||||
|
|
||||||
|
func Printf(format string, v ...any) {
|
||||||
|
if Enable {
|
||||||
|
log.Printf("[debug] "+format, v...)
|
||||||
|
}
|
||||||
|
}
|
5
go.mod
5
go.mod
@ -1,8 +1,9 @@
|
|||||||
module git.sr.ht/~sircmpwn/tokidoki
|
module git.sr.ht/~sircmpwn/tokidoki
|
||||||
|
|
||||||
go 1.17
|
go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e
|
||||||
github.com/emersion/go-imap v1.2.0
|
github.com/emersion/go-imap v1.2.0
|
||||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
|
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
|
||||||
github.com/emersion/go-vcard v0.0.0-20210521075357-3445b9171995
|
github.com/emersion/go-vcard v0.0.0-20210521075357-3445b9171995
|
||||||
@ -11,3 +12,5 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/text v0.3.7 // indirect
|
require golang.org/x/text v0.3.7 // indirect
|
||||||
|
|
||||||
|
replace github.com/emersion/go-webdav v0.3.2-0.20220310154811-85d2b222bbcd => github.com/bitfehler/go-webdav v0.3.2-0.20220503133151-e5312775c02f
|
||||||
|
3
go.sum
3
go.sum
@ -1,3 +1,6 @@
|
|||||||
|
github.com/bitfehler/go-webdav v0.3.2-0.20220503133151-e5312775c02f h1:gRchuZEVTYh+ymMqejZKbD67ZLLm59Q9lTG49e0Qu20=
|
||||||
|
github.com/bitfehler/go-webdav v0.3.2-0.20220503133151-e5312775c02f/go.mod h1:uSM1VveeKtogBVWaYccTksToczooJ0rrVGNsgnDsr4Q=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e h1:YGM1sI7edZOt8KAfX9Miq/X99d2QXdgjkJ7vN4HjxAA=
|
||||||
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e/go.mod h1:4xVTBPcT43a1pp3vdaa+FuRdX5XhKCZPpWv7m0z9ByM=
|
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e/go.mod h1:4xVTBPcT43a1pp3vdaa+FuRdX5XhKCZPpWv7m0z9ByM=
|
||||||
github.com/emersion/go-imap v1.2.0 h1:lyUQ3+EVM21/qbWE/4Ya5UG9r5+usDxlg4yfp3TgHFA=
|
github.com/emersion/go-imap v1.2.0 h1:lyUQ3+EVM21/qbWE/4Ya5UG9r5+usDxlg4yfp3TgHFA=
|
||||||
github.com/emersion/go-imap v1.2.0/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
github.com/emersion/go-imap v1.2.0/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||||
|
@ -5,74 +5,129 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/emersion/go-ical"
|
||||||
"github.com/emersion/go-vcard"
|
"github.com/emersion/go-vcard"
|
||||||
|
"github.com/emersion/go-webdav"
|
||||||
|
"github.com/emersion/go-webdav/caldav"
|
||||||
"github.com/emersion/go-webdav/carddav"
|
"github.com/emersion/go-webdav/carddav"
|
||||||
|
|
||||||
"git.sr.ht/~sircmpwn/tokidoki/auth"
|
"git.sr.ht/~sircmpwn/tokidoki/debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
type filesystemBackend struct {
|
type filesystemBackend struct {
|
||||||
path string
|
path string
|
||||||
|
caldavPrefix string
|
||||||
|
carddavPrefix string
|
||||||
|
userPrincipalBackend webdav.UserPrincipalBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
nilBackend carddav.Backend = (*filesystemBackend)(nil)
|
validFilenameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]+(.[a-zA-Z]+)?$`)
|
||||||
validFilenameRegex = regexp.MustCompile(`^/[A-Za-z0-9][A-Za-z0-9_-]+(.[a-zA-Z]+)?$`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewFilesystem(path string) (carddav.Backend, error) {
|
func NewFilesystem(fsPath, caldavPrefix, carddavPrefix string, userPrincipalBackend webdav.UserPrincipalBackend) (caldav.Backend, carddav.Backend, error) {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nilBackend, fmt.Errorf("failed to create filesystem backend: %s", err.Error())
|
return nil, nil, fmt.Errorf("failed to create filesystem backend: %s", err.Error())
|
||||||
}
|
}
|
||||||
if !info.IsDir() {
|
if !info.IsDir() {
|
||||||
return nilBackend, fmt.Errorf("base path for filesystem backend must be a directory")
|
return nil, nil, fmt.Errorf("base path for filesystem backend must be a directory")
|
||||||
}
|
}
|
||||||
return &filesystemBackend{
|
backend := &filesystemBackend{
|
||||||
path: path,
|
path: fsPath,
|
||||||
}, nil
|
caldavPrefix: caldavPrefix,
|
||||||
|
carddavPrefix: carddavPrefix,
|
||||||
|
userPrincipalBackend: userPrincipalBackend,
|
||||||
|
}
|
||||||
|
return backend, backend, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *filesystemBackend) pathForContext(ctx context.Context) (string, error) {
|
func (b *filesystemBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||||
authCtx, ok := auth.FromContext(ctx)
|
return b.userPrincipalBackend.CurrentUserPrincipal(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))
|
func (b *filesystemBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) {
|
||||||
path := filepath.Join(b.path, userDir)
|
upPath, err := b.userPrincipalBackend.CurrentUserPrincipal(ctx)
|
||||||
|
|
||||||
_, 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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return path.Join(upPath, b.carddavPrefix) + "/", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *filesystemBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
||||||
|
upPath, err := b.userPrincipalBackend.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)
|
||||||
|
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
|
// We are mapping to local filesystem path, so be conservative about what to accept
|
||||||
// TODO this changes once multiple addess books are supported
|
// TODO this changes once multiple addess books are supported
|
||||||
if !validFilenameRegex.MatchString(path) {
|
dir, file := path.Split(urlPath)
|
||||||
return "", fmt.Errorf("invalid request path")
|
// only accept resources in prefix, no subdirs for now
|
||||||
|
if dir != homeSetPath {
|
||||||
|
return "", fmt.Errorf("invalid request path %s", urlPath)
|
||||||
}
|
}
|
||||||
return filepath.Join(basePath, path), nil
|
// only accept simple file names for now
|
||||||
|
if !validFilenameRegex.MatchString(file) {
|
||||||
|
fmt.Printf("%s does not match regex!\n", file)
|
||||||
|
return "", fmt.Errorf("invalid file name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func etagForFile(path string) (string, error) {
|
||||||
@ -128,10 +183,27 @@ func vcardFromFile(path string, propFilter []string) (vcard.Card, error) {
|
|||||||
return vcardPropFilter(card, propFilter), nil
|
return vcardPropFilter(card, propFilter), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDefaultAddressBook(path string) error {
|
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
|
||||||
|
// return vcardPropFilter(card, propFilter), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDefaultAddressBook(path, localPath string) error {
|
||||||
// TODO what should the default address book look like?
|
// TODO what should the default address book look like?
|
||||||
defaultAB := carddav.AddressBook{
|
defaultAB := carddav.AddressBook{
|
||||||
Path: "/default",
|
Path: path,
|
||||||
Name: "My contacts",
|
Name: "My contacts",
|
||||||
Description: "Default address book",
|
Description: "Default address book",
|
||||||
MaxResourceSize: 1024,
|
MaxResourceSize: 1024,
|
||||||
@ -141,7 +213,7 @@ func createDefaultAddressBook(path string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating default address book: %s", err.Error())
|
return fmt.Errorf("error creating default address book: %s", err.Error())
|
||||||
}
|
}
|
||||||
err = os.WriteFile(path, blob, 0644)
|
err = os.WriteFile(localPath, blob, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error writing default address book: %s", err.Error())
|
return fmt.Errorf("error writing default address book: %s", err.Error())
|
||||||
}
|
}
|
||||||
@ -149,15 +221,23 @@ func createDefaultAddressBook(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *filesystemBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) {
|
func (b *filesystemBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) {
|
||||||
path, err := b.pathForContext(ctx)
|
debug.Printf("filesystem.AddressBook()")
|
||||||
|
path, err := b.localCardDAVPath(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
path = filepath.Join(path, "_default_ab.json")
|
path = filepath.Join(path, "addressbook.json")
|
||||||
|
|
||||||
|
debug.Printf("loading addressbook from %s", path)
|
||||||
|
|
||||||
data, err := ioutil.ReadFile(path)
|
data, err := ioutil.ReadFile(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
err = createDefaultAddressBook(path)
|
urlPath, err := b.AddressbookHomeSetPath(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
debug.Printf("creating default addressbook (URL:path): %s:%s", urlPath, path)
|
||||||
|
err = createDefaultAddressBook(urlPath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -175,8 +255,9 @@ func (b *filesystemBackend) AddressBook(ctx context.Context) (*carddav.AddressBo
|
|||||||
return &addressBook, nil
|
return &addressBook, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *filesystemBackend) GetAddressObject(ctx context.Context, path string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
|
func (b *filesystemBackend) GetAddressObject(ctx context.Context, objPath string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
|
||||||
localPath, err := b.safePath(ctx, path)
|
debug.Printf("filesystem.GetAddressObject(%s, %v)", objPath, req)
|
||||||
|
localPath, err := b.localCardDAVPath(ctx, objPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -202,7 +283,7 @@ func (b *filesystemBackend) GetAddressObject(ctx context.Context, path string, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
obj := carddav.AddressObject{
|
obj := carddav.AddressObject{
|
||||||
Path: path,
|
Path: objPath,
|
||||||
ModTime: info.ModTime(),
|
ModTime: info.ModTime(),
|
||||||
ETag: etag,
|
ETag: etag,
|
||||||
Card: card,
|
Card: card,
|
||||||
@ -210,17 +291,21 @@ func (b *filesystemBackend) GetAddressObject(ctx context.Context, path string, r
|
|||||||
return &obj, nil
|
return &obj, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *filesystemBackend) loadAll(ctx context.Context, propFilter []string) ([]carddav.AddressObject, error) {
|
func (b *filesystemBackend) loadAllContacts(ctx context.Context, propFilter []string) ([]carddav.AddressObject, error) {
|
||||||
var result []carddav.AddressObject
|
var result []carddav.AddressObject
|
||||||
|
|
||||||
path, err := b.pathForContext(ctx)
|
localPath, err := b.localCardDAVPath(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = filepath.Walk(path, func(filename string, info os.FileInfo, err error) error {
|
homeSetPath, err := b.AddressbookHomeSetPath(ctx)
|
||||||
// Skip address book meta data files
|
if err != nil {
|
||||||
if !info.Mode().IsRegular() || filepath.Ext(filename) == ".json" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,7 +320,7 @@ func (b *filesystemBackend) loadAll(ctx context.Context, propFilter []string) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
obj := carddav.AddressObject{
|
obj := carddav.AddressObject{
|
||||||
Path: "/" + filepath.Base(filename),
|
Path: path.Join(homeSetPath, filepath.Base(filename)),
|
||||||
ModTime: info.ModTime(),
|
ModTime: info.ModTime(),
|
||||||
ETag: etag,
|
ETag: etag,
|
||||||
Card: card,
|
Card: card,
|
||||||
@ -244,25 +329,72 @@ func (b *filesystemBackend) loadAll(ctx context.Context, propFilter []string) ([
|
|||||||
return nil
|
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, filepath.Base(filename)),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
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
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *filesystemBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
|
func (b *filesystemBackend) ListAddressObjects(ctx context.Context, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
|
||||||
|
debug.Printf("filesystem.ListAddressObjects(%v)", req)
|
||||||
var propFilter []string
|
var propFilter []string
|
||||||
if req != nil && !req.AllProp {
|
if req != nil && !req.AllProp {
|
||||||
propFilter = req.Props
|
propFilter = req.Props
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.loadAll(ctx, propFilter)
|
return b.loadAllContacts(ctx, propFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
|
func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
|
||||||
|
debug.Printf("filesystem.QueryAddressObjects(%v)", query)
|
||||||
var propFilter []string
|
var propFilter []string
|
||||||
if query != nil && !query.DataRequest.AllProp {
|
if query != nil && !query.DataRequest.AllProp {
|
||||||
propFilter = query.DataRequest.Props
|
propFilter = query.DataRequest.Props
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := b.loadAll(ctx, propFilter)
|
result, err := b.loadAllContacts(ctx, propFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
@ -270,31 +402,19 @@ func (b *filesystemBackend) QueryAddressObjects(ctx context.Context, query *card
|
|||||||
return carddav.Filter(query, result)
|
return carddav.Filter(query, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *filesystemBackend) hasUIDConflict(ctx context.Context, uid, path string) (bool, error) {
|
func (b *filesystemBackend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (loc string, err error) {
|
||||||
all, err := b.loadAll(ctx, nil)
|
debug.Printf("filesystem.PutAddressObject(%v, %v, %v)", objPath, card, opts)
|
||||||
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
|
// Object always get saved as <UID>.vcf
|
||||||
}
|
dirname, _ := path.Split(objPath)
|
||||||
|
objPath = path.Join(dirname, card.Value(vcard.FieldUID)+".vcf")
|
||||||
|
|
||||||
func (b *filesystemBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card) (loc string, err error) {
|
localPath, err := b.localCardDAVPath(ctx, objPath)
|
||||||
localPath, err := b.safePath(ctx, path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
conflict, err := b.hasUIDConflict(ctx, card.Value(vcard.FieldUID), path)
|
if _, err := os.Stat(localPath); !os.IsNotExist(err) {
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if conflict {
|
|
||||||
return "", carddav.NewPreconditionError(carddav.PreconditionNoUIDConflict)
|
return "", carddav.NewPreconditionError(carddav.PreconditionNoUIDConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,11 +430,13 @@ func (b *filesystemBackend) PutAddressObject(ctx context.Context, path string, c
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return path, nil
|
return objPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error {
|
func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string) error {
|
||||||
localPath, err := b.safePath(ctx, path)
|
debug.Printf("filesystem.DeleteAddressObject(%s)", path)
|
||||||
|
|
||||||
|
localPath, err := b.localCardDAVPath(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -324,3 +446,167 @@ func (b *filesystemBackend) DeleteAddressObject(ctx context.Context, path string
|
|||||||
}
|
}
|
||||||
return nil
|
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()")
|
||||||
|
|
||||||
|
path, err := b.localCalDAVPath(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path = filepath.Join(path, "calendar.json")
|
||||||
|
|
||||||
|
debug.Printf("loading calendar from %s", path)
|
||||||
|
|
||||||
|
data, err := ioutil.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
homeSetPath, err := b.CalendarHomeSetPath(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
debug.Printf("creating default calendar (URL:path): %s:%s", homeSetPath, path)
|
||||||
|
err = createDefaultCalendar(homeSetPath, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err = ioutil.ReadFile(path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error opening calendar: %s", err.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, nil
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
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 result, nil
|
||||||
|
// TODO implement:
|
||||||
|
//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 <UID>.ics
|
||||||
|
dirname, _ := path.Split(objPath)
|
||||||
|
objPath = path.Join(dirname, uid+".ics")
|
||||||
|
|
||||||
|
localPath, err := b.localCalDAVPath(ctx, objPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(localPath); !os.IsNotExist(err) {
|
||||||
|
return "", caldav.NewPreconditionError(caldav.PreconditionNoUIDConflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
enc := ical.NewEncoder(f)
|
||||||
|
err = enc.Encode(calendar)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return objPath, nil
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/emersion/go-vcard"
|
"github.com/emersion/go-vcard"
|
||||||
|
"github.com/emersion/go-webdav/caldav"
|
||||||
"github.com/emersion/go-webdav/carddav"
|
"github.com/emersion/go-webdav/carddav"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -11,8 +12,16 @@ type psqlBackend struct{}
|
|||||||
|
|
||||||
var _ carddav.Backend = (*psqlBackend)(nil)
|
var _ carddav.Backend = (*psqlBackend)(nil)
|
||||||
|
|
||||||
func NewPostgreSQL() carddav.Backend {
|
func NewPostgreSQL() (caldav.Backend, carddav.Backend, error) {
|
||||||
return &psqlBackend{}
|
return nil, &psqlBackend{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*psqlBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||||
|
panic("TODO")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*psqlBackend) AddressbookHomeSetPath(ctx context.Context) (string, error) {
|
||||||
|
panic("TODO")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*psqlBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) {
|
func (*psqlBackend) AddressBook(ctx context.Context) (*carddav.AddressBook, error) {
|
||||||
@ -31,7 +40,7 @@ func (*psqlBackend) QueryAddressObjects(ctx context.Context, query *carddav.Addr
|
|||||||
panic("TODO")
|
panic("TODO")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*psqlBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card) (loc string, err error) {
|
func (*psqlBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (loc string, err error) {
|
||||||
panic("TODO")
|
panic("TODO")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,21 +4,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/emersion/go-webdav"
|
||||||
|
"github.com/emersion/go-webdav/caldav"
|
||||||
"github.com/emersion/go-webdav/carddav"
|
"github.com/emersion/go-webdav/carddav"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewFromURL(storageURL string) (carddav.Backend, error) {
|
func NewFromURL(storageURL, caldavPrefix, carddavPrefix string, userPrincipalBackend webdav.UserPrincipalBackend) (caldav.Backend, carddav.Backend, error) {
|
||||||
u, err := url.Parse(storageURL)
|
u, err := url.Parse(storageURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error parsing storage URL: %s", err.Error())
|
return nil, nil, fmt.Errorf("error parsing storage URL: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
switch u.Scheme {
|
switch u.Scheme {
|
||||||
case "file":
|
case "file":
|
||||||
return NewFilesystem(u.Path)
|
return NewFilesystem(u.Path, caldavPrefix, carddavPrefix, userPrincipalBackend)
|
||||||
case "postgresql":
|
case "postgresql":
|
||||||
return NewPostgreSQL(), nil
|
return NewPostgreSQL()
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("no storage provider found for %s:// URL", u.Scheme)
|
return nil, nil, fmt.Errorf("no storage provider found for %s:// URL", u.Scheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user