Files
silo-server/internal/ebookconvert/cache.go
e99079abf8 Server-side Kindle→EPUB conversion (mobi/azw/azw3) for in-app reading (#171)
* Kindle->EPUB conversion: design + proven wasm build pipeline

Server-side MOBI/AZW/AZW3 -> EPUB conversion so the Android in-app reader
can render Kindle-family ebooks. Conversion runs in-process via libmobi's
mobitool compiled to wasm32-wasi, executed by wazero (pure Go) -- no cgo,
no external binary, arch-independent, sandboxed untrusted input.

This commit lands the design + the validated build artifact (spike done):
- docs/.../2026-06-17-kindle-epub-conversion-design.md (Codex-reviewed;
  9 review fixes folded in: failure contract, strong cache key + negative
  cache, wazero command-module specifics, FS-sandbox tightening,
  double-gated capability, serve headers, .wasm guardrails).
- tools/mobitool-wasm/{Dockerfile,README.md}: reproducible build of
  mobitool.wasm (wasi-sdk 25, libmobi 9062742, zlib 1.3.1->wasm), with a
  smoke-conversion gate. Build proven on native amd64.
- internal/ebookconvert/mobitool.wasm (+ .sha256): canonical artifact,
  built on amd64. go:embed target for the converter package (next).

Spike proven on amd64: -e EPUB path works with --with-libxml2=no (internal
xmlwriter); converts MOBI6/KF8/HUFF-CDIC/unicode -> well-formed EPUB;
verified end-to-end under wazero (WASI preopen + argv + _start). Build
gotcha: link libmobi against real (wasm) zlib, not --with-zlib=no, to avoid
miniz duplicate-symbol clash with mobitool's zip miniz. DRM gotcha:
mobitool prints "Document is encrypted" to stdout but exits 0 -> detect via
stdout + output validation, not exit code.

Not yet implemented: internal/ebookconvert Go package (wazero harness +
cache + singleflight), read-handler wiring, admin flag, client capability.
v1-scope proposal required before PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ebookconvert: converter core + cache (Codex-reviewed)

internal/ebookconvert: in-process MOBI/AZW/AZW3 -> EPUB via the embedded
mobitool.wasm on wazero. Converter compiles the module once and instantiates
per conversion (isolated). Cache adds on-disk, singleflighted, size-bounded,
negative-cached conversion keyed by file identity + module fingerprint.

18 tests pass (DRM-free->valid EPUB, DRM->ErrDRMProtected + no output,
oversize/corrupt/missing/timeout/cancel/after-close, 6/8-way concurrent,
EPUB structural validation incl. stored-mimetype + container rootfile,
cache miss/hit/key-change/singleflight/eviction/negative-cache).

Codex review fixes folded in:
- timeout/cancel classified before generic nonzero exit (WithCloseOnContextDone
  surfaces sys.ExitError special codes); no more bogus "exit <huge>".
- DRM detection scoped to known mobitool diagnostic LINES (Document is
  encrypted / DRM key not found / Invalid DRM pid / DRM expired / DRM support
  not included) -> no false-positive on book text; Print Replica -> clear fail.
- WithMemoryLimitPages cap; capped stdout/stderr writers; MaxOutputBytes.
- read-only fs.FS input mount + dedicated writable out dir; documented that
  FS isolation ultimately relies on running as a non-root user (memory-safety
  is the WASM boundary). validateEpub now requires STORED mimetype + verifies
  the container.xml OPF rootfile exists. Atomic moveFile. Closed-guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ebookconvert: wire Kindle->EPUB into the read handler + capability endpoint

Server now transparently serves Kindle-family ebooks as EPUB when the admin
flag ebook.kindle_conversion_enabled is on and the WASM converter initialized.

- handlers.EbookConversion (converter + per-request flag predicate) on the read
  handler; HandleReadFile -> h.serveEbook. Kindle + enabled -> cached EPUB with
  X-Silo-Ebook-Conversion: converted, epub MIME, ETag = exact conversion cache
  key, must-revalidate. Failure (DRM/corrupt/oversize/unservable) -> raw
  original + X-Silo-Ebook-Conversion: failed + no-store, so the client opens
  externally. Context cancel propagates (not a conversion verdict).
- GET /api/v1/ebooks/capability advertises {enabled, source_formats,
  served_format, header contract}; enabled only when flag on AND converter
  wired (double gate) so the Android client can decide whether to flip
  mobi/azw/azw3 to in-app.
- router: buildEbookConversion compiles the module once at startup (feature off
  if it fails), cache dir is a sibling of TranscodeDir, flag read per request.

Codex review fixes folded in: ETag derived from the exact SourceKey cache key
(id+size+mtime+oshash+module version), not a weaker hash; no-store on the raw
fallback; open/stat failure of a produced EPUB falls back to raw per the
contract instead of 500. 10 handler tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ebookconvert): harden conversion cache, HEAD path, and artifact verification

