Files
paliad/internal/handlers/files.go
mAi 940df95418 fix(submissions): t-paliad-259 — universal _skeleton.docx for fallback chain
Issue: m noticed the submission generator's preview still shows the raw
HL Patents Style .dotm letterhead for every submission_code that has no
per-firm template. Confirmed live: paliad.de's /healthz is green, the
preview path and /generate path both flow through resolveSubmissionTemplate,
and the only code wired in submissionTemplateRegistry is de.inf.lg.erwidg
(t-paliad-241). For every other code, the fallback was the bare letterhead
with zero placeholders — exactly what m observed.

Fix: slot a universal _skeleton.docx between the per-firm code-specific
template and the macro-only HL Patents Style:

  per-firm/{code}.docx → _skeleton.docx → HL Patents Style.dotm

The skeleton carries every placeholder SubmissionVarsService resolves
(all 48 keys across firm.*, today.*, user.*, project.*, parties.*, rule.*,
deadline.*) without baking in submission_code-specific prose, so any
code lands with variables substituted instead of the bare letterhead.

Changes:
- scripts/gen-skeleton-submission-template/main.go: byte-reproducible
  .docx generator mirroring gen-demo-submission-template but with a
  code-agnostic body (no Klageerwiderung "I./II./III." structure, a
  single [Schriftsatztext] block the lawyer replaces). One run per
  placeholder so the renderer's pass-1 substitution catches every token.
- internal/handlers/files.go: register slug submission/_skeleton.docx +
  fetchSubmissionSkeletonBytes helper (same stale-while-revalidate
  semantics as the existing per-code and HL-Patents-Style fetchers).
- internal/handlers/submission_drafts.go: insert the skeleton lookup
  between fetchSubmissionTemplateBytes (per-firm code) and
  fetchHLPatentsStyleBytes (bare letterhead). HL Patents Style remains
  the final fallback for resilience if mWorkRepo is unreachable.

The companion _skeleton.docx is committed to m/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_skeleton.docx (commit f2659e4)
so the file proxy can fetch it on first request.

Build hygiene: go build ./... clean, go test ./internal/... clean,
bun run build clean.
2026-05-25 14:44:58 +02:00

