init
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.direnv
|
||||
server.log
|
||||
23
README.md
Normal file
23
README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Postgres
|
||||
- url
|
||||
- Format: `postgres://user@host:port/dbname` or `postgres:///dbname?host=/var/run/postgresql`.
|
||||
- Default: `postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable`
|
||||
|
||||
# Users
|
||||
|
||||
- name: required
|
||||
- password: cleartext or bcrypt hash
|
||||
- password_cmd: shell command
|
||||
|
||||
# SQL
|
||||
## delte user
|
||||
```sql
|
||||
DELETE FROM users WHERE name = 'bob';
|
||||
```
|
||||
|
||||
## delete calendar
|
||||
```sql
|
||||
DELETE FROM calendars WHERE name = 'bob_calendar' AND owner_id = (SELECT id FROM users WHERE name = 'bob');
|
||||
/* or */
|
||||
DELETE FROM calendars WHERE path = '/bob/calendars/bob_calendar/';
|
||||
```
|
||||
29
config.yaml
Normal file
29
config.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
server:
|
||||
bind_address: "0.0.0.0:8080"
|
||||
public_url: "http://localhost:8080"
|
||||
redaction_text: "Busy (Private)"
|
||||
|
||||
database:
|
||||
url: "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable"
|
||||
|
||||
users:
|
||||
- name: "alice"
|
||||
password: "password123" # Cleartext (will be hashed in DB)
|
||||
- name: "bob"
|
||||
password_cmd: "echo secretpassword" # Command (output will be hashed in DB)
|
||||
- name: "charlie"
|
||||
password: "$2y$12$LU.8xNK6m98hEJ5oRnBsDuMamfIjXoWTW0eMIJ6yGdLoP3nJAHWH6" # Example dummy hash
|
||||
|
||||
calendars:
|
||||
- id: "Alice"
|
||||
owner: "alice"
|
||||
access:
|
||||
- user: "bob"
|
||||
mode: "read-only"
|
||||
- id: "team_project"
|
||||
owner: "alice"
|
||||
access:
|
||||
- user: "bob"
|
||||
mode: "read-write"
|
||||
- user: "charlie"
|
||||
mode: "read-only"
|
||||
22
go.mod
Normal file
22
go.mod
Normal file
@@ -0,0 +1,22 @@
|
||||
module nxcaldav
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
||||
github.com/emersion/go-webdav v0.7.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
49
go.sum
Normal file
49
go.sum
Normal file
@@ -0,0 +1,49 @@
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
560
internal/backend/db.go
Normal file
560
internal/backend/db.go
Normal file
@@ -0,0 +1,560 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"nxcaldav/internal/config"
|
||||
)
|
||||
|
||||
type DBBackend struct {
|
||||
pool *pgxpool.Pool
|
||||
redactionText string
|
||||
}
|
||||
|
||||
func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) {
|
||||
pool, err := pgxpool.New(ctx, cfg.Database.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to postgres: %v", err)
|
||||
}
|
||||
|
||||
b := &DBBackend{
|
||||
pool: pool,
|
||||
redactionText: cfg.Server.Redaction,
|
||||
}
|
||||
|
||||
if err := b.initSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := b.syncConfig(ctx, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) initSchema(ctx context.Context) error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS calendars (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS calendar_access (
|
||||
calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
mode TEXT NOT NULL,
|
||||
PRIMARY KEY (calendar_id, user_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS calendar_objects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
etag TEXT NOT NULL,
|
||||
UNIQUE (calendar_id, path)
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := b.pool.Exec(ctx, q); err != nil {
|
||||
return fmt.Errorf("failed to execute schema query: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) resolvePassword(u config.User) (string, error) {
|
||||
var raw string
|
||||
if u.PasswordCmd != "" {
|
||||
cmd := exec.Command("bash", "-c", u.PasswordCmd)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to run password command for %s: %v", u.Name, err)
|
||||
}
|
||||
raw = strings.TrimSpace(string(out))
|
||||
} else {
|
||||
raw = u.Password
|
||||
}
|
||||
|
||||
// If it already looks like a bcrypt hash, return as is.
|
||||
// bcrypt hashes usually start with $2a$ or $2b$ or $2y$.
|
||||
if strings.HasPrefix(raw, "$2") {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// Otherwise, hash it.
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
|
||||
tx, err := b.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
configUserNames := make(map[string]bool)
|
||||
configCalendarPaths := make(map[string]bool)
|
||||
|
||||
// Sync Users
|
||||
for _, u := range cfg.Users {
|
||||
configUserNames[u.Name] = true
|
||||
hashed, err := b.resolvePassword(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO users (name, password) VALUES ($1, $2)
|
||||
ON CONFLICT (name) DO UPDATE SET password = EXCLUDED.password`,
|
||||
u.Name, hashed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Sync Calendars and Access
|
||||
for _, c := range cfg.Calendars {
|
||||
path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID)
|
||||
configCalendarPaths[path] = true
|
||||
|
||||
var ownerID int
|
||||
err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", c.Owner).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("owner %s not found: %v", c.Owner, err)
|
||||
}
|
||||
|
||||
var calID int
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO calendars (owner_id, path, name) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
ownerID, path, c.ID).Scan(&calID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "DELETE FROM calendar_access WHERE calendar_id = $1", calID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, a := range c.Access {
|
||||
var userID int
|
||||
err := tx.QueryRow(ctx, "SELECT id FROM users WHERE name = $1", a.User).Scan(&userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("access user %s not found: %v", a.User, err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, "INSERT INTO calendar_access (calendar_id, user_id, mode) VALUES ($1, $2, $3)",
|
||||
calID, userID, a.Mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Orphaned User Check
|
||||
userRows, err := tx.Query(ctx, "SELECT name FROM users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var dbUsers []string
|
||||
for userRows.Next() {
|
||||
var name string
|
||||
if err := userRows.Scan(&name); err != nil {
|
||||
return err
|
||||
}
|
||||
dbUsers = append(dbUsers, name)
|
||||
}
|
||||
userRows.Close()
|
||||
|
||||
for _, name := range dbUsers {
|
||||
if !configUserNames[name] {
|
||||
log.Printf("WARNING: Orphaned user found in database: %s (Not in config.yaml)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Orphaned Calendar Check
|
||||
calRows, err := tx.Query(ctx, "SELECT path FROM calendars")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var dbPaths []string
|
||||
for calRows.Next() {
|
||||
var p string
|
||||
if err := calRows.Scan(&p); err != nil {
|
||||
return err
|
||||
}
|
||||
dbPaths = append(dbPaths, p)
|
||||
}
|
||||
calRows.Close()
|
||||
|
||||
for _, p := range dbPaths {
|
||||
if !configCalendarPaths[p] {
|
||||
log.Printf("WARNING: Orphaned calendar found in database: %s (Not in config.yaml)", p)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (b *DBBackend) VerifyUser(ctx context.Context, username, password string) (bool, error) {
|
||||
var hash string
|
||||
err := b.pool.QueryRow(ctx, "SELECT password FROM users WHERE name = $1", username).Scan(&hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// UserPrincipalBackend implementation
|
||||
|
||||
func (b *DBBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||
principal, ok := ctx.Value("principal").(string)
|
||||
if !ok {
|
||||
return "", errors.New("no principal in context")
|
||||
}
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) getUsername(ctx context.Context) (string, error) {
|
||||
principal, err := b.CurrentUserPrincipal(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.Trim(principal, "/"), nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(calendarPath, "/") {
|
||||
calendarPath += "/"
|
||||
}
|
||||
|
||||
var calID int
|
||||
var ownerName string
|
||||
err = b.pool.QueryRow(ctx, `
|
||||
SELECT c.id, u.name
|
||||
FROM calendars c
|
||||
JOIN users u ON c.owner_id = u.id
|
||||
WHERE c.path = $1`, calendarPath).Scan(&calID, &ownerName)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if ownerName == username {
|
||||
return calID, nil
|
||||
}
|
||||
|
||||
var mode string
|
||||
err = b.pool.QueryRow(ctx, `
|
||||
SELECT mode FROM calendar_access
|
||||
WHERE calendar_id = $1 AND user_id = (SELECT id FROM users WHERE name = $2)`,
|
||||
calID, username).Scan(&mode)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied"))
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if requiredMode == "write" && mode != "read-write" {
|
||||
return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
|
||||
}
|
||||
|
||||
return calID, nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) filterCalendar(ctx context.Context, calendarID int, original *ical.Calendar) (*ical.Calendar, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ownerName string
|
||||
err = b.pool.QueryRow(ctx, "SELECT u.name FROM users u JOIN calendars c ON c.owner_id = u.id WHERE c.id = $1", calendarID).Scan(&ownerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if username == ownerName {
|
||||
return original, nil
|
||||
}
|
||||
|
||||
filtered := ical.NewCalendar()
|
||||
filtered.Props = original.Props
|
||||
|
||||
for _, child := range original.Children {
|
||||
if child.Name != "VEVENT" && child.Name != "VTODO" {
|
||||
filtered.Children = append(filtered.Children, child)
|
||||
continue
|
||||
}
|
||||
|
||||
class := "PUBLIC"
|
||||
if prop := child.Props.Get("CLASS"); prop != nil {
|
||||
class = strings.ToUpper(prop.Value)
|
||||
}
|
||||
|
||||
switch class {
|
||||
case "PRIVATE":
|
||||
continue
|
||||
case "CONFIDENTIAL":
|
||||
redacted := ical.NewComponent(child.Name)
|
||||
propsToKeep := []string{
|
||||
"UID", "DTSTAMP", "DTSTART", "DTEND", "DURATION", "CLASS",
|
||||
"DUE", "COMPLETED", "STATUS", "PRIORITY", "PERCENT-COMPLETE",
|
||||
}
|
||||
for _, pName := range propsToKeep {
|
||||
if props, ok := child.Props[pName]; ok {
|
||||
redacted.Props[pName] = props
|
||||
}
|
||||
}
|
||||
redacted.Props.SetText("SUMMARY", b.redactionText)
|
||||
filtered.Children = append(filtered.Children, redacted)
|
||||
default:
|
||||
filtered.Children = append(filtered.Children, child)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered.Children) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// CalDAV Backend implementation
|
||||
|
||||
func (b *DBBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("/%s/calendars/", username), nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := b.pool.Query(ctx, `
|
||||
SELECT path, name FROM calendars
|
||||
WHERE owner_id = (SELECT id FROM users WHERE name = $1)
|
||||
OR id IN (SELECT calendar_id FROM calendar_access WHERE user_id = (SELECT id FROM users WHERE name = $1))`,
|
||||
username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var res []caldav.Calendar
|
||||
for rows.Next() {
|
||||
var cal caldav.Calendar
|
||||
if err := rows.Scan(&cal.Path, &cal.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cal.SupportedComponentSet = []string{"VEVENT", "VTODO"}
|
||||
res = append(res, cal)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) {
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
var cal caldav.Calendar
|
||||
err := b.pool.QueryRow(ctx, "SELECT path, name FROM calendars WHERE path = $1", path).Scan(&cal.Path, &cal.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cal.SupportedComponentSet = []string{"VEVENT", "VTODO"}
|
||||
return &cal, nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
|
||||
return webdav.NewHTTPError(http.StatusForbidden, errors.New("calendar creation only via config"))
|
||||
}
|
||||
|
||||
func (b *DBBackend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
|
||||
calID, err := b.checkAccess(ctx, path, "read")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var res []caldav.CalendarObject
|
||||
for rows.Next() {
|
||||
var obj caldav.CalendarObject
|
||||
var dataStr string
|
||||
if err := rows.Scan(&obj.Path, &dataStr, &obj.ETag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredData, err := b.filterCalendar(ctx, calID, calData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filteredData == nil {
|
||||
continue
|
||||
}
|
||||
obj.Data = filteredData
|
||||
res = append(res, obj)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
||||
lastSlash := strings.LastIndex(path, "/")
|
||||
if lastSlash == -1 {
|
||||
return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path"))
|
||||
}
|
||||
parentPath := path[:lastSlash+1]
|
||||
|
||||
calID, err := b.checkAccess(ctx, parentPath, "read")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var obj caldav.CalendarObject
|
||||
var dataStr string
|
||||
err = b.pool.QueryRow(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, path).Scan(&obj.Path, &dataStr, &obj.ETag)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
calData, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredData, err := b.filterCalendar(ctx, calID, calData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filteredData == nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
|
||||
}
|
||||
obj.Data = filteredData
|
||||
return &obj, nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
||||
lastSlash := strings.LastIndex(path, "/")
|
||||
if lastSlash == -1 {
|
||||
return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path"))
|
||||
}
|
||||
parentPath := path[:lastSlash+1]
|
||||
|
||||
calID, err := b.checkAccess(ctx, parentPath, "write")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(calendar); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dataStr := buf.String()
|
||||
etag := fmt.Sprintf(`"%d"`, len(calendar.Events()))
|
||||
|
||||
_, err = b.pool.Exec(ctx, `
|
||||
INSERT INTO calendar_objects (calendar_id, path, data, etag) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (calendar_id, path) DO UPDATE SET data = EXCLUDED.data, etag = EXCLUDED.etag`,
|
||||
calID, path, dataStr, etag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &caldav.CalendarObject{
|
||||
Path: path,
|
||||
Data: calendar,
|
||||
ETag: etag,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) DeleteCalendarObject(ctx context.Context, path string) error {
|
||||
lastSlash := strings.LastIndex(path, "/")
|
||||
if lastSlash == -1 {
|
||||
return webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path"))
|
||||
}
|
||||
parentPath := path[:lastSlash+1]
|
||||
|
||||
calID, err := b.checkAccess(ctx, parentPath, "write")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commandTag, err := b.pool.Exec(ctx, "DELETE FROM calendar_objects WHERE calendar_id = $1 AND path = $2", calID, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
return webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
||||
return b.ListCalendarObjects(ctx, path, nil)
|
||||
}
|
||||
318
internal/backend/mem.go
Normal file
318
internal/backend/mem.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
"nxcaldav/internal/config"
|
||||
)
|
||||
|
||||
type MemBackend struct {
|
||||
sync.RWMutex
|
||||
allCalendars map[string]*caldav.Calendar
|
||||
objects map[string]map[string]*caldav.CalendarObject
|
||||
userCalendars map[string][]string
|
||||
calendarOwner map[string]string
|
||||
calendarAccess map[string]map[string]string
|
||||
}
|
||||
|
||||
func NewMemBackend(cfg *config.Config) *MemBackend {
|
||||
b := &MemBackend{
|
||||
allCalendars: make(map[string]*caldav.Calendar),
|
||||
objects: make(map[string]map[string]*caldav.CalendarObject),
|
||||
userCalendars: make(map[string][]string),
|
||||
calendarOwner: make(map[string]string),
|
||||
calendarAccess: make(map[string]map[string]string),
|
||||
}
|
||||
|
||||
for _, c := range cfg.Calendars {
|
||||
path := fmt.Sprintf("/%s/calendars/%s/", c.Owner, c.ID)
|
||||
cal := &caldav.Calendar{
|
||||
Path: path,
|
||||
Name: c.ID,
|
||||
SupportedComponentSet: []string{"VEVENT", "VTODO"},
|
||||
}
|
||||
b.allCalendars[path] = cal
|
||||
b.calendarOwner[path] = c.Owner
|
||||
b.userCalendars[c.Owner] = append(b.userCalendars[c.Owner], path)
|
||||
|
||||
accessMap := make(map[string]string)
|
||||
for _, a := range c.Access {
|
||||
accessMap[a.User] = a.Mode
|
||||
b.userCalendars[a.User] = append(b.userCalendars[a.User], path)
|
||||
}
|
||||
b.calendarAccess[path] = accessMap
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MemBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||
principal, ok := ctx.Value("principal").(string)
|
||||
if !ok {
|
||||
return "", errors.New("no principal in context")
|
||||
}
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func (b *MemBackend) getUsername(ctx context.Context) (string, error) {
|
||||
principal, err := b.CurrentUserPrincipal(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.Trim(principal, "/"), nil
|
||||
}
|
||||
|
||||
func (b *MemBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) error {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(calendarPath, "/") {
|
||||
calendarPath += "/"
|
||||
}
|
||||
|
||||
owner := b.calendarOwner[calendarPath]
|
||||
if owner == username {
|
||||
return nil
|
||||
}
|
||||
|
||||
mode, ok := b.calendarAccess[calendarPath][username]
|
||||
if !ok {
|
||||
return webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied"))
|
||||
}
|
||||
|
||||
if requiredMode == "write" && mode != "read-write" {
|
||||
return webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *MemBackend) filterCalendar(ctx context.Context, calendarPath string, original *ical.Calendar) (*ical.Calendar, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(calendarPath, "/") {
|
||||
calendarPath += "/"
|
||||
}
|
||||
|
||||
owner := b.calendarOwner[calendarPath]
|
||||
if username == owner {
|
||||
return original, nil
|
||||
}
|
||||
|
||||
filtered := ical.NewCalendar()
|
||||
filtered.Props = original.Props
|
||||
|
||||
for _, child := range original.Children {
|
||||
if child.Name != "VEVENT" && child.Name != "VTODO" {
|
||||
filtered.Children = append(filtered.Children, child)
|
||||
continue
|
||||
}
|
||||
|
||||
class := "PUBLIC"
|
||||
if prop := child.Props.Get("CLASS"); prop != nil {
|
||||
class = strings.ToUpper(prop.Value)
|
||||
}
|
||||
|
||||
switch class {
|
||||
case "PRIVATE":
|
||||
continue
|
||||
case "CONFIDENTIAL":
|
||||
redacted := ical.NewComponent(child.Name)
|
||||
propsToKeep := []string{
|
||||
"UID", "DTSTAMP", "DTSTART", "DTEND", "DURATION", "CLASS",
|
||||
"DUE", "COMPLETED", "STATUS", "PRIORITY", "PERCENT-COMPLETE",
|
||||
}
|
||||
for _, pName := range propsToKeep {
|
||||
if props, ok := child.Props[pName]; ok {
|
||||
redacted.Props[pName] = props
|
||||
}
|
||||
}
|
||||
redacted.Props.SetText("SUMMARY", "Busy")
|
||||
filtered.Children = append(filtered.Children, redacted)
|
||||
default:
|
||||
filtered.Children = append(filtered.Children, child)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered.Children) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// CalDAV Backend implementation
|
||||
|
||||
func (b *MemBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("/%s/calendars/", username), nil
|
||||
}
|
||||
|
||||
func (b *MemBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
|
||||
paths := b.userCalendars[username]
|
||||
res := make([]caldav.Calendar, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if cal, ok := b.allCalendars[p]; ok {
|
||||
res = append(res, *cal)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (b *MemBackend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) {
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
if cal, ok := b.allCalendars[path]; ok {
|
||||
return cal, nil
|
||||
}
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
|
||||
}
|
||||
|
||||
func (b *MemBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
|
||||
return webdav.NewHTTPError(http.StatusForbidden, errors.New("calendar creation only via config"))
|
||||
}
|
||||
|
||||
func (b *MemBackend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
|
||||
if err := b.checkAccess(ctx, path, "read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
objs, ok := b.objects[path]
|
||||
if !ok {
|
||||
return []caldav.CalendarObject{}, nil
|
||||
}
|
||||
|
||||
res := make([]caldav.CalendarObject, 0, len(objs))
|
||||
for _, obj := range objs {
|
||||
filteredData, err := b.filterCalendar(ctx, path, obj.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filteredData == nil {
|
||||
continue
|
||||
}
|
||||
newObj := *obj
|
||||
newObj.Data = filteredData
|
||||
res = append(res, newObj)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (b *MemBackend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
||||
lastSlash := strings.LastIndex(path, "/")
|
||||
if lastSlash == -1 {
|
||||
return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path"))
|
||||
}
|
||||
parentPath := path[:lastSlash+1]
|
||||
|
||||
if err := b.checkAccess(ctx, parentPath, "read"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
|
||||
if objs, ok := b.objects[parentPath]; ok {
|
||||
if obj, ok := objs[path]; ok {
|
||||
filteredData, err := b.filterCalendar(ctx, parentPath, obj.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filteredData == nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
|
||||
}
|
||||
newObj := *obj
|
||||
newObj.Data = filteredData
|
||||
return &newObj, nil
|
||||
}
|
||||
}
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
|
||||
}
|
||||
|
||||
func (b *MemBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
||||
lastSlash := strings.LastIndex(path, "/")
|
||||
if lastSlash == -1 {
|
||||
return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path"))
|
||||
}
|
||||
parentPath := path[:lastSlash+1]
|
||||
|
||||
if err := b.checkAccess(ctx, parentPath, "write"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
|
||||
obj := &caldav.CalendarObject{
|
||||
Path: path,
|
||||
Data: calendar,
|
||||
ETag: fmt.Sprintf(`"%d"`, len(calendar.Events())),
|
||||
}
|
||||
|
||||
if _, ok := b.objects[parentPath]; !ok {
|
||||
b.objects[parentPath] = make(map[string]*caldav.CalendarObject)
|
||||
}
|
||||
b.objects[parentPath][path] = obj
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func (b *MemBackend) DeleteCalendarObject(ctx context.Context, path string) error {
|
||||
lastSlash := strings.LastIndex(path, "/")
|
||||
if lastSlash == -1 {
|
||||
return webdav.NewHTTPError(http.StatusBadRequest, errors.New("invalid object path"))
|
||||
}
|
||||
parentPath := path[:lastSlash+1]
|
||||
|
||||
if err := b.checkAccess(ctx, parentPath, "write"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
|
||||
if objs, ok := b.objects[parentPath]; ok {
|
||||
delete(objs, path)
|
||||
return nil
|
||||
}
|
||||
return webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
|
||||
}
|
||||
|
||||
func (b *MemBackend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
||||
return b.ListCalendarObjects(ctx, path, nil)
|
||||
}
|
||||
70
internal/config/config.go
Normal file
70
internal/config/config.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Access struct {
|
||||
User string `yaml:"user"`
|
||||
Mode string `yaml:"mode"` // "read-only" or "read-write"
|
||||
}
|
||||
|
||||
type Calendar struct {
|
||||
ID string `yaml:"id"`
|
||||
Owner string `yaml:"owner"`
|
||||
Access []Access `yaml:"access,omitempty"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string `yaml:"name"`
|
||||
Password string `yaml:"password"`
|
||||
PasswordCmd string `yaml:"password_cmd"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
BindAddress string `yaml:"bind_address"`
|
||||
PublicURL string `yaml:"public_url"`
|
||||
Redaction string `yaml:"redaction_text"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Users []User `yaml:"users"`
|
||||
Calendars []Calendar `yaml:"calendars"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var cfg Config
|
||||
err = yaml.NewDecoder(f).Decode(&cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg.setDefaults()
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.Server.BindAddress == "" {
|
||||
c.Server.BindAddress = ":8080"
|
||||
}
|
||||
if c.Server.Redaction == "" {
|
||||
c.Server.Redaction = "Busy"
|
||||
}
|
||||
if c.Database.URL == "" {
|
||||
c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable"
|
||||
}
|
||||
}
|
||||
72
main.go
Normal file
72
main.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
"nxcaldav/internal/backend"
|
||||
"nxcaldav/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("config.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
be, err := backend.NewDBBackend(ctx, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize database backend: %v", err)
|
||||
}
|
||||
|
||||
handler := &caldav.Handler{Backend: be}
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="CalDAV Server"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user against database (bcrypt)
|
||||
valid, err := be.VerifyUser(r.Context(), user, password)
|
||||
if err != nil {
|
||||
log.Printf("auth error for %s: %v", user, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("%s %s %s", user, r.Method, r.URL.Path)
|
||||
|
||||
principalPath := fmt.Sprintf("/%s/", user)
|
||||
ctx := context.WithValue(r.Context(), "principal", principalPath)
|
||||
|
||||
if r.URL.Path == "/.well-known/caldav" {
|
||||
http.Redirect(w, r, principalPath, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress)
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.BindAddress,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatalf("server failed: %v", err)
|
||||
}
|
||||
}
|
||||
2
orphan_test.log
Normal file
2
orphan_test.log
Normal file
@@ -0,0 +1,2 @@
|
||||
2026/03/21 00:44:12 WARNING: Orphaned user found in database: charlie (Not in config.yaml)
|
||||
Starting CalDAV server with Postgres on :8080...
|
||||
11
shell.nix
Normal file
11
shell.nix
Normal file
@@ -0,0 +1,11 @@
|
||||
{ pkgs ? import <nixpkgs> { } }: let
|
||||
my-python = pkgs.python312;
|
||||
python-with-my-packages = my-python.withPackages (p: with p; [
|
||||
ical
|
||||
ics
|
||||
caldav
|
||||
pyyaml
|
||||
]);
|
||||
in pkgs.mkShell {
|
||||
buildInputs = [ python-with-my-packages ];
|
||||
}
|
||||
86
test.py
Normal file
86
test.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import os
|
||||
import yaml
|
||||
import subprocess
|
||||
from caldav import DAVClient
|
||||
from ics import Calendar, Todo
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def get_password(user_cfg):
|
||||
if 'password_cmd' in user_cfg:
|
||||
return subprocess.check_output(user_cfg['password_cmd'], shell=True).decode().strip()
|
||||
return user_cfg.get('password')
|
||||
|
||||
def add_event(calendar, summary, classification, start_hour):
|
||||
now = datetime.now()
|
||||
dtstamp = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
dtstart = (now.replace(hour=start_hour, minute=0, second=0)).strftime("%Y%m%dT%H%M%SZ")
|
||||
dtend = (now.replace(hour=start_hour+1, minute=0, second=0)).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
print(f"Adding {classification} event: {summary} at {start_hour}:00")
|
||||
calendar.add_event(f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Example Corp//CalDAV Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:uid-{summary.lower().replace(' ', '-')}-{classification.lower()}
|
||||
DTSTAMP:{dtstamp}
|
||||
DTSTART:{dtstart}
|
||||
DTEND:{dtend}
|
||||
SUMMARY:{summary}
|
||||
CLASS:{classification}
|
||||
END:VEVENT
|
||||
END:VCALENDAR""")
|
||||
|
||||
def add_todo(calendar, summary, classification):
|
||||
now = datetime.now()
|
||||
dtstamp = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
print(f"Adding {classification} todo: {summary}")
|
||||
calendar.save_todo(f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Example Corp//CalDAV Client//EN
|
||||
BEGIN:VTODO
|
||||
UID:todo-{summary.lower().replace(' ', '-')}-{classification.lower()}
|
||||
DTSTAMP:{dtstamp}
|
||||
SUMMARY:{summary}
|
||||
CLASS:{classification}
|
||||
END:VTODO
|
||||
END:VCALENDAR""")
|
||||
|
||||
if __name__ == "__main__":
|
||||
with open("config.yaml") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
url = "http://localhost:8080/"
|
||||
|
||||
alice_cfg = next(u for u in config['users'] if u['name'] == 'alice')
|
||||
alice_pw = get_password(alice_cfg)
|
||||
alice_client = DAVClient(url, username=alice_cfg['name'], password=alice_pw)
|
||||
alice_principal = alice_client.principal()
|
||||
alice_personal = next(c for c in alice_principal.calendars() if "personal" in c.url.path)
|
||||
|
||||
print("\n--- Alice creating items for TODAY ---")
|
||||
add_event(alice_personal, "Public Dinner", "PUBLIC", 18)
|
||||
add_event(alice_personal, "Confidential Meeting", "CONFIDENTIAL", 14)
|
||||
add_todo(alice_personal, "Secret Task", "PRIVATE")
|
||||
add_todo(alice_personal, "Sensitive Project", "CONFIDENTIAL")
|
||||
|
||||
bob_cfg = next(u for u in config['users'] if u['name'] == 'bob')
|
||||
bob_pw = get_password(bob_cfg)
|
||||
bob_client = DAVClient(url, username=bob_cfg['name'], password=bob_pw)
|
||||
bob_principal = bob_client.principal()
|
||||
|
||||
print("\n--- Bob viewing Alice's calendar ---")
|
||||
bob_alice_personal = next(c for c in bob_principal.calendars() if "/alice/calendars/personal/" in c.url.path)
|
||||
|
||||
print("Checking Events...")
|
||||
events = bob_alice_personal.events()
|
||||
for e in events:
|
||||
c = Calendar(e.data)
|
||||
for ev in c.events:
|
||||
print(f" - Event: '{ev.name}' (UID: {ev.uid})")
|
||||
|
||||
print("Checking Todos...")
|
||||
todos = bob_alice_personal.todos()
|
||||
for t in todos:
|
||||
c = Calendar(t.data)
|
||||
for td in c.todos:
|
||||
print(f" - Todo: '{td.name}' (UID: {td.uid})")
|
||||
Reference in New Issue
Block a user