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.
92 lines
2.9 KiB
Go
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)
|
|
}
|
|
}
|