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

154 lines
5.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Where a note's attachment lives on dufs.
///
/// A note never stores attachment bytes: it carries a plain markdown link to
/// `https://kuhy-cloud.duckdns.org/todo-images/<noteId>/<uuid>.<ext>` — an
/// image as `![](…)`, any other file as `[name](…)` — so the CRDT log, the
/// sync format and every older build stay untouched (an old build just shows
/// the link as text). This file owns the stored path's shape;
/// `attachment_links.dart` owns the links that point at it.
///
/// Pure Dart: the desktop wrapper (`bin/todo_desktop.dart`) imports it.
library;
import 'package:uuid/uuid.dart';
/// Public origin of kuhy's dufs server (Caddy → dufs on 127.0.0.1:5000).
const kDufsBaseUrl = 'https://kuhy-cloud.duckdns.org';
/// The dufs folder the `todo` login is scoped to. Named for images, which
/// came first; it holds every kind of attachment.
const kImagesDir = 'todo-images';
/// Where the same folder sits on kuhy's PC, as written into `BACKLOG.md` so an
/// agent reading the backlog can open the attachment straight from disk.
const kBacklogImagesRoot = '~/data/cloud/$kImagesDir';
/// Extension given to a file whose name has none usable.
const kUnknownExtension = 'bin';
// Segments are restricted to URL- and filesystem-safe characters, which is
// what makes a ref safe to join onto a cache directory without a traversal
// check of its own. The single, dot-free extension also keeps the cache's
// `.pending` and `.tmp` companions from ever parsing as refs.
const _segment = '[A-Za-z0-9_-]+';
const _extension = '[a-z0-9]{1,10}';
/// The pattern of a stored path, `<noteId>/<uuid>.<ext>`, for link regexes.
const kAttachmentPathPattern = '$_segment/$_segment\\.$_extension';
final _refPath = RegExp('^$kAttachmentPathPattern\$');
final _usableExtension = RegExp('^$_extension\$');
final _noteId = RegExp('^$_segment\$');
/// Whether [noteId] can name an attachment folder.
bool isSafeNoteId(String noteId) => _noteId.hasMatch(noteId);
/// One stored attachment, addressed as `<noteId>/<file>` under [kImagesDir].
///
/// An extension type over that relative path: equality and hashing are the
/// string's, so refs drop straight into sets.
extension type const AttachmentRef._(String relativePath) {
/// A new, unique ref for an image being attached to [noteId]. Images are
/// always stored re-encoded, hence always `.jpg`.
factory AttachmentRef.create(String noteId) => _fresh(noteId, 'jpg');
/// A new, unique ref for the file [fileName] being attached to [noteId],
/// keeping its extension when that is short and plain, else
/// [kUnknownExtension].
factory AttachmentRef.forFile(String noteId, String fileName) =>
_fresh(noteId, extensionOf(fileName));
static AttachmentRef _fresh(String noteId, String extension) {
final ref = tryParse('$noteId/${const Uuid().v4()}.$extension');
if (ref == null) throw ArgumentError.value(noteId, 'noteId');
return ref;
}
/// Parses `<noteId>/<file>.<ext>`, or null when it is not a safe path.
static AttachmentRef? tryParse(String relativePath) =>
_refPath.hasMatch(relativePath) ? AttachmentRef._(relativePath) : null;
/// The note the attachment was added to (it names the folder only).
String get noteId => relativePath.substring(0, relativePath.indexOf('/'));
/// `<uuid>.<ext>`.
String get file => relativePath.substring(relativePath.indexOf('/') + 1);
/// The lower-case extension, without its dot.
String get extension =>
relativePath.substring(relativePath.lastIndexOf('.') + 1);
/// The public dufs URL stored in the note.
String get url => '$kDufsBaseUrl/$kImagesDir/$relativePath';
/// The markdown line that shows this as an inline image.
String get imageMarkdown => '![]($url)';
/// The markdown line that links this as the file [name].
String fileMarkdown(String name) => '[${linkLabel(name)}]($url)';
}
/// [fileName]'s extension, lower-cased, when it is 1–10 letters or digits;
/// otherwise [kUnknownExtension].
String extensionOf(String fileName) {
final dot = fileName.lastIndexOf('.');
if (dot <= 0) return kUnknownExtension;
final extension = fileName.substring(dot + 1).toLowerCase();
return _usableExtension.hasMatch(extension) ? extension : kUnknownExtension;
}
/// [name] made safe as a markdown link label: brackets would end the label
/// early and a line break would split the link, so both are replaced.
String linkLabel(String name) {
final label = name
.replaceAll('[', '(')
.replaceAll(']', ')')
.replaceAll(RegExp(r'[\r\n]+'), ' ')
.trim();
return label.isEmpty ? 'file' : label;
}
/// An attachment as inserted into a note: the stored ref, the name it was
/// picked under, and whether it is shown as an image.
class Attachment {
/// Creates an attachment.
const Attachment({
required this.ref,
required this.name,
required this.isImage,
});
/// Where it is stored.
final AttachmentRef ref;
/// The file name it was picked or dropped under.
final String name;
/// True for a re-encoded image (`![](…)`), false for a plain file.
final bool isImage;
/// The line inserted into the note.
String get markdown => isImage ? ref.imageMarkdown : ref.fileMarkdown(name);
/// JSON form, the desktop wrapper's answer to an attach.
Map<String, Object> toJson() => {
'ref': ref.relativePath,
'name': name,
'image': isImage,
};
/// Parses [toJson]'s output; throws [FormatException] on anything else.
static Attachment fromJson(Object? json) {
if (json case {
'ref': final String path,
'name': final String name,
'image': final bool isImage,
}) {
if (AttachmentRef.tryParse(path) case final ref?) {
return Attachment(ref: ref, name: name, isImage: isImage);
}
}
throw FormatException('Not an attachment: $json');
}
}