1226 lines
60 KiB
Go
1226 lines
60 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"textmachine/platform/internal/auth"
|
||
"textmachine/platform/internal/books"
|
||
"textmachine/platform/internal/ingest"
|
||
"textmachine/platform/internal/pgstore"
|
||
"textmachine/platform/internal/runner"
|
||
"textmachine/platform/internal/runs"
|
||
)
|
||
|
||
// Library is the library surface as the handlers below need it, as an interface, so this package
|
||
// keeps knowing nothing about SQL.
|
||
//
|
||
// Every method but one is a read. The one write is RenameBook, and it lives here rather than behind
|
||
// a dependency of its own because it is the same object under the same mount: a deployment that can
|
||
// show a library can rename a book in it, and a second `mounts` predicate would only be able to
|
||
// disagree with the first.
|
||
type Library interface {
|
||
ListBooks(ctx context.Context, userID string, limit int, cursor string) (pgstore.Library, error)
|
||
GetBook(ctx context.Context, userID, bookID string) (pgstore.Book, *pgstore.Run, error)
|
||
RenameBook(ctx context.Context, userID, bookID, title string) (pgstore.Book, *pgstore.Run, error)
|
||
ReadUsage(ctx context.Context, userID string) (pgstore.Usage, error)
|
||
ListChapters(ctx context.Context, userID, bookID string, limit int, cursor string) (pgstore.ChapterPage, error)
|
||
ListUnits(ctx context.Context, userID, bookID, chapterID string, limit int, cursor string) (pgstore.UnitPage, error)
|
||
ListNotes(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.NotePage, error)
|
||
ListBank(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.BankPage, error)
|
||
ReadStream(ctx context.Context, userID, bookID string) (pgstore.StreamState, error)
|
||
ReadFrames(ctx context.Context, bookID string, after int64, limit int) ([]pgstore.Frame, error)
|
||
}
|
||
|
||
// Runs is the write side: the run lifecycle, as the HTTP layer needs to see it.
|
||
type Runs interface {
|
||
Order(ctx context.Context, userID, bookID string) (runs.Options, error)
|
||
Start(ctx context.Context, in runs.StartRequest) (pgstore.Run, error)
|
||
Stop(ctx context.Context, userID, runID string) (pgstore.Run, error)
|
||
Resume(ctx context.Context, userID, runID string) (pgstore.Run, error)
|
||
}
|
||
|
||
// Intake is the book upload, as the HTTP layer needs to see it. The service takes a READER: the
|
||
// file is streamed to its place on disk and is never held in this process's memory.
|
||
type Intake interface {
|
||
Accept(ctx context.Context, in books.Intake) (pgstore.Book, error)
|
||
}
|
||
|
||
// contractSurface is EVERY route of the /v0 surface, in one list.
|
||
//
|
||
// A list rather than a dozen mux.Handle calls so that "every contract route" is something code can
|
||
// enumerate: the session guard, the body limit and the tests all walk this, and a route added
|
||
// without them is not expressible.
|
||
var contractSurface = []struct {
|
||
method, path string
|
||
// mounts says which dependency this route needs. An instance without it leaves the path a guarded
|
||
// 404 rather than a handler that answers 500 on every call: an instance with no engine binary is a
|
||
// read replica, not a broken one.
|
||
mounts func(Deps) bool
|
||
handler func(*v0) http.HandlerFunc
|
||
// body overrides the default request limit. Only the upload has one (PD-35/PD-72): a book is tens
|
||
// of megabytes and every other route carries a few kilobytes of JSON.
|
||
body func(Deps) int64
|
||
}{
|
||
// `/capabilities` is answered by every instance, read model or not: it is how a client learns which
|
||
// contract version it is talking to, and an instance that hid it would be one a client cannot
|
||
// decide to refuse.
|
||
{method: "GET", path: "/capabilities", handler: func(h *v0) http.HandlerFunc { return h.capabilities }},
|
||
|
||
{method: "GET", path: "/books", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBooks }},
|
||
{method: "GET", path: "/books/{bookId}", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.getBook }},
|
||
// The rename (canon §updateBook, declared since the surface's first version and unmounted until
|
||
// now — `PATCH` appeared in this file zero times, so the only name a reader ever saw was the one
|
||
// the intake derived from their file name: unified backlog row 274).
|
||
{method: "PATCH", path: "/books/{bookId}", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.updateBook }},
|
||
{method: "GET", path: "/books/{bookId}/chapters", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listChapters }},
|
||
{method: "GET", path: "/books/{bookId}/chapters/{chapterId}/units", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listUnits }},
|
||
{method: "GET", path: "/books/{bookId}/notes", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listNotes }},
|
||
{method: "GET", path: "/books/{bookId}/bank", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBank }},
|
||
// The correction door (canon 0.5.0). Unmounted it answers the guarded 404 the canon promises for
|
||
// `Capabilities.bank_corrections_enabled: false` — the same fact decides both. Its body cap IS
|
||
// the canon's 1 MiB document ceiling, so it rides the default deliberately.
|
||
{method: "POST", path: "/books/{bookId}/bank/corrections", mounts: hasBank, handler: func(h *v0) http.HandlerFunc { return h.bankCorrections }},
|
||
// The stream is registered outside the compression layer — compressing a stream buffers it, which
|
||
// is the one thing the contract forbids of this route — and that holds structurally: it does not
|
||
// go through writeJSON, which is where compression lives.
|
||
{method: "GET", path: "/books/{bookId}/events", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.streamEvents }},
|
||
{method: "GET", path: "/usage", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.usage }},
|
||
|
||
{method: "GET", path: "/books/{bookId}/run-options", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.runOptions }},
|
||
{method: "POST", path: "/books/{bookId}/runs", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.startRun }},
|
||
{method: "POST", path: "/runs/{runId}/stop", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.stopRun }},
|
||
{method: "POST", path: "/runs/{runId}/resume", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.resumeRun }},
|
||
|
||
{method: "POST", path: "/books", mounts: hasIntake, handler: func(h *v0) http.HandlerFunc { return h.createBook },
|
||
body: func(d Deps) int64 { return d.Upload.MaxBytes }},
|
||
|
||
// The export door (canon §createExport/§getExport). Unmounted it answers the guarded 404, and
|
||
// `Capabilities.export_formats` is empty by the SAME fact — a deployment that declared a format
|
||
// it cannot build would be one whose first click discovers an empty promise.
|
||
{method: "POST", path: "/books/{bookId}/exports", mounts: hasExports,
|
||
handler: func(h *v0) http.HandlerFunc { return h.createExport }},
|
||
{method: "GET", path: "/books/{bookId}/exports/{exportId}", mounts: hasExports,
|
||
handler: func(h *v0) http.HandlerFunc { return h.getExport }},
|
||
// The artifact itself — the address `Export.url` names. It carries no `operationId` because no
|
||
// generated client calls it: a browser NAVIGATES to it. It is listed HERE with the rest because
|
||
// it is a route of this surface, and everything this table gives a route — the session guard,
|
||
// the body cap, the "no session is 401" pin — is what an unlisted one would silently lack.
|
||
{method: "GET", path: "/books/{bookId}/exports/{exportId}/content", mounts: hasExports,
|
||
handler: func(h *v0) http.HandlerFunc { return h.downloadExport }},
|
||
}
|
||
|
||
func hasLibrary(d Deps) bool { return d.Library != nil }
|
||
func hasRuns(d Deps) bool { return d.Runs != nil }
|
||
func hasIntake(d Deps) bool { return d.Intake != nil }
|
||
func hasBank(d Deps) bool { return d.Bank != nil }
|
||
func hasExports(d Deps) bool { return d.Exports != nil }
|
||
|
||
// contractRoutes registers the /v0 surface.
|
||
//
|
||
// Every path is written with the version prefix in the PATTERN and registered on the same mux as the
|
||
// ops endpoints: a nested mux behind StripPrefix hands the inner handler a copy of the request and
|
||
// the pattern never comes back, which would put raw paths carrying book ids into the access log.
|
||
func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) http.Handler) {
|
||
h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, bank: d.Bank, exports: d.Exports,
|
||
upload: d.Upload, caps: d.Capabilities, keys: d.Keys, log: d.Log}
|
||
for _, r := range contractSurface {
|
||
if r.mounts != nil && !r.mounts(d) {
|
||
continue
|
||
}
|
||
limit := int64(DefaultMaxBody)
|
||
if r.body != nil {
|
||
limit = r.body(d)
|
||
}
|
||
mux.Handle(r.method+" "+APIPrefix+r.path, guard(limit, r.handler(h)))
|
||
}
|
||
}
|
||
|
||
type v0 struct {
|
||
lib Library
|
||
runs Runs
|
||
intake Intake
|
||
bank Bank
|
||
exports Exports
|
||
upload UploadLimits
|
||
caps Capabilities
|
||
keys IdempotencyKeys
|
||
log *slog.Logger
|
||
}
|
||
|
||
// The wire shapes below are the contract's, field for field. They are written out
|
||
// rather than generated because a generated struct would still need the projection written by hand,
|
||
// and a second copy of the field names is what makes a divergence invisible.
|
||
//
|
||
// Every one of them is an ALLOWLIST: a field not named here never reaches a client, and neither does
|
||
// a word from inside the translation machinery — which is why the projections below translate
|
||
// vocabularies instead of forwarding them.
|
||
//
|
||
// As of 0.6.0 no member here is ahead of the announced canon: `Run.stop_requested` landed in
|
||
// 0.4.0's Run (0.5.0 keeps it required), and the through-run bar with its `stage` member is what
|
||
// 0.6.0's Progress declares.
|
||
|
||
// `Progress` is the canon's through-run bar (0.6.0, D39.160): one monotonic fraction across both
|
||
// waves, and the `stage` caption derived by the PLATFORM from the same counters as the bar —
|
||
// never a forwarded engine string. `stage` is an OPEN vocabulary by the canon's own second
|
||
// exception: a client renders an unknown value neutrally, and new values do not move the version.
|
||
type wireProgress struct {
|
||
Done int `json:"done"`
|
||
Total int `json:"total"`
|
||
Stage string `json:"stage"`
|
||
ETASeconds *int `json:"eta_seconds"`
|
||
}
|
||
|
||
type wireBook struct {
|
||
ID string `json:"id"`
|
||
Revision int64 `json:"revision"`
|
||
Title string `json:"title"`
|
||
SourceLang string `json:"source_lang"`
|
||
TargetLang string `json:"target_lang"`
|
||
Status string `json:"status"`
|
||
RejectReason *string `json:"reject_reason"`
|
||
StructureVersion int `json:"structure_version"`
|
||
ShapeEpoch int `json:"shape_epoch"`
|
||
ChapterCount int `json:"chapter_count"`
|
||
ChaptersDone int `json:"chapters_done"`
|
||
CharacterCount *int64 `json:"character_count"`
|
||
// CharacterCountExact says WHICH number `character_count` is, and it exists because for two of
|
||
// the three source shapes this platform accepts the old one was not a character count at all: an
|
||
// EPUB is a ZIP archive and the intake counted the non-continuation bytes of the COMPRESSED
|
||
// stream, which is a property of the container and not of the same order of magnitude (unified
|
||
// backlog row 282). The engine's manifest now carries the real figure — runes of the ingested
|
||
// text, spaces included — and it is preferred whenever it exists.
|
||
//
|
||
// ⚠ A FLAG BESIDE THE NUMBER, and deliberately NOT a changed meaning for `null`: `null` already
|
||
// says «the book is still arriving», and re-using it for «we do not trust the number» would
|
||
// destroy a meaning the contract already promises. Form ratified by the orchestrator, act
|
||
// D39.201 §5(б), on two lawful shapes the zone put up.
|
||
CharacterCountExact bool `json:"character_count_exact"`
|
||
// Structure is where the chapter boundaries came from, in the ENGINE's own word, passed through
|
||
// untouched. A client needs it to know what a chapter NUMBER is worth before it offers an order
|
||
// phrased in one — see OrderOptions.chapter_orders.
|
||
//
|
||
// ⚠ THE VOCABULARY IS OPEN AND GROWS ADDITIVELY, so this comment does not list it: an earlier
|
||
// edition named three words, the engine shipped a fourth (`delimited`, 06.09), and the list went
|
||
// stale in place while reading like a promise. The canon holds the words; this holds the shape.
|
||
//
|
||
// ⛔ NULL IS NOT A WORD OF THAT VOCABULARY AND NOT `none`. `none` is a book the engine LOOKED at
|
||
// and found one chapter in — a statement about the book. Null is the absence of any statement:
|
||
// no manifest has been read. ⚠ That is NOT the same as «still arriving»: a book can be fully here
|
||
// and still answer null, because the cut has not been run. Pinned by
|
||
// TestABooksCutProvenanceIsTheEnginesOwnWordOrAnExplicitNull, whose book is `not_started`.
|
||
Structure *string `json:"structure"`
|
||
AddedAt time.Time `json:"added_at"`
|
||
NoteCount int `json:"note_count"`
|
||
}
|
||
|
||
type wireBookPage struct {
|
||
Revision int64 `json:"revision"`
|
||
NextCursor *string `json:"next_cursor"`
|
||
Books []wireBook `json:"books"`
|
||
}
|
||
|
||
type wireRun struct {
|
||
ID string `json:"id"`
|
||
BookID string `json:"book_id"`
|
||
Revision int64 `json:"revision"`
|
||
Status string `json:"status"`
|
||
StopForSigning bool `json:"stop_for_signing"`
|
||
StopRequested bool `json:"stop_requested"`
|
||
// OrderedChapters, OrderedUnits and DeliveredChapters are what the run bought and what it has
|
||
// handed over. `ordered_chapters` is what `ceiling_chapters` was called until 0.11.0, renamed
|
||
// because the word «ceiling» belonged to money and this number never was money — it is how much
|
||
// BOOK was sold.
|
||
//
|
||
// ⛔ EXACTLY ONE OF THE FIRST TWO IS SET, and which one says what unit this run is measured in —
|
||
// its bar, its delivery, all of it. A run sold in chapters carries `ordered_chapters`; one sold in
|
||
// CHARACTERS carries `ordered_units` and a null here, because the chapter figure such a run used
|
||
// to publish was the SPAN it reached into and overstated what was bought by up to a whole chapter.
|
||
OrderedChapters *int `json:"ordered_chapters"`
|
||
// OrderedUnits is how much a run sold in CHARACTERS bought, in the engine's own output units, and
|
||
// null for a run sold in chapters. ⚠ It is not the number of characters the buyer typed: that
|
||
// figure is resolved into units when the order is placed and is not stored.
|
||
OrderedUnits *int `json:"ordered_units"`
|
||
// DeliveredChapters is null for a run whose order was phrased in CHARACTERS. Such a run is
|
||
// measured in units end to end, and `0` here would read as «nothing happened» while work is being
|
||
// done and paid for; its progress is the `progress` pair, counted in the unit it was sold in.
|
||
//
|
||
// ⚠ NULL IS THE DISCRIMINATOR THE WIRE ACTUALLY HAS, and it is the only one: `ordered_chapters` is
|
||
// a positive span for a character order too, so it separates nothing. Null here means «read the
|
||
// bar as units»; `0` with `ordered_chapters: 0` means a re-pass.
|
||
DeliveredChapters *int `json:"delivered_chapters"`
|
||
// TermConsistencyFunded is the order form's promise, kept after the click: whether THIS run's
|
||
// reservation has room for the book-wide pass that keeps a book's terms consistent. The form
|
||
// answers the same question before the click, and the balance can move between the two.
|
||
TermConsistencyFunded bool `json:"term_consistency_funded"`
|
||
Progress wireProgress `json:"progress"`
|
||
PausedReason *string `json:"paused_reason"`
|
||
FailureReason *string `json:"failure_reason"`
|
||
StartedAt time.Time `json:"started_at"`
|
||
FinishedAt *time.Time `json:"finished_at"`
|
||
}
|
||
|
||
type wireBookDetail struct {
|
||
Revision int64 `json:"revision"`
|
||
Book wireBook `json:"book"`
|
||
Run *wireRun `json:"run"`
|
||
}
|
||
|
||
// wireEstimate is the PAIR a buyer is shown, and it is a pair because a projection has spread: «we
|
||
// expect this to cost about X, we will reserve up to Y, and you are billed by fact» (D39.196 §1,
|
||
// unified backlog row 276). A single number would become «you said 1.14 and took 1.31».
|
||
type wireEstimate struct {
|
||
ExpectedMicroUSD int64 `json:"expected_micro_usd"`
|
||
HoldMicroUSD int64 `json:"hold_micro_usd"`
|
||
}
|
||
|
||
// wireMoneyLimit is the money slider's own bounds. The minimum is the engine's INDIVISIBLE step, so
|
||
// the slider cannot express an order no run could move under; the maximum is the balance as it is.
|
||
//
|
||
// ⚠ IT IS NOT A RANGE, and a client must not read it as one: on an account that cannot afford a
|
||
// single reservation the minimum is ABOVE the maximum, which is not a defect but the honest shape of
|
||
// «nothing here is buyable». `order.verdict` says so in a word — `covers_none` — and that is what a
|
||
// client renders instead of a slider. The retired CeilingBounds carried the same caveat in the canon
|
||
// for the same reason.
|
||
type wireMoneyLimit struct {
|
||
MinMicroUSD int64 `json:"min_micro_usd"`
|
||
MaxMicroUSD int64 `json:"max_micro_usd"`
|
||
}
|
||
|
||
// wireOrderOptions is what the buyer is told BEFORE the click.
|
||
type wireOrderOptions struct {
|
||
ChaptersLeft int `json:"chapters_left"`
|
||
AffordableChapters int `json:"affordable_chapters"`
|
||
// Verdict answers the buyer's real question rather than handing them arithmetic to do:
|
||
// `covers_all`, `covers_part`, `covers_none`.
|
||
Verdict string `json:"verdict"`
|
||
// ChapterOrders says whether an order may be phrased in CHAPTERS at all. FALSE means this book
|
||
// takes an order in CHARACTERS, or the whole book, and `structure` says why — a client that
|
||
// offered a chapter slider anyway would be selling a number that does not name what the reader
|
||
// thinks it names.
|
||
ChapterOrders bool `json:"chapter_orders"`
|
||
// Structure is where the cut came from, verbatim, so the client can SAY it: null when no manifest
|
||
// has answered yet.
|
||
Structure *string `json:"structure"`
|
||
// SourceChars is how much text is left to translate, in runes — the unit of a character order.
|
||
SourceChars int64 `json:"source_chars"`
|
||
// TermConsistencyFunded says whether this order's reservation has room for the BOOK-WIDE pass
|
||
// that keeps a book's terms consistent, on top of the chapters themselves.
|
||
//
|
||
// ⚠ FALSE IS NOT AN ERROR AND IT MUST NOT BE SILENT — which is why it is a member of its own
|
||
// rather than folded into `verdict`. The pass DEGRADES rather than halts when the ceiling refuses
|
||
// it, so the book still arrives and only its terms wander; a buyer who is not told pays and
|
||
// discovers it in the text. Consistency of terms across a book is the owner's first stated
|
||
// priority (D39.198), and a refusal that takes the shape of silence is indistinguishable from
|
||
// normal work (D39.202 §3). Ratified 05.09 by the orchestrator.
|
||
TermConsistencyFunded bool `json:"term_consistency_funded"`
|
||
// Estimate is the quote for the DEFAULT order: everything that is left of this book.
|
||
Estimate wireEstimate `json:"estimate"`
|
||
}
|
||
|
||
type wireRunOptions struct {
|
||
Order wireOrderOptions `json:"order"`
|
||
// BalanceMicroUSD is the account's money AS IT IS — holds already debited from it.
|
||
BalanceMicroUSD int64 `json:"balance_micro_usd"`
|
||
Limit wireMoneyLimit `json:"limit"`
|
||
Blocked *Blocked `json:"blocked"`
|
||
}
|
||
|
||
// wireRunRequest is one purchase. THE THREE KINDS OF ORDER (D39.196 §1) plus the re-pass, and none
|
||
// of them is guessed: every member is a pointer, so «absent» is a fact and not a zero.
|
||
//
|
||
// With neither `chapters` nor `characters` the order is THE WHOLE BOOK, which is the ratified
|
||
// default — «не тронута ни одна ручка ⇒ заказ = вся книга». They are mutually exclusive, and so is
|
||
// `re_pass`, which buys no volume at all.
|
||
type wireRunRequest struct {
|
||
StopForSigning *bool `json:"stop_for_signing"`
|
||
Chapters *int `json:"chapters"`
|
||
Characters *int64 `json:"characters"`
|
||
RePass *bool `json:"re_pass"`
|
||
// RetiredCeilingChapters is `ceiling_chapters` — the member this minor RENAMED — and it is read
|
||
// for one reason: to REFUSE it.
|
||
//
|
||
// ⛔ THE «IGNORE UNKNOWN PROPERTIES» RULE DOES NOT COVER A RETIRED ONE, and the difference is a
|
||
// purchase. That rule exists so a client generated against a LATER minor is not broken by a field
|
||
// this build has never heard of; ignoring one from an EARLIER minor is the opposite trade. A
|
||
// 0.10.0 client sends `{"stop_for_signing":false,"ceiling_chapters":60}` meaning «sixty chapters»,
|
||
// and to a reader that ignores it that request says «no volume named» — which is now the ratified
|
||
// default for THE WHOLE BOOK. The client asks for sixty chapters and is charged a hold on the
|
||
// entire remainder, silently, with a 202 in its hand.
|
||
//
|
||
// So it is 400 with the member named. The minor is a breaking change by design (canon
|
||
// §Versioning: inside major 0 a differing minor carries them), and a client that has not moved
|
||
// deserves to be told which field moved rather than to be quietly re-quoted.
|
||
RetiredCeilingChapters *int `json:"ceiling_chapters"`
|
||
}
|
||
|
||
type wireUsage struct {
|
||
State string `json:"state"`
|
||
RemainingPercent int `json:"remaining_percent"`
|
||
HaltReason *string `json:"halt_reason"`
|
||
}
|
||
|
||
func (h *v0) listBooks(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
lib, err := h.lib.ListBooks(r.Context(), user, h.pageLimit(r), r.URL.Query().Get("cursor"))
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
out := wireBookPage{Revision: lib.Revision, Books: make([]wireBook, 0, len(lib.Books))}
|
||
if lib.NextCursor != "" {
|
||
out.NextCursor = &lib.NextCursor
|
||
}
|
||
for _, b := range lib.Books {
|
||
out.Books = append(out.Books, projectBook(b))
|
||
}
|
||
h.writeJSON(w, r, http.StatusOK, out)
|
||
}
|
||
|
||
func (h *v0) getBook(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
book, run, err := h.lib.GetBook(r.Context(), user, r.PathValue("bookId"))
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
// ONE counter per book (canon §Revision: every book-scoped read and every frame of that book
|
||
// carry the same number). The store answers a run's revision from its BOOK on every path that
|
||
// hands one out — the card here, and the stop and resume handles — so this layer projects what it
|
||
// was given rather than carrying a second copy of the rule.
|
||
out := wireBookDetail{Revision: book.Revision, Book: projectBook(book)}
|
||
if run != nil {
|
||
wr := projectRun(*run)
|
||
out.Run = &wr
|
||
}
|
||
h.writeJSON(w, r, http.StatusOK, out)
|
||
}
|
||
|
||
// updateBook is the canon's `updateBook`: a merge patch (RFC 7386) whose only member is `title`.
|
||
//
|
||
// ⚠ WHAT A RENAME REACHES, AND WHAT IT MUST NOT. The canon is explicit — «A title is DISPLAY and
|
||
// reaches nothing else — not the translation, whose configuration is written once at intake and
|
||
// never rewritten» — and the engine's own code says why that is the only affordable reading and not
|
||
// merely the tidy one: `title` is folded into the brief hash (backend/internal/config/book.go,
|
||
// BriefHash), which is the first field of the snapshot payload, so a title written into a book's
|
||
// `book.yaml` moves every request hash and makes the next run of an unfinished book refuse until the
|
||
// whole book is re-bought. What the user DOES see change is everything the platform owns: the card,
|
||
// the library listing, and the file name of every export built afterwards (exportName in
|
||
// exports.go) — which is the name that lands in their downloads folder.
|
||
//
|
||
// ⚠ ALREADY-BUILT EXPORTS NEED NOTHING DONE TO THEM — not rebuilt, not marked, not expired. The
|
||
// name a reader's browser saves is computed AT DOWNLOAD from the book's current title
|
||
// (exports.go, `exportName`, called inside `downloadExport`), so it is not baked into the artifact
|
||
// at all: an export built yesterday under the old name arrives today under the new one. Measured
|
||
// live, because "it should follow from the call site" is not a decision: an export was built under
|
||
// one title, the book renamed after it, and the same artifact downloaded again —
|
||
// `filename*=UTF-8”Имя ПОСЛЕ сборки.txt`. This answers the fork the pack posed (rebuild, mark, or
|
||
// leave) with the only one of the three that costs nothing and can be checked.
|
||
//
|
||
// ⚠ The title INSIDE the file (`dc:title`) is not this handler's to move, and that is a boundary
|
||
// rather than an omission: the engine reads it from `book.yaml` at build time, and the owner decided
|
||
// on 05.09 that a book's name reaches the reader's copy through a translation stage of its own with
|
||
// the owner's signature (unified backlog row 284, which names this row's own 274 as its kin). A
|
||
// second delivery path built here would be a second writer of one field.
|
||
//
|
||
// The request's Content-Type is not inspected, like every other body on this surface: a refusal this
|
||
// surface has never made is not one to invent for a route whose canon lists no `415`.
|
||
func (h *v0) updateBook(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
// Decoded into raw members rather than a struct, because a merge patch needs all THREE states a
|
||
// struct cannot give: absent (leave alone), null (RFC 7386 says "remove", which this surface
|
||
// narrows to a refusal), and a value. A `*string` collapses the first two.
|
||
var patch map[string]json.RawMessage
|
||
body, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
h.log.InfoContext(r.Context(), "the body of a book patch did not arrive", "err", err)
|
||
Invalid(w, r)
|
||
return
|
||
}
|
||
if err := json.Unmarshal(body, &patch); err != nil {
|
||
Invalid(w, r)
|
||
return
|
||
}
|
||
if patch == nil {
|
||
// A body of the bare literal `null` decodes into a NIL map without error, and every member
|
||
// then reads as absent — so it would have been answered 200 as an empty patch. The canon
|
||
// declares this body `required: true` with `schema: BookPatch` (`type: object`), and `null` is
|
||
// not an object: it is the one JSON value that means "no document", which is exactly what a
|
||
// required body may not be. Measured before it was fixed: `curl -X PATCH -d null` → `200`.
|
||
Invalid(w, r)
|
||
return
|
||
}
|
||
raw, present := patch["title"]
|
||
if !present {
|
||
// A patch that changes nothing is not an error and does not move the revision: «a member
|
||
// absent from the patch is left alone». The card is answered as a read of it would answer.
|
||
book, _, err := h.lib.GetBook(r.Context(), user, r.PathValue("bookId"))
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
h.writeJSON(w, r, http.StatusOK, projectBook(book))
|
||
return
|
||
}
|
||
title, item := patchedTitle(raw)
|
||
if item != nil {
|
||
Invalid(w, r, *item)
|
||
return
|
||
}
|
||
book, _, err := h.lib.RenameBook(r.Context(), user, r.PathValue("bookId"), title)
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
h.writeJSON(w, r, http.StatusOK, projectBook(book))
|
||
}
|
||
|
||
// patchedTitle judges the patch's `title` member and answers either the new name or what is wrong
|
||
// with it.
|
||
//
|
||
// The bounds are the canon's own (`BookPatch.title`: minLength 1, maxLength 200) and the maximum is
|
||
// the intake's constant rather than a second copy of the number — the two describe one column.
|
||
//
|
||
// ⚠ Refused rather than repaired, in both directions, and that is the difference between this and
|
||
// the intake: the intake TRUNCATES a name it derived from a file, because the user did not choose it
|
||
// and there is nothing to refuse. Here the user typed it, and silently storing something other than
|
||
// what they typed would answer a card they did not ask for.
|
||
func patchedTitle(raw json.RawMessage) (string, *Item) {
|
||
// `null` FIRST, because RFC 7386 gives it a meaning this surface deliberately does not grant:
|
||
// "remove this member". A book without a title is not a state this API has — the field is
|
||
// required on `Book`, and the intake fills it from the file name when nobody gives one — so
|
||
// there is nothing to grant, and quietly restoring the derived name would be a rename the user
|
||
// never asked for (canon §updateBook).
|
||
if string(raw) == "null" {
|
||
return "", &Item{Pointer: "/title", Code: ItemMalformed}
|
||
}
|
||
var title string
|
||
if err := json.Unmarshal(raw, &title); err != nil {
|
||
return "", &Item{Pointer: "/title", Code: ItemMalformed}
|
||
}
|
||
if strings.TrimSpace(title) == "" {
|
||
// A name of spaces satisfies `minLength: 1` and is still a blank row in a library. Refused,
|
||
// not trimmed: see above.
|
||
return "", &Item{Pointer: "/title", Code: ItemMalformed}
|
||
}
|
||
if i := strings.IndexFunc(title, unwritableInATitle); i >= 0 {
|
||
// ⚠ THIS ONE IS NOT TASTE, IT IS A 500 THIS SURFACE WOULD OTHERWISE ANSWER. Measured live
|
||
// before the guard: `{"title":"be\u0000fore"}` reached Postgres, which cannot store U+0000 in
|
||
// a `text` column, and the client got `500 internal_error` — a code this operation's canon
|
||
// does not list and which puts an ERROR line in the deployment's log for a request the caller
|
||
// simply got wrong.
|
||
return "", &Item{Pointer: "/title", Code: ItemMalformed}
|
||
}
|
||
if utf8.RuneCountInString(title) > books.MaxTitle {
|
||
return "", &Item{Pointer: "/title", Code: ItemTooLong}
|
||
}
|
||
return title, nil
|
||
}
|
||
|
||
// unwritableInATitle reports a rune that must not appear in a book's display name.
|
||
//
|
||
// CONTROL characters (Unicode Cc — which is C0 and C1, so NUL, the newline, the escape) and the two
|
||
// line separators U+2028/U+2029. A title is one line of text on a shelf; none of these can be part of
|
||
// a name, U+0000 cannot even be stored, and the rest are how a name comes to overwrite what is
|
||
// printed beside it.
|
||
//
|
||
// ⚠ AND DELIBERATELY NOT THE FORMAT CATEGORY (Cf). U+200E/U+200F and ZWJ/ZWNJ are ordinary content in
|
||
// Hebrew, Arabic, Devanagari and Persian, and banning "everything invisible" here would quietly
|
||
// break a language pair this repository does not contain yet — which is the generality invariant, not
|
||
// a hypothetical. The engine's own inbound fence draws the line in the same place and for the same
|
||
// reason (backend/internal/membank, the wire fence: controls and bidi OVERRIDES refused, bidi MARKS
|
||
// allowed).
|
||
func unwritableInATitle(r rune) bool {
|
||
return unicode.IsControl(r) || r == '\u2028' || r == '\u2029'
|
||
}
|
||
|
||
func (h *v0) runOptions(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
bookID := r.PathValue("bookId")
|
||
opts, err := h.runs.Order(r.Context(), user, bookID)
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
out := wireRunOptions{
|
||
Order: wireOrderOptions{
|
||
ChaptersLeft: opts.ChaptersLeft,
|
||
AffordableChapters: opts.AffordableChapters,
|
||
Verdict: opts.Verdict,
|
||
ChapterOrders: opts.ChapterOrders,
|
||
SourceChars: opts.SourceChars,
|
||
TermConsistencyFunded: opts.Whole.BondFunded,
|
||
Estimate: wireEstimate{
|
||
ExpectedMicroUSD: int64(opts.Whole.Expected),
|
||
HoldMicroUSD: int64(opts.Whole.Hold),
|
||
},
|
||
},
|
||
BalanceMicroUSD: int64(opts.Balance),
|
||
Limit: wireMoneyLimit{
|
||
MinMicroUSD: int64(opts.MinHold),
|
||
MaxMicroUSD: int64(opts.Balance),
|
||
},
|
||
}
|
||
if opts.Structure != "" {
|
||
structure := opts.Structure
|
||
out.Order.Structure = &structure
|
||
}
|
||
// Why a start is not on offer as the balance alone would suggest — and the two reasons are
|
||
// ordered, because only one of them can be carried.
|
||
//
|
||
// A run of this book's OWN comes first: it is not a shorter scale but no start at all, and it is
|
||
// the answer the door will give whatever the money says. Without it the form put `covers_all` in
|
||
// front of a buyer whose click the door was already going to refuse (PD-455). Another book's hold
|
||
// is the second: there a start IS on offer, only shorter than the account could otherwise afford,
|
||
// and naming that book is what lets a client offer the user somewhere to go (canon
|
||
// §RunOptions.blocked).
|
||
//
|
||
// ⛔ AND A REFUSAL THIS SURFACE HAS NO WORD FOR IS SAID OUT LOUD RATHER THAN DROPPED. The predicate
|
||
// is one definition with two bindings, but the mapping from its answer to a wire member is a
|
||
// SECOND, hand-kept thing: a refusal added to it tomorrow would reach the door and leave this form
|
||
// saying `covers_all`, which is PD-455 all over again and silent. It cannot be a compile error —
|
||
// the predicate answers an `error` — so it is an operator's line instead.
|
||
if opts.Refusal != nil && !errors.Is(opts.Refusal, pgstore.ErrRunInFlight) {
|
||
h.log.ErrorContext(r.Context(), "the order form has no word for a refusal admission would give",
|
||
"err", opts.Refusal)
|
||
}
|
||
// ⚠ The door's other refusals have no line here because this path cannot reach them: an unpriced
|
||
// book is answered `not_priced` above, and a book that is not ready to be translated — still
|
||
// arriving, still being cut, rejected, or owing its chapter tree — is not priced either, since one
|
||
// transaction of the materializer writes the price and the tree together (pgstore/readmodel.go,
|
||
// `update books set … expected_micro_usd …` beside writeChapters). A word invented for them here
|
||
// would be a wire value nothing can produce.
|
||
switch {
|
||
case errors.Is(opts.Refusal, pgstore.ErrRunInFlight):
|
||
out.Blocked = &Blocked{Code: CauseRunInFlight, BookID: bookID}
|
||
case opts.BlockedBy != "":
|
||
out.Blocked = &Blocked{Code: CauseCreditHeld, BookID: opts.BlockedBy}
|
||
}
|
||
h.writeJSON(w, r, http.StatusOK, out)
|
||
}
|
||
|
||
func (h *v0) startRun(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
// Unknown properties are IGNORED, not refused: RunRequest does not declare
|
||
// additionalProperties: false, and inside 0.x a minor bump is where optional fields appear — a
|
||
// server that rejected the whole request would break a client generated against a later contract
|
||
// for a field it was free to ignore.
|
||
// Read whole rather than streamed: the bytes are also this request's idempotency fingerprint.
|
||
//
|
||
// ⚠ A body that does not arrive — over the route's cap, or cut off — is `400`, like every other
|
||
// malformed JSON request and like the JSON write next door. It answered `413 payload_too_large`,
|
||
// a code the canon does not list among this operation's responses and defines as "over
|
||
// `intake_max_bytes`" — which is about an UPLOAD, and nothing here is one.
|
||
var req wireRunRequest
|
||
body, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
h.log.InfoContext(r.Context(), "the body of a run request did not arrive", "err", err)
|
||
Invalid(w, r)
|
||
return
|
||
}
|
||
if err := json.Unmarshal(body, &req); err != nil {
|
||
Invalid(w, r)
|
||
return
|
||
}
|
||
rePass := req.RePass != nil && *req.RePass
|
||
if req.StopForSigning == nil {
|
||
Invalid(w, r, Item{Pointer: "/stop_for_signing", Code: ItemMissing})
|
||
return
|
||
}
|
||
// ⚠ NOTHING IS REQUIRED BESIDES THAT, and the change is the order form's (D39.196 §1): a request
|
||
// naming no volume is the WHOLE BOOK, which is the ratified default rather than an omission. What
|
||
// `ceiling_chapters` used to guard — «a run started without a declared ceiling spends past the
|
||
// limit the user is entitled to set» — is now guarded by the thing that always guarded the money:
|
||
// the hold, which is computed from the order the server itself resolved.
|
||
//
|
||
// The kinds of order are mutually exclusive, and both at once is 400 rather than a guess: which
|
||
// of the two the caller meant is exactly the quiet half-belief this surface refuses elsewhere.
|
||
if req.RetiredCeilingChapters != nil {
|
||
// Named, not silently ignored — see wireRunRequest.RetiredCeilingChapters.
|
||
Invalid(w, r, Item{Pointer: "/ceiling_chapters", Code: ItemMalformed})
|
||
return
|
||
}
|
||
if req.Chapters != nil && req.Characters != nil {
|
||
Invalid(w, r, Item{Pointer: "/characters", Code: ItemMalformed})
|
||
return
|
||
}
|
||
if rePass && (req.Chapters != nil || req.Characters != nil) {
|
||
// A re-pass buys no volume, so a volume beside it would describe nothing.
|
||
pointer := "/chapters"
|
||
if req.Characters != nil {
|
||
pointer = "/characters"
|
||
}
|
||
Invalid(w, r, Item{Pointer: pointer, Code: ItemMalformed})
|
||
return
|
||
}
|
||
// The schema's own minimums belong HERE and not in the service: a request that violates the
|
||
// schema is malformed, and answering it with 409 would tell the client the options had moved — so
|
||
// it would re-read run-options and retry, forever, a request that can never succeed.
|
||
if req.Chapters != nil && *req.Chapters < 1 {
|
||
Invalid(w, r, Item{Pointer: "/chapters", Code: ItemOutOfRange})
|
||
return
|
||
}
|
||
if req.Characters != nil && *req.Characters < 1 {
|
||
Invalid(w, r, Item{Pointer: "/characters", Code: ItemOutOfRange})
|
||
return
|
||
}
|
||
// Fingerprinted over the BODY: this request is small and entirely declared, so "the same
|
||
// request" is decidable exactly.
|
||
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
|
||
in := runs.StartRequest{
|
||
UserID: user,
|
||
BookID: r.PathValue("bookId"),
|
||
VerifyBank: *req.StopForSigning,
|
||
RePass: rePass,
|
||
}
|
||
if !rePass {
|
||
in.Chapters, in.Characters = req.Chapters, req.Characters
|
||
}
|
||
run, err := h.runs.Start(r.Context(), in)
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
key.complete(r.Context(), http.StatusAccepted, "", h.writeJSON(w, r, http.StatusAccepted, projectRun(run)), nil)
|
||
}
|
||
|
||
// stopRun is the product "stop" action (canon §stopRun). The engine stops gracefully on a signal
|
||
// and the reconciler turns the exit into a status; what this call does is record that the stop was
|
||
// OURS — which is the only way the exit can afterwards be told from a crash (register row PD-152).
|
||
func (h *v0) stopRun(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
run, err := h.runs.Stop(r.Context(), user, r.PathValue("runId"))
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
h.writeJSON(w, r, http.StatusAccepted, projectRun(run))
|
||
}
|
||
|
||
// resumeRun continues a run that was stopped (canon §resumeRun).
|
||
func (h *v0) resumeRun(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
run, err := h.runs.Resume(r.Context(), user, r.PathValue("runId"))
|
||
if errors.Is(err, runs.ErrSourceGone) {
|
||
// The same fact as at admission, in THIS operation's own vocabulary. `book_not_ready` is the
|
||
// word for a book that is still arriving or was rejected, and it is what a start answers; here
|
||
// the caller is asking about a RUN, and what it is being told is that this run cannot be
|
||
// continued — with the cause that says why waiting will not help. It is the shape the canon
|
||
// already uses for the two axes that override its status table (`ceiling_reached`,
|
||
// `credit_unavailable`): the same code, a narrower cause.
|
||
h.log.ErrorContext(r.Context(), "resume refused: the book's source directory is gone", "err", err)
|
||
FailCause(w, r, CodeRunNotResumable, CauseSourceGone)
|
||
return
|
||
}
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
h.writeJSON(w, r, http.StatusAccepted, projectRun(run))
|
||
}
|
||
|
||
// maxIntakeField bounds one text field of the intake form, and maxIntakeParts how many parts may
|
||
// arrive before the file. Neither is the contract's business: they are what keeps a form with a
|
||
// megabyte-long title, or a hundred thousand empty parts, from being work this process does.
|
||
const (
|
||
maxIntakeField = 1 << 10
|
||
maxIntakeParts = 16
|
||
)
|
||
|
||
// createBook receives a book (canon §createBook).
|
||
//
|
||
// It is the only route on this surface that streams. The body is read part by part through
|
||
// r.MultipartReader and the file is handed to the intake as a READER, so a 60 MB upload costs a
|
||
// buffer and not 60 MB of this process — which is what ParseMultipartForm, the reflex alternative,
|
||
// would have cost in memory or in a second copy through a temporary file.
|
||
//
|
||
// ⚠ The FILE PART MUST COME LAST, and since 0.3.0 that is the contract's rule and not only this
|
||
// implementation's: a streaming reader hands parts over in wire order, and the book's row — which is
|
||
// what makes an upload visible while it arrives and findable when it dies halfway — cannot be
|
||
// written before the languages that row requires. A part sent after the file is REFUSED and never
|
||
// ignored (canon §createBook), which is the half 0.2.3 got wrong: it was legal to lose one silently.
|
||
func (h *v0) createBook(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
// A book is minutes of upload on a domestic connection, and the server's ReadTimeout — the whole
|
||
// of what bounds a half-fed request (PD-2) — covers the entire body. It is EXTENDED here for this
|
||
// route and never cleared: net/http's own documentation points at a per-request deadline for
|
||
// exactly this case, and clearing one instead is the mistake that re-created PD-2 (STACK §12).
|
||
if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(h.upload.Deadline)); err != nil {
|
||
// Not fatal: a ResponseWriter that cannot carry a deadline is a test double, and the real
|
||
// server's own timeout then still applies.
|
||
h.log.DebugContext(r.Context(), "upload deadline not extended", "err", err)
|
||
}
|
||
parts, err := r.MultipartReader()
|
||
if err != nil {
|
||
Invalid(w, r)
|
||
return
|
||
}
|
||
in := books.Intake{UserID: user}
|
||
titleGiven := false
|
||
// The file's own digest, taken as the bytes go past on their way to disk. It is what "the same
|
||
// request" means for this route, and it is why a repeat is read before it is answered.
|
||
digest := sha256.New()
|
||
// Inactive until claimed; deferred from here so every exit settles it. `complete` cancels it.
|
||
key := &idempotent{}
|
||
defer func() { key.release(r.Context()) }()
|
||
for n := 0; ; n++ {
|
||
if n >= maxIntakeParts {
|
||
// No field to point at: a form with too many parts is reported by the root code alone
|
||
// (canon §ErrorItem).
|
||
Invalid(w, r)
|
||
return
|
||
}
|
||
part, err := parts.NextPart()
|
||
if errors.Is(err, io.EOF) {
|
||
// Every field and no file: the form the contract requires was not sent.
|
||
Invalid(w, r, Item{Pointer: "/file", Code: ItemMissing})
|
||
return
|
||
}
|
||
if err != nil {
|
||
h.uploadFailed(w, r, err)
|
||
return
|
||
}
|
||
if part.FormName() == "file" {
|
||
if bad := missingIntakeFields(in, titleGiven); len(bad) > 0 {
|
||
// Either those fields were not sent, or they were sent AFTER the file — and the second
|
||
// is indistinguishable from the first to a reader that streams, which is exactly what
|
||
// the contract's `missing_or_late` says.
|
||
Invalid(w, r, bad...)
|
||
return
|
||
}
|
||
in.Filename = part.FileName()
|
||
// The FILE is digested as it goes past, and that digest is what settles "the same request"
|
||
// for this route: the form declares its parts and not its bytes, so two different books
|
||
// arrive under one key with the same declaration.
|
||
in.File = io.TeeReader(part, digest)
|
||
// The claim is taken HERE — after the declared parts are known and before a single byte of
|
||
// the file is written. Later would create the duplicate the key exists to prevent; earlier
|
||
// would have nothing to fingerprint.
|
||
claimed, ok := h.beginIdempotent(w, r, user, intakeFingerprint(in, titleGiven), bodyDecidesIdentity)
|
||
if !ok {
|
||
return
|
||
}
|
||
key = claimed
|
||
if key.replay != nil {
|
||
// A repeat of a completed attempt. Its bytes are read and thrown away — nothing is
|
||
// written and no book is created — and only then is it decided whether this is the
|
||
// same request or another one wearing its key.
|
||
if _, err := io.Copy(io.Discard, in.File); err != nil {
|
||
// The body did not arrive, so identity cannot be established. Whatever else is true,
|
||
// the stored answer is not owed to a request nobody could read.
|
||
h.uploadFailed(w, r, err)
|
||
return
|
||
}
|
||
// A part after the file is refused HERE too. It is refused for a fresh key, and a repeat
|
||
// that carried one and was answered 201 would make the same body legal or not depending
|
||
// on which key it wore.
|
||
switch next, err := parts.NextPart(); {
|
||
case err == nil:
|
||
Invalid(w, r, Item{Pointer: "/" + next.FormName(), Code: ItemMissingOrLate})
|
||
return
|
||
case !errors.Is(err, io.EOF):
|
||
h.uploadFailed(w, r, err)
|
||
return
|
||
}
|
||
key.replayIfIdentical(w, r, digest.Sum(nil))
|
||
return
|
||
}
|
||
// The file is the LAST part: everything after it is refused rather than ignored, and the
|
||
// refusal has to happen before the bytes are consumed — so the remainder is checked by
|
||
// handing the intake a reader that fails at the end of the body. See trailingParts.
|
||
break
|
||
}
|
||
value, err := readField(part)
|
||
if err != nil {
|
||
// A field longer than this deployment reads is named rather than folded into "the request
|
||
// could not be read": the canon asks the 400 to say which part was wrong.
|
||
if errors.Is(err, books.ErrBadIntake) {
|
||
Invalid(w, r, Item{Pointer: "/" + part.FormName(), Code: ItemTooLong})
|
||
return
|
||
}
|
||
h.uploadFailed(w, r, err)
|
||
return
|
||
}
|
||
switch part.FormName() {
|
||
case "title":
|
||
// The EMPTY STRING is a value and not an absence: it means "name it from the file"
|
||
// (canon §BookIntake.title). Anything else is a name the person chose, and no later
|
||
// parse overwrites it.
|
||
in.Title, titleGiven = value, true
|
||
case "source_lang":
|
||
in.SourceLang = value
|
||
case "target_lang":
|
||
in.TargetLang = value
|
||
}
|
||
// An unknown field is IGNORED rather than refused, for the same reason an unknown JSON
|
||
// property is: inside 0.x a minor bump is where optional fields appear, and a server that
|
||
// rejected the whole upload would break a client generated against a later contract.
|
||
}
|
||
// A part after the file is REFUSED and never ignored, and the refusal has to undo the upload:
|
||
// answering 400 while the book stays in the library would leave the user with an error and a
|
||
// book. It is done by failing the READ — the intake's own "the upload did not finish" path then
|
||
// removes the row and the directory, which is the path that already exists for exactly this.
|
||
trailing := &trailingParts{parts: parts}
|
||
in.File = io.MultiReader(in.File, trailing)
|
||
// A `408` is not a completed attempt, so the same key may be presented again (canon §createBook):
|
||
// that is what the deferred release does for every failure below.
|
||
book, err := h.intake.Accept(r.Context(), in)
|
||
if err != nil {
|
||
if errors.Is(err, errTrailingPart) {
|
||
h.log.InfoContext(r.Context(), "an upload carried a part after the file and was refused")
|
||
Invalid(w, r, Item{Pointer: "/" + trailing.name, Code: ItemMissingOrLate})
|
||
return
|
||
}
|
||
h.uploadFailed(w, r, err)
|
||
return
|
||
}
|
||
// Location names the book card, as a URI reference resolved against this request's URL: a client
|
||
// follows it as given rather than rebuilding the address from an identifier of its own.
|
||
location := APIPrefix + "/books/" + book.ID
|
||
w.Header().Set("Location", location)
|
||
key.complete(r.Context(), http.StatusCreated, location,
|
||
h.writeJSON(w, r, http.StatusCreated, projectBook(book)), digest.Sum(nil))
|
||
}
|
||
|
||
// intakeFingerprint is what a claim can be taken on: the DECLARED parts, known before a byte of the
|
||
// file has been read.
|
||
//
|
||
// It does not settle identity by itself and is not asked to. The file's own digest does, on the way
|
||
// past, and a repeat is compared against it before anything is replayed (idempotent.replayIfIdentical).
|
||
//
|
||
// ⚠ The request's `Content-Length` is NOT in here. It stood in for the file's size and was a proxy
|
||
// for neither half of the question: it counts the multipart framing, so a retry from another client
|
||
// library differs, and a chunked body declares nothing at all — which left two different files under
|
||
// one key indistinguishable (PD-262).
|
||
func intakeFingerprint(in books.Intake, titleGiven bool) []byte {
|
||
return []byte(strconv.FormatBool(titleGiven) + "\x00" + in.Title + "\x00" +
|
||
in.SourceLang + "\x00" + in.TargetLang + "\x00" + in.Filename)
|
||
}
|
||
|
||
// missingIntakeFields names what the form did not carry before its file part.
|
||
func missingIntakeFields(in books.Intake, titleGiven bool) []Item {
|
||
var out []Item
|
||
if !titleGiven {
|
||
out = append(out, Item{Pointer: "/title", Code: ItemMissingOrLate})
|
||
}
|
||
if in.SourceLang == "" {
|
||
out = append(out, Item{Pointer: "/source_lang", Code: ItemMissingOrLate})
|
||
}
|
||
if in.TargetLang == "" {
|
||
out = append(out, Item{Pointer: "/target_lang", Code: ItemMissingOrLate})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// errTrailingPart is a form that carried something after its file.
|
||
var errTrailingPart = errors.New("httpapi: a form part arrived after the file")
|
||
|
||
// trailingParts is what turns "the file is the last part" from this implementation's constraint into
|
||
// the contract's refusal.
|
||
//
|
||
// It reads as an empty tail of the file and, when the file's own bytes are exhausted, asks the
|
||
// multipart reader whether anything follows. A part that does FAILS the read, which is what makes
|
||
// the refusal undo the upload: the intake treats a read that failed as an upload that did not
|
||
// finish and removes the row and the directory it had already written. The part's NAME is kept
|
||
// beside the error because the caller reports it as the offending field.
|
||
type trailingParts struct {
|
||
parts *multipart.Reader
|
||
checked bool
|
||
name string
|
||
}
|
||
|
||
func (t *trailingParts) Read([]byte) (int, error) {
|
||
if t.checked {
|
||
return 0, io.EOF
|
||
}
|
||
t.checked = true
|
||
part, err := t.parts.NextPart()
|
||
if err != nil {
|
||
return 0, io.EOF // the form ended where it should: after the file
|
||
}
|
||
t.name = part.FormName()
|
||
if t.name == "" {
|
||
t.name = "form"
|
||
}
|
||
return 0, errTrailingPart
|
||
}
|
||
|
||
// readField reads one text field of the form, refusing one that is too long rather than silently
|
||
// keeping its first kilobyte.
|
||
func readField(part io.Reader) (string, error) {
|
||
b, err := io.ReadAll(io.LimitReader(part, maxIntakeField+1))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if len(b) > maxIntakeField {
|
||
return "", fmt.Errorf("%w: a form field is too long", books.ErrBadIntake)
|
||
}
|
||
return strings.TrimSpace(string(b)), nil
|
||
}
|
||
|
||
// uploadFailed maps the ways an upload ends badly.
|
||
//
|
||
// The size refusal is the reason PD-72 was opened and is closed with this route: MaxBytesReader is
|
||
// what enforces the cap, and *http.MaxBytesError is how it says so — the same error whether the
|
||
// client announced the size or simply kept sending.
|
||
func (h *v0) uploadFailed(w http.ResponseWriter, r *http.Request, err error) {
|
||
var tooLarge *http.MaxBytesError
|
||
switch {
|
||
case errors.As(err, &tooLarge):
|
||
Fail(w, r, CodePayloadTooLarge)
|
||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||
// The body did not finish inside the route's own deadline. 408 is what RFC 9110 §15.5.9 calls
|
||
// exactly this, and it tells a client that RETRYING is the remedy — which a 500 does not.
|
||
h.log.InfoContext(r.Context(), "upload did not finish inside the route's deadline")
|
||
Fail(w, r, CodeRequestTimeout)
|
||
case errors.Is(err, books.ErrMalformedLanguage):
|
||
// WHICH code is malformed is in the error's own tail; both are named when only one is, because
|
||
// a pair is what was rejected and pointing at one of the two would be a guess about which the
|
||
// person meant to change.
|
||
Invalid(w, r,
|
||
Item{Pointer: "/source_lang", Code: ItemMalformed},
|
||
Item{Pointer: "/target_lang", Code: ItemMalformed})
|
||
case errors.Is(err, books.ErrUnsupportedPair):
|
||
Invalid(w, r,
|
||
Item{Pointer: "/source_lang", Code: ItemUnsupportedPair},
|
||
Item{Pointer: "/target_lang", Code: ItemUnsupportedPair})
|
||
case errors.Is(err, books.ErrNoBookInSource):
|
||
// Answered now rather than as a book that appears in the library and vanishes minutes later;
|
||
// nothing was kept (backlog row 285).
|
||
Invalid(w, r, Item{Pointer: "/file", Code: ItemNoBook})
|
||
case errors.Is(err, books.ErrStructureNotDeliverable):
|
||
// Our reach, not their file: the item code lets a client phrase it as the temporary limit it
|
||
// is (backlog rows 283/325).
|
||
Invalid(w, r, Item{Pointer: "/file", Code: ItemNoChapterStructure})
|
||
case errors.Is(err, books.ErrBadIntake):
|
||
Invalid(w, r)
|
||
case errors.Is(err, context.Canceled), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF):
|
||
// The client went away mid-body. Nothing reaches it; the line is what an operator sees.
|
||
h.log.InfoContext(r.Context(), "upload did not finish", "err", err)
|
||
Invalid(w, r)
|
||
default:
|
||
h.fail(w, r, err)
|
||
}
|
||
}
|
||
|
||
func (h *v0) usage(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := principal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
u, err := h.lib.ReadUsage(r.Context(), user)
|
||
if err != nil {
|
||
h.fail(w, r, err)
|
||
return
|
||
}
|
||
out := wireUsage{State: usageState(u.RemainingPercent, u.Spendable), RemainingPercent: u.RemainingPercent}
|
||
// ⚠ Its own vocabulary, AccountHaltReason, and not the run's. A run stops for reasons that say
|
||
// nothing about the account, and lighting an account-wide state from one would tell a user with
|
||
// money that they have none (canon §AccountHaltReason). The two happen to share a value today and
|
||
// the types are separate on purpose.
|
||
if reason := ingest.ContractHaltReason(u.PausedReason); reason != "" {
|
||
out.HaltReason = &reason
|
||
}
|
||
h.writeJSON(w, r, http.StatusOK, out)
|
||
}
|
||
|
||
// lowCredit is the share at which the interface warns. The threshold belongs to the platform and
|
||
// never travels: a client that computed it from the percentage would carry a second copy of the
|
||
// policy, and the two would diverge on the day it changes (canon §Usage).
|
||
const lowCredit = 10
|
||
|
||
// spendable, not the percentage, decides "exhausted". The share is floored, so a large grant with a
|
||
// small remainder rounds to 0% — and the account screen then said "nothing left" while the run dialog
|
||
// offered a 300-chapter scale that started successfully. The two screens now answer from the same
|
||
// fact.
|
||
func usageState(percent int, spendable bool) string {
|
||
switch {
|
||
case !spendable:
|
||
return "exhausted"
|
||
case percent <= lowCredit:
|
||
return "low"
|
||
default:
|
||
return "ok"
|
||
}
|
||
}
|
||
|
||
// principal reads the caller established by the middleware. Absent means the guard did not run,
|
||
// which is a wiring defect rather than an unauthenticated request — so it is a 500, and it is
|
||
// logged: answering 401 would hide a route mounted without its guard.
|
||
func principal(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||
p, ok := caller(w, r)
|
||
return p.UserID, ok
|
||
}
|
||
|
||
// caller is the same reading for the one route that needs more of the principal than its id: the
|
||
// event stream re-asks, mid-flight, whether the session behind it is still usable (PD-379). Both
|
||
// facts come from the same value on purpose — a handler holding an id it cannot re-check is the
|
||
// state that vulnerability was made of.
|
||
func caller(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
||
p, ok := auth.FromContext(r.Context())
|
||
if !ok || p.UserID == "" {
|
||
Fail(w, r, CodeInternalError)
|
||
return auth.Principal{}, false
|
||
}
|
||
return p, true
|
||
}
|
||
|
||
// pageLimit reads the `limit` parameter.
|
||
//
|
||
// It NEVER refuses. Over the maximum the page size is clamped and answered, and anything else — a
|
||
// zero, a negative, a word — falls back to the deployment's default: the canon says outright that "a
|
||
// deployment that validates this parameter against the schema has to exempt it from rejection"
|
||
// (§Limit), because answering the default instead of clamping is what made "ask for more, get fewer
|
||
// rows than a smaller request" discoverable only by experiment. The clamp itself lives in the store,
|
||
// next to the page it bounds.
|
||
func (h *v0) pageLimit(r *http.Request) int {
|
||
raw := r.URL.Query().Get("limit")
|
||
if raw == "" {
|
||
return 0 // the deployment's default (GET /capabilities)
|
||
}
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil || n < 1 {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
|
||
// afterVersion reads the delta parameter shared by the notes and the bank.
|
||
func (h *v0) afterVersion(w http.ResponseWriter, r *http.Request) (*int64, bool) {
|
||
raw := r.URL.Query().Get("after_version")
|
||
if raw == "" {
|
||
return nil, true
|
||
}
|
||
n, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil || n < 0 {
|
||
Invalid(w, r, Item{Pointer: "/after_version", Code: ItemMalformed})
|
||
return nil, false
|
||
}
|
||
return &n, true
|
||
}
|
||
|
||
// fail maps a domain error onto the contract's codes.
|
||
//
|
||
// Neither title nor detail ever carries engine or database text (canon §Problem): what a client puts
|
||
// on a screen is drawn from the CODE, and an error string written for an operator would otherwise
|
||
// become the sentence a reader gets.
|
||
func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) {
|
||
var held *runs.CreditHeldError
|
||
switch {
|
||
case errors.Is(err, pgstore.ErrNoBook), errors.Is(err, pgstore.ErrNoAccount), errors.Is(err, pgstore.ErrNoRun):
|
||
Fail(w, r, CodeNotFound)
|
||
case errors.Is(err, pgstore.ErrNoChapter):
|
||
// The chapter existed and does not any more: a book cut again leaves the old ids GONE rather
|
||
// than absent, and the remedy differs — the client re-reads the tree instead of checking the
|
||
// address (canon §listUnits).
|
||
Fail(w, r, CodeGone)
|
||
case errors.Is(err, pgstore.ErrBadCursor):
|
||
FailCause(w, r, CodeInvalidRequest, CauseCursorInvalid)
|
||
case errors.Is(err, pgstore.ErrVersionTooOld):
|
||
FailCause(w, r, CodeInvalidRequest, CauseVersionTooOld)
|
||
case errors.Is(err, books.ErrBadIntake):
|
||
Invalid(w, r)
|
||
case errors.Is(err, pgstore.ErrRunInFlight):
|
||
Fail(w, r, CodeRunInFlight)
|
||
case errors.Is(err, runs.ErrSourceGone):
|
||
// The same 409 as below — the book is there and cannot be translated — with the cause that
|
||
// says waiting will not help. Logged at ERROR because it is also the operator's business: a
|
||
// directory under the books root went missing while its row lives on.
|
||
h.log.ErrorContext(r.Context(), "run refused: the book's source directory is gone", "err", err)
|
||
FailCause(w, r, CodeBookNotReady, CauseSourceGone)
|
||
case errors.Is(err, runs.ErrBookNotReady):
|
||
// The book is still being received or was rejected. 409 and not 404: the book exists and the
|
||
// client can see it — what it cannot do is start a translation of it yet.
|
||
Fail(w, r, CodeBookNotReady)
|
||
case errors.Is(err, runs.ErrNotStoppable):
|
||
Fail(w, r, CodeRunNotStoppable)
|
||
case errors.Is(err, runs.ErrCeilingReached):
|
||
// A run stopped at a limit is not continued by `resume`: the limit travels with the START of
|
||
// a run, so the remedy is a NEW run with a larger one (canon §resumeRun).
|
||
FailCause(w, r, CodeRunNotResumable, CauseCeilingReached)
|
||
case errors.Is(err, runs.ErrCreditUnavailable):
|
||
// The run itself has room left; the account does not. The remedy is money, not a new run.
|
||
FailCause(w, r, CodeRunNotResumable, CauseCreditUnavailable)
|
||
case errors.Is(err, runs.ErrNotResumable):
|
||
Fail(w, r, CodeRunNotResumable)
|
||
case errors.As(err, &held):
|
||
WriteProblem(w, r, Problem{Code: CodeCeilingUnavailable,
|
||
Cause: &Cause{Code: CauseCreditHeld},
|
||
Blocked: &Blocked{Code: CauseCreditHeld, BookID: held.BookID}})
|
||
case errors.Is(err, runs.ErrBalanceCannotCarry), errors.Is(err, pgstore.ErrInsufficientCredit):
|
||
// ⛔ `credit_unavailable`, NOT `bounds_moved`, and the difference is where the buyer goes next.
|
||
// `bounds_moved` promises that re-reading the options and retrying will work; here nothing
|
||
// moved and retrying is futile — what is missing is money. This is the refusal the order form
|
||
// exists to make honest, and it used to travel under the other word.
|
||
FailCause(w, r, CodeCeilingUnavailable, CauseCreditUnavailable)
|
||
case errors.Is(err, runs.ErrCeilingOutOfBounds):
|
||
// 409 and not 400: the request was legal when the options were read, and a hold taken for
|
||
// another book between that read and this call is what moved them (canon §startRun).
|
||
FailCause(w, r, CodeCeilingUnavailable, CauseBoundsMoved)
|
||
case errors.Is(err, runs.ErrNotPriced):
|
||
// 409: the book is there and can be read; what cannot happen yet is a QUOTE over it. It is not
|
||
// 400 (the request was fine) and not 500 (nothing is broken) — the engine has simply not been
|
||
// asked about this book since it learnt to publish prices, and the materializer's own debt is
|
||
// what asks.
|
||
FailCause(w, r, CodeCeilingUnavailable, CauseNotPriced)
|
||
case errors.Is(err, runs.ErrChapterOrdersUnavailable):
|
||
FailCause(w, r, CodeCeilingUnavailable, CauseChapterOrdersUnavailable)
|
||
case errors.Is(err, runs.ErrRePassUnavailable):
|
||
// Its own cause since 0.7.0: nothing to re-pass — no correction since the last run, or a
|
||
// resnapshot run already walked it in (canon §RunRequest.re_pass).
|
||
FailCause(w, r, CodeCeilingUnavailable, CauseRePassUnavailable)
|
||
case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete),
|
||
errors.Is(err, runs.ErrStorageUnavailable):
|
||
// A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145),
|
||
// no way to record how a unit ended, or no book storage under it. The last one is a book whose
|
||
// directory is missing together with the marker of the volume it lived on, and it is the
|
||
// host's fault rather than the book's — answering it as the book's is what PD-192 cost the
|
||
// intake, and there the answer had no way back.
|
||
h.log.ErrorContext(r.Context(), "run refused: this deployment cannot start runs", "err", err)
|
||
Fail(w, r, CodeServiceUnavailable)
|
||
default:
|
||
h.log.ErrorContext(r.Context(), "request failed", "err", err)
|
||
Fail(w, r, CodeInternalError)
|
||
}
|
||
}
|