progress
This commit is contained in:
2
.ignore
2
.ignore
@@ -1,4 +1,4 @@
|
|||||||
.direnv
|
.direnv
|
||||||
server.log
|
# server.log
|
||||||
mem.go
|
mem.go
|
||||||
nxcaldav
|
nxcaldav
|
||||||
|
|||||||
78
config.yaml
78
config.yaml
@@ -1,42 +1,58 @@
|
|||||||
server:
|
|
||||||
bind_address: "0.0.0.0:14243"
|
|
||||||
public_url: "http://localhost:8080"
|
|
||||||
redaction_text: "[-]"
|
|
||||||
default_class: "CONFIDENTIAL"
|
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable"
|
url: postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable
|
||||||
|
server:
|
||||||
|
bind_address: 0.0.0.0:14243
|
||||||
|
default_class: CONFIDENTIAL
|
||||||
|
public_url: http://nxc.nx2.site/
|
||||||
|
redaction_text: '[-]'
|
||||||
|
|
||||||
users:
|
users:
|
||||||
- name: "daniel"
|
- name: daniel
|
||||||
password: "Cyclist-Hypnotize7-Blurb"
|
password: Cyclist-Hypnotize7-Blurb
|
||||||
groups:
|
groups:
|
||||||
- family
|
- family
|
||||||
- name: "diane"
|
- name: lennart
|
||||||
password: "Carve-Unluckily-Reprint1"
|
password: ll
|
||||||
groups:
|
groups:
|
||||||
- family
|
- family
|
||||||
- name: "lennart"
|
- name: shared
|
||||||
password: "Baton6-Extortion-Monologue"
|
password: Oxidant-Ageless3-Dispersed
|
||||||
groups:
|
|
||||||
- family
|
|
||||||
- name: "shared"
|
|
||||||
password: "Oxidant-Ageless3-Dispersed"
|
|
||||||
|
|
||||||
calendars:
|
calendars:
|
||||||
- id: "default"
|
- id: preservation
|
||||||
owner: "lennart"
|
owner: lennart
|
||||||
- id: "family"
|
color: '#F6F5F4'
|
||||||
owner: "shared"
|
- id: effort
|
||||||
access:
|
owner: lennart
|
||||||
- groups: "family"
|
color: '#FF0000'
|
||||||
mode: "read-write"
|
- id: experience
|
||||||
|
owner: lennart
|
||||||
|
color: '#2C33FF'
|
||||||
|
- id: leisure
|
||||||
|
owner: lennart
|
||||||
|
color: '#10B400'
|
||||||
|
- id: daniel
|
||||||
|
owner: daniel
|
||||||
|
color: '#ff2222'
|
||||||
|
- access:
|
||||||
|
- group: family
|
||||||
|
mode: read-write
|
||||||
|
id: family
|
||||||
|
owner: shared
|
||||||
|
color: '#999999'
|
||||||
|
|
||||||
aggregates:
|
aggregates:
|
||||||
- id: "lennart_aggregate"
|
- access:
|
||||||
owner: "shared"
|
- group: family
|
||||||
sources: [ "default", "family" ]
|
mode: read-only
|
||||||
access:
|
- ics: future-only
|
||||||
- group: "diane"
|
id: lennart-aggregat
|
||||||
mode: "read-only"
|
owner: lennart
|
||||||
- ics: "future-only"
|
color: '#dd9999'
|
||||||
|
sources:
|
||||||
|
- preservation
|
||||||
|
- effort
|
||||||
|
- experience
|
||||||
|
- leisure
|
||||||
|
- family
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -88,7 +89,8 @@ func (b *DBBackend) initSchema(ctx context.Context) error {
|
|||||||
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
path TEXT UNIQUE NOT NULL,
|
path TEXT UNIQUE NOT NULL,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
description TEXT
|
description TEXT,
|
||||||
|
color TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS calendar_access (
|
`CREATE TABLE IF NOT EXISTS calendar_access (
|
||||||
calendar_id INTEGER REFERENCES calendars(id) ON DELETE CASCADE,
|
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
|
var calID int
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO calendars (owner_id, path, name) VALUES ($1, $2, $3)
|
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
|
ON CONFLICT (path) DO UPDATE SET owner_id = EXCLUDED.owner_id, name = EXCLUDED.name, color = EXCLUDED.color
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
ownerID, path, c.ID).Scan(&calID)
|
ownerID, path, c.ID, c.Color).Scan(&calID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -359,12 +361,12 @@ func (b *DBBackend) getUsername(ctx context.Context) (string, error) {
|
|||||||
// --- Internal Security & Privacy Helpers ---
|
// --- Internal Security & Privacy Helpers ---
|
||||||
|
|
||||||
// checkAccess verifies if the current user has the required permission for a real calendar.
|
// 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.
|
// 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)
|
username, err := b.getUsername(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasSuffix(calendarPath, "/") {
|
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)
|
WHERE c.path = $1`, calendarPath).Scan(&calID, &ownerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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
|
// Owners always have full access
|
||||||
if ownerName == username {
|
if ownerName == username {
|
||||||
return calID, nil
|
return calID, "owner", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check the calendar_access table for specific permissions
|
// 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)
|
calID, username).Scan(&mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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
|
// Enforce Read-Only mode
|
||||||
if requiredMode == "write" && mode != "read-write" {
|
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.
|
// filterCalendar is the privacy enforcement engine of the server.
|
||||||
// It is called every time a calendar object is retrieved from the database
|
// 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:
|
// It applies the following rules based on the 'CLASS' property of an event/task:
|
||||||
// 1. PRIVATE: Removes the object entirely.
|
// 1. PRIVATE: Removes the object entirely.
|
||||||
// 2. CONFIDENTIAL: Redacts the summary to the configured 'redactionText' while keeping time data.
|
// 2. CONFIDENTIAL: Redacts the summary to the configured 'redactionText' while keeping time data.
|
||||||
// 3. PUBLIC: Passes the object through unchanged.
|
// 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)
|
username, err := b.getUsername(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owners see everything unredacted
|
// Owners and users with read-write access see everything unredacted.
|
||||||
if username == ownerName {
|
// We check both username and mode for robustness.
|
||||||
|
if username == ownerName || mode == "owner" || mode == "read-write" {
|
||||||
return original, nil
|
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
|
JOIN users u ON c.owner_id = u.id
|
||||||
WHERE c.path = $1`, info.InternalPath).Scan(&calID, &ownerName)
|
WHERE c.path = $1`, info.InternalPath).Scan(&calID, &ownerName)
|
||||||
if err == nil {
|
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)
|
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
|
// Route to virtual aggregate logic if needed
|
||||||
if agg, ok := b.aggregates[p]; ok {
|
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
|
// Normal calendar logic
|
||||||
calID, err := b.checkAccess(ctx, p, "read")
|
calID, mode, err := b.checkAccess(ctx, p, "read")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -663,10 +666,10 @@ func (b *DBBackend) ListCalendarObjects(ctx context.Context, p string, req *cald
|
|||||||
return nil, err
|
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)
|
rows, err := b.pool.Query(ctx, "SELECT path, data, etag FROM calendar_objects WHERE calendar_id = $1", calID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -687,7 +690,7 @@ func (b *DBBackend) listCalendarObjectsRaw(ctx context.Context, calID int, owner
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply privacy filters (Private/Confidential)
|
// 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 {
|
if err != nil || filteredData == nil {
|
||||||
continue
|
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.
|
// 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) {
|
func (b *DBBackend) listAggregateObjects(ctx context.Context, aggPath string, agg *config.Aggregate) ([]caldav.CalendarObject, error) {
|
||||||
var res []caldav.CalendarObject
|
var res []caldav.CalendarObject
|
||||||
|
username, _ := b.getUsername(ctx)
|
||||||
|
|
||||||
for _, sourceID := range agg.Sources {
|
for _, sourceID := range agg.Sources {
|
||||||
sourcePath := b.prefix + fmt.Sprintf("/%s/calendars/%s/", agg.Owner, sourceID)
|
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
|
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 {
|
if err != nil {
|
||||||
continue
|
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.
|
// 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) {
|
func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
||||||
username, _ := b.getUsername(ctx)
|
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) + "/"
|
dirPath := path.Dir(p) + "/"
|
||||||
fileName := path.Base(p)
|
fileName := path.Base(p)
|
||||||
@@ -788,7 +806,21 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
|
|||||||
return nil, err
|
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 {
|
if err != nil || filteredData == nil {
|
||||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("item filtered out"))
|
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 += "/"
|
dirPath += "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
calID, err := b.checkAccess(ctx, dirPath, "read")
|
calID, mode, err := b.checkAccess(ctx, dirPath, "read")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -841,7 +873,7 @@ func (b *DBBackend) GetCalendarObject(ctx context.Context, p string, req *caldav
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply privacy filtering
|
// Apply privacy filtering
|
||||||
filteredData, err := b.filterCalendar(ctx, ownerName, calData)
|
filteredData, err := b.filterCalendar(ctx, ownerName, mode, calData)
|
||||||
if err != nil || filteredData == nil {
|
if err != nil || filteredData == nil {
|
||||||
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar object not found"))
|
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"))
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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"))
|
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 {
|
if err != nil {
|
||||||
return err
|
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.
|
// 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) {
|
func (b *DBBackend) QueryCalendarObjects(ctx context.Context, path_ string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
||||||
username, _ := b.getUsername(ctx)
|
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)
|
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 ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ type Access struct {
|
|||||||
type Calendar struct {
|
type Calendar struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Owner string `yaml:"owner"`
|
Owner string `yaml:"owner"`
|
||||||
|
Color string `yaml:"color,omitempty"`
|
||||||
Access []Access `yaml:"access,omitempty"`
|
Access []Access `yaml:"access,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Aggregate struct {
|
type Aggregate struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Owner string `yaml:"owner"`
|
Owner string `yaml:"owner"`
|
||||||
|
Color string `yaml:"color,omitempty"`
|
||||||
Sources []string `yaml:"sources"` // Calendar IDs
|
Sources []string `yaml:"sources"` // Calendar IDs
|
||||||
Access []Access `yaml:"access,omitempty"`
|
Access []Access `yaml:"access,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
107
internal/extra/color.go
Normal file
107
internal/extra/color.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package extra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"fmt"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"nxcaldav/internal/backend"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
buffer *bytes.Buffer
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||||
|
return rw.buffer.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) WriteHeader(status int) {
|
||||||
|
rw.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddColorToCalendarPropfind(r *http.Request, ctx context.Context, handler http.Handler, w http.ResponseWriter, be *backend.DBBackend) {
|
||||||
|
reqBody, _ := io.ReadAll(r.Body)
|
||||||
|
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
rw := &responseWriter{w, buf, http.StatusOK}
|
||||||
|
handler.ServeHTTP(rw, r.WithContext(ctx))
|
||||||
|
|
||||||
|
body := buf.Bytes()
|
||||||
|
|
||||||
|
// 1. Add namespaces to the root multistatus tag
|
||||||
|
reMultistatus := regexp.MustCompile(`(<[a-zA-Z0-9]*:?multistatus)`)
|
||||||
|
body = reMultistatus.ReplaceAll(body, []byte(`$1 xmlns:ICAL="http://apple.com/ns/ical/" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/"`))
|
||||||
|
|
||||||
|
// 2. Response processing
|
||||||
|
reResponse := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?response.*?>.*?</[a-zA-Z0-9]*:?response>`)
|
||||||
|
reHref := regexp.MustCompile(`<[a-zA-Z0-9]*:?href.*?>(.*?)</[a-zA-Z0-9]*:?href>`)
|
||||||
|
rePropstat := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?propstat.*?>.*?</[a-zA-Z0-9]*:?propstat>`)
|
||||||
|
reStatusOk := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?status.*?>HTTP/1.1 200 OK</[a-zA-Z0-9]*:?status>`)
|
||||||
|
reProp := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?prop.*?>.*?</[a-zA-Z0-9]*:?prop>`)
|
||||||
|
rePropClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?prop>`)
|
||||||
|
|
||||||
|
body = reResponse.ReplaceAllFunc(body, func(resp []byte) []byte {
|
||||||
|
hrefMatch := reHref.FindSubmatch(resp)
|
||||||
|
if len(hrefMatch) < 2 {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
href := string(hrefMatch[1])
|
||||||
|
color := be.GetColor(r.Context(), href)
|
||||||
|
if color == "" {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Strip any existing conflicting tags that might be in 404 blocks
|
||||||
|
reStrip := regexp.MustCompile(`(?s)<[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order).*?/>|<[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order).*?>.*?</[a-zA-Z0-9]*:?(calendar-color|getctag|calendar-order)>`)
|
||||||
|
resp = reStrip.ReplaceAll(resp, []byte(""))
|
||||||
|
|
||||||
|
fullColor := strings.ToLower(color)
|
||||||
|
if len(fullColor) == 7 && strings.HasPrefix(fullColor, "#") {
|
||||||
|
fullColor += "ff"
|
||||||
|
}
|
||||||
|
// Prepare the properties to inject
|
||||||
|
props := fmt.Sprintf("<ICAL:calendar-color>%s</ICAL:calendar-color>", fullColor)
|
||||||
|
props += fmt.Sprintf("<C:calendar-color>%s</C:calendar-color>", fullColor)
|
||||||
|
props += "<ICAL:calendar-order>0</ICAL:calendar-order>"
|
||||||
|
props += fmt.Sprintf("<CS:getctag>\"%d\"</CS:getctag>", time.Now().Unix())
|
||||||
|
|
||||||
|
// 2. Try to inject into an existing 200 OK propstat
|
||||||
|
has200 := false
|
||||||
|
resp = rePropstat.ReplaceAllFunc(resp, func(ps []byte) []byte {
|
||||||
|
if reStatusOk.Match(ps) {
|
||||||
|
has200 = true
|
||||||
|
return reProp.ReplaceAllFunc(ps, func(prop []byte) []byte {
|
||||||
|
return rePropClose.ReplaceAllFunc(prop, func(closeTag []byte) []byte {
|
||||||
|
return append([]byte(props), closeTag...)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ps
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. If no 200 OK propstat was found, create one!
|
||||||
|
if !has200 {
|
||||||
|
newPropstat := fmt.Sprintf("<propstat xmlns=\"DAV:\"><prop>%s</prop><status>HTTP/1.1 200 OK</status></propstat>", props)
|
||||||
|
reResponseClose := regexp.MustCompile(`</[a-zA-Z0-9]*:?response>`)
|
||||||
|
resp = reResponseClose.ReplaceAllFunc(resp, func(closeTag []byte) []byte {
|
||||||
|
return append([]byte(newPropstat), closeTag...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
})
|
||||||
|
|
||||||
|
// Headers are already set in h.ResponseWriter.Header() by caldav.Handler
|
||||||
|
// But Content-Length might have changed
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||||
|
w.WriteHeader(rw.status)
|
||||||
|
w.Write(body)
|
||||||
|
}
|
||||||
17
main.go
17
main.go
@@ -12,9 +12,12 @@ import (
|
|||||||
|
|
||||||
"github.com/emersion/go-webdav/caldav"
|
"github.com/emersion/go-webdav/caldav"
|
||||||
"nxcaldav/internal/backend"
|
"nxcaldav/internal/backend"
|
||||||
|
"nxcaldav/internal/extra"
|
||||||
"nxcaldav/internal/config"
|
"nxcaldav/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
path := "config.yaml";
|
path := "config.yaml";
|
||||||
if len(os.Args) == 3 {
|
if len(os.Args) == 3 {
|
||||||
@@ -61,12 +64,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// public ics access
|
||||||
prefix := cfg.Server.BasePath()
|
prefix := cfg.Server.BasePath()
|
||||||
if strings.HasPrefix(r.URL.Path, prefix+"/public/") {
|
if strings.HasPrefix(r.URL.Path, prefix+"/public/") {
|
||||||
be.ServePublicICS(w, r)
|
be.ServePublicICS(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// caldav access needs auth
|
||||||
user, password, ok := r.BasicAuth()
|
user, password, ok := r.BasicAuth()
|
||||||
if !ok {
|
if !ok {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="CalDAV Server"`)
|
w.Header().Set("WWW-Authenticate", `Basic realm="CalDAV Server"`)
|
||||||
@@ -88,13 +93,12 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("%s %s (user: %s)", r.Method, r.URL.Path, user)
|
log.Printf("[user: %s] %s %s ", user, r.Method, r.URL.Path)
|
||||||
prefix = cfg.Server.BasePath()
|
|
||||||
principalPath := prefix + fmt.Sprintf("/%s/", user)
|
principalPath := prefix + fmt.Sprintf("/%s/", user)
|
||||||
ctx := context.WithValue(r.Context(), "principal", principalPath)
|
ctx := context.WithValue(r.Context(), "principal", principalPath)
|
||||||
|
|
||||||
if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" {
|
if r.URL.Path == "/.well-known/caldav" || r.URL.Path == prefix+"/.well-known/caldav" {
|
||||||
// If we normalized the request, use the normalized host/scheme for the redirect
|
// If normalized request, use normalized host/scheme for redirect
|
||||||
if publicURL != nil && publicURL.Host != "" {
|
if publicURL != nil && publicURL.Host != "" {
|
||||||
scheme := r.URL.Scheme
|
scheme := r.URL.Scheme
|
||||||
if scheme == "" {
|
if scheme == "" {
|
||||||
@@ -109,7 +113,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
// needed because color info is not RFC, so regex hack
|
||||||
|
if r.Method == "PROPFIND" {
|
||||||
|
extra.AddColorToCalendarPropfind(r, ctx, handler, w, be)
|
||||||
|
} else {
|
||||||
|
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress)
|
fmt.Printf("Starting CalDAV server on %s...\n", cfg.Server.BindAddress)
|
||||||
|
|||||||
Reference in New Issue
Block a user