ZDDC/zddc/internal/handler/archivehandler.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

92 lines
2.9 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net/http"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ServeArchive handles requests under a .archive virtual path segment.
//
// contextPath: the URL path leading up to (but not including) .archive (e.g. "/Project-123")
// filename: the part after .archive/ (empty for directory listing)
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
email := EmailFromContext(r)
// ACL check on the context directory
dirPath := strings.TrimPrefix(contextPath, "/")
dirPath = strings.TrimSuffix(dirPath, "/")
absDir := filepath.Join(cfg.Root, filepath.FromSlash(dirPath))
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
if err != nil {
slog.Warn("ACL policy error", "path", absDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if filename == "" {
// Directory listing: return all trackingNumber.html entries this user can access
serveArchiveListing(cfg, idx, w, r, contextPath, email)
return
}
// Single file resolve
target, ok := archive.Resolve(idx, filename)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// ACL check on the resolved file's directory (prevents info leakage)
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target)))
chain, err = zddc.EffectivePolicy(cfg.Root, fileDir)
if err != nil {
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// 302 redirect to the real file path
http.Redirect(w, r, "/"+target, http.StatusFound)
}
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, email string) {
allEntries := idx.AllTrackingEntries()
archiveBase := contextPath + "/" + cfg.IndexPath + "/"
var result []listing.FileInfo
for _, item := range allEntries {
if item.HighestPath == "" {
continue
}
// ACL check on the resolved file's directory
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(item.HighestPath)))
chain, err := zddc.EffectivePolicy(cfg.Root, fileDir)
if err != nil || !zddc.AllowedWithChain(chain, email) {
continue
}
entryName := item.TrackingNumber + ".html"
result = append(result, listing.FileInfo{
Name: entryName,
URL: archiveBase + entryName,
IsDir: false,
})
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(result); err != nil {
slog.Error("encoding archive listing", "err", err)
}
}