feature/meldingen-hardening #6

Merged
brenno merged 5 commits from feature/meldingen-hardening into main 2026-06-11 20:40:08 +00:00
16 changed files with 346 additions and 91 deletions
Showing only changes of commit ee9e2bfc58 - Show all commits

View file

@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../utils/log.dart';
/// Directory (relative to the deck) where linked chart CSVs are kept, so the /// Directory (relative to the deck) where linked chart CSVs are kept, so the
/// data files stay tidily in one place separate from images/media. /// data files stay tidily in one place separate from images/media.
const String chartDataDirName = 'data'; const String chartDataDirName = 'data';
@ -155,7 +157,8 @@ class ChartSpec {
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)), ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
], ],
); );
} catch (_) { } catch (e, s) {
logError('ChartSpec.parse: decode chart JSON block', e, s);
return const ChartSpec(); return const ChartSpec();
} }
} }

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import '../models/annotation.dart'; import '../models/annotation.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import '../utils/log.dart';
/// Serializes the annotation layer into a sidecar payload that is fully /// Serializes the annotation layer into a sidecar payload that is fully
/// decoupled from the Marp markdown. /// decoupled from the Marp markdown.
@ -96,7 +97,8 @@ class AnnotationCodec {
used.add(target); used.add(target);
result[slides[target].id] = strokes; result[slides[target].id] = strokes;
} }
} catch (_) { } catch (e, s) {
logError('AnnotationCodec.decode: decode annotation sidecar JSON', e, s);
return {}; return {};
} }
return result; return result;

View file

@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding. /// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding.
/// Bestandsnaam: .ocideck_captions.json /// Bestandsnaam: .ocideck_captions.json
class CaptionService { class CaptionService {
@ -17,7 +19,8 @@ class CaptionService {
final data = jsonDecode(await file.readAsString()) as Map; final data = jsonDecode(await file.readAsString()) as Map;
final caption = data[p.basename(resolvedPath)]; final caption = data[p.basename(resolvedPath)];
return caption is String ? caption : null; return caption is String ? caption : null;
} catch (_) { } catch (e) {
logWarning('CaptionService.getCaption: read caption sidecar', e);
return null; return null;
} }
} }
@ -36,7 +39,9 @@ class CaptionService {
data = Map<String, dynamic>.from( data = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map, jsonDecode(await file.readAsString()) as Map,
); );
} catch (_) {} } catch (e, s) {
logError('CaptionService.saveCaption: parse existing sidecar', e, s);
}
} }
final key = p.basename(resolvedPath); final key = p.basename(resolvedPath);
if (caption.trim().isEmpty) { if (caption.trim().isEmpty) {

View file

@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Stores short, searchable image descriptions as a JSON sidecar in the image's /// Stores short, searchable image descriptions as a JSON sidecar in the image's
/// own directory. File name: .ocideck_descriptions.json, keyed by base name. /// own directory. File name: .ocideck_descriptions.json, keyed by base name.
/// ///
@ -19,7 +21,11 @@ class DescriptionService {
final data = jsonDecode(await file.readAsString()) as Map; final data = jsonDecode(await file.readAsString()) as Map;
final value = data[p.basename(imagePath)]; final value = data[p.basename(imagePath)];
return value is String ? value : null; return value is String ? value : null;
} catch (_) { } catch (e) {
logWarning(
'DescriptionService.getDescription: read description sidecar',
e,
);
return null; return null;
} }
} }
@ -33,7 +39,13 @@ class DescriptionService {
data = Map<String, dynamic>.from( data = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map, jsonDecode(await file.readAsString()) as Map,
); );
} catch (_) {} } catch (e, s) {
logError(
'DescriptionService.saveDescription: parse existing sidecar',
e,
s,
);
}
} }
final key = p.basename(imagePath); final key = p.basename(imagePath);
if (description.trim().isEmpty) { if (description.trim().isEmpty) {
@ -71,7 +83,9 @@ class DescriptionService {
result[p.join(dir, entry.key as String)] = entry.value as String; result[p.join(dir, entry.key as String)] = entry.value as String;
} }
} }
} catch (_) {} } catch (e) {
logWarning('DescriptionService.loadFor: read description sidecar', e);
}
} }
return result; return result;
} }

View file

