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.
98 lines
2.3 KiB
Go
98 lines
2.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// etagCache caches (etag, modtime) keyed by absolute file path.
|
|
// Key: path, Value: etagCacheEntry
|
|
var etagCacheM sync.Map
|
|
|
|
type etagCacheEntry struct {
|
|
modTime time.Time
|
|
etag string
|
|
}
|
|
|
|
// ServeFile serves a single file with ETag, conditional GET (If-None-Match,
|
|
// If-Modified-Since), and Cache-Control headers.
|
|
// fsPath is the absolute filesystem path to the file.
|
|
func ServeFile(w http.ResponseWriter, r *http.Request, fsPath string) {
|
|
f, err := os.Open(fsPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if info.IsDir() {
|
|
http.Error(w, "Not a file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
etag := computeETag(fsPath, info.ModTime(), f)
|
|
|
|
// Set headers
|
|
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, etag))
|
|
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
|
|
|
|
// Conditional GET: If-None-Match
|
|
if match := r.Header.Get("If-None-Match"); match != "" {
|
|
if match == fmt.Sprintf(`"%s"`, etag) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Content-Type
|
|
ext := filepath.Ext(fsPath)
|
|
ct := mime.TypeByExtension(ext)
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
w.Header().Set("Content-Type", ct)
|
|
|
|
// Seek back to start after ETag computation
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
|
|
}
|
|
|
|
// computeETag returns the SHA-256 ETag for a file, using a modtime-keyed cache.
|
|
func computeETag(path string, modTime time.Time, f *os.File) string {
|
|
if entry, ok := etagCacheM.Load(path); ok {
|
|
cached := entry.(etagCacheEntry)
|
|
if cached.modTime.Equal(modTime) {
|
|
return cached.etag
|
|
}
|
|
}
|
|
|
|
h := sha256.New()
|
|
if _, err := f.Seek(0, io.SeekStart); err == nil {
|
|
io.Copy(h, f)
|
|
}
|
|
etag := hex.EncodeToString(h.Sum(nil))[:32] // first 32 hex chars (128 bits) is sufficient
|
|
|
|
etagCacheM.Store(path, etagCacheEntry{modTime: modTime, etag: etag})
|
|
return etag
|
|
}
|