Skip to content

Handle file uploads

You’ll accept an avatar upload with validation, a multi-file import, a streamed large upload and a raw binary PUT, with size limits on each.

  • A gisila project. Somewhere to put files (local disk here; object storage works the same way).

Uploads should never be unbounded. Set an app-wide default and raise it only on upload routes:

import 'dart:io';
import 'package:gisila/gisila.dart';
final config = AppConfig(maxRequestBodyBytes: 1024 * 1024); // 1 MiB everywhere
@Post('/avatars', config: RouteConfig(maxBodyBytes: 5 * 1024 * 1024))

Both caps apply, so the route can only be raised up to the app-wide one: set the app default to your largest route’s limit, and lower it per route where needed. Oversized bodies are refused with 413 while streaming, so nothing over the limit is buffered.

2. A single file with validation: FileField

Section titled “2. A single file with validation: FileField”
class AvatarForm extends Form {
final file = FileField(
name: 'file',
required: true,
allowedContentTypes: {'image/png', 'image/jpeg', 'image/webp'},
);
final alt = StringField(name: 'alt', maxLength: 200);
@override
List<FormField<Object?>> collectFields() => [file, alt];
}
@Post('/avatars', config: RouteConfig(maxBodyBytes: 5 * 1024 * 1024))
@RequireAuth()
Future<Map<String, Object?>> uploadAvatar(AvatarForm form, RequestContext ctx) async {
final upload = form.file.value!;
final bytes = await upload.readAsBytes();
final path = 'storage/avatars/${ctx.principal!.id}.${upload.contentType.subType}';
await File(path).writeAsBytes(bytes);
return {'path': path, 'size': bytes.length, 'alt': form.alt.value};
}

allowedContentTypes checks the type the client declared. Don’t trust it for anything security-relevant: check magic bytes if it matters, and never use upload.name as a path.

A parameter of type UploadedFile reads the multipart field with the same name:

@Post('/attachments')
@RequireAuth()
Future<Map<String, Object?>> attach(UploadedFile file) async {
final bytes = await file.readAsBytes();
return {'filename': file.name, 'size': bytes.length};
}

A missing field is a 400. Make the parameter nullable (UploadedFile? file) to make it optional.

@Post('/imports')
@Permissions({'data:write'})
Future<Map<String, Object?>> importFiles(FormData body, ImportService imports) async {
final mode = body.fields['mode'] ?? 'append';
final files = body.files.values.toList();
if (files.isEmpty) throw BadRequest('Attach at least one file', code: 'no_files');
var rows = 0;
for (final f in files) {
rows += await imports.importCsv(await f.readAsBytes(), mode: mode);
}
return {'files': files.length, 'rows': rows};
}

The multipart parser caps bodies at 1000 parts and text fields at 1 MiB each; both are a 400 when exceeded.

readAsBytes() holds the whole file in memory. For large files, pipe the stream instead:

@Post('/videos', config: RouteConfig(maxBodyBytes: 500 * 1024 * 1024))
@RequireAuth()
Future<Map<String, Object?>> uploadVideo(UploadedFile file) async {
final out = File('storage/videos/${DateTime.now().microsecondsSinceEpoch}.mp4').openWrite();
await out.addStream(file.openRead());
await out.close();
return {'ok': true};
}

Each file’s stream can be read only once.

For a direct PUT of bytes (no multipart), take the Request:

@Put('/blobs/{id}', config: RouteConfig(maxBodyBytes: 50 * 1024 * 1024))
@RequireAuth()
Future<void> putBlob(String id, Request request) async {
final out = File('storage/blobs/$id').openWrite();
await out.addStream(request.read());
await out.close();
}
Terminal window
curl -s -F file=@me.png -F alt='My face' localhost:8080/avatars -H "authorization: Bearer $TOKEN"
# {"path":"storage/avatars/42.png","size":48213,"alt":"My face"}
curl -s -F file=@notes.txt localhost:8080/avatars -H "authorization: Bearer $TOKEN"
# 400 ... "details":{"file":"Unsupported file type \"text/plain\". Allowed: image/png, image/jpeg, image/webp"}
curl -s -X PUT --data-binary @big.bin -H 'content-type: application/octet-stream' \
localhost:8080/blobs/1 -H "authorization: Bearer $TOKEN" -o /dev/null -w '%{http_code}\n'
# 200, or 413 when over the cap