@ -10,6 +10,7 @@ import '../l10n/app_localizations.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../models/chart.dart'; import '../models/chart.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import '../utils/log.dart';
import 'annotation_codec.dart'; import 'annotation_codec.dart';
import 'caption_service.dart'; import 'caption_service.dart';
import 'image_service.dart'; import 'image_service.dart';
@ -64,6 +65,19 @@ class FileService {
ThemeProfile activeProfileFor({String? projectPath}) => ThemeProfile activeProfileFor({String? projectPath}) =>
resolveThemeProfile(_themeProfile(), projectPath: projectPath); resolveThemeProfile(_themeProfile(), projectPath: projectPath);
/// Resolve a project-relative [path] to an absolute path strictly inside
/// [projectPath], or null for absolute paths or `../` escapes. Used for file
/// references an untrusted deck controls (e.g. a chart's linked CSV) so it
/// can't read arbitrary files outside its own folder.
static String? _projectFile(String? projectPath, String path) {
if (projectPath == null || path.trim().isEmpty || p.isAbsolute(path)) {
return null;
}
final abs = p.normalize(p.join(projectPath, path));
if (abs != projectPath && !p.isWithin(projectPath, abs)) return null;
return abs;
}
ThemeProfile resolveThemeProfile( ThemeProfile resolveThemeProfile(
ThemeProfile profile, { ThemeProfile profile, {
String? projectPath, String? projectPath,
@ -112,7 +126,11 @@ class FileService {
List<FileSystemEntity> entries; List<FileSystemEntity> entries;
try { try {
entries = await dir.list(followLinks: false).toList(); entries = await dir.list(followLinks: false).toList();
} catch (_) { } catch (e) {
logWarning(
'FileService.scanPresentations: directory listing failed',
e,
);
return; return;
} }
for (final entity in entries) { for (final entity in entries) {
@ -124,7 +142,8 @@ class FileService {
String content; String content;
try { try {
content = await entity.readAsString(); content = await entity.readAsString();
} catch (_) { } catch (e) {
logWarning('FileService.scanPresentations: file not readable', e);
continue; continue;
} }
final deck = await openDeck(entity.path, content: content); final deck = await openDeck(entity.path, content: content);
@ -190,8 +209,9 @@ class FileService {
hydrated.slides, hydrated.slides,
); );
if (map.isNotEmpty) return hydrated.copyWith(annotations: map); if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
} catch (_) { } catch (e) {
// A broken sidecar must never block opening the deck. // A broken sidecar must never block opening the deck.
logWarning('FileService.openDeck: annotation sidecar unreadable', e);
} }
} }
} }
@ -229,11 +249,11 @@ class FileService {
slides.add(s); slides.add(s);
continue; continue;
} }
final abs = p.isAbsolute(spec.source!) // A chart's CSV link must stay inside the project (no absolute paths or
? spec.source! // `../` escapes) otherwise an untrusted deck could read arbitrary files.
: p.join(deck.projectPath!, spec.source!); final abs = _projectFile(deck.projectPath, spec.source!);
final file = File(abs); final file = abs == null ? null : File(abs);
if (!await file.exists()) { if (file == null || !await file.exists()) {
slides.add(s); slides.add(s);
continue; continue;
} }
@ -241,7 +261,8 @@ class FileService {
final csv = await file.readAsString(); final csv = await file.readAsString();
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock())); slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
changed = true; changed = true;
} catch (_) { } catch (e) {
logWarning('FileService._hydrateCharts: chart CSV unreadable', e);
slides.add(s); slides.add(s);
} }
} }
@ -325,9 +346,18 @@ class FileService {
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug. /// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
String? addAsset(String path, String subdir) { String? addAsset(String path, String subdir) {
if (path.trim().isEmpty) return null; if (path.trim().isEmpty) return null;
final abs = p.isAbsolute(path) final String abs;
? path if (p.isAbsolute(path)) {
: (deck.projectPath != null ? p.join(deck.projectPath!, path) : path); // Absolute paths come from the picker (the user explicitly chose them).
abs = path;
} else if (deck.projectPath != null) {
// A relative asset must not escape the project via `../`.
final resolved = _projectFile(deck.projectPath, path);
if (resolved == null) return null;
abs = resolved;
} else {
abs = path;
}
final file = File(abs); final file = File(abs);
if (!file.existsSync()) return null; if (!file.existsSync()) return null;
final rel = p.posix.join(subdir, p.basename(abs)); final rel = p.posix.join(subdir, p.basename(abs));
@ -415,7 +445,8 @@ class FileService {
profile, profile,
logoRel == null ? null : '../$logoRel', logoRel == null ? null : '../$logoRel',
); );
} catch (_) { } catch (e) {
logWarning('FileService._packageThemeCss: theme asset not bundled', e);
return null; return null;
} }
} }
@ -429,7 +460,8 @@ class FileService {
final Archive archive; final Archive archive;
try { try {
archive = ZipDecoder().decodeBytes(zipBytes); archive = ZipDecoder().decodeBytes(zipBytes);
} catch (_) { } catch (e, s) {
logError('FileService.importPackageBytes: ZIP decode failed', e, s);
return null; return null;
} }
@ -448,14 +480,33 @@ class FileService {
final destDir = _uniqueDir(destParentDir, folderName); final destDir = _uniqueDir(destParentDir, folderName);
await destDir.create(recursive: true); await destDir.create(recursive: true);
for (final f in archive.files) { // Resolve an archive entry name to a path strictly inside [destDir], or
if (!f.isFile) continue; // null when it would escape (zip-slip: `../`, absolute paths, ).
final out = File(p.join(destDir.path, f.name)); String? safeOutPath(String entryName) {
await out.parent.create(recursive: true); final resolved = p.normalize(p.join(destDir.path, entryName));
await out.writeAsBytes(f.content as List<int>, flush: true); if (resolved != destDir.path && !p.isWithin(destDir.path, resolved)) {
return null;
}
return resolved;
} }
return p.join(destDir.path, mdEntry.name); var extracted = 0;
for (final f in archive.files) {
if (!f.isFile) continue;
final outPath = safeOutPath(f.name);
if (outPath == null) continue; // skip path-traversal entries
final content = f.content as List<int>;
// Bound total extracted size so a small zip can't fill the disk (zip bomb).
extracted += content.length;
if (extracted > _maxDownloadBytes) break;
final out = File(outPath);
await out.parent.create(recursive: true);
await out.writeAsBytes(content, flush: true);
}
// The main markdown must itself resolve inside the extraction folder.
final mdPath = safeOutPath(mdEntry.name);
return mdPath;
} }
Directory _uniqueDir(String parent, String name) { Directory _uniqueDir(String parent, String name) {
@ -471,26 +522,65 @@ class FileService {
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt; /// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het /// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
/// markdown-bestand terug. /// markdown-bestand terug.
/// Cap on how much we download / extract, to bound memory and disk use.
static const _maxDownloadBytes = 64 * 1024 * 1024; // 64 MB
/// Hosts an import must never reach (loopback, private and link-local ranges)
/// so a deck URL can't be used to probe the local machine or intranet (SSRF).
static bool _isBlockedHost(String host) {
final h = host.toLowerCase();
if (h.isEmpty || h == 'localhost' || h.endsWith('.localhost')) return true;
final addr = InternetAddress.tryParse(host);
if (addr == null) return false; // a hostname; can't classify offline
if (addr.isLoopback || addr.isLinkLocal || addr.isMulticast) return true;
final raw = addr.rawAddress;
if (addr.type == InternetAddressType.IPv4) {
final a = raw[0], b = raw[1];
if (a == 0 || a == 10 || a == 127) {
return true; // this-host/private/loopback
}
if (a == 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a == 192 && b == 168) return true; // 192.168.0.0/16
if (a == 169 && b == 254) return true; // 169.254.0.0/16 link-local
} else if ((raw[0] & 0xfe) == 0xfc) {
return true; // fc00::/7 unique-local
}
return false;
}
Future<String?> importFromUrl(String url, String destParentDir) async { Future<String?> importFromUrl(String url, String destParentDir) async {
final uri = Uri.tryParse(url.trim()); final uri = Uri.tryParse(url.trim());
if (uri == null || !uri.hasScheme) return null; if (uri == null || !uri.hasScheme) return null;
// Only fetch over web schemes, and never reach private/loopback hosts.
final scheme = uri.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') return null;
if (_isBlockedHost(uri.host)) return null;
final List<int> bytes; final List<int> bytes;
try { try {
final client = HttpClient(); final client = HttpClient()
..connectionTimeout = const Duration(seconds: 15);
try { try {
final request = await client.getUrl(uri); final request = await client.getUrl(uri);
final response = await request.close(); // Don't auto-follow redirects: a 3xx could point at a private host and
// bypass the SSRF check above.
request.followRedirects = false;
final response = await request.close().timeout(
const Duration(seconds: 30),
);
if (response.statusCode != 200) return null; if (response.statusCode != 200) return null;
if (response.contentLength > _maxDownloadBytes) return null;
final builder = BytesBuilder(copy: false); final builder = BytesBuilder(copy: false);
await for (final chunk in response) { await for (final chunk in response) {
builder.add(chunk); builder.add(chunk);
if (builder.length > _maxDownloadBytes) return null; // runaway body
} }
bytes = builder.takeBytes(); bytes = builder.takeBytes();
} finally { } finally {
client.close(force: true); client.close(force: true);
} }
} catch (_) { } catch (e) {
logError('FileService.importFromUrl: download failed', e);
return null; return null;
} }
@ -509,7 +599,8 @@ class FileService {
final String markdown; final String markdown;
try { try {
markdown = utf8.decode(bytes); markdown = utf8.decode(bytes);
} catch (_) { } catch (e, s) {
logError('FileService.importFromUrl: UTF-8 decode failed', e, s);
return null; return null;
} }
if (!markdown.contains('marp') && !markdown.contains('---')) return null; if (!markdown.contains('marp') && !markdown.contains('---')) return null;
@ -630,8 +721,9 @@ class FileService {
'assets/themes/ocideck.css', 'assets/themes/ocideck.css',
)).replaceFirst('@theme ocideck', '@theme $safeThemeName'); )).replaceFirst('@theme ocideck', '@theme $safeThemeName');
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl)); await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
} catch (_) { } catch (e) {
// Asset not bundled in this build context; skip // Asset not bundled in this build context; skip
logWarning('FileService._writeTheme: theme asset not bundled', e);
} }
} }

