3 Commits
Author SHA1 Message Date
arnef be47d6844c wip 2026-08-29 19:16:12 +02:00
arnefandCopilot f463c01f0f Add a standalone docker-compose.yml example using the pre-built image
Complements the existing "build: ." example (for a full checkout) with
a minimal compose file that just pulls git.arnef.de/arnef/nidus:latest,
for users who only want to run the container without cloning the repo.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 22:00:20 +02:00
arnefandCopilot cd0761c9d1 Remove TLS/Reverse proxy and Dependencies sections from README
Both were low-value docs (TLS config is already in the Configuration
reference table; Go/JS dependencies are visible in go.mod and
web/package.json) that just added noise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 21:59:05 +02:00
7 changed files with 157 additions and 64 deletions
+19 -51
View File
@@ -110,14 +110,30 @@ Pushing a version tag (e.g. `v1.2.3`) or publishing a release triggers
which builds and publishes a multi-arch (`linux/amd64` + `linux/arm64`) which builds and publishes a multi-arch (`linux/amd64` + `linux/arm64`)
image to `git.arnef.de/arnef/nidus`, tagged with the version, `<major>.<minor>`, image to `git.arnef.de/arnef/nidus`, tagged with the version, `<major>.<minor>`,
`latest`, and the short commit SHA. It authenticates via the `latest`, and the short commit SHA. It authenticates via the
`REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets. Point `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets.
`docker-compose.yaml`'s `image:` at it instead of `build: .` to use it
directly, e.g.: A minimal standalone `docker-compose.yml` that pulls this image instead of
building from a checkout — just fetch `config.example.yaml`, copy it to
`config.yaml`, and adjust it to your needs:
```yaml ```yaml
services: services:
davserver: davserver:
image: git.arnef.de/arnef/nidus:latest image: git.arnef.de/arnef/nidus:latest
ports:
- "8080:8080"
volumes:
- ./config.yaml:/app/config.yaml:ro
- dav-data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
volumes:
dav-data:
``` ```
--- ---
@@ -230,33 +246,6 @@ make web-assets # regenerate templ code + rebuild web/static/app.css and web/st
--- ---
## TLS / Reverse proxy
### Self-signed certificate (development)
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
```
Update `config.yaml`:
```yaml
tls:
enabled: true
cert_file: cert.pem
key_file: key.pem
```
### Caddy reverse proxy (recommended for production)
```
dav.example.com {
reverse_proxy localhost:8080
}
```
---
## Configuration reference ## Configuration reference
```yaml ```yaml
@@ -370,24 +359,3 @@ reviewed and tested where practical, but not every part of the codebase
has been fully reviewed yet — use accordingly, especially before relying has been fully reviewed yet — use accordingly, especially before relying
on this in security-sensitive environments. on this in security-sensitive environments.
---
## Dependencies
| Package | Purpose |
|---------|---------|
| `github.com/emersion/go-webdav` | WebDAV/CalDAV/CardDAV protocol layer |
| `github.com/emersion/go-ical` | iCalendar parsing/serialisation |
| `github.com/emersion/go-vcard` | vCard parsing/serialisation |
| `golang.org/x/crypto` | bcrypt |
| `golang.org/x/net` | `golang.org/x/net/webdav` |
| `gopkg.in/yaml.v3` | YAML config parsing |
| `modernc.org/sqlite` | Pure-Go SQLite driver (shares, web UI sessions) |
| `github.com/a-h/templ` | Type-safe Go HTML templates (web UI) |
Front-end (dev-only, not required at runtime — see [Web UI](#web-ui)):
| Tool | Purpose |
|------|---------|
| Tailwind CSS v4 (`web/package.json`) | Utility-first CSS, compiled to `web/static/app.css` |
| [htmx](https://htmx.org/) (`web/static/htmx.min.js`, vendored) | Partial page updates without a JS framework |
+11 -2
View File
@@ -82,7 +82,7 @@ func main() {
continue continue
} }
for _, cal := range cals { for _, cal := range cals {
if err := st.EnsureCollection(user.Username, "cal-"+cal.Name); err != nil { if err := st.EnsureCollection(user.Username, "col/calendars/"+cal.Name); err != nil {
logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err) logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err)
} }
} }
@@ -92,12 +92,21 @@ func main() {
continue continue
} }
for _, book := range books { for _, book := range books {
if err := st.EnsureCollection(user.Username, "card-"+book); err != nil { if err := st.EnsureCollection(user.Username, "col/addressbooks/"+book); err != nil {
logger.Warn("creating address book collection", "user", user.Username, "book", book, "error", err) logger.Warn("creating address book collection", "user", user.Username, "book", book, "error", err)
} }
} }
} }
// Run migration from legacy flat layout (cal-*/card-* dirs and data/files/)
// into new structured col/ subdirectory layout (<user>/col/<type>/<name>).
logger.Info("starting migration from old layout to new col/ subdirectory structure")
if err := store.MigrateDataDir(cfg.Storage.DataDir); err != nil {
logger.Error("running storage migration", "error", err)
os.Exit(1)
}
logger.Info("migration complete")
// ---- Middleware ---- // ---- Middleware ----
authMw := auth.NewMiddleware(cfg, dbase, logger) authMw := auth.NewMiddleware(cfg, dbase, logger)
+4 -4
View File
@@ -96,7 +96,7 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)} cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)}
for _, cal := range names { for _, cal := range names {
if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil { if err := b.store.EnsureCollection(p.Username, "col/calendars/"+cal.Name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err) b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
continue continue
} }
@@ -116,11 +116,11 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
disk, _ := b.store.ListCollections(p.Username) disk, _ := b.store.ListCollections(p.Username)
configured := make(map[string]bool) configured := make(map[string]bool)
for _, cal := range names { for _, cal := range names {
configured["cal-"+cal.Name] = true configured["col/calendars/"+cal.Name] = true
} }
for _, dir := range disk { for _, dir := range disk {
if strings.HasPrefix(dir, "cal-") && !configured[dir] { if strings.HasPrefix(dir, "col/calendars/") && !configured[dir] {
name := strings.TrimPrefix(dir, "cal-") name := strings.TrimPrefix(dir, "col/calendars/")
cals = append(cals, b.calendarMeta(p.Username, name, name)) cals = append(cals, b.calendarMeta(p.Username, name, name))
} }
} }
+3 -3
View File
@@ -74,7 +74,7 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook,
var books []carddav.AddressBook var books []carddav.AddressBook
for _, name := range names { for _, name := range names {
if err := b.store.EnsureCollection(p.Username, "card-"+name); err != nil { if err := b.store.EnsureCollection(p.Username, "col/addressbooks/"+name); err != nil {
b.logger.Warn("ensuring address book directory", "book", name, "error", err) b.logger.Warn("ensuring address book directory", "book", name, "error", err)
continue continue
} }
@@ -88,8 +88,8 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook,
configured["card-"+n] = true configured["card-"+n] = true
} }
for _, dir := range disk { for _, dir := range disk {
if strings.HasPrefix(dir, "card-") && !configured[dir] { if strings.HasPrefix(dir, "col/addressbooks/") && !configured[dir] {
name := strings.TrimPrefix(dir, "card-") name := strings.TrimPrefix(dir, "col/addressbooks/")
books = append(books, b.bookMeta(p.Username, name, name)) books = append(books, b.bookMeta(p.Username, name, name))
} }
} }
+105
View File
@@ -195,6 +195,111 @@ func (s *Store) DeleteCollection(user, collection string) error {
return err return err
} }
// cleanupLegacy moves a user's data from the old flat layout (cal-*/card-* dirs
// directly under user/root, and files/<username>) into the new structured col/
// subdirectory layout (<user>/col/calendars/*, <user>/col/addressbooks/*,
// <user>/col/files).
func cleanupLegacy(dataDir, username string) error {
username = filepath.Base(username) // sanitize path traversal
userRoot := filepath.Join(dataDir, username)
// 1 - Move cal-*/card-* flat directories into col/calendars/ / col/addressbooks/
entries, err := os.ReadDir(userRoot)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, e := range entries {
if !e.IsDir() || e.Name() == "col" {
continue
}
name := e.Name()
var dst string
switch {
case strings.HasPrefix(name, "cal-"):
dst = filepath.Join(userRoot, "col", "calendars", strings.TrimPrefix(name, "cal-"))
case strings.HasPrefix(name, "card-"):
dst = filepath.Join(userRoot, "col", "addressbooks", strings.TrimPrefix(name, "card-"))
default:
continue
}
if _, err := os.Stat(dst); err == nil {
continue // already migrated
}
os.MkdirAll(filepath.Dir(dst), 0o755)
if err := os.Rename(filepath.Join(userRoot, name), dst); err != nil {
return fmt.Errorf("moving %s to %q: %w", name, dst, err)
}
}
// 2 - Move files/<username>/* into <user>/col/files/
oldFilesRoot := filepath.Join(dataDir, "files", username)
if stat, err := os.Stat(oldFilesRoot); err == nil && stat.IsDir() {
dstFiles := filepath.Join(userRoot, "col", "files")
os.MkdirAll(dstFiles, 0o755)
subEntries, err := os.ReadDir(oldFilesRoot)
if err == nil {
for _, s := range subEntries {
src := filepath.Join(oldFilesRoot, s.Name())
dst := filepath.Join(dstFiles, s.Name())
if s.IsDir() {
os.MkdirAll(filepath.Dir(dst), 0o755)
}
os.Rename(src, dst)
}
}
os.RemoveAll(oldFilesRoot)
}
return nil
}
// MigrateDataDir iterates all user data directories under dataDir and calls
// cleanupLegacy on each, handling both old flat-layout users (data/<username>/cal-*/...)
// and old files-direct-layout users (data/files/<username>/).
func MigrateDataDir(dataDir string) error {
users, err := os.ReadDir(dataDir)
if errors.Is(err, os.ErrNotExist) || len(users) == 0 {
return nil
}
if err != nil {
return err
}
var dirs []string
// Collect all directories under data/ (excluding "col" which is the new layout, and non-dirs like nidus.db)
for _, e := range users {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "files" {
// Collect all user dirs under data/files/ (legacy flat layout)
fileUsers, err2 := os.ReadDir(filepath.Join(dataDir, "files"))
if err2 == nil {
for _, fu := range fileUsers {
if fu.IsDir() {
dirs = append(dirs, fu.Name())
}
}
}
} else if name != "col" {
dirs = append(dirs, name)
}
}
for _, user := range dirs {
if err := cleanupLegacy(dataDir, user); err != nil {
return fmt.Errorf("migrating %q: %w", user, err)
}
}
return nil
}
// sanitize removes path-traversal characters from a path segment. // sanitize removes path-traversal characters from a path segment.
func sanitize(s string) string { func sanitize(s string) string {
s = filepath.Base(s) s = filepath.Base(s)
+2 -2
View File
@@ -117,9 +117,9 @@ func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind str
return return
} }
collPrefix := "cal-" collPrefix := "col/calendars/"
if kind == "addressbook" { if kind == "addressbook" {
collPrefix = "card-" collPrefix = "col/addressbooks/"
} }
switch r.Method { switch r.Method {
+13 -2
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"github.com/yourusername/caldav-server/internal/auth" "github.com/yourusername/caldav-server/internal/auth"
@@ -12,9 +13,19 @@ import (
xwebdav "golang.org/x/net/webdav" xwebdav "golang.org/x/net/webdav"
) )
// sanitize removes path-traversal characters from a path segment.
func sanitize(s string) string {
s = filepath.Base(s)
s = strings.ReplaceAll(s, "..", "")
if s == "." || s == "" {
return "_"
}
return s
}
// NewHandler returns an http.Handler that provides standard WebDAV file access, // NewHandler returns an http.Handler that provides standard WebDAV file access,
// mounted at the fixed URL /files/ for every user and rooted at // mounted at the fixed URL /files/ for every user and rooted at
// dataDir/files/<username>/ on disk. The URL is the same for all users — // dataDir/<username>/col/files/ on disk. The URL is the same for all users —
// which user's directory is served is resolved from the Basic Auth identity // which user's directory is served is resolved from the Basic Auth identity
// in the request context, not from the URL. // in the request context, not from the URL.
// //
@@ -38,7 +49,7 @@ func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Ha
h, ok := handlers[p.Username] h, ok := handlers[p.Username]
if !ok { if !ok {
username := p.Username username := p.Username
userDir := filepath.Join(dataDir, "files", username) userDir := filepath.Join(dataDir, sanitize(username), "col", "files")
if err := os.MkdirAll(userDir, 0o755); err != nil { if err := os.MkdirAll(userDir, 0o755); err != nil {
mu.Unlock() mu.Unlock()
logger.Error("creating user WebDAV dir", "user", username, "error", err) logger.Error("creating user WebDAV dir", "user", username, "error", err)