561 lines
14 KiB
Go
561 lines
14 KiB
Go
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)
|
|
}
|