todo-app/lib/images/dufs_image_client.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

146 lines
4.3 KiB
Dart

/// The three WebDAV calls attachment sync needs, against kuhy's dufs.
///
/// Deliberately not dufs-cloud's full `DufsClient` (~700 lines with listing,
/// moves, streaming progress and an xml parser): todo only ever puts, gets and
/// checks one folder. dufs creates missing parent folders on PUT (verified
/// against dufs 0.46.0), so there is no MKCOL step.
///
/// Bodies stream both ways: an attachment can be gigabytes, and neither the
/// phone nor the wrapper may hold one in memory.
///
/// Pure Dart: the desktop wrapper imports it.
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:todo/images/attachment_ref.dart';
/// A scoped dufs login, e.g. `todo` → `/todo-images`.
class DufsLogin {
/// Creates a login for [user] with [password] against [baseUrl].
const DufsLogin({
required this.user,
required this.password,
this.baseUrl = kDufsBaseUrl,
});
/// The login name (`add_dufs_login.sh`'s first argument).
final String user;
/// Its generated password.
final String password;
/// The dufs origin, without a trailing slash.
final String baseUrl;
}
/// Outcome of [DufsImageClient.check], for the settings screen.
enum DufsCheck {
/// The login can see its folder.
ok,
/// dufs refused the login (401/403).
rejected,
/// dufs answered something else, or could not be reached.
unreachable,
}
/// Upload / download / check for attachments under [kImagesDir].
class DufsImageClient {
/// Creates a client for [login]; [httpClient] is injectable for tests.
DufsImageClient(this.login, {http.Client? httpClient})
: _http = httpClient ?? http.Client();
/// The credentials every request carries.
final DufsLogin login;
final http.Client _http;
Map<String, String> get _auth {
final pair = utf8.encode('${login.user}:${login.password}');
return {'authorization': 'Basic ${base64.encode(pair)}'};
}
Uri _uri(String relative) =>
Uri.parse('${login.baseUrl}/$kImagesDir/$relative');
/// PUTs the [length] bytes of [body] as [ref]; true on a 2xx.
///
/// [body] is only listened to once the connection is up, so a file stream
/// passed here is never read when dufs is unreachable.
Future<bool> upload(
AttachmentRef ref,
Stream<List<int>> body,
int length,
) async {
try {
final request = _StreamBodyRequest('PUT', _uri(ref.relativePath), body)
..contentLength = length
..headers.addAll({
..._auth,
'content-type': ref.extension == 'jpg'
? 'image/jpeg'
: 'application/octet-stream',
});
final response = await _http.send(request);
await response.stream.drain<void>();
return response.statusCode >= 200 && response.statusCode < 300;
} on Exception {
return false;
}
}
/// GETs [ref], returning its body as a stream, or null when it is missing
/// or unreachable. The caller must consume the stream.
Future<Stream<List<int>>?> openDownload(AttachmentRef ref) async {
try {
final request = http.Request('GET', _uri(ref.relativePath))
..headers.addAll(_auth);
final response = await _http.send(request);
if (response.statusCode == 200) return response.stream;
await response.stream.drain<void>();
return null;
} on Exception {
return null;
}
}
/// Whether the login can see its folder (PROPFIND, depth 0).
Future<DufsCheck> check() async {
try {
final request = http.Request('PROPFIND', _uri(''))
..headers.addAll({..._auth, 'depth': '0'});
final response = await _http.send(request);
await response.stream.drain<void>();
return switch (response.statusCode) {
207 => DufsCheck.ok,
401 || 403 => DufsCheck.rejected,
_ => DufsCheck.unreachable,
};
} on Exception {
return DufsCheck.unreachable;
}
}
/// Releases the underlying HTTP client.
void close() => _http.close();
}
/// A request whose body is an existing stream, pulled by the client as it
/// sends. Unlike `http.StreamedRequest`, nothing is buffered when the
/// connection fails before the body is wanted.
class _StreamBodyRequest extends http.BaseRequest {
_StreamBodyRequest(super.method, super.url, this._body);
final Stream<List<int>> _body;
@override
http.ByteStream finalize() {
super.finalize();
return http.ByteStream(_body);
}
}