1import * as ByteSize from "../../../ByteSize.ts"
2import type { Config, MultipartError, PartInfo } from "../../MultipartParser.ts"
3import * as CT from "./contentType.ts"
4import * as HP from "./headers.ts"
5import * as Search from "./search.ts"
6
7const State = {
8 headers: 0,
9 body: 1
10} as const
11type State = (typeof State)[keyof typeof State]
12
13const errInvalidDisposition: MultipartError = { _tag: "InvalidDisposition" }
14const errEndNotReached: MultipartError = { _tag: "EndNotReached" }
15const errMaxParts: MultipartError = { _tag: "ReachedLimit", limit: "MaxParts" }
16const errMaxTotalSize: MultipartError = {
17 _tag: "ReachedLimit",
18 limit: "MaxTotalSize"
19}
20const errMaxPartSize: MultipartError = {
21 _tag: "ReachedLimit",
22 limit: "MaxPartSize"
23}
24const errMaxFieldSize: MultipartError = {
25 _tag: "ReachedLimit",
26 limit: "MaxFieldSize"
27}
28
29const constCR = new TextEncoder().encode("\r\n")
30
31export function defaultIsFile(info: PartInfo) {
32 return (
33 info.filename !== undefined ||
34 info.contentType === "application/octet-stream"
35 )
36}
37
38function parseBoundary(headers: Record<string, string>) {
39 const contentType = CT.parse(headers["content-type"])
40 return contentType.parameters.boundary
41}
42
43function noopOnChunk(_chunk: Uint8Array | null) {}
44
45const toLimit = (input: ByteSize.Input): number =>
46 input === Infinity ? Infinity : Number(ByteSize.fromInputUnsafe(input))
47
48export function make({
49 headers,
50 onFile: onPart,
51 onField,
52 onError,
53 onDone,
54 isFile = defaultIsFile,
55 maxParts = Infinity,
56 maxTotalSize: maxTotalSizeInput = Infinity,
57 maxPartSize: maxPartSizeInput = Infinity,
58 maxFieldSize: maxFieldSizeInput = 1024 * 1024
59}: Config) {
60 const maxTotalSize = toLimit(maxTotalSizeInput)
61 const maxPartSize = toLimit(maxPartSizeInput)
62 const maxFieldSize = toLimit(maxFieldSizeInput)
63 const boundary = parseBoundary(headers)
64 if (boundary === undefined) {
65 onError({ _tag: "InvalidBoundary" })
66 return {
67 write: noopOnChunk,
68 end() {}
69 }
70 }
71
72 const state = {
73 state: State.headers as State,
74 index: 0,
75 parts: 0,
76 onChunk: noopOnChunk,
77 info: undefined as any as PartInfo,
78 headerSkip: 0,
79 partSize: 0,
80 totalSize: 0,
81 isFile: false,
82 fieldChunks: [] as Array<Uint8Array>,
83 fieldSize: 0,
84 done: false,
85 stopped: false
86 }
87
88 function skipBody() {
89 state.state = State.body
90 state.isFile = true
91 state.onChunk = noopOnChunk
92 }
93
94 function stop(error: MultipartError) {
95 state.stopped = true
96 if (state.state === State.body && state.isFile) {
97 state.onChunk(null)
98 }
99 onError(error)
100 }
101
102 const headerParser = HP.make()
103
104 const split = Search.make(
105 `\r\n--${boundary}`,
106 function(index, chunk) {
107 if (state.stopped) {
108 return
109 }
110
111 if (index === 0) {
112 // data before the first boundary
113 skipBody()
114 return
115 } else if (index !== state.index) {
116 if (state.index > 0) {
117 if (state.isFile) {
118 state.onChunk(null)
119 } else {
120 if (state.fieldChunks.length === 1) {
121 onField(state.info, state.fieldChunks[0])
122 } else {
123 const buf = new Uint8Array(state.fieldSize)
124 let offset = 0
125 for (let i = 0; i < state.fieldChunks.length; i++) {
126 const chunk = state.fieldChunks[i]
127 buf.set(chunk, offset)
128 offset += chunk.length
129 }
130 onField(state.info, buf)
131 }
132 state.fieldSize = 0
133 state.fieldChunks = []
134 }
135 }
136 state.partSize = 0
137
138 state.state = State.headers
139 state.index = index
140 state.headerSkip = 2 // skip the first \r\n
141
142 // trailing --
143 if (chunk[0] === 45 && chunk[1] === 45) {
144 state.done = true
145 return onDone()
146 }
147
148 state.parts++
149 if (state.parts > maxParts) {
150 return stop(errMaxParts)
151 }
152 }
153
154 if ((state.partSize += chunk.length) > maxPartSize) {
155 return stop(errMaxPartSize)
156 }
157
158 if (state.state === State.headers) {
159 const result = headerParser(chunk, state.headerSkip)
160 state.headerSkip = 0
161
162 if (result._tag === "Continue") {
163 return
164 } else if (result._tag === "Failure") {
165 skipBody()
166 return onError({ _tag: "BadHeaders", error: result })
167 }
168
169 const contentType = CT.parse(result.headers["content-type"] as string)
170 const contentDisposition = CT.parse(
171 result.headers["content-disposition"] as string,
172 true
173 )
174
175 if (
176 "form-data" === contentDisposition.value &&
177 !("name" in contentDisposition.parameters)
178 ) {
179 skipBody()
180 return onError(errInvalidDisposition)
181 }
182
183 let encodedFilename: string | undefined
184 if ("filename*" in contentDisposition.parameters) {
185 const parts = contentDisposition.parameters["filename*"].split("''")
186 if (parts.length === 2) {
187 try {
188 encodedFilename = decodeURIComponent(parts[1])
189 } catch {
190 encodedFilename = parts[1]
191 }
192 }
193 }
194
195 state.info = {
196 name: contentDisposition.parameters.name ?? "",
197 filename: encodedFilename ?? contentDisposition.parameters.filename,
198 contentType: contentType.value === ""
199 ? contentDisposition.parameters.filename !== undefined
200 ? "application/octet-stream"
201 : "text/plain"
202 : contentType.value,
203 contentTypeParameters: contentType.parameters,
204 contentDisposition: contentDisposition.value,
205 contentDispositionParameters: contentDisposition.parameters as any,
206 headers: result.headers
207 }
208
209 state.state = State.body
210 state.isFile = isFile(state.info)
211
212 if (state.isFile) {
213 state.onChunk = onPart(state.info)
214 }
215
216 if (result.endPosition < chunk.length) {
217 if (state.isFile) {
218 state.onChunk(chunk.subarray(result.endPosition))
219 } else {
220 const buf = chunk.subarray(result.endPosition)
221 if ((state.fieldSize += buf.length) > maxFieldSize) {
222 return stop(errMaxFieldSize)
223 }
224 state.fieldChunks.push(buf)
225 }
226 }
227 } else if (state.isFile) {
228 state.onChunk(chunk)
229 } else {
230 if ((state.fieldSize += chunk.length) > maxFieldSize) {
231 return stop(errMaxFieldSize)
232 }
233 state.fieldChunks.push(chunk)
234 }
235 },
236 constCR,
237 2
238 )
239
240 return {
241 write(chunk: Uint8Array) {
242 if (state.stopped) {
243 return
244 }
245 if ((state.totalSize += chunk.length) > maxTotalSize) {
246 return stop(errMaxTotalSize)
247 }
248 return split.write(chunk)
249 },
250 end() {
251 split.end()
252 if (!state.done && !state.stopped) {
253 stop(errEndNotReached)
254 }
255
256 state.state = State.headers
257 state.index = 0
258 state.parts = 0
259 state.onChunk = noopOnChunk
260 state.info = undefined as any as PartInfo
261 state.totalSize = 0
262 state.partSize = 0
263 state.fieldChunks = []
264 state.fieldSize = 0
265 state.done = false
266 state.stopped = false
267 }
268 } as const
269}
270
271const utf8Decoder = new TextDecoder("utf-8")
272function getDecoder(charset: string) {
273 if (charset === "utf-8" || charset === "utf8" || charset === "") {
274 return utf8Decoder
275 }
276
277 try {
278 return new TextDecoder(charset)
279 } catch (error) {
280 return utf8Decoder
281 }
282}
283
284export function decodeField(info: PartInfo, value: Uint8Array): string {
285 return getDecoder(info.contentTypeParameters.charset ?? "utf-8").decode(value)
286}