View file

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../utils/log.dart';
/// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum, /// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum,
/// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op /// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op
@ -20,7 +21,9 @@ class ImageDedupService {
try { try {
final size = File(path).statSync().size; final size = File(path).statSync().size;
bySize.putIfAbsent(size, () => []).add(path); bySize.putIfAbsent(size, () => []).add(path);
} catch (_) {} } catch (e) {
logWarning('ImageDedupService.findDuplicateGroups: stat for size', e);
}
} }
// Stap 2: alleen binnen gelijke groottes de md5 berekenen. // Stap 2: alleen binnen gelijke groottes de md5 berekenen.
@ -32,7 +35,9 @@ class ImageDedupService {
try { try {
final digest = await md5.bind(File(path).openRead()).single; final digest = await md5.bind(File(path).openRead()).single;
byHash.putIfAbsent(digest.toString(), () => []).add(path); byHash.putIfAbsent(digest.toString(), () => []).add(path);
} catch (_) {} } catch (e) {
logWarning('ImageDedupService.findDuplicateGroups: md5 hash', e);
}
} }
for (final group in byHash.values) { for (final group in byHash.values) {
if (group.length >= 2) groups.add(group); if (group.length >= 2) groups.add(group);
@ -51,7 +56,8 @@ class ImageDedupService {
DateTime modifiedOf(String path) { DateTime modifiedOf(String path) {
try { try {
return File(path).statSync().modified; return File(path).statSync().modified;
} catch (_) { } catch (e) {
logWarning('ImageDedupService.chooseKeeper: stat modified time', e);
return DateTime.fromMillisecondsSinceEpoch(0); return DateTime.fromMillisecondsSinceEpoch(0);
} }
} }

View file

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Vindt en herschrijft afbeeldingsverwijzingen (`![](pad)`) in /// Vindt en herschrijft afbeeldingsverwijzingen (`![](pad)`) in
/// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten /// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten
@ -31,7 +32,8 @@ class ImageReferenceService {
List<FileSystemEntity> entries; List<FileSystemEntity> entries;
try { try {
entries = await dir.list(followLinks: false).toList(); entries = await dir.list(followLinks: false).toList();
} catch (_) { } catch (e) {
logWarning('ImageReferenceService.findDeckFiles: list directory', e);
return; return;
} }
for (final entity in entries) { for (final entity in entries) {
@ -69,7 +71,8 @@ class ImageReferenceService {
String content; String content;
try { try {
content = await File(deckFile).readAsString(); content = await File(deckFile).readAsString();
} catch (_) { } catch (e) {
logWarning('ImageReferenceService.countReferences: read deck file', e);
continue; continue;
} }
final mdDir = p.dirname(deckFile); final mdDir = p.dirname(deckFile);
@ -100,7 +103,8 @@ class ImageReferenceService {
String content; String content;
try { try {
content = await File(deckFile).readAsString(); content = await File(deckFile).readAsString();
} catch (_) { } catch (e) {
logWarning('ImageReferenceService.referencingFiles: read deck file', e);
continue; continue;
} }
final mdDir = p.dirname(deckFile); final mdDir = p.dirname(deckFile);
@ -127,7 +131,8 @@ class ImageReferenceService {
String content; String content;
try { try {
content = await file.readAsString(); content = await file.readAsString();
} catch (_) { } catch (e) {
logWarning('ImageReferenceService.replaceReferences: read deck file', e);
return false; return false;
} }
final mdDir = p.dirname(deckFile); final mdDir = p.dirname(deckFile);
@ -150,7 +155,8 @@ class ImageReferenceService {
if (!changed) return false; if (!changed) return false;
try { try {
await file.writeAsString(updated); await file.writeAsString(updated);
} catch (_) { } catch (e) {
logWarning('ImageReferenceService.replaceReferences: write deck file', e);
return false; return false;
} }
return true; return true;
@ -159,7 +165,9 @@ class ImageReferenceService {
String? _resolve(String ref, String mdDir) { String? _resolve(String ref, String mdDir) {
final cleaned = ref.trim(); final cleaned = ref.trim();
if (cleaned.isEmpty || cleaned.contains('://')) return null; if (cleaned.isEmpty || cleaned.contains('://')) return null;
return p.normalize(p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned)); return p.normalize(
p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned),
);
} }
} }

View file

@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import '../utils/log.dart';
class ImageService { class ImageService {
final String Function() _languageCode; final String Function() _languageCode;
@ -46,7 +47,8 @@ class ImageService {
if (bytes.isEmpty) return false; if (bytes.isEmpty) return false;
await Pasteboard.writeImage(bytes); await Pasteboard.writeImage(bytes);
return true; return true;
} catch (_) { } catch (e) {
logError('ImageService.copyImageBytesToClipboard: write image', e);
return false; return false;
} }
} }
@ -60,7 +62,8 @@ class ImageService {
final file = File(path); final file = File(path);
if (!await file.exists()) return false; if (!await file.exists()) return false;
return copyImageBytesToClipboard(await file.readAsBytes()); return copyImageBytesToClipboard(await file.readAsBytes());
} catch (_) { } catch (e) {
logWarning('ImageService.copyImageToClipboard: read image file', e);
return false; return false;
} }
} }

View file

@ -5,6 +5,7 @@ import '../models/chart.dart';
import '../models/deck.dart'; import '../models/deck.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import '../utils/log.dart';
const _uuid = Uuid(); const _uuid = Uuid();
@ -528,7 +529,8 @@ class MarkdownService {
static String _decodeText(String encoded) { static String _decodeText(String encoded) {
try { try {
return utf8.decode(base64Url.decode(encoded.trim())); return utf8.decode(base64Url.decode(encoded.trim()));
} catch (_) { } catch (e, s) {
logError('MarkdownService._decodeText: base64/utf8 decode', e, s);
return ''; return '';
} }
} }
@ -538,7 +540,9 @@ class MarkdownService {
final decoded = utf8.decode(base64Url.decode(encoded.trim())); final decoded = utf8.decode(base64Url.decode(encoded.trim()));
final raw = jsonDecode(decoded); final raw = jsonDecode(decoded);
if (raw is List) return raw.map((v) => v.toString()).toList(); if (raw is List) return raw.map((v) => v.toString()).toList();
} catch (_) {} } catch (e, s) {
logError('MarkdownService._decodeBullets: base64/utf8/json decode', e, s);
}
return const []; return const [];
} }
@ -646,7 +650,8 @@ class MarkdownService {
Deck? parseDeck(String markdown, {String? filePath}) { Deck? parseDeck(String markdown, {String? filePath}) {
try { try {
return _doParse(markdown, filePath: filePath); return _doParse(markdown, filePath: filePath);
} catch (_) { } catch (e, s) {
logError('MarkdownService.parseDeck: parse markdown', e, s);
return null; return null;
} }
} }
@ -692,11 +697,22 @@ class MarkdownService {
} else if (line.startsWith('tlp:')) { } else if (line.startsWith('tlp:')) {
tlp = TlpLevelX.fromKey(line.substring(4)); tlp = TlpLevelX.fromKey(line.substring(4));
} else if (line.startsWith('ocideck_style_profile:')) { } else if (line.startsWith('ocideck_style_profile:')) {
final encoded = line.substring(22).trim(); // Best-effort: a corrupt profile token must not fail the whole
final decoded = utf8.decode(base64Url.decode(encoded)); // parse (which would blank the audience window). Keep the default.
themeProfile = ThemeProfile.fromJson( try {
Map<String, Object?>.from(jsonDecode(decoded) as Map), final encoded = line.substring(22).trim();
); final decoded = utf8.decode(base64Url.decode(encoded));
themeProfile = ThemeProfile.fromJson(
Map<String, Object?>.from(jsonDecode(decoded) as Map),
);
} catch (e, s) {
logError(
'MarkdownService._doParse: decode ocideck_style_profile',
e,
s,
);
// Leave themeProfile at its default.
}
} }
} }
content = content.substring(end + 5).trim(); content = content.substring(end + 5).trim();

View file

@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../utils/log.dart';
/// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck. /// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck.
class RecoverySnapshot { class RecoverySnapshot {
final String id; final String id;
@ -72,7 +74,8 @@ class RecoveryService {
dir, dir,
snapshot.id, snapshot.id,
).writeAsString(jsonEncode(snapshot.toJson()), flush: true); ).writeAsString(jsonEncode(snapshot.toJson()), flush: true);
} catch (_) { } catch (e) {
logWarning('RecoveryService.save: write recovery snapshot', e);
// Autosave mag nooit de app verstoren. // Autosave mag nooit de app verstoren.
} }
} }
@ -81,7 +84,9 @@ class RecoveryService {
try { try {
final file = _file(await _dir(), id); final file = _file(await _dir(), id);
if (file.existsSync()) await file.delete(); if (file.existsSync()) await file.delete();
} catch (_) {} } catch (e) {
logWarning('RecoveryService.discard: delete recovery file', e);
}
} }
Future<List<RecoverySnapshot>> loadAll() async { Future<List<RecoverySnapshot>> loadAll() async {
@ -93,12 +98,15 @@ class RecoveryService {
try { try {
final data = jsonDecode(await entry.readAsString()); final data = jsonDecode(await entry.readAsString());
out.add(RecoverySnapshot.fromJson(Map<String, Object?>.from(data))); out.add(RecoverySnapshot.fromJson(Map<String, Object?>.from(data)));
} catch (_) {} } catch (e, s) {
logError('RecoveryService.loadAll: decode recovery snapshot', e, s);
}
} }
} }
out.sort((a, b) => b.savedAt.compareTo(a.savedAt)); out.sort((a, b) => b.savedAt.compareTo(a.savedAt));
return out; return out;
} catch (_) { } catch (e) {
logWarning('RecoveryService.loadAll: list recovery dir', e);
return const []; return const [];
} }
} }
@ -110,10 +118,14 @@ class RecoveryService {
if (entry is File && entry.path.endsWith('.json')) { if (entry is File && entry.path.endsWith('.json')) {
try { try {
await entry.delete(); await entry.delete();
} catch (_) {} } catch (e) {
logWarning('RecoveryService.clearAll: delete recovery file', e);
}
} }
} }
} catch (_) {} } catch (e) {
logWarning('RecoveryService.clearAll: list recovery dir', e);
}
} }
} }

