package main import ( "context" "fmt" "log/slog" "net" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "git.arnef.de/arnef/nidus/internal/auth" "git.arnef.de/arnef/nidus/internal/caldav" "git.arnef.de/arnef/nidus/internal/carddav" "git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/web" filewebdav "git.arnef.de/arnef/nidus/internal/webdav" webstatic "git.arnef.de/arnef/nidus/web" ) func main() { // ---- Configuration ---- cfg, err := config.Load() if err != nil { fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) os.Exit(1) } // ---- Logging ---- logger := buildLogger(cfg) logger.Info("starting DAV server", "host", cfg.Server.Host, "port", cfg.Server.Port, "base_url", cfg.Server.BaseURL) // ---- Storage ---- st, err := store.NewStore(cfg.Storage.DataDir) if err != nil { logger.Error("initialising store", "error", err) os.Exit(1) } // ---- Run migration if data directory needs restructuring ---- if err := st.Migrate(); err != nil { logger.Error("migration failed", "error", err) os.Exit(1) } logger.Info("data directory migration completed") // ---- Database (users, calendars, address books, sharing) ---- dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db") dbase, err := db.Open(dbPath) if err != nil { logger.Error("initialising database", "error", err) os.Exit(1) } defer dbase.Close() if n, err := dbase.UserCount(); err != nil { logger.Error("counting users", "error", err) os.Exit(1) } else if n == 0 { logger.Warn("no users exist yet — create one with: nidusctl user create ") } // Pre-create on-disk collections for every registered calendar/address // book, in case they were added via nidusctl/web UI while the server // wasn't running. users, err := dbase.ListUsers() if err != nil { logger.Error("listing users", "error", err) os.Exit(1) } for _, user := range users { cals, err := dbase.ListCalendars(user.Username) if err != nil { logger.Warn("listing calendars", "user", user.Username, "error", err) continue } for _, cal := range cals { if err := st.EnsureCollection(user.Username, "cal-"+cal.Name); err != nil { logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err) } } books, err := dbase.ListAddressBooks(user.Username) if err != nil { logger.Warn("listing address books", "user", user.Username, "error", err) continue } for _, book := range books { if err := st.EnsureCollection(user.Username, "card-"+book); err != nil { logger.Warn("creating address book collection", "user", user.Username, "book", book, "error", err) } } } // ---- Middleware ---- authMw := auth.NewMiddleware(cfg, dbase, logger) // ---- Handlers ---- calHandler := caldav.NewHandler(cfg, st, dbase, logger) cardHandler := carddav.NewHandler(cfg, st, dbase, logger) fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger) webUI := web.NewServer(cfg, st, dbase, logger) mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, webUI, logger) // ---- HTTP Server ---- addr := net.JoinHostPort(cfg.Server.Host, fmt.Sprintf("%d", cfg.Server.Port)) srv := &http.Server{ Addr: addr, Handler: withRequestLogging(mux, logger), ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } // ---- Graceful shutdown ---- idleConnsClosed := make(chan struct{}) go func() { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit logger.Info("shutting down...") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { logger.Error("graceful shutdown failed", "error", err) } close(idleConnsClosed) }() logger.Info("server ready", "addr", addr) err = srv.ListenAndServe() if err != nil && err != http.ErrServerClosed { logger.Error("server error", "error", err) os.Exit(1) } <-idleConnsClosed logger.Info("server stopped") } // buildMux creates the top-level HTTP multiplexer. func buildMux( cfg *config.Config, authMw *auth.Middleware, calHandler, cardHandler, fileHandler http.Handler, webUI *web.Server, logger *slog.Logger, ) *http.ServeMux { mux := http.NewServeMux() // Web UI (own cookie-based auth, not Basic Auth) — dashboard, login, // share management. Mounted at "/web/"; /cal/, /card/, /files/ keep // their own dedicated prefixes. mux.Handle("/web/", http.StripPrefix("/web", webUI.Handler(webstatic.FS()))) // /.well-known/ redirects for auto-discovery mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, cfg.Server.BaseURL+"/cal/", http.StatusMovedPermanently) }) mux.HandleFunc("/.well-known/carddav", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, cfg.Server.BaseURL+"/card/", http.StatusMovedPermanently) }) // Health check (unauthenticated) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) }) // CalDAV, CardDAV, and file WebDAV — all behind Basic Auth mux.Handle("/cal/", authMw.Wrap(calHandler)) mux.Handle("/card/", authMw.Wrap(cardHandler)) mux.Handle("/files/", authMw.Wrap(fileHandler)) // Root — simple HTML welcome page (unauthenticated) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } // Only plain GET/HEAD get the welcome page. Any other method // (PROPFIND, LOCK, PUT, ...) hitting "/" means a client is pointed // at the wrong URL — reject it explicitly instead of returning a // misleading 200 OK, which breaks WebDAV clients expecting 207. if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "not a WebDAV collection; use /cal/, /card/ or /files/", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, welcomePage, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL, cfg.Server.BaseURL) }) return mux } // withRequestLogging wraps a handler with access-log middleware. func withRequestLogging(next http.Handler, logger *slog.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rw := &responseWriter{ResponseWriter: w, code: http.StatusOK} next.ServeHTTP(rw, r) logger.Info("request", "method", r.Method, "path", r.URL.Path, "status", rw.code, "duration_ms", time.Since(start).Milliseconds(), "remote_addr", r.RemoteAddr, ) }) } // responseWriter captures the status code for logging. type responseWriter struct { http.ResponseWriter code int } func (rw *responseWriter) WriteHeader(code int) { rw.code = code rw.ResponseWriter.WriteHeader(code) } // buildLogger creates a slog.Logger based on config. func buildLogger(cfg *config.Config) *slog.Logger { level := slog.LevelInfo switch cfg.Logging.Level { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError } var handler slog.Handler opts := &slog.HandlerOptions{Level: level} if cfg.Logging.Format == "json" { handler = slog.NewJSONHandler(os.Stdout, opts) } else { handler = slog.NewTextHandler(os.Stdout, opts) } return slog.New(handler) } const welcomePage = ` DAV Server

🗓 DAV Server

This server provides CalDAV, CardDAV, and WebDAV access.

Endpoints

Auto-discovery

Authentication: HTTP Basic Auth

Open the web dashboard →

`