textmachine/platform/internal/httpapi/exports.go

305 lines
13 KiB
Go

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)
}
}