todo-app/lib/desktop/wrapper_attachment_serving.dart
Krzysztof kuhy Rudnicki 9f2ffdb8a9
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Attach any file to a note, of any size, not only images
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.

There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.

The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.

Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:17:06 +02:00

128 lines
4.7 KiB
Dart

/// Serving one cached attachment from the desktop wrapper, for an `<img>` in
/// the page or a file opened in a new tab.
///
/// The wrapper's origin also holds the GitHub token (localStorage) and the
/// notes (IndexedDB), so an attached `.html` or `.svg` rendered here would
/// run script with access to both. Only types that cannot script are shown
/// inline — raster images, PDF, plain text, audio and video — always with
/// `nosniff` so the browser cannot second-guess the type; everything else is
/// sent as a download.
///
/// Single byte ranges are honoured so a video can seek.
library;
import 'dart:io';
import 'package:todo/images/attachment_ref.dart';
/// The content type for an attachment with [extension], and whether it may
/// be shown inline (see the library comment).
({ContentType type, bool inline}) attachmentContentType(String extension) {
final type = switch (extension) {
'jpg' || 'jpeg' => ContentType('image', 'jpeg'),
'png' || 'gif' || 'webp' || 'bmp' => ContentType('image', extension),
'pdf' => ContentType('application', 'pdf'),
'txt' ||
'md' ||
'log' ||
'csv' => ContentType('text', 'plain', charset: 'utf-8'),
'mp4' || 'm4v' => ContentType('video', 'mp4'),
'webm' => ContentType('video', 'webm'),
'ogv' => ContentType('video', 'ogg'),
'mov' => ContentType('video', 'quicktime'),
'mp3' => ContentType('audio', 'mpeg'),
'm4a' => ContentType('audio', 'mp4'),
'ogg' || 'oga' || 'opus' => ContentType('audio', 'ogg'),
'wav' => ContentType('audio', 'wav'),
'flac' => ContentType('audio', 'flac'),
_ => null,
};
return type == null
? (type: ContentType.binary, inline: false)
: (type: type, inline: true);
}
/// `Content-Disposition` for [fileName]: RFC 6266 with an RFC 5987
/// `filename*`, so any name (spaces, Polish letters, quotes) survives.
String contentDisposition(String fileName, {required bool inline}) {
final encoded = Uri.encodeComponent(fileName).replaceAllMapped(
RegExp("[!'()*]"),
(m) => '%${m[0]!.codeUnitAt(0).toRadixString(16).toUpperCase()}',
);
return "${inline ? 'inline' : 'attachment'}; filename*=UTF-8''$encoded";
}
/// Answers a GET or HEAD for [ref] from [file], named [fileName] when the
/// page passed one (else the stored name). 404 when it is not cached.
Future<void> serveAttachment(
HttpRequest request,
AttachmentRef ref,
File file, {
String? fileName,
}) async {
final response = request.response;
if (!file.existsSync()) {
response.statusCode = HttpStatus.notFound;
return;
}
final length = await file.length();
final kind = attachmentContentType(ref.extension);
response.headers
..contentType = kind.type
..set('x-content-type-options', 'nosniff')
..set(HttpHeaders.acceptRangesHeader, 'bytes')
..set(
'content-disposition',
contentDisposition(fileName ?? ref.file, inline: kind.inline),
)
// The name is a fresh uuid per attachment, so the bytes never change.
..set(HttpHeaders.cacheControlHeader, 'private, max-age=31536000');
var start = 0;
var end = length - 1;
final range = request.headers.value(HttpHeaders.rangeHeader);
if (range != null) {
final parsed = parseByteRange(range, length);
if (parsed == null) {
response
..statusCode = HttpStatus.requestedRangeNotSatisfiable
..headers.set(HttpHeaders.contentRangeHeader, 'bytes */$length');
return;
}
(start, end) = parsed;
response
..statusCode = HttpStatus.partialContent
..headers.set(
HttpHeaders.contentRangeHeader,
'bytes $start-$end/$length',
);
}
response.contentLength = length == 0 ? 0 : end - start + 1;
if (request.method == 'HEAD' || length == 0) return;
// A client leaving mid-body (a video seek abandons its old range) does not
// throw here: dart:io absorbs it and the stream just stops being read.
await response.addStream(file.openRead(start, end + 1));
}
/// The inclusive byte range a single-range `Range` header asks for within
/// [length] bytes, or null when it is malformed, multi-range or outside the
/// file.
(int, int)? parseByteRange(String header, int length) {
final match = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(header.trim());
if (match == null) return null;
final first = int.tryParse(match[1]!);
final last = int.tryParse(match[2]!);
final int start;
final int end;
if (first == null) {
// `bytes=-N`: the final N bytes.
if (last == null || last == 0) return null;
start = last >= length ? 0 : length - last;
end = length - 1;
} else {
start = first;
end = last == null || last >= length ? length - 1 : last;
}
if (start >= length || start > end) return null;
return (start, end);
}