ZDDC/zddc/cmd/zddc-server/main.go
ZDDC ea385b5366 Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.

See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.
2026-04-27 11:05:47 -05:00

197 lines
5 KiB
Go

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})))
}