package handler import ( "bytes" "encoding/json" "io" "log/slog" "net/http" "os" "path" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zipfs" ) // maxZipMemberBytes caps the size of a single zip member the server // will materialise in memory to extract. Zip transmittals carry // documents, not multi-gigabyte blobs; a member declaring more than // this in its central-directory header is refused (413) rather than // buffered. Streaming/large-member support is a future optimisation. const maxZipMemberBytes = 512 << 20 // 512 MiB // ServeZip serves the contents of the .zip file at zipAbsPath as a // virtual directory. memberPath is the slash-separated path inside the // zip (already URL-decoded; "" = the zip's root level). // // Only reached via the dispatcher's splitZipPath intercept, which has // already (a) confirmed zipAbsPath is a regular .zip file under // cfg.Root and (b) enforced ACL using the chain of the directory // CONTAINING the zip — a zip has no .zddc of its own, exactly like the // .archive virtual path. Write methods are rejected by the dispatcher // before this is reached; zip access here is read-only. // // Routing inside the zip mirrors the server's slash convention: // - memberPath names a file member → extract + stream it // - memberPath names a directory level → JSON listing (Accept: json) // or the browse SPA (HTML) // - request had no trailing slash but the // member is a directory level → 302 to add the slash // - otherwise → 404 func ServeZip(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbsPath, memberPath string) { zr, closeZip, err := zipfs.ResolveCloser(zipAbsPath) if err != nil { if os.IsNotExist(err) { http.Error(w, "Not Found", http.StatusNotFound) } else { slog.Warn("open zip archive", "path", zipAbsPath, "err", err) http.Error(w, "Bad zip archive", http.StatusBadGateway) } return } defer closeZip() member := strings.Trim(memberPath, "/") zipName := path.Base(zipAbsPath) // File member? if member != "" { if rc, size, mod, name, ok := zipfs.OpenMember(zr, member); ok { defer rc.Close() if size > maxZipMemberBytes { http.Error(w, "Zip member too large to serve", http.StatusRequestEntityTooLarge) return } // Buffer into memory so http.ServeContent gets a ReadSeeker // (Range / conditional GET). Bounded by maxZipMemberBytes. buf, err := io.ReadAll(rc) if err != nil { slog.Warn("extract zip member", "zip", zipAbsPath, "member", member, "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate") w.Header().Set("X-ZDDC-Source", "zip:"+zipName) http.ServeContent(w, r, name, mod, bytes.NewReader(buf)) return } } // Directory level. The dispatcher only routes here when the URL had // a tail segment or a trailing slash, so a no-slash URL that lands // on a directory level gets normalised to the slash form. baseURL := r.URL.Path if !strings.HasSuffix(baseURL, "/") { if zipfs.IsDirLevel(zr, member) { http.Redirect(w, r, baseURL+"/", http.StatusFound) return } http.Error(w, "Not Found", http.StatusNotFound) return } entries, valid := zipfs.List(zr, member, baseURL) if !valid { http.Error(w, "Not Found", http.StatusNotFound) return } // Vary: Accept — same URL serves the JSON listing or the browse // SPA depending on Accept; without it caches can cross the wires. w.Header().Set("Vary", "Accept") if strings.Contains(r.Header.Get("Accept"), "application/json") { body, err := json.Marshal(entries) if err != nil { slog.Error("encoding zip 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") w.Header().Set("X-ZDDC-Source", "zip:"+zipName) 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, exactly like // ServeDirectory's and ServeArchive's HTML branches. It auto-detects // server mode by re-fetching this URL with Accept: application/json // (→ the JSON branch above), then renders the zip's contents. body := apps.EmbeddedBytes("browse") if len(body) == 0 { jsonBody, _ := json.Marshal(entries) 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) }