41
lib/utils/log.dart Normal file
View file

@ -0,0 +1,41 @@
/// Lightweight logging for failures the app deliberately swallows.
///
/// Many call sites here used to be bare `catch (_) {}` blocks. Swallowing was
/// usually the *right* behaviour a broken sidecar, an unreadable file or an
/// unsupported platform must never crash a presentation but the failure then
/// vanished without a trace, which made real bugs invisible. Routing these
/// through [logError]/[logWarning] keeps the fail-soft behaviour while making
/// the cause observable.
///
/// Records go to the `dart:developer` logging stream (DevTools / the VM
/// service), not stdout, so release builds stay quiet. Pass only an operation
/// description and the caught error object never deck or file *contents*,
/// which can be personal data.
library;
import 'dart:developer' as developer;
const _name = 'ocideck';
// Severity levels mirror package:logging (WARNING = 900, SEVERE = 1000).
const int _levelWarning = 900;
const int _levelError = 1000;
/// An unexpected failure that was handled by falling back. [op] is a short
/// description of what was attempted, e.g. `'openDeck: read annotation sidecar'`.
void logError(String op, Object error, [StackTrace? stack]) {
developer.log(
op,
name: _name,
error: error,
stackTrace: stack,
level: _levelError,
);
}
/// An expected-but-notable condition where the app fell back to a default
/// (e.g. an absent optional file, an unsupported platform capability). Lower
/// severity than [logError]; [error] is optional.
void logWarning(String op, [Object? error]) {
developer.log(op, name: _name, error: error, level: _levelWarning);
}

View file

@ -1,8 +1,14 @@
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'log.dart';
/// Schemes a deck link may open. Anything else (file:, javascript:, custom app
/// schemes, ) is refused so a deck can't hand the OS a dangerous or
/// unexpected URI.
const _allowedUrlSchemes = {'https', 'http', 'mailto'};
/// Open een link uit slide-tekst in de externe browser. Kale domeinen /// Open een link uit slide-tekst in de externe browser. Kale domeinen
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige /// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige,
/// of niet-openbare URLs. /// niet-openbare of niet-toegestane URLs.
Future<void> openExternalUrl(String url) async { Future<void> openExternalUrl(String url) async {
var u = url.trim(); var u = url.trim();
if (u.isEmpty) return; if (u.isEmpty) return;
@ -11,11 +17,13 @@ Future<void> openExternalUrl(String url) async {
} }
final uri = Uri.tryParse(u); final uri = Uri.tryParse(u);
if (uri == null) return; if (uri == null) return;
if (!_allowedUrlSchemes.contains(uri.scheme.toLowerCase())) return;
try { try {
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication); await launchUrl(uri, mode: LaunchMode.externalApplication);
} }
} catch (_) { } catch (e) {
logWarning('openExternalUrl: launching external URL failed', e);
// Nooit de presentatie laten crashen op een kapotte link. // Nooit de presentatie laten crashen op een kapotte link.
} }
} }

View file

@ -9,6 +9,7 @@ import '../../services/image_dedup_service.dart';
import '../../services/image_reference_service.dart'; import '../../services/image_reference_service.dart';
import '../../services/image_service.dart'; import '../../services/image_service.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/log.dart';
/// Resultaat van de afbeeldingencarousel. /// Resultaat van de afbeeldingencarousel.
class ImagePickResult { class ImagePickResult {
@ -169,7 +170,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
if (_exts.contains(ext)) found.add(e.path); if (_exts.contains(ext)) found.add(e.path);
} }
} }
} catch (_) {} } catch (e) {
logWarning('_ImageCarouselPickerState._loadImages: directory scan', e);
}
} }
// Stat each file exactly once (instead of repeatedly inside the sort // Stat each file exactly once (instead of repeatedly inside the sort
@ -179,7 +182,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
DateTime modified; DateTime modified;
try { try {
modified = File(path).statSync().modified; modified = File(path).statSync().modified;
} catch (_) { } catch (e) {
logWarning('_ImageCarouselPickerState._loadImages: statSync', e);
modified = DateTime.fromMillisecondsSinceEpoch(0); modified = DateTime.fromMillisecondsSinceEpoch(0);
} }
withTimes.add((path, modified)); withTimes.add((path, modified));
@ -323,9 +327,10 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
// bestand staan waar de meeste slides (open of niet) naar wijzen. Open // bestand staan waar de meeste slides (open of niet) naar wijzen. Open
// decks worden via usageOf geteld en hier overgeslagen. // decks worden via usageOf geteld en hier overgeslagen.
final deckFiles = await refs.findDeckFiles(widget.searchPaths); final deckFiles = await refs.findDeckFiles(widget.searchPaths);
final diskCounts = await refs.countReferences(_withoutOpenDecks(deckFiles), [ final diskCounts = await refs.countReferences(
for (final group in groups) ...group, _withoutOpenDecks(deckFiles),
]); [for (final group in groups) ...group],
);
if (!mounted) return; if (!mounted) return;
final plan = <({String keeper, List<String> remove})>[ final plan = <({String keeper, List<String> remove})>[
@ -359,13 +364,13 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
// Keeper eerst, zodat zijn eigen tekst vooraan blijft staan. // Keeper eerst, zodat zijn eigen tekst vooraan blijft staan.
final ordered = [entry.keeper, ...entry.remove]; final ordered = [entry.keeper, ...entry.remove];
final captions = <String?>[ final captions = <String?>[
for (final path in ordered) await widget.captionService.getCaption(path), for (final path in ordered)
await widget.captionService.getCaption(path),
]; ];
final mergedCaption = dedup.mergeMetadata(captions); final mergedCaption = dedup.mergeMetadata(captions);
final mergedDescription = dedup.mergeMetadata( final mergedDescription = dedup.mergeMetadata([
[for (final path in ordered) _descriptions[path]], for (final path in ordered) _descriptions[path],
separator: ', ', ], separator: ', ');
);
if (mergedCaption.isNotEmpty) { if (mergedCaption.isNotEmpty) {
await widget.captionService.saveCaption(entry.keeper, mergedCaption); await widget.captionService.saveCaption(entry.keeper, mergedCaption);
} }
@ -390,7 +395,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
try { try {
final file = File(path); final file = File(path);
if (file.existsSync()) await file.delete(); if (file.existsSync()) await file.delete();
} catch (_) {} } catch (e) {
logWarning('_ImageCarouselPickerState._dedupe: delete file', e);
}
await widget.captionService.saveCaption(path, ''); await widget.captionService.saveCaption(path, '');
await widget.descriptionService.removeDescription(path); await widget.descriptionService.removeDescription(path);
_descriptions.remove(path); _descriptions.remove(path);
@ -426,9 +433,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
: updatedDeckFiles.length == 1 : updatedDeckFiles.length == 1
? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}' ? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}'
: ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}'; : ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}';
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('$removedText$filesText')), context,
); ).showSnackBar(SnackBar(content: Text('$removedText$filesText')));
} }
Future<bool?> _showDedupeDialog( Future<bool?> _showDedupeDialog(
@ -719,11 +726,18 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
final confirmed = await _showDeleteDialog(path, usages, slideCount); final confirmed = await _showDeleteDialog(path, usages, slideCount);
if (confirmed != true) return; if (confirmed != true) return;
var deleted = false;
try { try {
final file = File(path); final file = File(path);
if (file.existsSync()) await file.delete(); if (file.existsSync()) await file.delete();
} catch (_) {} deleted = true;
// Drop the sidecar metadata too. } catch (e) {
debugPrint('Kon afbeelding niet verwijderen: $e');
}
// Only drop the sidecar metadata and the carousel entry once the file is
// actually gone; otherwise the image would disappear from the UI while it
// still exists on disk, having silently lost its caption/description.
if (!deleted) return;
await widget.captionService.saveCaption(path, ''); await widget.captionService.saveCaption(path, '');
await widget.descriptionService.removeDescription(path); await widget.descriptionService.removeDescription(path);
@ -1083,7 +1097,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117), color: _untaggedOnly
? const Color(0xFF1D2433)
: const Color(0xFF0D1117),
borderRadius: BorderRadius.circular(9), borderRadius: BorderRadius.circular(9),
border: Border.all( border: Border.all(
color: _untaggedOnly color: _untaggedOnly
@ -2053,7 +2069,9 @@ class _FileSizeState extends State<_FileSize> {
? '${mb.toStringAsFixed(1)} MB' ? '${mb.toStringAsFixed(1)} MB'
: '${kb.toStringAsFixed(0)} KB'; : '${kb.toStringAsFixed(0)} KB';
if (mounted) setState(() => _size = label); if (mounted) setState(() => _size = label);
} catch (_) {} } catch (e) {
logWarning('_FileSizeState._load: compute size label', e);
}
} }
@override @override

View file

@ -15,6 +15,7 @@ import '../../services/slide_rasterizer.dart';
import '../../state/slide_clipboard_provider.dart'; import '../../state/slide_clipboard_provider.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/log.dart';
import '../dialogs/add_slide_dialog.dart'; import '../dialogs/add_slide_dialog.dart';
import '../dialogs/import_slides_dialog.dart'; import '../dialogs/import_slides_dialog.dart';
import '../dialogs/slide_finder_dialog.dart'; import '../dialogs/slide_finder_dialog.dart';
@ -216,7 +217,9 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
tlp: deck.tlp, tlp: deck.tlp,
); );
if (images.isNotEmpty) bytes = images.first; if (images.isNotEmpty) bytes = images.first;
} catch (_) {} } catch (e) {
logWarning('_SlideListPanelState._copySlideAsImage: rasterize slide', e);
}
if (!mounted) return; if (!mounted) return;
final ok = final ok =
bytes != null && await ImageService().copyImageBytesToClipboard(bytes); bytes != null && await ImageService().copyImageBytesToClipboard(bytes);

