feature/meldingen-hardening #6
16 changed files with 346 additions and 91 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Directory (relative to the deck) where linked chart CSVs are kept, so the
|
||||
/// data files stay tidily in one place — separate from images/media.
|
||||
const String chartDataDirName = 'data';
|
||||
|
|
@ -155,7 +157,8 @@ class ChartSpec {
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
|
||||
import '../models/annotation.dart';
|
||||
import '../models/slide.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Serializes the annotation layer into a sidecar payload that is fully
|
||||
/// decoupled from the Marp markdown.
|
||||
|
|
@ -96,7 +97,8 @@ class AnnotationCodec {
|
|||
used.add(target);
|
||||
result[slides[target].id] = strokes;
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (e, s) {
|
||||
logError('AnnotationCodec.decode: decode annotation sidecar JSON', e, s);
|
||||
return {};
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'dart:io';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding.
|
||||
/// Bestandsnaam: .ocideck_captions.json
|
||||
class CaptionService {
|
||||
|
|
@ -17,7 +19,8 @@ class CaptionService {
|
|||
final data = jsonDecode(await file.readAsString()) as Map;
|
||||
final caption = data[p.basename(resolvedPath)];
|
||||
return caption is String ? caption : null;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('CaptionService.getCaption: read caption sidecar', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +39,9 @@ class CaptionService {
|
|||
data = Map<String, dynamic>.from(
|
||||
jsonDecode(await file.readAsString()) as Map,
|
||||
);
|
||||
} catch (_) {}
|
||||
} catch (e, s) {
|
||||
logError('CaptionService.saveCaption: parse existing sidecar', e, s);
|
||||
}
|
||||
}
|
||||
final key = p.basename(resolvedPath);
|
||||
if (caption.trim().isEmpty) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'dart:io';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Stores short, searchable image descriptions as a JSON sidecar in the image's
|
||||
/// 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 value = data[p.basename(imagePath)];
|
||||
return value is String ? value : null;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning(
|
||||
'DescriptionService.getDescription: read description sidecar',
|
||||
e,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +39,13 @@ class DescriptionService {
|
|||
data = Map<String, dynamic>.from(
|
||||
jsonDecode(await file.readAsString()) as Map,
|
||||
);
|
||||
} catch (_) {}
|
||||
} catch (e, s) {
|
||||
logError(
|
||||
'DescriptionService.saveDescription: parse existing sidecar',
|
||||
e,
|
||||
s,
|
||||
);
|
||||
}
|
||||
}
|
||||
final key = p.basename(imagePath);
|
||||
if (description.trim().isEmpty) {
|
||||
|
|
@ -71,7 +83,9 @@ class DescriptionService {
|
|||
result[p.join(dir, entry.key as String)] = entry.value as String;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logWarning('DescriptionService.loadFor: read description sidecar', e);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import '../l10n/app_localizations.dart';
|
|||
import '../models/settings.dart';
|
||||
import '../models/chart.dart';
|
||||
import '../models/slide.dart';
|
||||
import '../utils/log.dart';
|
||||
import 'annotation_codec.dart';
|
||||
import 'caption_service.dart';
|
||||
import 'image_service.dart';
|
||||
|
|
@ -64,6 +65,19 @@ class FileService {
|
|||
ThemeProfile activeProfileFor({String? 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 profile, {
|
||||
String? projectPath,
|
||||
|
|
@ -112,7 +126,11 @@ class FileService {
|
|||
List<FileSystemEntity> entries;
|
||||
try {
|
||||
entries = await dir.list(followLinks: false).toList();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning(
|
||||
'FileService.scanPresentations: directory listing failed',
|
||||
e,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (final entity in entries) {
|
||||
|
|
@ -124,7 +142,8 @@ class FileService {
|
|||
String content;
|
||||
try {
|
||||
content = await entity.readAsString();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('FileService.scanPresentations: file not readable', e);
|
||||
continue;
|
||||
}
|
||||
final deck = await openDeck(entity.path, content: content);
|
||||
|
|
@ -190,8 +209,9 @@ class FileService {
|
|||
hydrated.slides,
|
||||
);
|
||||
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
// 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);
|
||||
continue;
|
||||
}
|
||||
final abs = p.isAbsolute(spec.source!)
|
||||
? spec.source!
|
||||
: p.join(deck.projectPath!, spec.source!);
|
||||
final file = File(abs);
|
||||
if (!await file.exists()) {
|
||||
// A chart's CSV link must stay inside the project (no absolute paths or
|
||||
// `../` escapes) — otherwise an untrusted deck could read arbitrary files.
|
||||
final abs = _projectFile(deck.projectPath, spec.source!);
|
||||
final file = abs == null ? null : File(abs);
|
||||
if (file == null || !await file.exists()) {
|
||||
slides.add(s);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -241,7 +261,8 @@ class FileService {
|
|||
final csv = await file.readAsString();
|
||||
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
|
||||
changed = true;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('FileService._hydrateCharts: chart CSV unreadable', e);
|
||||
slides.add(s);
|
||||
}
|
||||
}
|
||||
|
|
@ -325,9 +346,18 @@ class FileService {
|
|||
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
|
||||
String? addAsset(String path, String subdir) {
|
||||
if (path.trim().isEmpty) return null;
|
||||
final abs = p.isAbsolute(path)
|
||||
? path
|
||||
: (deck.projectPath != null ? p.join(deck.projectPath!, path) : path);
|
||||
final String abs;
|
||||
if (p.isAbsolute(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);
|
||||
if (!file.existsSync()) return null;
|
||||
final rel = p.posix.join(subdir, p.basename(abs));
|
||||
|
|
@ -415,7 +445,8 @@ class FileService {
|
|||
profile,
|
||||
logoRel == null ? null : '../$logoRel',
|
||||
);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('FileService._packageThemeCss: theme asset not bundled', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -429,7 +460,8 @@ class FileService {
|
|||
final Archive archive;
|
||||
try {
|
||||
archive = ZipDecoder().decodeBytes(zipBytes);
|
||||
} catch (_) {
|
||||
} catch (e, s) {
|
||||
logError('FileService.importPackageBytes: ZIP decode failed', e, s);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -448,14 +480,33 @@ class FileService {
|
|||
final destDir = _uniqueDir(destParentDir, folderName);
|
||||
await destDir.create(recursive: true);
|
||||
|
||||
for (final f in archive.files) {
|
||||
if (!f.isFile) continue;
|
||||
final out = File(p.join(destDir.path, f.name));
|
||||
await out.parent.create(recursive: true);
|
||||
await out.writeAsBytes(f.content as List<int>, flush: true);
|
||||
// Resolve an archive entry name to a path strictly inside [destDir], or
|
||||
// null when it would escape (zip-slip: `../`, absolute paths, …).
|
||||
String? safeOutPath(String entryName) {
|
||||
final resolved = p.normalize(p.join(destDir.path, entryName));
|
||||
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) {
|
||||
|
|
@ -471,26 +522,65 @@ class FileService {
|
|||
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
|
||||
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
|
||||
/// 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 {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
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;
|
||||
try {
|
||||
final client = HttpClient();
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 15);
|
||||
try {
|
||||
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.contentLength > _maxDownloadBytes) return null;
|
||||
final builder = BytesBuilder(copy: false);
|
||||
await for (final chunk in response) {
|
||||
builder.add(chunk);
|
||||
if (builder.length > _maxDownloadBytes) return null; // runaway body
|
||||
}
|
||||
bytes = builder.takeBytes();
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logError('FileService.importFromUrl: download failed', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -509,7 +599,8 @@ class FileService {
|
|||
final String markdown;
|
||||
try {
|
||||
markdown = utf8.decode(bytes);
|
||||
} catch (_) {
|
||||
} catch (e, s) {
|
||||
logError('FileService.importFromUrl: UTF-8 decode failed', e, s);
|
||||
return null;
|
||||
}
|
||||
if (!markdown.contains('marp') && !markdown.contains('---')) return null;
|
||||
|
|
@ -630,8 +721,9 @@ class FileService {
|
|||
'assets/themes/ocideck.css',
|
||||
)).replaceFirst('@theme ocideck', '@theme $safeThemeName');
|
||||
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
// Asset not bundled in this build context; skip
|
||||
logWarning('FileService._writeTheme: theme asset not bundled', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:io';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum,
|
||||
/// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op
|
||||
|
|
@ -20,7 +21,9 @@ class ImageDedupService {
|
|||
try {
|
||||
final size = File(path).statSync().size;
|
||||
bySize.putIfAbsent(size, () => []).add(path);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logWarning('ImageDedupService.findDuplicateGroups: stat for size', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Stap 2: alleen binnen gelijke groottes de md5 berekenen.
|
||||
|
|
@ -32,7 +35,9 @@ class ImageDedupService {
|
|||
try {
|
||||
final digest = await md5.bind(File(path).openRead()).single;
|
||||
byHash.putIfAbsent(digest.toString(), () => []).add(path);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logWarning('ImageDedupService.findDuplicateGroups: md5 hash', e);
|
||||
}
|
||||
}
|
||||
for (final group in byHash.values) {
|
||||
if (group.length >= 2) groups.add(group);
|
||||
|
|
@ -51,7 +56,8 @@ class ImageDedupService {
|
|||
DateTime modifiedOf(String path) {
|
||||
try {
|
||||
return File(path).statSync().modified;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('ImageDedupService.chooseKeeper: stat modified time', e);
|
||||
return DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Vindt en herschrijft afbeeldingsverwijzingen (``) in
|
||||
/// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten
|
||||
|
|
@ -31,7 +32,8 @@ class ImageReferenceService {
|
|||
List<FileSystemEntity> entries;
|
||||
try {
|
||||
entries = await dir.list(followLinks: false).toList();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.findDeckFiles: list directory', e);
|
||||
return;
|
||||
}
|
||||
for (final entity in entries) {
|
||||
|
|
@ -69,7 +71,8 @@ class ImageReferenceService {
|
|||
String content;
|
||||
try {
|
||||
content = await File(deckFile).readAsString();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.countReferences: read deck file', e);
|
||||
continue;
|
||||
}
|
||||
final mdDir = p.dirname(deckFile);
|
||||
|
|
@ -100,7 +103,8 @@ class ImageReferenceService {
|
|||
String content;
|
||||
try {
|
||||
content = await File(deckFile).readAsString();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.referencingFiles: read deck file', e);
|
||||
continue;
|
||||
}
|
||||
final mdDir = p.dirname(deckFile);
|
||||
|
|
@ -127,7 +131,8 @@ class ImageReferenceService {
|
|||
String content;
|
||||
try {
|
||||
content = await file.readAsString();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.replaceReferences: read deck file', e);
|
||||
return false;
|
||||
}
|
||||
final mdDir = p.dirname(deckFile);
|
||||
|
|
@ -150,7 +155,8 @@ class ImageReferenceService {
|
|||
if (!changed) return false;
|
||||
try {
|
||||
await file.writeAsString(updated);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.replaceReferences: write deck file', e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
@ -159,7 +165,9 @@ class ImageReferenceService {
|
|||
String? _resolve(String ref, String mdDir) {
|
||||
final cleaned = ref.trim();
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/slide.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
class ImageService {
|
||||
final String Function() _languageCode;
|
||||
|
|
@ -46,7 +47,8 @@ class ImageService {
|
|||
if (bytes.isEmpty) return false;
|
||||
await Pasteboard.writeImage(bytes);
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logError('ImageService.copyImageBytesToClipboard: write image', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +62,8 @@ class ImageService {
|
|||
final file = File(path);
|
||||
if (!await file.exists()) return false;
|
||||
return copyImageBytesToClipboard(await file.readAsBytes());
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('ImageService.copyImageToClipboard: read image file', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../models/chart.dart';
|
|||
import '../models/deck.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/slide.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
|
|
@ -528,7 +529,8 @@ class MarkdownService {
|
|||
static String _decodeText(String encoded) {
|
||||
try {
|
||||
return utf8.decode(base64Url.decode(encoded.trim()));
|
||||
} catch (_) {
|
||||
} catch (e, s) {
|
||||
logError('MarkdownService._decodeText: base64/utf8 decode', e, s);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -538,7 +540,9 @@ class MarkdownService {
|
|||
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
|
||||
final raw = jsonDecode(decoded);
|
||||
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 [];
|
||||
}
|
||||
|
||||
|
|
@ -646,7 +650,8 @@ class MarkdownService {
|
|||
Deck? parseDeck(String markdown, {String? filePath}) {
|
||||
try {
|
||||
return _doParse(markdown, filePath: filePath);
|
||||
} catch (_) {
|
||||
} catch (e, s) {
|
||||
logError('MarkdownService.parseDeck: parse markdown', e, s);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -692,11 +697,22 @@ class MarkdownService {
|
|||
} else if (line.startsWith('tlp:')) {
|
||||
tlp = TlpLevelX.fromKey(line.substring(4));
|
||||
} else if (line.startsWith('ocideck_style_profile:')) {
|
||||
// Best-effort: a corrupt profile token must not fail the whole
|
||||
// parse (which would blank the audience window). Keep the default.
|
||||
try {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck.
|
||||
class RecoverySnapshot {
|
||||
final String id;
|
||||
|
|
@ -72,7 +74,8 @@ class RecoveryService {
|
|||
dir,
|
||||
snapshot.id,
|
||||
).writeAsString(jsonEncode(snapshot.toJson()), flush: true);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('RecoveryService.save: write recovery snapshot', e);
|
||||
// Autosave mag nooit de app verstoren.
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +84,9 @@ class RecoveryService {
|
|||
try {
|
||||
final file = _file(await _dir(), id);
|
||||
if (file.existsSync()) await file.delete();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logWarning('RecoveryService.discard: delete recovery file', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<RecoverySnapshot>> loadAll() async {
|
||||
|
|
@ -93,12 +98,15 @@ class RecoveryService {
|
|||
try {
|
||||
final data = jsonDecode(await entry.readAsString());
|
||||
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));
|
||||
return out;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('RecoveryService.loadAll: list recovery dir', e);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
|
@ -110,10 +118,14 @@ class RecoveryService {
|
|||
if (entry is File && entry.path.endsWith('.json')) {
|
||||
try {
|
||||
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
41
lib/utils/log.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
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
|
||||
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige
|
||||
/// of niet-openbare URLs.
|
||||
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige,
|
||||
/// niet-openbare of niet-toegestane URLs.
|
||||
Future<void> openExternalUrl(String url) async {
|
||||
var u = url.trim();
|
||||
if (u.isEmpty) return;
|
||||
|
|
@ -11,11 +17,13 @@ Future<void> openExternalUrl(String url) async {
|
|||
}
|
||||
final uri = Uri.tryParse(u);
|
||||
if (uri == null) return;
|
||||
if (!_allowedUrlSchemes.contains(uri.scheme.toLowerCase())) return;
|
||||
try {
|
||||
if (await canLaunchUrl(uri)) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import '../../services/image_dedup_service.dart';
|
|||
import '../../services/image_reference_service.dart';
|
||||
import '../../services/image_service.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/log.dart';
|
||||
|
||||
/// Resultaat van de afbeeldingencarousel.
|
||||
class ImagePickResult {
|
||||
|
|
@ -169,7 +170,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
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
|
||||
|
|
@ -179,7 +182,8 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
DateTime modified;
|
||||
try {
|
||||
modified = File(path).statSync().modified;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('_ImageCarouselPickerState._loadImages: statSync', e);
|
||||
modified = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
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
|
||||
// decks worden via usageOf geteld en hier overgeslagen.
|
||||
final deckFiles = await refs.findDeckFiles(widget.searchPaths);
|
||||
final diskCounts = await refs.countReferences(_withoutOpenDecks(deckFiles), [
|
||||
for (final group in groups) ...group,
|
||||
]);
|
||||
final diskCounts = await refs.countReferences(
|
||||
_withoutOpenDecks(deckFiles),
|
||||
[for (final group in groups) ...group],
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
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.
|
||||
final ordered = [entry.keeper, ...entry.remove];
|
||||
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 mergedDescription = dedup.mergeMetadata(
|
||||
[for (final path in ordered) _descriptions[path]],
|
||||
separator: ', ',
|
||||
);
|
||||
final mergedDescription = dedup.mergeMetadata([
|
||||
for (final path in ordered) _descriptions[path],
|
||||
], separator: ', ');
|
||||
if (mergedCaption.isNotEmpty) {
|
||||
await widget.captionService.saveCaption(entry.keeper, mergedCaption);
|
||||
}
|
||||
|
|
@ -390,7 +395,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) await file.delete();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logWarning('_ImageCarouselPickerState._dedupe: delete file', e);
|
||||
}
|
||||
await widget.captionService.saveCaption(path, '');
|
||||
await widget.descriptionService.removeDescription(path);
|
||||
_descriptions.remove(path);
|
||||
|
|
@ -426,9 +433,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
: updatedDeckFiles.length == 1
|
||||
? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}'
|
||||
: ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$removedText$filesText')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('$removedText$filesText')));
|
||||
}
|
||||
|
||||
Future<bool?> _showDedupeDialog(
|
||||
|
|
@ -719,11 +726,18 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
final confirmed = await _showDeleteDialog(path, usages, slideCount);
|
||||
if (confirmed != true) return;
|
||||
|
||||
var deleted = false;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) await file.delete();
|
||||
} catch (_) {}
|
||||
// Drop the sidecar metadata too.
|
||||
deleted = true;
|
||||
} 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.descriptionService.removeDescription(path);
|
||||
|
||||
|
|
@ -1083,7 +1097,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117),
|
||||
color: _untaggedOnly
|
||||
? const Color(0xFF1D2433)
|
||||
: const Color(0xFF0D1117),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: _untaggedOnly
|
||||
|
|
@ -2053,7 +2069,9 @@ class _FileSizeState extends State<_FileSize> {
|
|||
? '${mb.toStringAsFixed(1)} MB'
|
||||
: '${kb.toStringAsFixed(0)} KB';
|
||||
if (mounted) setState(() => _size = label);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logWarning('_FileSizeState._load: compute size label', e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import '../../services/slide_rasterizer.dart';
|
|||
import '../../state/slide_clipboard_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/log.dart';
|
||||
import '../dialogs/add_slide_dialog.dart';
|
||||
import '../dialogs/import_slides_dialog.dart';
|
||||
import '../dialogs/slide_finder_dialog.dart';
|
||||
|
|
@ -216,7 +217,9 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
|||
tlp: deck.tlp,
|
||||
);
|
||||
if (images.isNotEmpty) bytes = images.first;
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logWarning('_SlideListPanelState._copySlideAsImage: rasterize slide', e);
|
||||
}
|
||||
if (!mounted) return;
|
||||
final ok =
|
||||
bytes != null && await ImageService().copyImageBytesToClipboard(bytes);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../../models/deck.dart';
|
|||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '../../services/markdown_service.dart';
|
||||
import '../../utils/log.dart';
|
||||
import '../../utils/url_launcher_util.dart';
|
||||
import '../slides/slide_preview.dart';
|
||||
import 'annotation_overlay.dart';
|
||||
|
|
@ -133,7 +134,12 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
try {
|
||||
final self = await WindowController.fromCurrentEngine();
|
||||
await self.close();
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logWarning(
|
||||
'_AudienceWindowAppState._onPresenterCall: close window',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import '../../models/deck.dart';
|
|||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '../../services/markdown_service.dart';
|
||||
import '../../utils/log.dart';
|
||||
import '../../utils/url_launcher_util.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../slides/inline_markdown.dart';
|
||||
|
|
@ -73,7 +74,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
try {
|
||||
final displays = await screenRetriever.getAllDisplays();
|
||||
displayCount = displays.length;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('FullscreenPresenter.present: display detection failed', e);
|
||||
displayCount = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -203,7 +205,11 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
|
||||
);
|
||||
await audience.coverScreen(external: true);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logError(
|
||||
'FullscreenPresenter.showDualScreen: audience window setup failed',
|
||||
e,
|
||||
);
|
||||
audience = null;
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +289,8 @@ bool autoAdvanceWaitsForMedia(Slide slide) {
|
|||
Future<bool> _wakeLockEnabled() async {
|
||||
try {
|
||||
return await WakelockPlus.enabled;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -291,7 +298,8 @@ Future<bool> _wakeLockEnabled() async {
|
|||
Future<void> _enableWakeLock() async {
|
||||
try {
|
||||
await WakelockPlus.enable();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('fullscreen_presenter._enableWakeLock: enable failed', e);
|
||||
// Best-effort: unsupported platforms should not interrupt presenting.
|
||||
}
|
||||
}
|
||||
|
|
@ -303,7 +311,8 @@ Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
|
|||
} else {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e);
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
|
@ -560,10 +569,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
}
|
||||
_lastInkLiveSent = now;
|
||||
audienceChannel
|
||||
.invokeMethod('inkLive', {
|
||||
'index': _index,
|
||||
'stroke': stroke?.toJson(),
|
||||
})
|
||||
.invokeMethod('inkLive', {'index': _index, 'stroke': stroke?.toJson()})
|
||||
.catchError((_) => null);
|
||||
}
|
||||
|
||||
|
|
@ -707,7 +713,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
_displays = displays;
|
||||
_displayIndex = current < 0 ? 0 : current;
|
||||
});
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logWarning(
|
||||
'_FullscreenPresenterState._loadDisplays: screen detection failed',
|
||||
e,
|
||||
);
|
||||
// Screen detection is best-effort; presenting should still work.
|
||||
}
|
||||
}
|
||||
|
|
@ -724,7 +734,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
);
|
||||
await windowManager.setFullScreen(true);
|
||||
if (mounted) setState(() => _displayIndex = index);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
logError(
|
||||
'_FullscreenPresenterState._moveToDisplay: moving window to display failed',
|
||||
e,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
@ -1425,6 +1439,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
|
||||
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
|
||||
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,
|
||||
tool: _tool,
|
||||
color: _inkColor,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue