progress
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -88,7 +89,8 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
|
||||
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT
|
||||
description TEXT,
|
||||
color TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS calendar_access (
|
||||
calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
|
||||
@@ -200,10 +202,10 @@ func (b *DBBackend) syncConfig(ctx context.Context, cfg *config.Config) error {
|
||||
|
||||
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
|
||||
INSERT INTO calendars (owner_id, path, name, color) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name, color = EXCLUDED.color
|
||||
RETURNING id`,
|
||||
ownerID, path, c.ID).Scan(&calID)
|
||||
ownerID, path, c.ID, c.Color).Scan(&calID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -359,12 +361,12 @@ func (b *DBBackend) getUsername(ctx context.Context) (string, error) {
|
||||
// --- Internal Security & Privacy Helpers ---
|
||||
|
||||
// checkAccess verifies if the current user has the required permission for a real calendar.
|
||||
// It returns the internal database ID of the calendar if access is granted.
|
||||
// It returns the internal database ID of the calendar and the granted mode if access is granted.
|
||||
// It is called before any read (PROPFIND, GET, REPORT) or write (PUT, DELETE) operation.
|
||||
func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, error) {
|
||||
func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requiredMode string) (int, string, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(calendarPath, "/") {
|
||||
@@ -380,14 +382,14 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
|
||||
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, "", webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
|
||||
}
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
// Owners always have full access
|
||||
if ownerName == username {
|
||||
return calID, nil
|
||||
return calID, "owner", nil
|
||||
}
|
||||
|
||||
// Check the calendar_access table for specific permissions
|
||||
@@ -398,34 +400,35 @@ func (b *DBBackend) checkAccess(ctx context.Context, calendarPath string, requir
|
||||
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, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("access denied"))
|
||||
}
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
// Enforce Read-Only mode
|
||||
if requiredMode == "write" && mode != "read-write" {
|
||||
return 0, webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
|
||||
return 0, "", webdav.NewHTTPError(http.StatusForbidden, errors.New("read-only access"))
|
||||
}
|
||||
|
||||
return calID, nil
|
||||
return calID, mode, nil
|
||||
}
|
||||
|
||||
// filterCalendar is the privacy enforcement engine of the server.
|
||||
// It is called every time a calendar object is retrieved from the database
|
||||
// for a user who is NOT the owner.
|
||||
// for a user who is NOT the owner (unless they have read-write access).
|
||||
// It applies the following rules based on the 'CLASS' property of an event/task:
|
||||
// 1. PRIVATE: Removes the object entirely.
|
||||
// 2. CONFIDENTIAL: Redacts the summary to the configured 'redactionText' while keeping time data.
|
||||
// 3. PUBLIC: Passes the object through unchanged.
|
||||
func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, original *ical.Calendar) (*ical.Calendar, error) {
|
||||
func (b *DBBackend) filterCalendar(ctx context.Context, ownerName string, mode string, original *ical.Calendar) (*ical.Calendar, error) {
|
||||
username, err := b.getUsername(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Owners see everything unredacted
|
||||
if username == ownerName {
|
||||
// Owners and users with read-write access see everything unredacted.
|
||||
// We check both username and mode for robustness.
|
||||
if username == ownerName || mode == "owner" || mode == "read-write" {
|
||||
return original, nil
|
||||
}
|
||||
|
||||
@@ -499,7 +502,7 @@ func (b *DBBackend) ServePublicICS(w http.ResponseWriter, r *http.Request) {
|
||||
JOIN users u ON c.owner_id = u.id
|
||||
WHERE c.path = $1`, info.InternalPath).Scan(&calID, &ownerName)
|
||||
if err == nil {
|
||||
objects, err = b.listCalendarObjectsRaw(ctx, calID, ownerName)
|
||||
objects, err = b.listCalendarObjectsRaw(ctx, calID, ownerName, "read")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +642,7 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
|
||||
}
|
||||
|
||||
username, _ := b.getUsername(ctx)
|
||||
log.Printf("[ListCalendarObjects] user=%s path=%s", username, p)
|
||||
log.Printf("[user: %s] ListCalendarObjects %s", username, p)
|
||||
|
||||
// Route to virtual aggregate logic if needed
|
||||
if agg, ok := b.aggregates[p]; ok {
|
||||
@@ -652,7 +655,7 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
|
||||
}
|
||||
|
||||
// Normal calendar logic
|
||||
calID, err := b.checkAccess(ctx, p, "read")
|
||||
calID, mode, err := b.checkAccess(ctx, p, "read")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -663,10 +666,10 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.listCalendarObjectsRaw(ctx, calID, ownerName)
|
||||
return b.listCalendarObjectsRaw(ctx, calID, ownerName, mode)
|
||||
}
|
||||
|
||||
func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, ownerName string) ([]caldav.CalendarObject, error) {
|
||||
func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, ownerName string, mode string) ([]caldav.CalendarObject, error) {
|
||||
rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -687,7 +690,7 @@ func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, owner
|
||||
}
|
||||
|
||||
// Apply privacy filters (Private/Confidential)
|
||||
filteredData, err := b.filterCalendar(ctx, ownerName, calData)
|
||||
filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
|
||||
if err != nil || filteredData == nil {
|
||||
continue
|
||||
}
|
||||
@@ -701,6 +704,7 @@ func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, owner
|
||||
// It rewrites paths so the client thinks items are in the virtual directory.
|
||||
func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, agg *config.Aggregate) ([]caldav.CalendarObject, error) {
|
||||
var res []caldav.CalendarObject
|
||||
username, _ := b.getUsername(ctx)
|
||||
|
||||
for _, sourceID := range agg.Sources {
|
||||
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
|
||||
@@ -715,7 +719,21 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
|
||||
continue
|
||||
}
|
||||
|
||||
objs, err := b.listCalendarObjectsRaw(ctx, calID, ownerName)
|
||||
// Determine access mode for this user on this source calendar
|
||||
mode := "read"
|
||||
if username == ownerName {
|
||||
mode = "owner"
|
||||
} else if username != "" {
|
||||
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 {
|
||||
mode = "read" // Assume read-only if no explicit entry
|
||||
}
|
||||
}
|
||||
|
||||
objs, err := b.listCalendarObjectsRaw(ctx, calID, ownerName, mode)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -746,7 +764,7 @@ func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, ag
|
||||
// Called during GET, multiget REPORT, or when clicking an event in the client.
|
||||
func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
||||
username, _ := b.getUsername(ctx)
|
||||
log.Printf("[GetCalendarObject] user=%s path=%s", username, p)
|
||||
log.Printf("[user: %s] GetCalendarObject %s", username, p)
|
||||
|
||||
dirPath := path.Dir(p) + "/"
|
||||
fileName := path.Base(p)
|
||||
@@ -788,7 +806,21 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredData, err := b.filterCalendar(ctx, ownerName, calData)
|
||||
// Determine access mode for this user on this source calendar
|
||||
mode := "read"
|
||||
if username == ownerName {
|
||||
mode = "owner"
|
||||
} else if username != "" {
|
||||
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 {
|
||||
mode = "read" // Assume read-only if no explicit entry
|
||||
}
|
||||
}
|
||||
|
||||
filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
|
||||
if err != nil || filteredData == nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item filtered out"))
|
||||
}
|
||||
@@ -814,7 +846,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
|
||||
dirPath += "/"
|
||||
}
|
||||
|
||||
calID, err := b.checkAccess(ctx, dirPath, "read")
|
||||
calID, mode, err := b.checkAccess(ctx, dirPath, "read")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -841,7 +873,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
|
||||
}
|
||||
|
||||
// Apply privacy filtering
|
||||
filteredData, err := b.filterCalendar(ctx, ownerName, calData)
|
||||
filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
|
||||
if err != nil || filteredData == nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
|
||||
}
|
||||
@@ -859,7 +891,7 @@ func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *i
|
||||
return nil, webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
|
||||
}
|
||||
|
||||
calID, err := b.checkAccess(ctx, dirPath, "write")
|
||||
calID, _, err := b.checkAccess(ctx, dirPath, "write")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -895,7 +927,7 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error {
|
||||
return webdav.NewHTTPError(http.StatusForbidden, errors.New("aggregates are read-only"))
|
||||
}
|
||||
|
||||
calID, err := b.checkAccess(ctx, dirPath, "write")
|
||||
calID, _, err := b.checkAccess(ctx, dirPath, "write")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -915,6 +947,32 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error {
|
||||
// we use it to enforce privacy rules for the initial report.
|
||||
func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path_ string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
||||
username, _ := b.getUsername(ctx)
|
||||
log.Printf("[QueryCalendarObjects] user=%s path=%s", username, path_)
|
||||
log.Printf("[user: %s] QueryCalendarObjects %s", username, path_)
|
||||
return b.ListCalendarObjects(ctx, path_, nil)
|
||||
}
|
||||
|
||||
func (b *DBBackend) GetColor(ctx context.Context, p string) string {
|
||||
// If p is a full URL, extract the path
|
||||
if strings.HasPrefix(p, "http") {
|
||||
if u, err := url.Parse(p); err == nil {
|
||||
p = u.Path
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(p, "/") {
|
||||
p += "/"
|
||||
}
|
||||
|
||||
// Check if it's an aggregate
|
||||
if agg, ok := b.aggregates[p]; ok {
|
||||
return agg.Color
|
||||
}
|
||||
|
||||
// Check if it's a real calendar
|
||||
var color string
|
||||
err := b.pool.QueryRow(ctx, "SELECT color FROM calendars WHERE path = $1", p).Scan(&color)
|
||||
if err == nil {
|
||||
return color
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user