mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 15:08:26 +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>
159 lines
4.9 KiB
Dart
159 lines
4.9 KiB
Dart
/// Desktop (web build): every attachment operation goes through the local
|
|
/// wrapper's `/images/*` routes; the page never sees the dufs password.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'package:todo/attachments/file_post.dart' as file_post;
|
|
import 'package:todo/attachments/image_store_api.dart';
|
|
import 'package:todo/images/attachment_ref.dart';
|
|
import 'package:todo/sync/desktop_wrapper.dart';
|
|
|
|
/// Opens the store against the wrapper that served this page.
|
|
Future<ImageStore> openImageStore() async =>
|
|
WebImageStore(origin: servingWrapperOrigin());
|
|
|
|
/// Sends a file to the wrapper and returns its answer; injectable for tests.
|
|
typedef FilePost =
|
|
Future<({int status, String body})> Function(Uri url, XFile file);
|
|
|
|
/// [ImageStore] backed by the desktop wrapper at [origin].
|
|
class WebImageStore extends ImageStore {
|
|
/// Creates a store talking to [origin]; [httpClient] and [postFile] are
|
|
/// injectable.
|
|
WebImageStore({
|
|
required this.origin,
|
|
http.Client? httpClient,
|
|
FilePost? postFile,
|
|
}) : _http = httpClient ?? http.Client(),
|
|
_postFile = postFile ?? file_post.postFile;
|
|
|
|
/// The wrapper's origin, e.g. `http://localhost:8730`.
|
|
final String origin;
|
|
|
|
final http.Client _http;
|
|
final FilePost _postFile;
|
|
|
|
// Sizes the wrapper reported. A ref's bytes never change (a fresh uuid per
|
|
// attachment), so a known size never goes stale.
|
|
final Map<AttachmentRef, int> _sizes = {};
|
|
|
|
// Bumped whenever the wrapper may hold new bytes, so an image that failed
|
|
// to load (not downloaded yet) is requested again under a fresh URL.
|
|
int _revision = 0;
|
|
|
|
Uri _images(String rest) => Uri.parse('$origin/images/$rest');
|
|
|
|
@override
|
|
Future<AttachmentRef?> attach(String noteId, Uint8List bytes) async {
|
|
final ref = AttachmentRef.create(noteId);
|
|
final http.Response response;
|
|
try {
|
|
response = await _http.post(_images(ref.relativePath), body: bytes);
|
|
} on Exception {
|
|
throw const ImageStoreException(
|
|
'The desktop helper is not running; the image was not saved',
|
|
);
|
|
}
|
|
if (response.statusCode == 415) return null;
|
|
if (response.statusCode != 204) {
|
|
throw ImageStoreException(
|
|
'The desktop helper refused the image (${response.statusCode})',
|
|
);
|
|
}
|
|
_revision++;
|
|
notifyListeners();
|
|
unawaited(_reconcile(const {}));
|
|
return ref;
|
|
}
|
|
|
|
@override
|
|
Future<Attachment> attachFile(String noteId, XFile file) async {
|
|
final url = _images(
|
|
'attach',
|
|
).replace(queryParameters: {'note': noteId, 'name': file.name});
|
|
final ({int status, String body}) response;
|
|
try {
|
|
response = await _postFile(url, file);
|
|
} on Object {
|
|
throw ImageStoreException(
|
|
'The desktop helper is not running; ${file.name} was not saved',
|
|
);
|
|
}
|
|
if (response.status != 200) {
|
|
throw ImageStoreException(
|
|
'The desktop helper refused ${file.name} (${response.status})',
|
|
);
|
|
}
|
|
final attachment = Attachment.fromJson(jsonDecode(response.body));
|
|
_revision++;
|
|
notifyListeners();
|
|
unawaited(_reconcile(const {}));
|
|
return attachment;
|
|
}
|
|
|
|
@override
|
|
Future<void> reconcile(Iterable<String> noteTexts) =>
|
|
_reconcile(attachmentRefsAcross(noteTexts));
|
|
|
|
Future<void> _reconcile(Set<AttachmentRef> refs) async {
|
|
try {
|
|
final response = await _http.post(
|
|
_images('reconcile'),
|
|
headers: {'content-type': 'application/json'},
|
|
body: jsonEncode({
|
|
'refs': [for (final ref in refs) ref.relativePath],
|
|
}),
|
|
);
|
|
if (response.statusCode != 200) return;
|
|
final report = jsonDecode(response.body) as Map<String, dynamic>;
|
|
if ((report['downloaded'] as int? ?? 0) > 0) {
|
|
_revision++;
|
|
notifyListeners();
|
|
}
|
|
} on Exception {
|
|
// Best-effort: the next sync, resume or attach reconciles again.
|
|
}
|
|
}
|
|
|
|
@override
|
|
ImageSource sourceFor(AttachmentRef ref) =>
|
|
NetworkImageSource('$origin/images/${ref.relativePath}?r=$_revision');
|
|
|
|
@override
|
|
bool isPending(AttachmentRef ref) => false;
|
|
|
|
@override
|
|
Future<int?> sizeOf(AttachmentRef ref) async {
|
|
if (_sizes[ref] case final known?) return known;
|
|
try {
|
|
final response = await _http.head(_images(ref.relativePath));
|
|
final length = response.statusCode == 200
|
|
? int.tryParse(response.headers['content-length'] ?? '')
|
|
: null;
|
|
if (length != null) _sizes[ref] = length;
|
|
return length;
|
|
} on Exception {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<ImageStoreStatus> status() async {
|
|
try {
|
|
final response = await _http.get(_images('status'));
|
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
|
return ImageStoreStatus(
|
|
configured: body['configured'] as bool? ?? false,
|
|
pending: body['pending'] as int? ?? 0,
|
|
);
|
|
} on Exception {
|
|
return const ImageStoreStatus(configured: false, pending: 0);
|
|
}
|
|
}
|
|
}
|