Addresses adversarial review + CodeRabbit findings on the Kindle->EPUB feature.

Correctness:
- Stop poisoning the negative cache on transient timeouts. Introduce
  ErrConversionTimedOut (distinct, non-wrapping ErrConversionFailed); classify
  the per-call timeout as transient and propagate a caller's cancel/deadline
  verbatim instead of reclassifying it as a conversion failure. remember() now
  only caches deterministic verdicts (DRM / failed), so a one-off timeout under
  load no longer wedges a convertible book onto raw-fallback for 6h.
- Detach the singleflight conversion from any single caller's context (DoChan +
  context.WithoutCancel), so one caller cancelling no longer aborts the shared
  work for the others; the cache is still populated for the next reader.
- enforceBudget never evicts the entry it is about to return, and skips other
  conversions' in-flight "converting-*" temp files.
- Cache hits refresh mtime so the mtime-ordered budget eviction is a real LRU,
  not FIFO.

Read path:
- HEAD is now cache-only via Cache.Lookup: a hit serves real converted headers,
  a negatively-cached source serves the failed contract, a miss advertises the
  converted representation cheaply without triggering a (minute-long, ~1 GiB)
  conversion. The GET still delivers the body + authoritative verdict.
- The admin flag is read through a short-TTL predicate so the read path and the
  capability endpoint no longer hit the DB per request.

Artifact / build:
- Add an in-code provenance test (embedded mobitool.wasm matches its recorded
  sha256) and a self-hosted CI job that runs the ebookconvert smoke conversions
  + provenance check, so the committed wasm can't silently rot.
- Pin + checksum-verify wasmtime in the build Dockerfile (drop curl|bash).

Docs: correct the design doc cache-key + setting-name descriptions, document the
HEAD/timeout/LRU semantics and resource limits, note DRM-marker brittleness, and
fix the README markdown table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: remove ebookconvert workflow

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-17 13:09:55 -04:00

285 lines
9.2 KiB
Go

