File Storage
Storage() saves and reads files on a disk: the local folder, AWS S3 (and R2, MinIO, Spaces), or memory.
It is in the dox_storage package. A new project already has it.
import 'package:dox_storage/dox_storage.dart';
Route.post('/avatar', (DoxRequest req) async {
RequestFile file = req.input('avatar');
String path = await Storage().putRequestFile('avatars', file);
return <String, String>{'path': path}; // /avatars/<uuid>.png
});
Config
A new project already has lib/config/storage.dart, with one local disk in the storage folder.
STORAGE_DISK in .env picks the default disk.
To add disks, edit the file. This example adds a url to the local disk, an s3 disk and a memory disk:
import 'package:dox_storage/dox_storage.dart';
import 'package:my_app/config/env.dart';
StorageConfig storageConfig = StorageConfig(
defaultDisk: env.storageDisk,
disks: <String, Map<String, dynamic>>{
'local': <String, dynamic>{
'driver': 'local',
'root': 'storage',
'url': 'https://app.test/files', // for signedUrl(); url() gives /storage/<path> without it
},
/// only when AWS_BUCKET is set
if (env.awsBucket != null)
's3': <String, dynamic>{
'driver': 's3',
'bucket': env.awsBucket,
'region': env.awsDefaultRegion,
'key': env.awsAccessKeyId,
'secret': env.awsSecretAccessKey,
'endpoint': env.awsEndpoint, // R2, MinIO, Spaces
},
'memory': <String, dynamic>{'driver': 'memory'},
},
);
A local disk writes a file in place by default. To write to a temp file and rename it when the write is done, add 'atomic_writes': true to the disk. A failed or stopped upload then never leaves a partial file, and the old file stays. It is off by default.
The rename makes a new file. So the folder must be writable, the file gets the default mode and the owner of the app, a hard link keeps the old content, and a symlink is replaced.
The s3 disk reads five keys that a new project does not have yet. Add them to
lib/config/env.dart and run dart run build_runner build. They are nullable,
and the s3 disk is only added when AWS_BUCKET is set. So the app starts without them:
String? get awsBucket;
String get awsDefaultRegion => 'us-east-1';
String? get awsAccessKeyId;
String? get awsSecretAccessKey;
/// R2, MinIO or Spaces. Null for AWS.
String? get awsEndpoint;
The provider of a new project sets these disks in every isolate. It is in lib/providers/, so the generated container finds it:
import 'package:dox_storage/dox_storage.dart';
import 'package:my_app/config/storage.dart';
class AppStorageProvider extends StorageProvider {
AppStorageProvider() : super(storageConfig);
}
| Driver | Options |
|---|---|
local |
root (default storage), url, app_key (default: your APP_KEY), visibility |
s3 |
bucket, region, key, secret, token, endpoint, path_style (MinIO), url (CDN), visibility, acl, batch_delete |
memory |
none. For tests |
The s3 disk needs bucket, key and secret. When one is missing, the app stops at start with an ArgumentError that names the setting.
Functions
// save with a new uuid file name, returns '/images/<uuid>.png'
String path = await Storage().put('images', bytes, extension: 'png');
String path2 = await Storage().putRequestFile('images', file);
String path3 = await Storage().putRequestFileAs('avatars', 'user-1.png', file); // your own name
Uint8List? bytes = await Storage().get(path); // null when missing
bool found = await Storage().exists(path); // files only
bool folder = await Storage().directoryExists('images');
await Storage().delete(path);
int size = await Storage().size(path); // throws when missing
DateTime changed = await Storage().lastModified(path); // throws when missing
await Storage().append('logs/app.log', 'new line'); // a missing file is made
await Storage().prepend('logs/app.log', 'first line');
await Storage().makeDirectory('reports'); // local disk only
String url = await Storage().url(path); // /storage/<path> when the disk has no `url`
String signed = await Storage().signedUrl(path, expiresIn: Duration(minutes: 10));
String same = await Storage().temporaryUrl(path); // the same as signedUrl()
// another disk
await Storage().disk('s3').put('reports', bytes, extension: 'pdf');
On a local disk with no url option, url() gives /storage/<path> (no host). Dox serves it only when you add Storage.route('storage', signed: false). signedUrl() still needs url.
get() gives null for a missing file, but size() and lastModified() throw FileNotFoundException.
append() and prepend() add a new line between the old and the new text, but only when the file is not empty. Change it with separator.
They keep the visibility and content type of the file.
They read the file and write it back, so two writers at the same time can lose a write.
makeDirectory() makes a folder only on a local disk. On any other disk it does nothing.
exists() finds files only. directoryExists() finds folders. On a local disk it checks the real folder. On S3 and memory disks, a folder exists when a file is under it.
putRequestFile on the local disk moves the upload temp file. It does not read it into memory.
An uploaded file can store itself. store gives it a new uuid name. storeAs uses your name, and replaces an old file with the same name. Both take disk::
RequestFile file = req.input('image');
String path = await file.store('images');
String onS3 = await file.store('images', disk: 's3');
String avatar = await file.storeAs('avatars', 'user-1.png');
storeAs and putRequestFileAs use the name as it is. Do not use the file name from the client. Make your own name, like user-1.png.
putRequestFile and file.store() take the extension from the file name. When the bytes are a known format (png, jpg, pdf, zip, ...) that does not match the name, the extension of the bytes is used. So a png sent as a.html is saved as .png and is never served as a web page.
stream and download
Route.get('/photo', (DoxRequest req) {
return Storage().stream(req.input('path')); // shown in the browser
});
Route.get('/report', (DoxRequest req) {
return Storage().disk('s3').download('reports/2024.pdf'); // saved as a file
});
Both read the file as a stream, from any disk, with Content-Length.
A big file is never loaded into memory. A missing file answers 404.
Full disk API
Storage().use('s3') returns the dox_storage Disk: copy, move, listAll, getMetaData,
putStream, setVisibility and more. See dox_storage.
Serve local signed URLs
// lib/routes/web.dart
Storage.route('files'); // GET /files/{key*}, signed URLs only
Storage.route('public', signed: false); // any file of the disk
Storage.route('docs', disk: 'memory', signed: false); // another disk than the default
Storage.route('uploads', download: true); // every file is a download
An html, svg, xml or javascript file is always sent as a download in a sandbox, so its script does not run on your site.
Every file has X-Content-Type-Options: nosniff.
String url = await Storage().signedUrl('invoices/1.pdf'); // https://app.test/files/invoices/1.pdf?expires=...&signature=...
A missing, wrong or expired signature answers 403.
Signed URLs work only on a local disk. For another disk, use signed: false.
A signed URL works only on the route of its own disk.
An old signed URL, made before the disk name was part of the signature, still works on any local disk with the same app key. This ends when it expires.
Set the disk url option to the full URL of the route (https://app.test/files).
Tests
To swap a disk with a memory disk in tests, see Testing.
Your own driver
Implement DriverContract from package:dox_storage/dox_storage.dart. Register it in a provider, so every isolate has it: