package httpapi import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" "textmachine/platform/internal/exports" "textmachine/platform/internal/pgstore" ) // exports.go: the export door on the wire (canon §createExport/§getExport) — the last two of the // contract's twenty operations, and the third creating call this surface carries. // // Three routes and not two. The canon names the two operations and then describes a THIRD address // in words — `Export.url`, «minted for THIS response and for the authenticated owner, never // indexed, on the same origin as this API… A browser NAVIGATES to it: it is a download, not a // call.» It has no `operationId` because no generated client calls it; it is still a route this // service serves, so it lives in `contractSurface` with the other fifteen and takes the same // session guard. // // ⚠ THE LINK IS AUTHENTICATED, NOT SIGNED, and that is a decision with an argument rather than a // shortcut. A capability URL would need a deployment secret nobody rotates, a second authorization // mechanism beside the session, and it would still be WEAKER than what is here: a link that leaks // is useless to anyone but its owner, because the ownership check on every request is the same one // every other `/v0` route runs. What the canon asks for — owner-bound, un-indexed, same-origin, // expiring — all holds; what it does not get is a token a third party could be handed on purpose. // A browser navigating top-level sends the `__Host` cookie (SameSite=Lax permits exactly that), and // a desktop or CLI client sends its bearer, so both of the canon's clients can follow it. // Exports is the door's service, as the HTTP layer needs to see it. type Exports interface { Create(ctx context.Context, userID, bookID, format string) (pgstore.Export, error) Read(ctx context.Context, userID, bookID, exportID string) (pgstore.Export, error) Open(ctx context.Context, userID, bookID, exportID string) (*os.File, pgstore.Export, error) Knows(format string) bool } // exportPollRetry is what a client is told to wait between polls of a build. Generous next to the // idempotency key's two seconds, because the work behind it is an engine re-chunk of a whole book — // a poll every second would be CPU spent on answering "not yet". const exportPollRetry = 5 * time.Second type wireExportRequest struct { Format *string `json:"format"` } // wireExport is `Export`, field for field. Every member is required by the canon, so every one is // written — `null` where the canon says null, never omitted. type wireExport struct { ID string `json:"id"` Revision int64 `json:"revision"` State string `json:"state"` Format string `json:"format"` ExpiresAt *time.Time `json:"expires_at"` FailureCode *string `json:"failure_code"` URL *string `json:"url"` } // projectExport turns a stored export into the wire shape. // // ⚠ What does NOT cross: the artifact's path, its size, and whether the file came out whole. The // first is server topology; the last two are the OPERATOR's, because the honesty a reader is owed // about an incomplete book is written INSIDE the file — on its first page and at every hole // (D39.175 п.1) — and a second, quieter copy of it on the wire would be one for a client to render // instead of the one the reader can actually act on. func projectExport(e pgstore.Export) wireExport { out := wireExport{ID: e.ID, Revision: e.Revision, State: string(e.State), Format: e.Format, ExpiresAt: e.ExpiresAt} if e.FailureCode != "" { code := e.FailureCode out.FailureCode = &code } if e.State == pgstore.ExportReady { u := exportContentPath(e.BookID, e.ID) out.URL = &u } return out } // exportStatusPath is the address `Location` names on the 202 and the one a client polls. func exportStatusPath(bookID, exportID string) string { return APIPrefix + "/books/" + url.PathEscape(bookID) + "/exports/" + url.PathEscape(exportID) } // exportContentPath is where the file itself is served from. A path and not an absolute URL: the // canon says «on the same origin as this API», and a service that wrote its own origin into a body // would be a service that has to be told what it is called. func exportContentPath(bookID, exportID string) string { return exportStatusPath(bookID, exportID) + "/content" } // createExport accepts one export request (canon §createExport). func (h *v0) createExport(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } // Read whole rather than streamed: the bytes are also this request's idempotency fingerprint. // A body that does not arrive is `400`, like every other malformed JSON request on this surface. body, err := io.ReadAll(r.Body) if err != nil { h.log.InfoContext(r.Context(), "the body of an export request did not arrive", "err", err) Invalid(w, r) return } var req wireExportRequest if err := json.Unmarshal(body, &req); err != nil { Invalid(w, r) return } if req.Format == nil || *req.Format == "" { Invalid(w, r, Item{Pointer: "/format", Code: ItemMissing}) return } // The canon's own answer for a format outside the published set is `400`, and it is decided // HERE rather than in the service: the legal set is what `GET /capabilities` published, so a // request naming something else is malformed against a document the client already holds — not // a state that could change if it waited. if !h.exports.Knows(*req.Format) { Invalid(w, r, Item{Pointer: "/format", Code: ItemUnknown}) return } // Fingerprinted over the BODY: this request is small and entirely declared, so "the same // request" is decidable exactly — the same shape the run admission uses. key, ok := h.beginIdempotent(w, r, user, body, fingerprintIsTheRequest) if !ok { return } defer key.release(r.Context()) // every exit settles the key; `complete` below cancels it e, err := h.exports.Create(r.Context(), user, r.PathValue("bookId"), *req.Format) if err != nil { h.failExport(w, r, err) return } // The `202` names the status resource in `Location` and the SAME address is what a repeat under // the same key replays — that is the canon's «Repeating with the same `Idempotency-Key` returns // the original `202` and `Location` rather than building a second copy». loc := exportStatusPath(e.BookID, e.ID) w.Header().Set("Location", loc) key.complete(r.Context(), http.StatusAccepted, loc, h.writeJSON(w, r, http.StatusAccepted, projectExport(e)), nil) } // getExport is the poll (canon §getExport). Every poll ends: `pending` → `ready` → `expired`, or // `pending` → `failed`. func (h *v0) getExport(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } e, err := h.exports.Read(r.Context(), user, r.PathValue("bookId"), r.PathValue("exportId")) if err != nil { h.failExport(w, r, err) return } if e.State == pgstore.ExportPending { // Declared on the `200` in the canon precisely because RFC 9110 does not define the header // for one, and sent only while the build runs. w.Header().Set("Retry-After", strconv.Itoa(int(exportPollRetry.Seconds()))) } h.writeJSON(w, r, http.StatusOK, projectExport(e)) } // downloadExport serves the artifact itself — the address `Export.url` names. // // It does NOT go through writeJSON, and so does not go through the compression layer: the body is // an EPUB (a zip) or a whole book's text, and buffering a book to compress an already-compressed // container is work with a negative return. `http.ServeContent` is what answers, so a Range request // — the one thing a download of tens of megabytes actually needs — is handled by the standard // library rather than by a hand-rolled half of it. func (h *v0) downloadExport(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } f, e, err := h.exports.Open(r.Context(), user, r.PathValue("bookId"), r.PathValue("exportId")) if err != nil { h.failExport(w, r, err) return } defer f.Close() fi, err := f.Stat() if err != nil { h.log.ErrorContext(r.Context(), "a ready export could not be measured", "export", e.ID, "err", err) Fail(w, r, CodeInternalError) return } // `application/octet-stream` and not a per-format table: the formats are a DEPLOYMENT's // declaration (config.ExportConfig.Formats), so a table here would be a second list to keep in // step with the engine, and it would be wrong for exactly the format nobody remembered to add. // The name carries the extension, which is what a browser and an operating system actually // dispatch on. w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", exportDisposition(h.exportName(r.Context(), user, e), e.Format)) // A download is a file and not a page: nothing here may be sniffed into markup, and the // no-store/noindex headers the middleware already sets are the rest of PT-34. w.Header().Set("X-Content-Type-Options", "nosniff") http.ServeContent(w, r, "", fi.ModTime(), f) } // exportName is the file name a reader sees. The BOOK's title, because that is what a library is // sorted by; the export id only if the title is unusable. func (h *v0) exportName(ctx context.Context, user string, e pgstore.Export) string { if h.lib == nil { return e.ID } book, _, err := h.lib.GetBook(ctx, user, e.BookID) if err != nil || strings.TrimSpace(book.Title) == "" { return e.ID } return book.Title } // exportDisposition builds `Content-Disposition` for a name that may be in any script. // // BOTH forms, and the order is the one RFC 6266 §4.3 prescribes: a plain `filename` an old client // can read, then `filename*` in the RFC 8187 encoding, which every current one prefers. Without the // second a Chinese or Russian title arrives as mojibake or as the opaque id; without the first the // header is not what the RFC calls interoperable. // // ⚠ NOT `mime.BEncoding`. That is RFC 2047, which belongs in mail headers and is not what an // extended HTTP parameter is: `filename*=?utf-8?b?…?=` does not parse as a media parameter at all, // so a client that reads `filename*` first gets nothing. func exportDisposition(name, format string) string { safe := asciiFallback(name) if safe == "" { safe = "book" } return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", safe+"."+format, rfc8187(name+"."+format)) } // rfc8187 percent-encodes everything outside RFC 8187's `attr-char`. Written out rather than reached // for in the standard library because the escapers there are for URLs: each of them leaves at least // one character an extended parameter may not carry unescaped. func rfc8187(s string) string { const hex = "0123456789ABCDEF" var b strings.Builder for _, c := range []byte(s) { switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', strings.IndexByte("!#$&+-.^_`|~", c) >= 0: b.WriteByte(c) default: b.WriteByte('%') b.WriteByte(hex[c>>4]) b.WriteByte(hex[c&0x0f]) } } return b.String() } // asciiFallback reduces a title to something safe to put between quotes in a header: printable // ASCII only, no quote, no backslash, no control byte, and never empty-by-accident. func asciiFallback(name string) string { var b strings.Builder for _, r := range name { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': b.WriteRune(r) case r == ' ' || r == '-' || r == '_' || r == '.': b.WriteRune('-') } } return strings.Trim(b.String(), "-") } // failExport maps this door's own refusals; everything else falls through to the shared mapper. // // ⚠ THERE IS NO BOOK-STATE REFUSAL HERE, and its absence is ratified rather than forgotten (04.09). // The canon says it in words — «Nothing about a book's state conflicts with exporting it» — so a // book that has not been cut is ACCEPTED like any other and answered by the export's own outcome. // `book_not_ready` on this door was this code's invention. func (h *v0) failExport(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, pgstore.ErrNoExport): // One answer for "no such export" and "somebody else's": telling them apart would answer // "whose book is this" to a caller who is not its owner (API1 BOLA). Fail(w, r, CodeNotFound) case errors.Is(err, exports.ErrUnknownFormat): // Reachable only when the deployment's declaration changed between the capability read and // this call. Still `400`, which is the canon's word for the format being outside the set. Invalid(w, r, Item{Pointer: "/format", Code: ItemUnknown}) case errors.Is(err, exports.ErrNotReady): // A download of an export that has not been built. There is no artifact at this address, so // `404` — the STATE of the export lives on the poll, which is the resource that carries it, // and answering a state here would be a second place to read it from. Fail(w, r, CodeNotFound) case errors.Is(err, exports.ErrExpired): // It existed and the link has lapsed. `410` and not `404`, for the same reason a re-cut // chapter is gone rather than absent: the remedy is to build again, not to check the address. Fail(w, r, CodeGone) default: h.fail(w, r, err) } }