305 lines
13 KiB
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)
|
|
}
|
|
}
|