Files

761 lines
24 KiB
Go

// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"reflect"
"strings"
"github.com/valyala/fasthttp"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
"github.com/zap-proto/zip/internal/jsontag"
)
// Body is bytes and the media type that names them.
//
// As a field of an op's In it is the request body as it arrived. The REST door
// puts the request's content (its Content-Encoding undone) and its
// Content-Type in it and decodes nothing else; the input's other fields still
// bind from the path, the query, the declared headers and the declared
// cookies. An upload, a pack stream, a statement file and a signed webhook are
// typed ops this way, and the wire they speak does not move:
//
// type ScanIn struct {
// Org string `json:"org" url:"org"`
// Body zip.Body `json:"body"` // the receipt, as sent
// }
// zip.Post(app, "/v1/books/:org/scan", scan, zip.Consumes("application/pdf", "image/png"))
//
// One media is the exception over HTTP: the server reads a multipart/form-data
// body as a form while it reads the request, so the field holds that form
// written back out — the same bytes a handler reading the request body gets —
// and not the bytes as sent. ZAP carries those as sent. A signature over a
// multipart body's bytes cannot be checked over HTTP; check it over the parts.
//
// Over MCP, the CLI and the call plane the input arrives as one value, and the
// field is its bytes as a base64 string; its Type is then the first media type
// the op [Consumes].
//
// As an op's Out it is the answer: Bytes, or Reader read to its end and written
// chunk by chunk as it arrives, under Type, with Name as the filename a client
// saves it under (Content-Disposition: attachment). Status and Header are the
// status and headers it states; each must be one the op declared
// ([WithStatus], [WithResponseHeader]), as for any answer. An answer with no
// bytes and no Type carries no Content-Type, so a 304 is a Body whose Status is
// 304 and nothing else.
//
// The document publishes a request Body as the op's [Consumes] media with
// {type: string, format: binary}, and an answer as its [Produces] media the
// same way.
type Body struct {
// Type is the media type: the request's Content-Type as sent, or the
// answer's.
Type string
// Bytes are the bytes as received (a multipart form over HTTP excepted;
// see Body) or as written.
Bytes []byte
// Name is the filename an answer is saved under. Empty sends no
// Content-Disposition.
Name string
// Status is the answer's status, zero for the op's own. A request ignores it.
Status int
// Header are the answer's headers beside Content-Type and
// Content-Disposition. A request ignores them.
Header map[string]string `zap:"-"`
// Reader, when set, is read to its end and written as it arrives in place
// of Bytes. It is closed afterwards when it is an io.Closer.
Reader io.Reader `zap:"-"`
}
// StatusCode is the status the answer states; zero is the op's own.
func (b Body) StatusCode() int { return b.Status }
// body is the Body an answer is, promoted to a type that embeds one: a
// [Verbatim], or a page that also sets a cookie.
func (b *Body) body() *Body { return b }
// answerBody is an answer that is a [Body], or embeds one.
type answerBody interface{ body() *Body }
// drain reads Reader into Bytes, for a transport whose answer is one message.
func (b *Body) drain() error {
r := b.Reader
b.Reader = nil
if c, ok := r.(io.Closer); ok {
defer func() { _ = c.Close() }()
}
got, err := io.ReadAll(r)
b.Bytes = got
return err
}
// release closes Reader, when it is an io.Closer, for an answer refused after
// its handler ran: a relay's Reader is an upstream body, and nothing else will
// read it. A nil Body releases nothing.
func (b *Body) release() {
if b == nil {
return
}
if c, ok := b.Reader.(io.Closer); ok {
_ = c.Close()
}
}
// ResponseHeaders are the headers the answer states.
func (b Body) ResponseHeaders() map[string]string { return b.Header }
// MarshalJSON writes the bytes as one base64 string, the form an argument
// object carries them in.
func (b Body) MarshalJSON() ([]byte, error) { return json.Marshal(b.Bytes) }
// UnmarshalJSON reads the bytes from one base64 string.
func (b *Body) UnmarshalJSON(data []byte) error {
var raw []byte
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("a body is one base64 string: %w", err)
}
b.Bytes = raw
return nil
}
// JSONSchema is the body as an argument object carries it.
func (Body) JSONSchema() map[string]any {
return map[string]any{"type": "string", "contentEncoding": "base64"}
}
// File is one part of a multipart form: the filename the client gave it, its
// media type and its bytes. A field of type File, *File, []File or []*File in
// an op's In binds the part (or parts) of its form name.
//
// An argument object carries it as {"name", "type", "bytes"}, the bytes in
// base64, so a filename the handler reads survives MCP and the CLI.
type File struct {
// Name is the filename the part was sent under.
Name string `json:"name,omitempty"`
// Type is the part's media type.
Type string `json:"type,omitempty"`
// Bytes are the part's bytes.
Bytes []byte `json:"bytes"`
}
// Parser is an input that decodes its own request body. The REST door hands it
// the request's Content-Type and bytes instead of decoding JSON, so an op can
// read YAML or JSON, apply an RFC 7386 merge patch, refuse an unknown field,
// cap its own size or decode tolerantly, and still be a typed op: the document
// publishes the input's reflected schema under the media the op [Consumes]
// (application/json when it names none).
//
// An error that is an [HTTPError] is answered as it is, so a cap answers its
// own 413; any other error is 400 "invalid body". An argument object (MCP, the
// CLI) is handed over as application/json; the call plane carries the input as
// a ZAP message and does not parse.
type Parser interface {
Parse(media string, body []byte) error
}
// Consumes names the media types an op's request body may arrive in. It is
// what the document publishes the request body under.
//
// Without it an op with a [Body] field consumes application/octet-stream, one
// that takes a [File] multipart/form-data, and any other application/json.
// form: fields bind a form only when the op consumes one: name
// application/x-www-form-urlencoded or multipart/form-data here. Without that
// the tags are another binder's and the op reads JSON as it always did.
func Consumes(media ...string) OpOption {
if len(media) == 0 {
panic("zip: Consumes needs at least one media type")
}
return func(op *registeredOp) { op.Consumes = append([]string(nil), media...) }
}
// The media types a request kind defaults to.
const (
mimeOctet = "application/octet-stream"
mimeForm = "application/x-www-form-urlencoded"
mimeMultipart = "multipart/form-data"
)
var (
bodyType = reflect.TypeOf(Body{})
fileType = reflect.TypeOf(File{})
parserType = reflect.TypeOf((*Parser)(nil)).Elem()
)
// intake is how an op's input arrives, read once from In at registration and
// settled against the media the op consumes.
type intake struct {
// raw is the index path of In's [Body] field, nil when it has none.
raw []int
// files says In takes a multipart part ([File]); fields says it has form:
// fields.
files, fields bool
// form says the op reads its body as a form: it takes a part, or it has
// form: fields and consumes a form media type.
form bool
// names are the form keys In binds, fields and parts alike. A form is
// read for these and no others.
names map[string]bool
// parse says *In decodes its own body ([Parser]).
parse bool
// cookies says In declares a cookie: field.
cookies bool
}
// intakeOf reads the request kind off In, refusing a shape the wire cannot
// carry rather than registering an op that misreads it.
func intakeOf(in reflect.Type) intake {
var r intake
t := in
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return r
}
r.parse = reflect.PointerTo(t).Implements(parserType)
for _, f := range wireFields(t) {
switch {
case f.Type == bodyType:
if r.raw != nil {
panic(fmt.Sprintf("zip: %s takes two zip.Body fields; a request has one body", t))
}
r.raw = f.Index
case isFile(f.Type):
r.files = true
r.name(formFieldName(f))
case formFieldName(f) != "":
r.fields = true
r.name(formFieldName(f))
case f.Type.Kind() == reflect.Pointer && f.Type.Elem() == bodyType:
panic(fmt.Sprintf("zip: %s.%s is a *zip.Body; a request body is always there, so declare zip.Body", t, f.Name))
}
if cookieFieldName(f) != "" {
r.cookies = true
}
}
return r
}
// name records a form key In binds.
func (r *intake) name(key string) {
if r.names == nil {
r.names = map[string]bool{}
}
r.names[key] = true
}
// settle decides whether the body is a form, from the media the op consumes,
// and refuses a shape the wire cannot carry rather than registering an op that
// misreads it.
func (r *intake) settle(method string, in reflect.Type, media []string) {
r.form = r.files || r.fields && formMedia(media)
kinds := 0
for _, k := range []bool{r.raw != nil, r.form, r.parse} {
if k {
kinds++
}
}
if kinds > 1 {
panic(fmt.Sprintf("zip: %s takes its body more than one way (a zip.Body field, a form, a Parse method); a request body is read once", in))
}
if kinds > 0 && !hasBody(method) {
panic(fmt.Sprintf("zip: %s carries no request body, so %s cannot take one", method, in))
}
}
// whole says the op takes the request body itself — as bytes, as a form or
// through a Parser — rather than as one JSON value the seam decodes.
func (r intake) whole() bool { return r.raw != nil || r.form || r.parse }
// formMedia reports whether a media list names a form encoding.
func formMedia(media []string) bool {
for _, m := range media {
if isFormMedia(m) {
return true
}
}
return false
}
// consumes is the request media an op names, defaulted by its request kind.
// Nil is application/json.
func consumes(op *registeredOp) []string {
if len(op.Consumes) > 0 {
return op.Consumes
}
switch {
case op.req.raw != nil:
return []string{mimeOctet}
case op.req.files:
return []string{mimeMultipart}
}
return nil
}
// isFile reports whether t binds a multipart part: File, *File, []File or []*File.
func isFile(t reflect.Type) bool {
if t.Kind() == reflect.Slice {
t = t.Elem()
}
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t == fileType
}
// formFieldName is the form key a field binds, or "" when it is not a form
// field. A file part with no form: tag binds under its JSON name.
func formFieldName(f reflect.StructField) string {
if tag, ok := f.Tag.Lookup("form"); ok {
if name := jsontag.Name(f.Name, tag); name != "-" {
return name
}
return ""
}
if isFile(f.Type) {
return jsonFieldName(f)
}
return ""
}
// cookieFieldName is the request cookie a field reads, or "".
func cookieFieldName(f reflect.StructField) string {
if tag, ok := f.Tag.Lookup("cookie"); ok {
if name := jsontag.Name(f.Name, tag); name != "-" {
return name
}
}
return ""
}
// input is one request as the invoke seam takes it.
//
// sent says body is a REST request body exactly as it arrived, under media,
// which a [Body] field takes whole and a form or a [Parser] reads by its media.
// Otherwise body is the whole input as one value — an argument object over
// MCP and the CLI, a ZAP message over the call plane — and dec decodes it.
type input struct {
dec decoder
body []byte
sent bool
media string
// form reads the request's form fields and parts. REST only.
form func() (formData, error)
query map[string]string
path map[string]string
header func(string) string
cookie func(string) string
}
// formData is a request's form: its fields and its file parts by name.
type formData struct {
values map[string][]string
files map[string][]File
}
// decode reads the body into v (a *In) the way the op's request kind says.
// consumes is what the op declared it takes.
func (r intake) decode(v any, in input, consumes []string) error {
if !in.sent {
return r.decodeArgs(v, in)
}
switch {
case r.raw != nil:
field := reflect.ValueOf(v).Elem().FieldByIndex(r.raw)
field.Set(reflect.ValueOf(Body{Type: in.media, Bytes: append([]byte(nil), in.body...)}))
return nil
case r.parse:
return parsed(v.(Parser).Parse(in.media, in.body))
case r.form && isFormMedia(in.media):
fd, err := in.form()
if err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
return bindForm(v, fd)
case r.form:
// A form op reads JSON only when it says it takes JSON; it then reads
// the same fields under the same names, so one object binds whichever
// way it was sent. A body in any other media binds nothing, as a form
// reader finds no fields in it.
if !isJSONMedia(in.media) || !takesJSON(consumes) {
return nil
}
return r.decodeArgs(v, input{dec: jsonenc.Unmarshal, body: in.body, media: mimeJSON})
}
if len(in.body) == 0 {
return nil
}
if err := in.dec(in.body, v); err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
return nil
}
// decodeArgs reads an input that arrived as one value.
func (r intake) decodeArgs(v any, in input) error {
if len(in.body) > 0 {
if r.parse && in.media == mimeJSON {
if err := parsed(v.(Parser).Parse(mimeJSON, in.body)); err != nil {
return err
}
} else if err := in.dec(in.body, v); err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
if r.form && in.media == mimeJSON {
if err := bindFormArgs(v, in.body); err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
}
}
return nil
}
// parsed is a Parser's error as the seam answers it.
func parsed(err error) error {
if err == nil {
return nil
}
var he *HTTPError
if errors.As(err, &he) {
return he
}
return ErrBadRequest("invalid body: " + err.Error())
}
// isFormMedia reports whether a Content-Type is a form encoding.
func isFormMedia(media string) bool {
m, _, _ := mime.ParseMediaType(media)
return m == mimeForm || m == mimeMultipart
}
// isJSONMedia reports whether a Content-Type is JSON: application/json or a
// +json suffix.
func isJSONMedia(media string) bool {
m, _, _ := mime.ParseMediaType(media)
return m == mimeJSON || strings.HasSuffix(m, "+json")
}
// takesJSON reports whether a declared media list names JSON.
func takesJSON(consumes []string) bool {
for _, m := range consumes {
if isJSONMedia(m) {
return true
}
}
return false
}
// bindForm writes a request's form onto v's form fields and file parts.
func bindForm(v any, fd formData) error {
rv := reflect.ValueOf(v).Elem()
for _, f := range wireFields(rv.Type()) {
name := formFieldName(f)
if name == "" {
continue
}
fv := rv.FieldByIndex(f.Index)
if isFile(f.Type) {
setFiles(fv, fd.files[name])
continue
}
setValues(fv, fd.values[name])
}
return nil
}
// setValues writes a form field's values: each repeated key one element of a
// list, else the first value.
func setValues(fv reflect.Value, values []string) {
if len(values) == 0 {
return
}
if fv.Kind() == reflect.Slice && fv.Type().Elem().Kind() != reflect.Uint8 && !readsText(fv.Type()) {
out := reflect.MakeSlice(fv.Type(), len(values), len(values))
for i, s := range values {
setScalar(out.Index(i), s)
}
fv.Set(out)
return
}
setScalar(fv, values[0])
}
// setFiles writes the parts of one name onto a File, *File, []File or []*File.
func setFiles(fv reflect.Value, parts []File) {
if len(parts) == 0 {
return
}
one := func(t reflect.Type, p File) reflect.Value {
if t.Kind() == reflect.Pointer {
c := p
return reflect.ValueOf(&c)
}
return reflect.ValueOf(p)
}
if fv.Kind() == reflect.Slice {
out := reflect.MakeSlice(fv.Type(), len(parts), len(parts))
for i, p := range parts {
out.Index(i).Set(one(fv.Type().Elem(), p))
}
fv.Set(out)
return
}
fv.Set(one(fv.Type(), parts[0]))
}
// bindFormArgs writes an argument object's form fields onto v, read under
// their form names: an argument object names a form field the way the form
// does, so one name reaches it over REST, MCP and the CLI.
func bindFormArgs(v any, body []byte) error {
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil {
return err
}
rv := reflect.ValueOf(v).Elem()
for _, f := range wireFields(rv.Type()) {
name := formFieldName(f)
raw, ok := obj[name]
if name == "" || !ok || string(raw) == "null" {
continue
}
fv := rv.FieldByIndex(f.Index)
if isFile(f.Type) {
if err := json.Unmarshal(raw, fv.Addr().Interface()); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
continue
}
values, err := argValues(raw)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
setValues(fv, values)
}
return nil
}
// argValues reads one argument as the strings a form would carry: a list
// element by element, a string unquoted, any other value as written.
func argValues(raw json.RawMessage) ([]string, error) {
raw = json.RawMessage(strings.TrimSpace(string(raw)))
if len(raw) > 0 && raw[0] == '[' {
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
out := make([]string, 0, len(items))
for _, it := range items {
v, err := argValues(it)
if err != nil {
return nil, err
}
out = append(out, v...)
}
return out, nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
return []string{s}, nil
}
return []string{string(raw)}, nil
}
// clearCookies zeroes v's cookie: fields, so the request's cookie is the only
// thing that sets one: not the body, not an argument object, not the URL. A
// page on another site can make a browser send a body; it cannot make it send
// a cookie it does not hold.
func clearCookies(v any) {
rv := reflect.ValueOf(v).Elem()
for _, f := range wireFields(rv.Type()) {
if cookieFieldName(f) != "" {
fv := rv.FieldByIndex(f.Index)
fv.Set(reflect.Zero(fv.Type()))
}
}
}
// bindCookies copies declared cookie values onto the input, as bindHeaders does
// for headers.
func bindCookies(in any, get func(string) string) {
if get == nil {
return
}
v := reflect.ValueOf(in).Elem()
for _, f := range wireFields(v.Type()) {
name := cookieFieldName(f)
if name == "" {
continue
}
if got := get(name); got != "" {
setScalar(v.FieldByIndex(f.Index), got)
}
}
}
// defaultBodyType fills a [Body] field's Type, when an argument object left it
// empty, with the first media the op consumes.
func (r intake) defaultBodyType(v any, media []string) {
if r.raw == nil || len(media) == 0 {
return
}
field := reflect.ValueOf(v).Elem().FieldByIndex(r.raw)
if b := field.Addr().Interface().(*Body); b.Type == "" {
b.Type = media[0]
}
}
// base64Text is how bytes travel inside JSON.
func base64Text(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
// maxCodings is how many content codings content undoes in one request. A
// body is coded once, twice at most when a proxy codes it again; each further
// layer would be decoded up to the body limit for nothing.
const maxCodings = 3
// content is a REST request's body with its Content-Encoding undone, each
// coding in turn and the result held to the app's BodyLimit, for an op that
// takes the body itself.
//
// It does not read through fiber's Body, which answers a coding it cannot undo
// with the error's text in place of the bytes (and sometimes a status on the
// response): a handler that takes the bytes as they are would run on that
// text. Here a coding this server does not undo is 415, as is a chain of more
// than [maxCodings]; a body that decodes past the limit is 413, and one that
// does not decode is 400, each before the handler runs. Every Content-Encoding
// line counts, since two lines say what one comma-separated line says (RFC
// 9110 §5.3). The bytes are the op's own: the decoders allocate, and an
// unencoded body is fiber's copy.
func content(c fiber.Ctx) ([]byte, error) {
var lines []string
for _, v := range c.Request().Header.PeekAll(fasthttp.HeaderContentEncoding) {
lines = append(lines, string(v))
}
coding := strings.TrimSpace(strings.Join(lines, ","))
if coding == "" {
return c.Body(), nil
}
limit := c.App().Config().BodyLimit
body := c.Request().Body()
// The last coding listed was applied last (RFC 9110 §8.4), so it is undone
// first.
codings := strings.Split(strings.ToLower(coding), ",")
if len(codings) > maxCodings {
return nil, Errorf(http.StatusUnsupportedMediaType, "the request names %d content codings; this server undoes at most %d", len(codings), maxCodings)
}
for i := len(codings) - 1; i >= 0; i-- {
var err error
if body, err = undo(strings.TrimSpace(codings[i]), body, limit); err != nil {
return nil, err
}
}
return append([]byte(nil), body...), nil
}
// undo removes one content coding from b, held to limit bytes.
func undo(coding string, b []byte, limit int) ([]byte, error) {
var r fasthttp.Request
r.SetBodyRaw(b)
var (
out []byte
err error
)
switch coding {
case "identity":
return b, nil
case "gzip", "x-gzip":
out, err = r.BodyGunzipWithLimit(limit)
case "deflate":
out, err = r.BodyInflateWithLimit(limit)
case "br":
out, err = r.BodyUnbrotliWithLimit(limit)
case "zstd":
out, err = r.BodyUnzstdWithLimit(limit)
default:
return nil, Errorf(http.StatusUnsupportedMediaType, "Content-Encoding %q is not one this server undoes; send gzip, deflate, br, zstd or none", coding)
}
switch {
case errors.Is(err, fasthttp.ErrBodyTooLarge):
return nil, Errorf(http.StatusRequestEntityTooLarge, "the request body decodes to more than %d bytes", limit)
case err != nil:
return nil, ErrBadRequest(fmt.Sprintf("invalid body: its %s coding does not decode: %v", coding, err))
}
return out, nil
}
// maxFormFields is the most pieces a url-encoded form may carry between its
// '&'s, a field or empty: the bound Go's own query parser holds (net/url,
// urlmaxqueryparams). Counting separators rather than fields keeps the check
// one pass over the bytes, so a form of nothing but '&' costs no more to refuse
// than any other. A multipart form is held to mime/multipart's own bounds on
// parts and part headers.
const maxFormFields = 10000
// readForm reads a REST request's form from its content: url-encoded fields,
// or a multipart form's fields and parts, each part read whole, keeping only
// the keys the op binds (names). The content is already decoded and held to the
// body limit, and a multipart form is parsed from it in memory, so no part is
// larger than the body that carried it. A url-encoded form is decoded as fiber
// decodes one, and one of more than [maxFormFields] pieces is refused before
// any is decoded.
func readForm(c fiber.Ctx, media string, body []byte, names map[string]bool) (formData, error) {
fd := formData{values: map[string][]string{}, files: map[string][]File{}}
if m, _, _ := mime.ParseMediaType(media); m != mimeMultipart {
if bytes.Count(body, []byte("&")) >= maxFormFields {
return fd, fmt.Errorf("a form carries more than %d pieces between its '&'s", maxFormFields)
}
var args fasthttp.Args
args.ParseBytes(body)
for k, v := range args.All() {
if names[string(k)] {
fd.values[string(k)] = append(fd.values[string(k)], string(v))
}
}
return fd, nil
}
boundary := string(c.Request().Header.MultipartFormBoundary())
if boundary == "" {
return fd, errors.New("a multipart form names no boundary")
}
form, err := multipart.NewReader(bytes.NewReader(body), boundary).ReadForm(int64(len(body)) + 1)
if err != nil {
return fd, err
}
defer func() { _ = form.RemoveAll() }()
for k, v := range form.Value {
if names[k] {
fd.values[k] = v
}
}
for k, parts := range form.File {
if !names[k] {
continue
}
for _, h := range parts {
f, err := h.Open()
if err != nil {
return fd, err
}
b, err := io.ReadAll(f)
_ = f.Close()
if err != nil {
return fd, err
}
fd.files[k] = append(fd.files[k], File{Name: h.Filename, Type: h.Header.Get("Content-Type"), Bytes: b})
}
}
return fd, nil
}