From 77061b270839f79ccda02391b2ee5a362fc66cbb Mon Sep 17 00:00:00 2001 From: m Date: Tue, 14 Apr 2026 18:32:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20file=20proxy=20=E2=80=94=20serve=20HL?= =?UTF-8?q?=20Patents=20Style.dotm=20from=20Gitea=20with=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /files/{filename} route (behind auth) that proxies files from Gitea raw URLs with in-memory caching. Uses SHA-based cache invalidation: checks Gitea commit API every 5 min, only re-downloads when file changes. - internal/handlers/files.go: proxy handler with SHA-based cache - POST /api/files/refresh: cache-bust endpoint - GITEA_TOKEN env var for private repo access - Download card on landing page with i18n DE/EN --- cmd/server/main.go | 7 +- docker-compose.yml | 1 + frontend/src/client/i18n.ts | 6 + frontend/src/index.tsx | 14 ++ internal/handlers/files.go | 237 ++++++++++++++++++++++++++++++++++ internal/handlers/handlers.go | 5 +- 6 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 internal/handlers/files.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 7dbcdbc..6def0d6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,13 @@ func main() { client := auth.NewClient(supabaseURL, supabaseAnonKey) + giteaToken := os.Getenv("GITEA_TOKEN") + if giteaToken == "" { + log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos") + } + mux := http.NewServeMux() - handlers.Register(mux, client) + handlers.Register(mux, client, giteaToken) log.Printf("patholo server starting on :%s", port) if err := http.ListenAndServe(":"+port, mux); err != nil { diff --git a/docker-compose.yml b/docker-compose.yml index aaf0ac7..f5992d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,5 @@ services: - PORT=8080 - SUPABASE_URL=${SUPABASE_URL} - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} + - GITEA_TOKEN=${GITEA_TOKEN} restart: unless-stopped diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index fc69796..b37efa2 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -33,6 +33,9 @@ const translations: Record> = { "index.cost.desc": "Sch\u00e4tzung der Verfahrenskosten f\u00fcr DE-Gerichte, UPC und EPA-Verfahren. Gerichts- und Anwaltskosten auf einen Blick.", "index.deadline.title": "Fristenrechner", "index.deadline.desc": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren mit Feiertags-Anpassung.", + "index.downloads": "Downloads", + "index.style.title": "HL Patents Style", + "index.style.desc": "Word-Vorlage im HL Patents Style. Formatierung, Schriftarten und Makros f\u00fcr standardisierte Schrifts\u00e4tze.", "index.offices": "Standorte", "index.munich": "M\u00fcnchen", @@ -166,6 +169,9 @@ const translations: Record> = { "index.cost.desc": "Estimate litigation costs for DE courts, UPC, and EPA proceedings. Court and attorney fees at a glance.", "index.deadline.title": "Deadline Calculator", "index.deadline.desc": "Calculate procedural deadlines for UPC, German, and EPA proceedings with holiday adjustment.", + "index.downloads": "Downloads", + "index.style.title": "HL Patents Style", + "index.style.desc": "Word template in HL Patents style. Formatting, fonts, and macros for standardised briefs.", "index.offices": "Offices", "index.munich": "Munich", diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index ed2bfc7..92e1ac9 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -7,6 +7,7 @@ const ICON_FILE = '" + ( @@ -73,6 +74,19 @@ export function renderIndex(): string { +
+
+

Downloads

+
+ + +
+ +

Standorte

diff --git a/internal/handlers/files.go b/internal/handlers/files.go new file mode 100644 index 0000000..4bc83f8 --- /dev/null +++ b/internal/handlers/files.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "sync" + "time" +) + +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 +} + +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: "HL Patents Style.dotm", + ContentType: "application/vnd.ms-word.template.macroEnabled.12", + RepoOwner: "m", + RepoName: "mWorkRepo", + FilePath: "6 - material/Templates/Word/HL Patents Style.dotm", + }, +} + +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"}) +} + +// 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) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index cb97be1..533f3d6 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -9,8 +9,9 @@ import ( var authClient *auth.Client -func Register(mux *http.ServeMux, client *auth.Client) { +func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string) { authClient = client + giteaToken = giteaAPIToken // API endpoints (JSON, public) mux.HandleFunc("POST /api/login", handleAPILogin) @@ -31,6 +32,8 @@ func Register(mux *http.ServeMux, client *auth.Client) { protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage) protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI) protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes) + protected.HandleFunc("GET /files/{filename}", handleFileDownload) + protected.HandleFunc("POST /api/files/refresh", handleFileRefresh) mux.Handle("/", client.Middleware(protected)) }