package handler import ( "encoding/json" "log/slog" "net/http" "path/filepath" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "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/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // ServeArchive handles requests under a project's .archive virtual path. // // The dispatcher canonicalizes every .archive request to //.archive/... // before reaching here (any deeper //sub/.../archive/... gets a 302 // to the project-rooted form), so this handler only ever sees one shape: // project = first URL segment, filename = whatever follows .archive/. // // Permissions follow the FILE, not .archive itself. .archive is a virtual // surface — it has no on-disk directory and no .zddc of its own. Two gates // only: // // 1. Listing: returned entries are filtered by the per-target file's ACL // chain. If the project bucket is empty (or doesn't exist in the index) // the response is 404; if the user can read NO entries in a non-empty // bucket the response is 403, so existence of an inaccessible project's // archive does not leak. // // 2. Resolve: only the per-target file's ACL gates access. A user with // no project-root permission but an explicit allow on one transmittal // folder can fetch that file's tracking-number URL; conversely, a user // with broad project access but a narrower deny on a specific subtree // gets 404 (not 403) on its tracking numbers — existence must not leak. // // Listings serve the embedded `browse` SPA on Accept: text/html and the // JSON entry array on Accept: application/json — same content negotiation // as ServeDirectory, so the SPA's auto-detect path-fetch works at .archive // URLs identically to real directories. func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, filename string) { if project == "" { http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. //.archive/)", http.StatusNotFound) return } email := EmailFromContext(r) decider := DeciderFromContext(r) ctx := r.Context() if filename == "" { serveArchiveListing(cfg, idx, w, r, project, email, decider) return } target, ok := archive.Resolve(idx, project, filename) if !ok { http.Error(w, "Not Found", http.StatusNotFound) return } // Per-target ACL is the only gate. 404 (not 403) so the tracking // number's mere existence isn't disclosed to a caller who can't // actually read the resolved file. 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 allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed { http.Error(w, "Not Found", http.StatusNotFound) return } // Serve in place — DO NOT redirect to the resolved file's real path. // People share .archive/.html#section URLs and expect the // link to keep tracking the latest revision; redirecting would pin // the bookmark to a specific transmittal-folder snapshot. The // canonicalization redirect (///.archive/X → //.archive/X) // happens upstream in the dispatcher and is a different thing — it // only collapses the .archive prefix, not the resolved bytes. // // Cache-Control: no-cache forces conditional revalidation each load — // http.ServeFile sets Last-Modified/ETag from the on-disk file, so // when the resolver picks a newer target the ETag changes and the // browser refetches. absFile := filepath.Join(cfg.Root, filepath.FromSlash(target)) w.Header().Set("Cache-Control", "no-cache") http.ServeFile(w, r, absFile) } func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, email string, decider policy.Decider) { ctx := r.Context() allEntries := idx.AllEntries(project) if len(allEntries) == 0 { // Project bucket missing or empty. 404 with no body distinction // from "unknown project" — a caller probing for project names // gets the same shape whether or not the project exists. http.Error(w, "Not Found", http.StatusNotFound) return } archiveBase := "/" + project + "/" + cfg.IndexPath + "/" // ACL chains are folder-keyed and the listing typically hits the same // few directories repeatedly (one per transmittal folder), so cache // the allow/deny decision per directory rather than re-walking .zddc // files for every entry. aclCache := make(map[string]bool) allowed := func(targetPath string) bool { fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath))) if v, ok := aclCache[fileDir]; ok { return v } chain, err := zddc.EffectivePolicy(cfg.Root, fileDir) if err != nil { aclCache[fileDir] = false return false } v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath) aclCache[fileDir] = v return v } result := make([]listing.FileInfo, 0, len(allEntries)) for _, e := range allEntries { if !allowed(e.TargetPath) { continue } result = append(result, listing.FileInfo{ Name: e.URLName, URL: archiveBase + e.URLName, IsDir: false, }) } // Existence-leak guard: if the user can read no entries in a // non-empty bucket, 403 — never confirm the project's archive // exists to a caller with no permissions in it. if len(result) == 0 { http.Error(w, "Forbidden", http.StatusForbidden) return } // Vary: Accept is critical because the same URL serves either the // JSON listing or the embedded browse SPA depending on Accept; // without it, browsers/CDNs may serve one Accept's body for the // other Accept value and break the SPA's JSON auto-fetch. w.Header().Set("Vary", "Accept") if strings.Contains(r.Header.Get("Accept"), "application/json") { body, err := json.Marshal(result) if err != nil { slog.Error("encoding archive listing", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } etag := `"` + listingETag(body) + `"` w.Header().Set("Content-Type", "application/json") w.Header().Set("ETag", etag) w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate") if match := r.Header.Get("If-None-Match"); match != "" && match == etag { w.WriteHeader(http.StatusNotModified) return } _, _ = w.Write(body) return } // HTML: serve the embedded `browse` SPA. The SPA auto-detects the // server-mode listing by re-fetching this same URL with // Accept: application/json — that path lands in the JSON branch // above and renders the archive entries as a sortable, filterable // flat list. body := apps.EmbeddedBytes("browse") if len(body) == 0 { // Bootstrap state: a fresh build hasn't populated browse.html // into the embed yet. Fall through to JSON for clients that // will still parse it. jsonBody, err := json.Marshal(result) if err != nil { slog.Error("encoding archive listing (no-embed fallback)", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(jsonBody) return } etag := `"` + apps.EmbeddedETag("browse") + `"` w.Header().Set("ETag", etag) w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("X-ZDDC-Source", "embedded:browse") if match := r.Header.Get("If-None-Match"); match != "" && match == etag { w.WriteHeader(http.StatusNotModified) return } _, _ = w.Write(body) }