The "Composer actually works" milestone per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice B. Builds on
Slice A's substrate (submission_bases, submission_sections, base_id on
drafts); no new migrations needed.
Backend additions:
- internal/services/submission_md.go (~240 LoC): Markdown → OOXML
walker. Per the head's Slice B brief, scope is paragraphs +
bold/italic + blank-line spacing. Placeholders pass through
unchanged for the v1 substitution pass. CRLF normalisation; nested
formatting (***bold-italic***); two delimiter forms (* and _);
XML-escaping for &/</>; explicit empty-paragraph emit so blank
lines round-trip. 12 unit tests.
- internal/services/submission_compose.go (~470 LoC): SubmissionComposer
service. Pipeline: ConvertDotmToDocx pre-pass → extract
word/document.xml → render each included section's content_md_<lang>
→ splice via {{#section:KEY}}/{{/section:KEY}} anchor pairs in
the body → strip anchors for excluded sections → append unanchored
sections before <w:sectPr> → repack zip → run v1 placeholder pass.
RE2-friendly anchor scanner walks markers in body-order and matches
open/close pairs with a stack (handles unbalanced anchors
defensively). 6 unit tests covering anchor-mode splice,
append-mode-no-anchors, excluded-section drop, placeholder
resolution, lang column pick, order_index ASC.
- internal/services/submission_section_service.go: SectionPatch +
Update method. Six optional fields (content_md_de/en, included,
label_de/en, order_index). Sentinel ErrSubmissionSectionNotFound on
RLS-filtered miss.
- internal/handlers/submission_sections.go (NEW, ~150 LoC):
PATCH /api/submission-drafts/{draft_id}/sections/{section_id}.
Owner-scoped via SubmissionDraftService.Get; section-belongs-to-draft
cross-check. 404 on both missing-draft and section-belongs-elsewhere
paths.
- internal/handlers/files.go: fetchComposerBaseBytes + composerBaseSlugMap
reuse the existing Gitea proxy cache for base .docx bytes. hlc-letterhead
→ existing firmSkeletonSubmissionSlug, neutral → existing
skeletonSubmissionSlug.
- internal/handlers/submission_drafts.go: exportSubmissionDraft helper
branches on draft.BaseID. When set AND base + bytes + sections all
resolve → Composer pipeline. Else v1 fallback render path stays.
Audit metadata jsonb gains "composer": true + "base_id" flag when
composer was used.
Wiring:
- handlers.Services gains SubmissionComposer.
- dbServices.submissionComposer wired from svc.SubmissionComposer.
- main.go instantiates NewSubmissionComposer with the existing
SubmissionRenderer (so the {{rule.X}} alias contract stays preserved
inside section content).
Frontend additions (~400 LoC):
- client/submission-draft.ts: paintSectionList rewritten to render a
contentEditable per included section with a per-section B/I
toolbar. Per-section autosave debounced 500ms; mousedown handlers on
toolbar buttons preserve editor focus mid-command. domToMarkdown
walks the contentEditable's DOM tree back to Markdown source-of-
truth (b/strong → **…**, i/em → *…*, div/p → paragraph break, br
→ newline). Updated state.view.sections in-place on PATCH success
without re-painting (avoids focus-stealing on every keystroke);
re-paints only on structural changes (included toggle, label edits,
order changes).
- client/submission-draft.ts: onSectionToggleIncluded hides/shows a
section via PATCH. flushSectionAutosave on blur force-flushes
pending edits so leaving an editor doesn't strand unsynced changes.
- styles/global.css: editor surface (contentEditable area with focus
ring + placeholder), toolbar buttons (B/I 1.8rem squares),
per-section "Hide"/"Include" toggle in the head row.
- Updated i18n hint copy: "Inhalt pro Abschnitt — Autosave nach
500ms. Letztes Layout in Word."
Templates regenerated on Gitea:
- _skeleton.docx → composer-mode body (anchors only): blob SHA
ac0cdeaf49f7cd417ec143e2319ffbb02ec65644.
- _firm-skeleton.docx → composer-mode body (anchors only, preserves
sectPr → firm header/footer rIds): blob SHA
f1e9a9fb9a29ca01bf7bee709a45c5dda2a8e317.
- Both uploaded as mAi via --netrc-file ~/.netrc-mai.
- gen-skeleton-submission-template script gains an -anchors flag
(default true) so future regens emit composer-ready bodies. The
_firm-skeleton.docx regen was done via a one-off /tmp helper since
the gen-hl-skeleton-template script requires the proprietary .dotm
source which lives in HL/mWorkRepo; extending that script to accept
an existing .docx as input is a follow-up cleanup.
Build hygiene: go build/vet/test -short ./internal/... ./cmd/... all
clean; bun run build clean (2900 i18n keys, data-i18n scan clean).
NO behavior change for pre-Composer drafts (base_id NULL → v1
fallback render path stays compiled in). NO migrations needed in this
slice — sections were already in the schema from Slice A; only
content_md_de/en UPDATEs happen via the new PATCH endpoint.
Hard rules per Q2/Q10 ratification still honoured:
- No building_block_id lineage (Slice C territory; Q2).
- Caption/letterhead/signature are regular prose sections, seeded from
base spec; lawyer can edit/hide freely (Q10).
- {{rule.X}} aliases preserved (renderer pass unchanged).
NOT in scope per Slice B brief:
- Headings 1–3, lists, blockquote (Slice D's MD walker extension).
- Building blocks library (Slice C).
- Reorder / add-custom-section (Slice F).
- Auto-upgrade of pre-Composer drafts (Slice C — explicitly NOT in
this slice per head's brief msg #2393).
t-paliad-313 Slice B
638 lines
22 KiB
Go
638 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/branding"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
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",
|
|
},
|
|
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
|
|
// placeholder bag as the universal _skeleton.docx, but additionally
|
|
// preserves every HL paragraph + character style from the HL Patents
|
|
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
|
|
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
|
|
// (header logo + firm-address footer). Slotted ahead of the universal
|
|
// skeleton in the fallback chain so any submission_code without a
|
|
// dedicated per-code template still renders as a real firm-branded
|
|
// Schriftsatz with variables substituted, rather than a plain skeleton.
|
|
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
|
|
firmSkeletonSubmissionSlug: {
|
|
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
|
|
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
|
|
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
RepoOwner: "m",
|
|
RepoName: "mWorkRepo",
|
|
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
|
|
},
|
|
// English skeleton variant (t-paliad-276). Sibling of
|
|
// `_skeleton.docx`; used when a draft's language='en' and no
|
|
// per-code EN template exists. If the file isn't authored yet in
|
|
// mWorkRepo, the Gitea fetch fails and resolveSubmissionTemplate
|
|
// falls through to the DE skeleton — visible to the user as the
|
|
// "Fallback: universelles Skelett" notice on the draft editor.
|
|
skeletonSubmissionENSlug: {
|
|
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
|
|
DownloadName: branding.Name + " — Submission skeleton.docx",
|
|
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
RepoOwner: "m",
|
|
RepoName: "mWorkRepo",
|
|
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.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"
|
|
|
|
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
|
|
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
|
|
// surface as skeletonSubmissionSlug; carries HL paragraph + character
|
|
// styles from the source .dotm on top. Sits between the per-code
|
|
// template and the generic universal skeleton in the fallback chain so
|
|
// codes without a dedicated template still render with firm branding.
|
|
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
|
|
|
|
// skeletonSubmissionENSlug names the English skeleton variant used when
|
|
// a draft's language='en' and no per-code EN template exists
|
|
// (t-paliad-276). Same role as skeletonSubmissionSlug but in EN.
|
|
const skeletonSubmissionENSlug = "submission/_skeleton.en.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.
|
|
//
|
|
// t-paliad-276: codes that ship an EN sibling
|
|
// (e.g. `de.inf.lg.erwidg.en.docx`) also register it in
|
|
// submissionTemplateENRegistry; the language-aware lookup
|
|
// (resolveSubmissionTemplate(ctx, code, lang)) prefers the language-
|
|
// suffixed slug and falls back to the unsuffixed one when no per-firm
|
|
// EN variant exists.
|
|
var submissionTemplateRegistry = map[string]string{
|
|
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
|
|
}
|
|
|
|
// submissionTemplateENRegistry maps a submission_code to the EN
|
|
// variant slug. Empty when no EN template has been authored — the
|
|
// lookup falls through to the unsuffixed (DE-baked) template and the
|
|
// editor surfaces the "Fallback: universelles Skelett" notice when
|
|
// even the skeleton has no EN sibling.
|
|
var submissionTemplateENRegistry = map[string]string{}
|
|
|
|
// 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.
|
|
//
|
|
// Language-suffixed variants (t-paliad-276) are served via
|
|
// fetchSubmissionTemplateBytesForLang — this base function returns the
|
|
// unsuffixed registry entry only (the legacy DE-baked template).
|
|
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"})
|
|
}
|
|
|
|
// fetchSubmissionTemplateBytesForLang returns the per-(code, lang)
|
|
// template bytes when a language-suffixed variant is registered. Used
|
|
// only for the EN variant today; DE goes through the unsuffixed
|
|
// fetchSubmissionTemplateBytes (which is the legacy / authoritative
|
|
// DE registry). t-paliad-276.
|
|
//
|
|
// Returned bool = "variant registered AND fetched OK". A registered
|
|
// variant whose file 404s on Gitea returns (nil, "", false, nil) so
|
|
// the caller falls through to the unsuffixed template, mirroring the
|
|
// behaviour for unregistered codes.
|
|
func fetchSubmissionTemplateBytesForLang(ctx context.Context, submissionCode, lang string) ([]byte, string, bool, error) {
|
|
if lang != "en" {
|
|
// Only EN has a separate registry today. DE goes through the
|
|
// unsuffixed path which is the authoritative DE template.
|
|
return nil, "", false, nil
|
|
}
|
|
slug, ok := submissionTemplateENRegistry[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 {
|
|
// Treat upstream miss as "variant unavailable" so the
|
|
// resolver falls through to the DE template instead of
|
|
// surfacing a 502.
|
|
log.Printf("file proxy: EN variant fetch failed for %s (%s): %v — falling through", submissionCode, slug, err)
|
|
return nil, "", false, nil
|
|
}
|
|
} else if needsCheck {
|
|
go fileCheckAndRefresh(ce, entry)
|
|
}
|
|
|
|
ce.mu.RLock()
|
|
defer ce.mu.RUnlock()
|
|
if len(ce.data) == 0 {
|
|
return nil, "", false, nil
|
|
}
|
|
out := make([]byte, len(ce.data))
|
|
copy(out, ce.data)
|
|
_ = ctx
|
|
return out, ce.sha, true, nil
|
|
}
|
|
|
|
// fetchSubmissionSkeletonBytesForLang returns the cached skeleton
|
|
// template bytes for the requested language. EN falls back to DE when
|
|
// the EN skeleton hasn't been authored yet (t-paliad-276). Returned
|
|
// bool flags whether the bytes match the requested language — false
|
|
// means the resolver should communicate "fallback" to the UI.
|
|
func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]byte, string, bool, error) {
|
|
if lang == "en" {
|
|
entry, ok := fileRegistry[skeletonSubmissionENSlug]
|
|
if ok {
|
|
ce := getCacheEntry(skeletonSubmissionENSlug)
|
|
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 {
|
|
ce.mu.RLock()
|
|
if len(ce.data) > 0 {
|
|
out := make([]byte, len(ce.data))
|
|
copy(out, ce.data)
|
|
sha := ce.sha
|
|
ce.mu.RUnlock()
|
|
return out, sha, true, nil
|
|
}
|
|
ce.mu.RUnlock()
|
|
} else {
|
|
log.Printf("file proxy: EN skeleton fetch failed (%s): %v — falling back to DE", skeletonSubmissionENSlug, err)
|
|
}
|
|
} else {
|
|
if needsCheck {
|
|
go fileCheckAndRefresh(ce, entry)
|
|
}
|
|
ce.mu.RLock()
|
|
if len(ce.data) > 0 {
|
|
out := make([]byte, len(ce.data))
|
|
copy(out, ce.data)
|
|
sha := ce.sha
|
|
ce.mu.RUnlock()
|
|
return out, sha, true, nil
|
|
}
|
|
ce.mu.RUnlock()
|
|
}
|
|
}
|
|
}
|
|
// Fall through to the DE skeleton; bool=false flags that the
|
|
// returned bytes don't carry the requested language.
|
|
bytes, sha, err := fetchSubmissionSkeletonBytes(ctx)
|
|
if err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
return bytes, sha, lang == "de", nil
|
|
}
|
|
|
|
// 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) {
|
|
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
|
|
}
|
|
|
|
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
|
|
// template bytes (HL paragraph/character styles + 48-key placeholder
|
|
// bag) plus its provenance SHA. Sits between the per-code template and
|
|
// the generic universal skeleton in resolveSubmissionTemplate's
|
|
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
|
|
// as the other Gitea-backed template parts.
|
|
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
|
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
|
|
}
|
|
|
|
// composerBaseSlugMap routes a Composer base.slug to the existing
|
|
// fileRegistry slug whose Gitea object backs it (t-paliad-313 Slice B).
|
|
// Slice A seeded two bases that already share .docx files with the v1
|
|
// fallback chain — no new Gitea uploads needed for those. Future bases
|
|
// (e.g. lg-duesseldorf, upc-formal in Slice E) register their own
|
|
// fileRegistry entries via the same shape and add a row here.
|
|
var composerBaseSlugMap = map[string]string{
|
|
"hlc-letterhead": firmSkeletonSubmissionSlug,
|
|
"neutral": skeletonSubmissionSlug,
|
|
}
|
|
|
|
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
|
|
// pulled from the shared Gitea proxy cache. ErrComposerBaseNotProxied
|
|
// when the slug has no registered fileRegistry entry — a base authored
|
|
// without a file-registry mapping (rare; admin oversight) renders as
|
|
// "Vorlagenbasis nicht erreichbar" upstream of this call.
|
|
var ErrComposerBaseNotProxied = errors.New("composer base: Gitea slug not registered")
|
|
|
|
func fetchComposerBaseBytes(ctx context.Context, base *services.SubmissionBase) ([]byte, string, error) {
|
|
if base == nil {
|
|
return nil, "", fmt.Errorf("composer base: nil base")
|
|
}
|
|
slug, ok := composerBaseSlugMap[base.Slug]
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("%w: base slug %q", ErrComposerBaseNotProxied, base.Slug)
|
|
}
|
|
return fetchSubmissionTemplateSlug(ctx, slug)
|
|
}
|
|
|
|
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
|
|
// the firm-skeleton and universal-skeleton accessors. Factored out so
|
|
// the two paths can't drift apart on caching semantics.
|
|
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
|
|
entry, ok := fileRegistry[slug]
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("file proxy: %s 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, "", 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", slug)
|
|
}
|
|
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)
|
|
}
|