package main import ( "context" "fmt" "log/slog" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/handler" "codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) func main() { cfg, err := config.Load() if err != nil { fmt.Fprintf(os.Stderr, "configuration error: %v\n", err) os.Exit(1) } logRing := setupLogger(cfg.LogLevel) slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr) // Build archive index slog.Info("building archive index...") start := time.Now() idx, err := archive.BuildIndex(cfg.Root) if err != nil { slog.Error("failed to build archive index", "err", err) os.Exit(1) } slog.Info("archive index built", "duration", time.Since(start)) // TLS config tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg) if err != nil { slog.Error("failed to configure TLS", "err", err) os.Exit(1) } // Context for graceful shutdown ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer cancel() // Start file-system watcher watcher, err := archive.NewWatcher(cfg.Root, idx) if err != nil { slog.Warn("failed to start filesystem watcher (index will not auto-update)", "err", err) } else { go func() { if err := watcher.Start(ctx); err != nil && ctx.Err() == nil { slog.Error("watcher error", "err", err) } }() } // HTTP handler mux := http.NewServeMux() // Middleware chain (outermost → innermost): // ACLMiddleware — extract email from cfg.EmailHeader, store in // request context. Outermost so the email is // available to AccessLogMiddleware (Go's context // propagates DOWN the chain via r.WithContext, not // UP — so AccessLog can't read a context value set // by an inner middleware after next.ServeHTTP // returns). // AccessLogMiddleware — structured per-request log; reads email from // the context the outer ACL middleware set. // CORSMiddleware — Origin / preflight handling. // dispatch — the actual request handler. mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dispatch(cfg, idx, logRing, w, r) }))))) srv := &http.Server{ Addr: cfg.Addr, Handler: mux, TLSConfig: tlsCfg, } // Serve in goroutine if useTLS { go func() { slog.Info("listening", "addr", cfg.Addr, "tls", true) if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { slog.Error("server error", "err", err) cancel() } }() } else { go func() { slog.Info("listening", "addr", cfg.Addr, "tls", false) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server error", "err", err) cancel() } }() } <-ctx.Done() slog.Info("shutting down...") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { slog.Error("shutdown error", "err", err) } slog.Info("stopped") } // dispatch routes a request to the appropriate handler. func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w http.ResponseWriter, r *http.Request) { urlPath := r.URL.Path email := handler.EmailFromContext(r) // Profile routes — the page itself is reachable to anyone (anonymous // included); admin-only sub-resources (whoami / config / logs / // projects / .zddc editor) keep their existing per-resource 404 // existence-leakage gates inside ServeProfile. if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") { handler.ServeProfile(cfg, ring, w, r) return } // Project list API: GET / with Accept: application/json if urlPath == "/" { accept := r.Header.Get("Accept") if strings.Contains(accept, "application/json") { handler.ServeProjectList(cfg, w, r) return } } // Split path into segments segments := strings.Split(strings.Trim(urlPath, "/"), "/") // Reserve dot-prefixed path segments. The listing pipeline already hides // hidden entries (internal/listing/listing.go:17, projectshandler.go:40), // but direct URL access would still serve them. 404 here so hidden trees // like /srv/.devshell (the in-image dev-shell's persistent home dir on // the same Azure Files PVC as served data) cannot be fetched. The // recognized virtual prefixes (.profile handled above, cfg.IndexPath // handled below) are explicitly allowed through. for _, seg := range segments { if seg == "" || !strings.HasPrefix(seg, ".") { continue } if seg == cfg.IndexPath { continue } http.NotFound(w, r) return } // Check for .archive segment in the path for i, seg := range segments { if seg == cfg.IndexPath { // contextPath is everything before .archive contextPath := "/" + strings.Join(segments[:i], "/") var filename string if i+1 < len(segments) { filename = strings.Join(segments[i+1:], "/") } handler.ServeArchive(cfg, idx, w, r, contextPath, filename) return } } // Resolve the physical path cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/")) absPath := filepath.Join(cfg.Root, cleanPath) // Guard against path traversal if !strings.HasPrefix(absPath, cfg.Root+string(filepath.Separator)) && absPath != cfg.Root { http.Error(w, "Not Found", http.StatusNotFound) return } // Check filesystem info, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { http.Error(w, "Not Found", http.StatusNotFound) } else { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } return } if info.IsDir() { // ACL check chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) if !zddc.AllowedWithChain(chain, email) { http.Error(w, "Forbidden", http.StatusForbidden) return } if !strings.HasSuffix(urlPath, "/") { http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) return } handler.ServeDirectory(cfg, w, r) return } // Regular file: ACL on parent directory chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath)) if !zddc.AllowedWithChain(chain, email) { http.Error(w, "Forbidden", http.StatusForbidden) return } handler.ServeFile(w, r, absPath) } // setupLogger installs a slog default that fans every record out to stderr // (the existing TextHandler — user-visible logging is unchanged) AND to an // in-memory ring buffer that backs the /.profile/logs endpoint. Returns // the ring so handlers can read it. func setupLogger(level string) *handler.LogRing { var l slog.Level switch strings.ToLower(level) { case "debug": l = slog.LevelDebug case "warn": l = slog.LevelWarn case "error": l = slog.LevelError default: l = slog.LevelInfo } ring := handler.NewLogRing(500) text := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l}) rh := handler.NewRingHandler(ring, l) slog.SetDefault(slog.New(handler.NewMultiHandler(text, rh))) return ring }