package ebookconvert
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
// moduleVersion is a short fingerprint of the embedded wasm, mixed into every
// cache key so bumping mobitool.wasm transparently invalidates old conversions.
var moduleVersion = func() string {
sum := sha256.Sum256(mobitoolWasm)
return hex.EncodeToString(sum[:])[:16]
}()
// ModuleVersion returns the embedded converter's fingerprint. Callers can mix
// it into HTTP ETags so a converter bump invalidates client/proxy caches.
func ModuleVersion() string { return moduleVersion }
// SourceKey identifies a source file for caching. Built by the caller from the
// catalog/file metadata. Size+ModTimeNano are cheap and computed on every read;
// Checksum (the scanner's content hash, if available) hardens against a replace
// that preserves size+mtime — set it when you have it.
type SourceKey struct {
FileID int
Size int64
ModTimeNano int64
Checksum string // optional; scanner checksum
}
func (k SourceKey) hash() string {
h := sha256.New()
fmt.Fprintf(h, "v1|mod=%s|id=%d|sz=%d|mt=%d|ck=%s",
moduleVersion, k.FileID, k.Size, k.ModTimeNano, k.Checksum)
return hex.EncodeToString(h.Sum(nil))
}
// CacheKey returns the exact identity used to cache this source's conversion
// (source identity + converter module version). Use it to derive an HTTP ETag
// so the validator and the cache entry can never disagree.
func (k SourceKey) CacheKey() string { return k.hash() }
// CacheOptions configures a Cache.
type CacheOptions struct {
// Dir is the on-disk cache root for converted EPUBs (required).
Dir string
// MaxBytes bounds total cached EPUB size; oldest are evicted past it.
// Zero uses DefaultCacheMaxBytes.
MaxBytes int64
// NegativeTTL is how long a DRM/failed result is remembered to avoid
// reconverting a known-bad source. Zero uses DefaultNegativeTTL.
NegativeTTL time.Duration
}
const (
DefaultCacheMaxBytes = 2 << 30 // 2 GiB
DefaultNegativeTTL = 6 * time.Hour
)
// Cache wraps a Converter with an on-disk, size-bounded, singleflighted cache
// and an in-memory negative cache for DRM/failed sources.
type Cache struct {
conv *Converter
opts CacheOptions
group singleflight.Group
mu sync.Mutex
neg map[string]negEntry // key hash -> negative result
}
type negEntry struct {
err error
expiry time.Time
}
// NewCache creates the cache dir and returns a ready Cache.
func NewCache(conv *Converter, opts CacheOptions) (*Cache, error) {
if conv == nil {
return nil, ErrUnavailable
}
if opts.Dir == "" {
return nil, errors.New("ebookconvert: cache Dir is required")
}
if opts.MaxBytes <= 0 {
opts.MaxBytes = DefaultCacheMaxBytes
}
if opts.NegativeTTL <= 0 {
opts.NegativeTTL = DefaultNegativeTTL
}
if err := os.MkdirAll(opts.Dir, 0o755); err != nil {
return nil, fmt.Errorf("ebookconvert: create cache dir: %w", err)
}
return &Cache{conv: conv, opts: opts, neg: make(map[string]negEntry)}, nil
}
// GetOrConvert returns the path to a cached EPUB for srcPath/key, converting on
// miss. Concurrent calls for the same key collapse to a single conversion.
// Returns ErrDRMProtected / ErrConversionFailed (also served from the negative
// cache), ErrConversionTimedOut, ErrSourceTooLarge, or the caller's context
// error if it cancels while waiting.
func (c *Cache) GetOrConvert(ctx context.Context, srcPath string, key SourceKey) (string, error) {
kh := key.hash()
dst := filepath.Join(c.opts.Dir, kh+".epub")
// Fast path: already converted. Refresh mtime so the mtime-ordered budget
// eviction behaves as a real LRU — recently-read entries sort newest and
// survive eviction.
if fi, err := os.Stat(dst); err == nil && fi.Size() > 0 {
now := time.Now()
_ = os.Chtimes(dst, now, now)
return dst, nil
}
// Negative cache: known DRM/deterministically-bad source.
if err := c.negativeLookup(kh); err != nil {
return "", err
}
// Singleflight via DoChan with a *detached* context: coalesced callers share
// one conversion, so a single caller canceling its request must not abort
// the shared work for the others (golang.org/x/sync/singleflight.Do has no
// per-caller context, and binding it to the first caller would propagate that
// caller's cancellation to all). The conversion stays bounded by the
// Converter's own timeout + concurrency semaphore; the caller's context only
// governs how long *this* caller waits (the select below). A caller that
// gives up still leaves the conversion running to populate the cache.
ch := c.group.DoChan(kh, func() (interface{}, error) {
// Re-check after acquiring the singleflight slot (another caller may
// have just produced it).
if fi, statErr := os.Stat(dst); statErr == nil && fi.Size() > 0 {
return dst, nil
}
// Convert to a temp file in the cache dir, then atomically rename in.
tmp, tmpErr := os.CreateTemp(c.opts.Dir, "converting-*.epub")
if tmpErr != nil {
return "", fmt.Errorf("%w: cache temp: %v", ErrConversionFailed, tmpErr)
}
tmpPath := tmp.Name()
_ = tmp.Close()
_ = os.Remove(tmpPath) // Convert recreates it
if convErr := c.conv.Convert(context.WithoutCancel(ctx), srcPath, tmpPath); convErr != nil {
_ = os.Remove(tmpPath)
c.remember(kh, convErr)
return "", convErr
}
if renErr := os.Rename(tmpPath, dst); renErr != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("%w: cache rename: %v", ErrConversionFailed, renErr)
}
c.enforceBudget(dst)
return dst, nil
})
select {
case res := <-ch:
if res.Err != nil {
return "", res.Err
}
return res.Val.(string), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// Lookup returns a cached conversion result WITHOUT converting on miss. It lets
// cheap callers (e.g. HEAD requests) avoid kicking off a full conversion:
// - ok == true, err == nil: a ready EPUB exists at path.
// - err != nil: the source is negatively cached (DRM/failed); path is empty.
// - ok == false, err == nil: cache miss — caller must decide whether to convert.
func (c *Cache) Lookup(key SourceKey) (path string, err error, ok bool) {
kh := key.hash()
dst := filepath.Join(c.opts.Dir, kh+".epub")
if fi, statErr := os.Stat(dst); statErr == nil && fi.Size() > 0 {
now := time.Now()
_ = os.Chtimes(dst, now, now) // a HEAD hit counts as access for LRU
return dst, nil, true
}
if negErr := c.negativeLookup(kh); negErr != nil {
return "", negErr, false
}
return "", nil, false
}
func (c *Cache) negativeLookup(kh string) error {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.neg[kh]
if !ok {
return nil
}
if time.Now().After(e.expiry) {
delete(c.neg, kh)
return nil
}
return e.err
}
// remember negatively caches only *deterministic* bad outcomes — DRM
// (ErrDRMProtected) and conversion failures (ErrConversionFailed: corrupt
// source, unconvertible layout, oversize output) — so repeat reads of a
// known-bad file skip reconversion for NegativeTTL. Transient outcomes
// (ErrConversionTimedOut, context cancellation/deadline, ErrSourceTooLarge) are
// deliberately NOT cached so the next read can retry — a one-off timeout under
// load must not wedge a convertible book onto the raw-fallback path.
func (c *Cache) remember(kh string, err error) {
if !errors.Is(err, ErrDRMProtected) && !errors.Is(err, ErrConversionFailed) {
return
}
c.mu.Lock()
c.neg[kh] = negEntry{err: err, expiry: time.Now().Add(c.opts.NegativeTTL)}
c.mu.Unlock()
}
// enforceBudget evicts the oldest (by mtime) finished cache entries until the
// total is within MaxBytes. It never evicts keep (the entry the caller is about
// to return) and ignores in-flight "converting-*" temp files, since deleting
// either would hand back a vanished path or corrupt a concurrent conversion.
// Best-effort; logs nothing (caller has no logger here).
func (c *Cache) enforceBudget(keep string) {
entries, err := os.ReadDir(c.opts.Dir)
if err != nil {
return
}
type item struct {
path string
size int64
mod time.Time
}
var items []item
var total int64
for _, e := range entries {
name := e.Name()
if e.IsDir() || filepath.Ext(name) != ".epub" || strings.HasPrefix(name, "converting-") {
continue // skip dirs, non-epubs, and other conversions' in-flight temps
}
fi, statErr := e.Info()
if statErr != nil {
continue
}
items = append(items, item{filepath.Join(c.opts.Dir, name), fi.Size(), fi.ModTime()})
total += fi.Size()
}
if total <= c.opts.MaxBytes {
return
}
sort.Slice(items, func(i, j int) bool { return items[i].mod.Before(items[j].mod) })
for _, it := range items {
if total <= c.opts.MaxBytes {
break
}
if it.path == keep {
continue // never evict the entry we're about to return
}
if os.Remove(it.path) == nil {
total -= it.size
}
}
}
// SourceKeyFromStat builds a SourceKey from a file path + fileID, reading
// size/mtime via stat. Checksum is left empty (pass one explicitly if known).
func SourceKeyFromStat(fileID int, path string) (SourceKey, error) {
fi, err := os.Stat(path)
if err != nil {
return SourceKey{}, err
}
return SourceKey{FileID: fileID, Size: fi.Size(), ModTimeNano: fi.ModTime().UnixNano()}, nil
}
// for tests / diagnostics
func (k SourceKey) String() string { return strconv.Itoa(k.FileID) + ":" + k.hash()[:8] }