420 lines
12 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sync"
"time"
"mgit.msbls.de/m/paliad/internal/branding"
)
const (
giteaBaseURL = "https://mgit.msbls.de"
checkInterval = 5 * time.Minute
)
type fileEntry struct {
RawURL string
DownloadName string
ContentType string
RepoOwner string
RepoName string
FilePath string
}
// fileRegistry maps the public download slug to the upstream Gitea object.
//
// RawURL / FilePath reference the actual file in mWorkRepo and must match the
// blob's name there exactly; renaming would 404 the proxy. DownloadName is
// what the browser saves the file as — that's a branding surface, so it
// renders branding.Name instead of the upstream filename.
//
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
// identifier so existing bookmarks keep working post-rebrand.
//
// Per-submission templates (slug `submission/<code>.docx`) are server-only:
// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes.
// handleFileDownload serves any slug that lands here, but the public URL
// surface for submission templates is the export endpoint, not /files.
var fileRegistry = map[string]fileEntry{
"hl-patents-style.dotm": {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
DownloadName: branding.Name + " Patents Style.dotm",
ContentType: "application/vnd.ms-word.template.macroEnabled.12",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/HL Patents Style.dotm",
},
// Per-submission demo template (t-paliad-241). Exercises every
// placeholder SubmissionVarsService resolves so the
// /projects/{id}/submissions/{code}/draft editor has variables to
// substitute. One file per submission_code; future codes register
// the same way — slug shape "submission/<code>.docx" so the
// namespace stays separate from the universal style template.
"submission/de.inf.lg.erwidg.docx": {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
DownloadName: "Klageerwiderung — " + branding.Name + ".docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
},
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
// that carries every placeholder SubmissionVarsService resolves but no
// submission_code-specific body structure. Slot between the per-firm
// per-code template and the bare HL Patents Style .dotm fallback: every
// submission_code without a dedicated template still renders with
// variables substituted instead of the macro-only letterhead.
skeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
// the shared fileRegistry cache. Exported via a const so handler code
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
// universal HL Patents Style as the global fallback.
//
// Add new entries here as the firm authors per-submission templates;
// the file itself lives in mWorkRepo and is served through the shared
// Gitea proxy cache so refreshes are visible to all consumers in one
// place.
var submissionTemplateRegistry = map[string]string{
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
}
// fetchSubmissionTemplateBytes returns the per-submission_code template
// bytes (and provenance SHA) when one is registered. The bool result
// distinguishes "no per-code template registered" (callers fall back to
// HL Patents Style) from an upstream fetch error.
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
slug, ok := submissionTemplateRegistry[submissionCode]
if !ok {
return nil, "", false, nil
}
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
}
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
return nil, "", false, err
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, true, nil
}
type cacheEntry struct {
mu sync.RWMutex
data []byte
sha string
lastChecked time.Time
checking bool
}
var (
giteaToken string
fileCache = make(map[string]*cacheEntry)
fileCacheMu sync.Mutex
httpClient = &http.Client{Timeout: 30 * time.Second}
)
func getCacheEntry(name string) *cacheEntry {
fileCacheMu.Lock()
defer fileCacheMu.Unlock()
ce, ok := fileCache[name]
if !ok {
ce = &cacheEntry{}
fileCache[name] = ce
}
return ce
}
func handleFileDownload(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
entry, ok := fileRegistry[filename]
if !ok {
http.NotFound(w, r)
return
}
ce := getCacheEntry(filename)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
log.Printf("file proxy: fetch %s failed: %v", filename, err)
http.Error(w, "Failed to fetch file", http.StatusBadGateway)
return
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
w.Header().Set("Content-Type", entry.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, entry.DownloadName))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(ce.data)))
w.Write(ce.data)
}
func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
fileCacheMu.Lock()
for name := range fileCache {
fileCache[name] = &cacheEntry{}
}
fileCacheMu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
// resolveSubmissionTemplate's fallback chain — used for every
// submission_code that has no dedicated template registered. Same
// stale-while-revalidate semantics as the rest of the file proxy: first
// call warms the cache synchronously from mWorkRepo via Gitea; later
// calls return immediately while a background refresh runs.
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
entry, ok := fileRegistry[skeletonSubmissionSlug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
}
ce := getCacheEntry(skeletonSubmissionSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
return nil, "", err
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, nil
}
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
// bytes. Shared accessor used by both the /files/{slug} download path
// (Word auto-update channel) and the submission generator
// (handlers/submissions.go) so a refresh through one path is visible to
// the other. First call warms the cache from Gitea synchronously;
// subsequent calls are sub-millisecond. A stale-but-present cache is
// returned immediately while a background refresh runs.
func fetchHLPatentsStyleBytes(ctx context.Context) ([]byte, error) {
entry, ok := fileRegistry[hlPatentsStyleSlug]
if !ok {
return nil, fmt.Errorf("file proxy: %s not registered", hlPatentsStyleSlug)
}
ce := getCacheEntry(hlPatentsStyleSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
return nil, err
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, fmt.Errorf("file proxy: %s cache empty after fetch", hlPatentsStyleSlug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx // ctx reserved for future timeout pass-through; fileFetch
// uses the package httpClient timeout today.
return out, nil
}
// fileFetch downloads the file synchronously (first request).
func fileFetch(ce *cacheEntry, entry fileEntry) error {
sha, _ := giteaLatestSHA(entry)
data, err := giteaDownload(entry)
if err != nil {
return err
}
ce.mu.Lock()
ce.data = data
ce.sha = sha
ce.lastChecked = time.Now()
ce.mu.Unlock()
return nil
}
// fileCheckAndRefresh checks the latest commit SHA and re-downloads if changed.
func fileCheckAndRefresh(ce *cacheEntry, entry fileEntry) {
ce.mu.Lock()
if ce.checking {
ce.mu.Unlock()
return
}
ce.checking = true
ce.mu.Unlock()
defer func() {
ce.mu.Lock()
ce.checking = false
ce.mu.Unlock()
}()
latestSHA, err := giteaLatestSHA(entry)
if err != nil {
log.Printf("file proxy: SHA check for %s failed: %v", entry.DownloadName, err)
ce.mu.Lock()
ce.lastChecked = time.Now()
ce.mu.Unlock()
return
}
ce.mu.RLock()
unchanged := latestSHA == ce.sha && ce.sha != ""
ce.mu.RUnlock()
if unchanged {
ce.mu.Lock()
ce.lastChecked = time.Now()
ce.mu.Unlock()
return
}
data, err := giteaDownload(entry)
if err != nil {
log.Printf("file proxy: download %s failed: %v", entry.DownloadName, err)
ce.mu.Lock()
ce.lastChecked = time.Now()
ce.mu.Unlock()
return
}
ce.mu.Lock()
ce.data = data
ce.sha = latestSHA
ce.lastChecked = time.Now()
ce.mu.Unlock()
log.Printf("file proxy: updated %s (SHA: %.8s)", entry.DownloadName, latestSHA)
}
// giteaLatestSHA returns the SHA of the latest commit that touched the file.
func giteaLatestSHA(entry fileEntry) (string, error) {
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?path=%s&limit=1&sha=main",
giteaBaseURL, entry.RepoOwner, entry.RepoName, url.QueryEscape(entry.FilePath))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", err
}
if giteaToken != "" {
req.Header.Set("Authorization", "token "+giteaToken)
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("gitea API returned %d", resp.StatusCode)
}
var commits []struct {
SHA string `json:"sha"`
}
if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
return "", err
}
if len(commits) == 0 {
return "", fmt.Errorf("no commits for path %s", entry.FilePath)
}
return commits[0].SHA, nil
}
// giteaDownload fetches the raw file content from Gitea.
func giteaDownload(entry fileEntry) ([]byte, error) {
req, err := http.NewRequest("GET", entry.RawURL, nil)
if err != nil {
return nil, err
}
if giteaToken != "" {
req.Header.Set("Authorization", "token "+giteaToken)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea raw returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}