todo-app/lib/desktop/wrapper_image_routes.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

186 lines
6.7 KiB
Dart

/// The desktop wrapper's `/images/*` routes: the web page's only way to
/// store, show and sync attachments (images and any other file).
///
/// The page never talks to dufs itself. That keeps the dufs password out of
/// the browser, needs no CORS on dufs, and gives the desktop the same offline
/// store the phone has (an [ImageCacheStore] on disk). Same-origin with the
/// page, so an `<img src="/images/…">` just works.
///
/// * `POST /images/attach?note=<noteId>&name=<file name>` — a picked or
/// dropped file of any size, streamed to disk; stored as a re-encoded image
/// when it is one, byte-for-byte otherwise. Answers with the [Attachment].
/// * `POST /images/<noteId>/<uuid>.jpg` — raw bytes of a pasted image;
/// re-encoded here (the page must not decode on its UI thread), stored,
/// queued. 415 when the bytes are not an image.
/// * `GET|HEAD /images/<noteId>/<uuid>.<ext>[?name=<file name>]` — the cached
/// file (see `wrapper_attachment_serving.dart`), 404 when absent.
/// * `POST /images/reconcile` — `{"refs": ["<noteId>/<uuid>.<ext>", …]}`:
/// every attachment the notes link to. Uploads the queue, fetches what is
/// missing, evicts the rest; answers with the [ReconcileReport].
/// * `GET /images/status` — `{"configured": bool, "pending": n}`.
library;
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:todo/desktop/dufs_login_env.dart';
import 'package:todo/desktop/wrapper_attachment_serving.dart';
import 'package:todo/desktop/wrapper_paths.dart';
import 'package:todo/images/attachment_ingest.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_processing.dart';
/// The routes the real wrapper uses: cache under
/// `~/.local/share/todo-desktop/images`, login from `todo.env`.
WrapperImageRoutes defaultWrapperImageRoutes(String home) => WrapperImageRoutes(
store: ImageCacheStore(Directory(defaultImageCachePath(home))),
loginEnvPath: defaultDufsLoginEnvPath(home),
);
/// Handles `/images/*` for the wrapper.
class WrapperImageRoutes {
/// Creates the routes over [store], reading the dufs login from
/// [loginEnvPath]. [httpClient] and [prepare] are injectable for tests.
WrapperImageRoutes({
required this.store,
required this.loginEnvPath,
http.Client Function()? httpClient,
Future<Uint8List?> Function(Uint8List)? prepare,
}) : _httpClient = httpClient ?? http.Client.new,
_prepare = prepare ?? prepareImageInBackground,
_ingest = AttachmentIngest(
store,
prepare: prepare ?? prepareImageInBackground,
);
/// The on-disk attachment cache and upload queue.
final ImageCacheStore store;
/// `todo.env` written by `add_dufs_login.sh`.
final String loginEnvPath;
final http.Client Function() _httpClient;
final Future<Uint8List?> Function(Uint8List) _prepare;
final AttachmentIngest _ingest;
/// Every path these routes answer starts with this.
static const prefix = '/images/';
/// Answers [request] and returns true when it is an `/images/` route.
Future<bool> handle(HttpRequest request) async {
final path = request.uri.path;
if (!path.startsWith(prefix)) return false;
final rest = path.substring(prefix.length);
final response = request.response;
final method = request.method;
if (rest == 'attach' && method == 'POST') {
await _attach(request);
} else if (rest == 'reconcile' && method == 'POST') {
await _reconcile(request);
} else if (rest == 'status' && method == 'GET') {
_json(response, {
'configured': _login() != null,
'pending': store.pending().length,
});
} else if (AttachmentRef.tryParse(rest) case final ref?) {
if (method == 'GET' || method == 'HEAD') {
await serveAttachment(
request,
ref,
store.fileFor(ref),
fileName: request.uri.queryParameters['name'],
);
} else if (method == 'POST' && ref.extension == 'jpg') {
await _put(request, ref);
} else {
response.statusCode = HttpStatus.methodNotAllowed;
}
} else {
response.statusCode = HttpStatus.notFound;
}
return true;
}
DufsLogin? _login() => readDufsLoginEnv(File(loginEnvPath));
Future<void> _attach(HttpRequest request) async {
final query = request.uri.queryParameters;
final noteId = query['note'] ?? '';
final response = request.response;
if (!isSafeNoteId(noteId)) {
// Read the body anyway: answering mid-upload looks like a dropped
// connection to the browser, not a 400.
await request.drain<void>();
response.statusCode = HttpStatus.badRequest;
return;
}
try {
final attachment = await _ingest.fromStream(
noteId,
query['name'] ?? '',
request,
);
_json(response, attachment.toJson());
} on FileSystemException catch (e) {
response
..statusCode = HttpStatus.internalServerError
..write(e.message);
}
}
Future<void> _put(HttpRequest request, AttachmentRef ref) async {
final builder = BytesBuilder(copy: false);
var tooLarge = false;
// Read to the end even once over the cap: answering while the browser
// is still sending makes it see a dropped connection, not the 413.
await for (final chunk in request) {
tooLarge =
tooLarge || builder.length + chunk.length > kMaxImageDecodeBytes;
if (!tooLarge) builder.add(chunk);
}
if (tooLarge) {
request.response.statusCode = HttpStatus.requestEntityTooLarge;
return;
}
final jpeg = await _prepare(builder.takeBytes());
if (jpeg == null) {
request.response.statusCode = HttpStatus.unsupportedMediaType;
return;
}
await store.put(ref, jpeg);
request.response.statusCode = HttpStatus.noContent;
}
Future<void> _reconcile(HttpRequest request) async {
final Set<AttachmentRef> refs;
try {
final body = jsonDecode(await utf8.decodeStream(request));
refs = {
for (final raw in (body as Map<String, dynamic>)['refs'] as List)
?AttachmentRef.tryParse(raw as String),
};
} on Object {
request.response.statusCode = HttpStatus.badRequest;
return;
}
final login = _login();
final client = login == null
? null
: DufsImageClient(login, httpClient: _httpClient());
try {
_json(request.response, (await store.reconcile(refs, client)).toJson());
} finally {
client?.close();
}
}
void _json(HttpResponse response, Object body) {
response.headers.contentType = ContentType.json;
response.write(jsonEncode(body));
}
}