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) } 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() mux.Handle("/", handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, handler.ACLMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dispatch(cfg, idx, 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, w http.ResponseWriter, r *http.Request) { urlPath := r.URL.Path email := handler.EmailFromContext(r) // 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, "/"), "/") // 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) } func setupLogger(level string) { 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 } slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l}))) }