mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
Some checks failed
📎 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>
128 lines
4.7 KiB
Dart
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);
|
|
}
|