Files
silo/internal/kc/pack.go
Forbes c216d64702 feat(kc): checkout packing + ETag caching (Phase 2)
Implements issue #142 — .kc checkout pipeline that repacks silo/ entries
with current DB state before serving downloads.

When a client downloads a .kc file via GET /api/items/{pn}/file/{rev},
the server now:
1. Reads the file from storage into memory
2. Checks for silo/ directory (plain .fcstd files bypass packing)
3. Repacks silo/ entries with current item_metadata + revision history
4. Streams the repacked ZIP to the client

New files:
- internal/kc/pack.go: Pack() replaces silo/ entries in ZIP, preserving
  all non-silo entries (FreeCAD files, thumbnails) with original
  compression and timestamps. HasSiloDir() for lightweight detection.
- internal/api/pack_handlers.go: packKCFile server helper, computeETag,
  canSkipRepack lazy optimization.

ETag caching:
- ETag computed from revision_number + metadata.updated_at
- If-None-Match support returns 304 Not Modified before reading storage
- Cache-Control: private, must-revalidate

Lazy packing optimization:
- Skips repack if revision_hash matches and metadata unchanged since upload

Phase 2 packs: manifest.json, metadata.json, history.json,
dependencies.json (empty []). Approvals, macros, jobs deferred to
Phase 3-5.

Closes #142
2026-02-18 17:01:26 -06:00

132 lines
3.4 KiB
Go

package kc
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
// HasSiloDir opens a ZIP archive and returns true if any entry starts with "silo/".
// This is a lightweight check used to short-circuit before gathering DB data.
func HasSiloDir(data []byte) (bool, error) {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return false, fmt.Errorf("kc: open zip: %w", err)
}
for _, f := range r.File {
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
return true, nil
}
}
return false, nil
}
// Pack takes original ZIP file bytes and a PackInput, and returns new ZIP bytes
// with all silo/ entries replaced by the data from input. Non-silo entries
// (FreeCAD Document.xml, thumbnails, etc.) are copied verbatim with their
// original compression method and timestamps preserved.
//
// If the original ZIP contains no silo/ directory, the original bytes are
// returned unchanged (plain .fcstd pass-through).
func Pack(original []byte, input *PackInput) ([]byte, error) {
r, err := zip.NewReader(bytes.NewReader(original), int64(len(original)))
if err != nil {
return nil, fmt.Errorf("kc: open zip: %w", err)
}
// Partition entries into silo/ vs non-silo.
hasSilo := false
for _, f := range r.File {
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
hasSilo = true
break
}
}
if !hasSilo {
return original, nil // plain .fcstd, no repacking needed
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// Copy all non-silo entries verbatim.
for _, f := range r.File {
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
continue
}
if err := copyZipEntry(zw, f); err != nil {
return nil, fmt.Errorf("kc: copying entry %s: %w", f.Name, err)
}
}
// Write new silo/ entries from PackInput.
if input.Manifest != nil {
if err := writeJSONEntry(zw, "silo/manifest.json", input.Manifest); err != nil {
return nil, fmt.Errorf("kc: writing manifest.json: %w", err)
}
}
if input.Metadata != nil {
if err := writeJSONEntry(zw, "silo/metadata.json", input.Metadata); err != nil {
return nil, fmt.Errorf("kc: writing metadata.json: %w", err)
}
}
if input.History != nil {
if err := writeJSONEntry(zw, "silo/history.json", input.History); err != nil {
return nil, fmt.Errorf("kc: writing history.json: %w", err)
}
}
if input.Dependencies != nil {
if err := writeJSONEntry(zw, "silo/dependencies.json", input.Dependencies); err != nil {
return nil, fmt.Errorf("kc: writing dependencies.json: %w", err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("kc: closing zip writer: %w", err)
}
return buf.Bytes(), nil
}
// copyZipEntry copies a single entry from the original ZIP to the new writer,
// preserving the file header (compression method, timestamps, etc.).
func copyZipEntry(zw *zip.Writer, f *zip.File) error {
header := f.FileHeader
w, err := zw.CreateHeader(&header)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
_, err = io.Copy(w, rc)
return err
}
// writeJSONEntry writes a new silo/ entry as JSON with Deflate compression.
func writeJSONEntry(zw *zip.Writer, name string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
header := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
}
w, err := zw.CreateHeader(header)
if err != nil {
return err
}
_, err = w.Write(data)
return err
}