View file

@ -6,6 +6,7 @@ import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../services/markdown_service.dart'; import '../../services/markdown_service.dart';
import '../../utils/log.dart';
import '../../utils/url_launcher_util.dart'; import '../../utils/url_launcher_util.dart';
import '../slides/slide_preview.dart'; import '../slides/slide_preview.dart';
import 'annotation_overlay.dart'; import 'annotation_overlay.dart';
@ -133,7 +134,12 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
try { try {
final self = await WindowController.fromCurrentEngine(); final self = await WindowController.fromCurrentEngine();
await self.close(); await self.close();
} catch (_) {} } catch (e) {
logWarning(
'_AudienceWindowAppState._onPresenterCall: close window',
e,
);
}
} }
return null; return null;
} }

View file

@ -13,6 +13,7 @@ import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../services/markdown_service.dart'; import '../../services/markdown_service.dart';
import '../../utils/log.dart';
import '../../utils/url_launcher_util.dart'; import '../../utils/url_launcher_util.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../slides/inline_markdown.dart'; import '../slides/inline_markdown.dart';
@ -73,7 +74,8 @@ class FullscreenPresenter extends StatefulWidget {
try { try {
final displays = await screenRetriever.getAllDisplays(); final displays = await screenRetriever.getAllDisplays();
displayCount = displays.length; displayCount = displays.length;
} catch (_) { } catch (e) {
logWarning('FullscreenPresenter.present: display detection failed', e);
displayCount = 0; displayCount = 0;
} }
} }
@ -203,7 +205,11 @@ class FullscreenPresenter extends StatefulWidget {
WindowConfiguration(arguments: argument, hiddenAtLaunch: true), WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
); );
await audience.coverScreen(external: true); await audience.coverScreen(external: true);
} catch (_) { } catch (e) {
logError(
'FullscreenPresenter.showDualScreen: audience window setup failed',
e,
);
audience = null; audience = null;
} }
@ -283,7 +289,8 @@ bool autoAdvanceWaitsForMedia(Slide slide) {
Future<bool> _wakeLockEnabled() async { Future<bool> _wakeLockEnabled() async {
try { try {
return await WakelockPlus.enabled; return await WakelockPlus.enabled;
} catch (_) { } catch (e) {
logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e);
return false; return false;
} }
} }
@ -291,7 +298,8 @@ Future<bool> _wakeLockEnabled() async {
Future<void> _enableWakeLock() async { Future<void> _enableWakeLock() async {
try { try {
await WakelockPlus.enable(); await WakelockPlus.enable();
} catch (_) { } catch (e) {
logWarning('fullscreen_presenter._enableWakeLock: enable failed', e);
// Best-effort: unsupported platforms should not interrupt presenting. // Best-effort: unsupported platforms should not interrupt presenting.
} }
} }
@ -303,7 +311,8 @@ Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
} else { } else {
await WakelockPlus.disable(); await WakelockPlus.disable();
} }
} catch (_) { } catch (e) {
logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e);
// Best-effort cleanup. // Best-effort cleanup.
} }
} }
@ -560,10 +569,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
} }
_lastInkLiveSent = now; _lastInkLiveSent = now;
audienceChannel audienceChannel
.invokeMethod('inkLive', { .invokeMethod('inkLive', {'index': _index, 'stroke': stroke?.toJson()})
'index': _index,
'stroke': stroke?.toJson(),
})
.catchError((_) => null); .catchError((_) => null);
} }
@ -707,7 +713,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_displays = displays; _displays = displays;
_displayIndex = current < 0 ? 0 : current; _displayIndex = current < 0 ? 0 : current;
}); });
} catch (_) { } catch (e) {
logWarning(
'_FullscreenPresenterState._loadDisplays: screen detection failed',
e,
);
// Screen detection is best-effort; presenting should still work. // Screen detection is best-effort; presenting should still work.
} }
} }
@ -724,7 +734,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
); );
await windowManager.setFullScreen(true); await windowManager.setFullScreen(true);
if (mounted) setState(() => _displayIndex = index); if (mounted) setState(() => _displayIndex = index);
} catch (_) { } catch (e) {
logError(
'_FullscreenPresenterState._moveToDisplay: moving window to display failed',
e,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -1425,6 +1439,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
// Annotatielaag bovenop de dia. Laat klikken door wanneer er // Annotatielaag bovenop de dia. Laat klikken door wanneer er
// geen gereedschap actief is (zodat tikken blijft doorbladeren). // geen gereedschap actief is (zodat tikken blijft doorbladeren).
AnnotationLayer( AnnotationLayer(
// Keyed by slide so a slide change (e.g. auto-advance) while a
// stroke is in progress resets the layer instead of committing
// the half-drawn stroke onto the next slide.
key: ValueKey(slide.id),
strokes: _currentStrokes, strokes: _currentStrokes,
tool: _tool, tool: _tool,
color: _inkColor, color: _inkColor,