///|
/// File upload support — multipart/form-data parsing with structured metadata.
///
/// Extracted from Context to keep the core type lean.
///
/// ## Usage
///
/// ```
/// // Single file
/// match ctx.form_file("avatar") {
/// Some(file) => {
/// log_info("Received file", [
/// ("name", file.filename),
/// ("size", file.size.to_string()),
/// ])
/// ctx.save_uploaded_file(file, "./uploads/" + file.filename)
/// }
/// None => ctx.abort_with_status(400, "No file uploaded")
/// }
///
/// // All fields (including non-file form fields)
/// let form = ctx.multipart_form()
/// for field_name, file in form {
/// println("\{field_name}: \{file.filename} (\{file.size} bytes)")
/// }
/// ```
///|
/// Structured metadata for an uploaded file.
pub(all) struct FileHeader {
/// The form field name (e.g., "avatar")
field_name : String
/// The original filename from the client (e.g., "photo.jpg")
filename : String
/// The raw file content as a string
content : String
/// MIME type of the file (e.g., "image/jpeg"), if detectable
content_type : String
/// File size in bytes
size : Int64
}
///|
/// Extract the boundary string from a multipart/form-data Content-Type header.
fn extract_boundary(content_type : String) -> String? {
match content_type.find("boundary=") {
Some(pos) => {
let start = pos + "boundary=".length()
Some(content_type[start:].to_owned())
}
None => None
}
}
///|
/// Find the end of headers (blank line) in a multipart part, starting from `start`.
/// Returns the position right after the blank line, or None.
fn find_header_end(body : String, start : Int) -> Int? {
// Try \r\n\r\n first (RFC compliant)
match body[start:].find("\r\n\r\n") {
Some(pos) => Some(start + pos + 4)
None =>
// Fall back to \n\n
match body[start:].find("\n\n") {
Some(pos) => Some(start + pos + 2)
None => None
}
}
}
///|
/// Extract the filename from a multipart part's Content-Disposition header.
/// Searches forward from the boundary marker before field_pos, through the
/// entire header block (including after `name=`).
fn extract_filename(body : String, field_pos : Int) -> String {
// Find the start of the current part (the "--boundary" before this field)
let part_start = match body[:field_pos].rev_find("\n--") {
Some(pos) => pos + 1 // start after the newline
None => 0
}
// Find end of headers (blank line after this field's headers)
let header_end = match body[field_pos:].find("\r\n\r\n") {
Some(pos) => field_pos + pos
None =>
match body[field_pos:].find("\n\n") {
Some(pos) => field_pos + pos
None => body.length()
}
}
// Search for filename="..." in the header region
let header_region = body[part_start:header_end].to_owned()
match header_region.find("filename=\"") {
Some(fn_start) => {
let after_fn = fn_start + "filename=\"".length()
match header_region[after_fn:].find("\"") {
Some(fn_end) => header_region[after_fn:after_fn + fn_end].to_owned()
None => ""
}
}
None => ""
}
}
///|
/// Extract the content-type of a file part from its headers.
/// Searches forward from the boundary marker before field_pos.
fn extract_part_content_type(body : String, field_pos : Int) -> String {
let part_start = match body[:field_pos].rev_find("\n--") {
Some(pos) => pos + 1
None => 0
}
let header_end = match body[field_pos:].find("\r\n\r\n") {
Some(pos) => field_pos + pos
None =>
match body[field_pos:].find("\n\n") {
Some(pos) => field_pos + pos
None => body.length()
}
}
let header_region = body[part_start:header_end].to_owned()
match header_region.find("Content-Type:") {
Some(ct_start) => {
let after_header = ct_start + "Content-Type:".length()
let ct_value = header_region[after_header:].trim()
match ct_value.find("\r\n") {
Some(line_end) => ct_value[:line_end].to_owned()
None =>
match ct_value.find("\n") {
Some(line_end) => ct_value[:line_end].to_owned()
None => ct_value.to_owned()
}
}
}
None => "application/octet-stream"
}
}
///|
/// Strip trailing newlines from multipart content.
fn strip_trailing_newlines(s : String) -> String {
let s = match s.strip_suffix("\r\n") {
Some(v) => v.to_owned()
None => s
}
match s.strip_suffix("\n") {
Some(v) => v.to_owned()
None => s
}
}
///| ——————————————————————————————————————————————————————————————————————
/// Context extension methods for file upload
///| ——————————————————————————————————————————————————————————————————————
///|
/// FormFile returns the metadata and content of an uploaded file from a
/// multipart/form-data request.
///
/// Returns `Some(FileHeader)` with filename, content, content-type, and size,
/// or `None` if the field is not present or not a multipart request.
///
/// ```
/// match ctx.form_file("avatar") {
/// Some(file) => {
/// println("Received: \{file.filename} (\{file.size} bytes)")
/// ctx.save_uploaded_file(file, "./uploads/" + file.filename)
/// }
/// None => ctx.abort_with_status(400, "No file uploaded")
/// }
/// ```
pub async fn Context::form_file(
self : Context,
field_name : String,
) -> FileHeader? {
let content_type = self.content_type()
if !content_type.has_prefix("multipart/form-data") {
return None
}
let boundary = match extract_boundary(content_type) {
Some(b) => b
None => return None
}
let body = self.body_string()
let field_marker = "name=\"" + field_name + "\""
match body.find(field_marker) {
Some(field_pos) => {
// Find end of headers
let content_start = match find_header_end(body, field_pos) {
Some(pos) => pos
None => return None
}
// Find the closing boundary
let boundary_marker = "--" + boundary
let rest = body[content_start:]
let end_pos = match rest.find(boundary_marker) {
Some(pos) => pos
None => return None
}
let raw_content = rest[:end_pos].to_owned()
let content = strip_trailing_newlines(raw_content)
let filename = extract_filename(body, field_pos)
let part_ct = extract_part_content_type(body, field_pos)
Some(FileHeader::{
field_name,
filename,
content,
content_type: part_ct,
size: content.length().to_int64(),
})
}
None => None
}
}
///|
/// Save an uploaded file to the specified destination path.
///
/// ```
/// match ctx.form_file("avatar") {
/// Some(file) => ctx.save_uploaded_file(file, "./uploads/" + file.filename)
/// None => ctx.abort_with_status(400, "No file uploaded")
/// }
/// ```
pub async fn Context::save_uploaded_file(
self : Context,
file : FileHeader,
dst : String,
) -> Unit {
try {
@fs.write_file(dst, file.content)
} catch {
_ => self.add_error("Failed to save file to " + dst)
}
}
///|
/// Convenience: save raw content to disk (backward compatible).
pub async fn Context::save_uploaded_file_content(
self : Context,
content : String,
dst : String,
) -> Unit {
try {
@fs.write_file(dst, content)
} catch {
_ => self.add_error("Failed to save file to " + dst)
}
}
///|
/// GetPostFormFile returns the original filename from a multipart form upload.
/// Returns the filename extracted from the Content-Disposition header.
pub async fn Context::get_post_form_file(
self : Context,
field_name : String,
) -> String? {
let content_type = self.content_type()
if !content_type.has_prefix("multipart/form-data") {
return None
}
let body = self.body_string()
let field_marker = "name=\"" + field_name + "\""
match body.find(field_marker) {
Some(pos) => {
let filename = extract_filename(body, pos)
if filename != "" { Some(filename) } else { None }
}
None => None
}
}
///|
/// MultipartForm parses the entire multipart/form-data body and returns a map
/// of field name → FileHeader for all file fields.
///
/// This is useful for forms with multiple file uploads.
pub async fn Context::multipart_form(self : Context) -> Map[String, FileHeader] {
let content_type = self.content_type()
if !content_type.has_prefix("multipart/form-data") {
return Map([])
}
let boundary = match extract_boundary(content_type) {
Some(b) => b
None => return Map([])
}
let body = self.body_string()
let result : Map[String, FileHeader] = Map([])
let boundary_marker = "--" + boundary
let mut search_from = 0
while search_from < body.length() {
match body[search_from:].find("name=\"") {
Some(name_start) => {
let after_name = search_from + name_start + "name=\"".length()
match body[after_name:].find("\"") {
Some(name_end) => {
let field_name = body[after_name:after_name + name_end].to_owned()
// Find content after headers
let content_start = match find_header_end(body, after_name + name_end) {
Some(pos) => pos
None => break
}
let remaining = body[content_start:]
match remaining.find(boundary_marker) {
Some(end) => {
let raw_content = remaining[:end].to_owned()
let content = strip_trailing_newlines(raw_content)
let filename = extract_filename(body, after_name)
let part_ct = extract_part_content_type(body, after_name)
result.set(field_name, FileHeader::{
field_name,
filename,
content,
content_type: part_ct,
size: content.length().to_int64(),
})
search_from = content_start + end + boundary_marker.length()
}
None => break
}
}
None => break
}
}
None => break
}
}
result
}
///| ——————————————————————————————————————————————————————————————————————
/// Streaming multipart parser — reads body in chunks via @io.Reader
///| ——————————————————————————————————————————————————————————————————————
///|
/// A streaming multipart parser that reads the request body incrementally
/// instead of buffering the entire body in memory.
///
/// ## When to use
///
/// - Large file uploads (> 10 MB) where loading into memory is expensive
/// - Progressive processing of upload data
///
/// ## When NOT to use
///
/// - You need random access to multipart fields (use `multipart_form` instead)
/// - The form has many small fields (uses more overhead per part)
///
/// ```
/// let stream = MultipartStream::new(ctx)
/// loop {
/// match stream.next_part() {
/// Some(part) => {
/// // Process chunk by chunk
/// loop {
/// match part.read_chunk(8192) {
/// Some(chunk) => { /* write chunk */ }
/// None => break // part complete
/// }
/// }
/// }
/// None => break // no more parts
/// }
/// }
/// ```
///|
/// A single part in a streaming multipart parse, with header info
/// and a reader for chunked body access.
pub(all) struct MultipartPartReader {
/// Form field name
field_name : String
/// Original filename (empty for non-file fields)
filename : String
/// Content-Type of this part
content_type : String
/// Remaining content bytes count (from Content-Length or boundary search)
mut remaining : Int64
/// Current position in the raw body string
mut pos : Int
/// Reference to the full body + boundary for scanning
body : String
/// Boundary marker for finding the end
boundary_marker : String
/// Whether this part has been fully consumed
mut done : Bool
}
///|
/// Create a streaming multipart parser from a Context.
///
/// The parser reads the request body into a buffer and provides
/// incremental access to each multipart part.
pub fn MultipartStream::new(ctx : Context) -> MultipartStream? {
let content_type = ctx.content_type()
if !content_type.has_prefix("multipart/form-data") {
return None
}
let boundary = match extract_boundary(content_type) {
Some(b) => b
None => return None
}
Some(MultipartStream::{
body: "",
boundary,
pos: 0,
done: false,
})
}
///|
/// Streaming multipart parser state.
pub(all) struct MultipartStream {
/// The body buffer (accumulated from reader)
mut body : String
/// Boundary string
boundary : String
/// Current scan position
mut pos : Int
/// Whether parsing is complete
mut done : Bool
}
///|
/// Read the next available part from the multipart stream.
/// Returns `None` when all parts have been consumed.
///
/// The returned `MultipartPartReader` lets you read the part body
/// chunk by chunk via `read_chunk()`.
pub fn MultipartStream::next_part(
self : MultipartStream,
) -> MultipartPartReader? {
if self.done {
return None
}
let boundary_marker = "--" + self.boundary
let end_marker = boundary_marker + "--"
// Find the next field in the accumulated body
while self.pos < self.body.length() {
match self.body[self.pos:].find("name=\"") {
Some(name_start) => {
let after_name = self.pos + name_start + "name=\"".length()
match self.body[after_name:].find("\"") {
Some(name_end) => {
let field_name = self.body[after_name:after_name + name_end].to_owned()
// Find end of headers
let content_start = match find_header_end(self.body, after_name + name_end) {
Some(pos) => pos
None => {
self.pos = self.pos + 1
continue
}
}
let filename = extract_filename(self.body, after_name)
let part_ct = extract_part_content_type(self.body, after_name)
self.pos = content_start
return Some(MultipartPartReader::{
field_name,
filename,
content_type: part_ct,
remaining: 0L,
pos: content_start,
body: self.body,
boundary_marker,
done: false,
})
}
None => {
self.pos = self.pos + 1
continue
}
}
}
None => {
// Check for closing boundary
if self.body[self.pos:].find(end_marker) is Some(_) {
self.done = true
}
return None
}
}
}
None
}
///|
/// Read the next chunk of the current multipart part body.
/// Returns `Some(chunk)` with data, or `None` when the part is complete.
///
/// ```
/// let stream = MultipartStream::new(ctx)
/// match stream.next_part() {
/// Some(part) => {
/// loop {
/// match part.read_chunk(4096) {
/// Some(chunk) => { /* process chunk */ }
/// None => break
/// }
/// }
/// }
/// None => {}
/// }
/// ```
pub fn MultipartPartReader::read_chunk(
self : MultipartPartReader,
max_size : Int,
) -> String? {
if self.done {
return None
}
let remaining_body = self.body[self.pos:]
match remaining_body.find(self.boundary_marker) {
Some(end_pos) => {
// The boundary is ahead; we can read up to it
let available = if end_pos < max_size { end_pos } else { max_size }
if available <= 0 {
// At the boundary — skip it and mark done
let after_boundary = self.pos + end_pos + self.boundary_marker.length()
self.pos = after_boundary
// Check for closing --
if self.body[after_boundary:].has_prefix("--") {
self.done = true
}
return None
}
let chunk = remaining_body[:available].to_owned()
self.pos = self.pos + available
Some(chunk)
}
None => {
// Boundary not yet in buffer — return what we have
if remaining_body.length() > 0 {
let chunk = remaining_body.to_owned()
self.pos = self.body.length()
Some(chunk)
} else {
None
}
}
}
}
///|
/// Convenience: drain a multipart part completely into a string.
pub fn MultipartPartReader::drain(self : MultipartPartReader) -> String {
let mut result = ""
let mut remaining = true
while remaining {
let chunk = self.read_chunk(65536)
match chunk {
Some(c) => result = result + c
None => remaining = false
}
}
result
}
///| ——————————————————————————————————————————————————————————————————————
/// Resumable upload support (断点续传)
///| ——————————————————————————————————————————————————————————————————————
///|
/// Parsed Content-Range header info for resumable uploads.
pub(all) struct ContentRange {
/// Starting byte position (inclusive)
start : Int64
/// Ending byte position (inclusive)
end : Int64
/// Total file size (0 if unknown)
total : Int64
}
///|
/// Parse a `Content-Range` header value.
///
/// Supports format: `bytes -/`
///
/// ```
/// let range = parse_content_range("bytes 0-499/1024")
/// assert_eq(range.start, 0)
/// assert_eq(range.end, 499)
/// assert_eq(range.total, 1024)
/// ```
pub fn parse_content_range(header : String) -> ContentRange? {
if !header.has_prefix("bytes ") {
return None
}
let rest = header[6:].to_owned() // skip "bytes "
match rest.find("/") {
Some(slash_pos) => {
let range_part = rest[:slash_pos].to_owned()
let total_str = rest[slash_pos + 1:].to_owned()
let total = @string.parse_int64(total_str) catch { _ => 0L }
match range_part.find("-") {
Some(dash_pos) => {
let start_str = range_part[:dash_pos].to_owned()
let end_str = range_part[dash_pos + 1:].to_owned()
let start = @string.parse_int64(start_str) catch { _ => return None }
let end = @string.parse_int64(end_str) catch { _ => return None }
Some(ContentRange::{ start, end, total })
}
None => None
}
}
None => None
}
}
///|
/// Check if the request contains a Content-Range header for resumable upload.
pub fn Context::content_range(self : Context) -> ContentRange? {
match self.header("Content-Range") {
Some(h) => parse_content_range(h)
None => None
}
}
///|
/// Save an uploaded file with resumable upload support.
///
/// If the request includes a `Content-Range` header, the function
/// appends the content to the existing file (resume). Otherwise,
/// it overwrites (new upload).
///
/// ```
/// match ctx.form_file("video") {
/// Some(file) => {
/// ctx.save_uploaded_file_resumable(file, "./uploads/video.mp4")
/// }
/// None => ctx.abort_with_status(400, "No file")
/// }
/// ```
pub async fn Context::save_uploaded_file_resumable(
self : Context,
file : FileHeader,
dst : String,
) -> Unit {
let range = self.content_range()
match range {
Some(r) => {
// Resumable: append chunk to existing file
if r.start > 0L {
// Read existing file and append
let existing = @fs.read_file(dst).text() catch { _ => "" }
@fs.write_file(dst, existing + file.content) catch {
_ => self.add_error("Failed to write resumable chunk to " + dst)
}
} else {
// First chunk: overwrite
@fs.write_file(dst, file.content) catch {
_ => self.add_error("Failed to write file to " + dst)
}
}
}
None => {
// No range: simple write
@fs.write_file(dst, file.content) catch {
_ => self.add_error("Failed to write file to " + dst)
}
}
}
}
///| ——————————————————————————————————————————————————————————————————————
/// Large file chunked processing (大文件分块处理)
///| ——————————————————————————————————————————————————————————————————————
///|
/// Configuration for chunked file processing.
pub(all) struct ChunkedWriteConfig {
/// Chunk size in bytes (default: 1 MB)
chunk_size : Int
/// Whether to process chunks via callback instead of buffering all
process_callback : Bool
}
///|
/// Default chunked write config (1 MB chunks).
pub fn ChunkedWriteConfig::default() -> ChunkedWriteConfig {
{ chunk_size: 1024 * 1024, process_callback: true }
}
///|
/// Callback type for processing file chunks.
/// Called with each chunk of data; return false to abort.
pub type ChunkCallback = (String, Int, Int) -> Bool
///|
/// Save an uploaded file in chunks, processing each chunk via callback.
///
/// Useful for very large files where you want to:
/// - Track progress
/// - Process data incrementally (e.g., hash, validate)
/// - Avoid loading the entire file into memory at once (still uses body_string
/// for the initial read, but writes and processes incrementally)
///
/// ```
/// let mut bytes_written = 0L
/// ctx.save_uploaded_chunked(file, "./uploads/large.bin",
/// ChunkedWriteConfig::default(),
/// fn(chunk, index, total_chunks) {
/// bytes_written = bytes_written + chunk.length().to_int64()
/// println("chunk \{index}/\{total_chunks}")
/// true // continue
/// })
/// ```
pub async fn Context::save_uploaded_chunked(
self : Context,
file : FileHeader,
dst : String,
config : ChunkedWriteConfig,
callback : ChunkCallback,
) -> Unit {
let content = file.content
let total_len = content.length()
let chunk_size = config.chunk_size
let total_chunks = (total_len + chunk_size - 1) / chunk_size
let mut accumulated = ""
let mut i = 0
while i < total_chunks {
let start = i * chunk_size
let end = if start + chunk_size < total_len {
start + chunk_size
} else {
total_len
}
let chunk_content = content[start:end].to_owned()
// Callback for progress/validation
let should_continue = callback(chunk_content, i, total_chunks)
if !should_continue {
return
}
accumulated = accumulated + chunk_content
i = i + 1
}
// Write the complete file
try {
@fs.write_file(dst, accumulated)
} catch {
_ => self.add_error("Failed to write chunked file to " + dst)
}
}
///|
/// Progress info for large file uploads.
pub(all) struct UploadProgress {
/// Total file size in bytes
total_size : Int64
/// Bytes processed so far
bytes_processed : Int64
/// Percentage complete (0.0 - 100.0)
percentage : Double
/// Current chunk index
current_chunk : Int
/// Total number of chunks
total_chunks : Int
}
///|
/// Create a progress tracker for large uploads.
pub fn UploadProgress::new(
total_size : Int64,
chunk_size : Int,
) -> UploadProgress {
{
total_size,
bytes_processed: 0L,
percentage: 0.0,
current_chunk: 0,
total_chunks: ((total_size.to_double() / chunk_size.to_double()).ceil()).to_int(),
}
}
///|
/// Update progress after processing a chunk.
pub fn UploadProgress::update(
self : UploadProgress,
chunk_bytes : Int64,
) -> UploadProgress {
let new_processed = self.bytes_processed + chunk_bytes
let pct = if self.total_size > 0L {
new_processed.to_double() / self.total_size.to_double() * 100.0
} else {
100.0
}
{
total_size: self.total_size,
bytes_processed: new_processed,
percentage: pct,
current_chunk: self.current_chunk + 1,
total_chunks: self.total_chunks,
}
}