parseMultipartGraphQL parses a graphql-multipart-request-spec body and returns a populated gqlReq with file values injected at the paths declared in the `map` part. When `backend` is nil, files are inlined as base64 (legacy mode). When `backend` is non-nil, files are streamed to the backend and the
(r *http.Request, conf UploadsConfig, backend fstable.Backend)
| 72 | // |
| 73 | // Spec: https://github.com/jaydenseric/graphql-multipart-request-spec |
| 74 | func parseMultipartGraphQL(r *http.Request, conf UploadsConfig, backend fstable.Backend) (gqlReq, error) { |
| 75 | maxSize := conf.MaxSize |
| 76 | if maxSize <= 0 { |
| 77 | maxSize = defaultMaxUploadSize |
| 78 | } |
| 79 | |
| 80 | // Cap the raw body before any parsing; protects against zip-bomb |
| 81 | // style abuse (large declared length, slow trickle). |
| 82 | r.Body = http.MaxBytesReader(nil, r.Body, maxSize) |
| 83 | |
| 84 | if err := r.ParseMultipartForm(maxSize); err != nil { |
| 85 | return gqlReq{}, fmt.Errorf("multipart: parse failed: %w", err) |
| 86 | } |
| 87 | |
| 88 | // 1. operations: { query, operationName, variables } |
| 89 | opsRaw := r.FormValue("operations") |
| 90 | if opsRaw == "" { |
| 91 | return gqlReq{}, errors.New("multipart: missing 'operations' field") |
| 92 | } |
| 93 | var ops gqlReq |
| 94 | if err := json.Unmarshal([]byte(opsRaw), &ops); err != nil { |
| 95 | return gqlReq{}, fmt.Errorf("multipart: 'operations' is not valid JSON: %w", err) |
| 96 | } |
| 97 | |
| 98 | // 2. map: { "0": ["variables.file"], "1": ["variables.files.0"] } |
| 99 | mapRaw := r.FormValue("map") |
| 100 | if mapRaw == "" { |
| 101 | // Spec mandates `map`; without it, no file injection happens. |
| 102 | // Treat as a structural error rather than silently dropping uploads. |
| 103 | return gqlReq{}, errors.New("multipart: missing 'map' field") |
| 104 | } |
| 105 | var fileMap map[string][]string |
| 106 | if err := json.Unmarshal([]byte(mapRaw), &fileMap); err != nil { |
| 107 | return gqlReq{}, fmt.Errorf("multipart: 'map' is not valid JSON: %w", err) |
| 108 | } |
| 109 | |
| 110 | // Decode variables to a generic structure so we can write file |
| 111 | // values at arbitrary paths. |
| 112 | var vars map[string]any |
| 113 | if len(ops.Vars) > 0 { |
| 114 | if err := json.Unmarshal(ops.Vars, &vars); err != nil { |
| 115 | return gqlReq{}, fmt.Errorf("multipart: 'variables' is not valid JSON: %w", err) |
| 116 | } |
| 117 | } else { |
| 118 | vars = make(map[string]any) |
| 119 | } |
| 120 | root := map[string]any{"variables": vars} |
| 121 | |
| 122 | allowed := buildMIMEAllowlist(conf.AllowedMIME) |
| 123 | ctx := r.Context() |
| 124 | |
| 125 | for partName, paths := range fileMap { |
| 126 | fhs, ok := r.MultipartForm.File[partName] |
| 127 | if !ok || len(fhs) == 0 { |
| 128 | return gqlReq{}, fmt.Errorf("multipart: 'map' references file %q which is missing from the request", partName) |
| 129 | } |
| 130 | fh := fhs[0] |
| 131 |