feature/meldingen-hardening #6
8 changed files with 1127 additions and 919 deletions
|
|
@ -1,9 +1,23 @@
|
|||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Release signing is read from android/key.properties (kept out of version
|
||||
// control). When it is absent we fall back to the debug key so that
|
||||
// `flutter run --release` keeps working during development — but a build meant
|
||||
// for distribution must provide a real keystore here.
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
||||
if (hasReleaseKeystore) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.ocideck"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
|
|
@ -25,11 +39,27 @@ android {
|
|||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseKeystore) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Use the real release keystore when configured; otherwise fall back
|
||||
// to the debug key so `flutter run --release` still works locally.
|
||||
// Do NOT distribute a build signed with the debug key.
|
||||
signingConfig = if (hasReleaseKeystore) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,10 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _consentKey = 'app_consent_accepted';
|
||||
|
||||
final consentProvider =
|
||||
NotifierProvider<ConsentNotifier, ConsentState>(() {
|
||||
final consentProvider = NotifierProvider<ConsentNotifier, ConsentState>(() {
|
||||
return ConsentNotifier();
|
||||
});
|
||||
|
||||
|
|
@ -12,15 +12,9 @@ class ConsentState {
|
|||
final bool hasAccepted;
|
||||
final bool isLoading;
|
||||
|
||||
const ConsentState({
|
||||
required this.hasAccepted,
|
||||
this.isLoading = false,
|
||||
});
|
||||
const ConsentState({required this.hasAccepted, this.isLoading = false});
|
||||
|
||||
ConsentState copyWith({
|
||||
bool? hasAccepted,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
ConsentState copyWith({bool? hasAccepted, bool? isLoading}) {
|
||||
return ConsentState(
|
||||
hasAccepted: hasAccepted ?? this.hasAccepted,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
|
|
@ -41,6 +35,8 @@ class ConsentNotifier extends Notifier<ConsentState> {
|
|||
final hasAccepted = prefs.getBool(_consentKey) ?? false;
|
||||
state = state.copyWith(hasAccepted: hasAccepted, isLoading: false);
|
||||
} catch (e) {
|
||||
// Can't read the flag: fail closed (gate stays up) but don't hang loading.
|
||||
debugPrint('ConsentNotifier: could not read consent flag: $e');
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +47,9 @@ class ConsentNotifier extends Notifier<ConsentState> {
|
|||
await prefs.setBool(_consentKey, true);
|
||||
state = state.copyWith(hasAccepted: true);
|
||||
} catch (e) {
|
||||
// Persisting failed; let the user through this session, but the gate will
|
||||
// reappear next launch. Surface the failure instead of swallowing it.
|
||||
debugPrint('ConsentNotifier: could not persist consent: $e');
|
||||
state = state.copyWith(hasAccepted: true);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +60,7 @@ class ConsentNotifier extends Notifier<ConsentState> {
|
|||
await prefs.setBool(_consentKey, false);
|
||||
state = state.copyWith(hasAccepted: false);
|
||||
} catch (e) {
|
||||
debugPrint('ConsentNotifier: could not persist consent revocation: $e');
|
||||
state = state.copyWith(hasAccepted: false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,7 +201,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
|
||||
void removeSlide(int index) {
|
||||
final deck = state.deck;
|
||||
if (deck == null || deck.slides.length <= 1) return;
|
||||
if (deck == null ||
|
||||
deck.slides.length <= 1 ||
|
||||
index < 0 ||
|
||||
index >= deck.slides.length) {
|
||||
return;
|
||||
}
|
||||
final slides = List<Slide>.from(deck.slides)..removeAt(index);
|
||||
_mutate(deck.copyWith(slides: slides));
|
||||
}
|
||||
|
|
@ -254,7 +259,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
|
||||
void duplicateSlide(int index) {
|
||||
final deck = state.deck;
|
||||
if (deck == null) return;
|
||||
if (deck == null || index < 0 || index >= deck.slides.length) return;
|
||||
final slides = List<Slide>.from(deck.slides);
|
||||
slides.insert(index + 1, Slide.duplicate(slides[index]));
|
||||
_mutate(deck.copyWith(slides: slides));
|
||||
|
|
@ -272,7 +277,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
|
||||
void updateSlide(int index, Slide updated) {
|
||||
final deck = state.deck;
|
||||
if (deck == null) return;
|
||||
if (deck == null || index < 0 || index >= deck.slides.length) return;
|
||||
final slides = List<Slide>.from(deck.slides);
|
||||
slides[index] = updated;
|
||||
// Snel typen op dezelfde slide telt als één ongedaan-maken-stap.
|
||||
|
|
@ -393,6 +398,9 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
title: sub(s.title),
|
||||
subtitle: sub(s.subtitle),
|
||||
bullets: [for (final b in s.bullets) sub(b)],
|
||||
bullets2: [for (final b in s.bullets2) sub(b)],
|
||||
columnTitle1: sub(s.columnTitle1),
|
||||
columnTitle2: sub(s.columnTitle2),
|
||||
quote: sub(s.quote),
|
||||
quoteAuthor: sub(s.quoteAuthor),
|
||||
customMarkdown: sub(s.customMarkdown),
|
||||
|
|
@ -414,6 +422,9 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
s.title,
|
||||
s.subtitle,
|
||||
...s.bullets,
|
||||
...s.bullets2,
|
||||
s.columnTitle1,
|
||||
s.columnTitle2,
|
||||
s.quote,
|
||||
s.quoteAuthor,
|
||||
s.customMarkdown,
|
||||
|
|
|
|||
|
|
@ -101,9 +101,26 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
|||
for (final sub in _subs.values) {
|
||||
sub.cancel();
|
||||
}
|
||||
// The tabs' notifiers are not disposed here: at teardown the widget tree is
|
||||
// still unmounting and may read a tab one last time. The process is ending
|
||||
// anyway. The per-close path (_disposeTab) is what prevents the real leak.
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Tear down a tab that is being removed: stop listening to it and dispose
|
||||
/// its notifiers so their listeners and undo/redo history are released. The
|
||||
/// dispose is deferred to a microtask so any widget still referencing this
|
||||
/// tab while it unmounts has finished before the notifiers go away.
|
||||
void _disposeTab(TabInfo tab) {
|
||||
_subs.remove(tab.id)?.cancel();
|
||||
final deckNotifier = tab.deckNotifier;
|
||||
final editorNotifier = tab.editorNotifier;
|
||||
Future.microtask(() {
|
||||
deckNotifier.dispose();
|
||||
editorNotifier.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
TabInfo _createTab() {
|
||||
final id = _nextId++;
|
||||
final recoveryId = _uuid.v4();
|
||||
|
|
@ -163,7 +180,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
|||
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
|
||||
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
|
||||
if (replaceEmpty) {
|
||||
_subs.remove(state.tabs.first.id)?.cancel();
|
||||
_disposeTab(state.tabs.first);
|
||||
state = state.copyWith(tabs: restored, selectedIndex: 0);
|
||||
} else {
|
||||
final tabs = [...state.tabs, ...restored];
|
||||
|
|
@ -282,7 +299,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
|||
}
|
||||
final tab = state.tabs[index];
|
||||
_recovery.discard(tab.recoveryId);
|
||||
_subs.remove(tab.id)?.cancel();
|
||||
_disposeTab(tab);
|
||||
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
|
||||
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
|
||||
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);
|
||||
|
|
|
|||
|
|
@ -166,7 +166,9 @@ void main() {
|
|||
expect(n.state.revision, greaterThan(revisionBefore));
|
||||
});
|
||||
|
||||
test('clearAllChecklists is a single undoable step that restores the checks', () {
|
||||
test(
|
||||
'clearAllChecklists is a single undoable step that restores the checks',
|
||||
() {
|
||||
final n = _notifier()..newDeck('D');
|
||||
final slide = Slide.create(SlideType.bullets).copyWith(
|
||||
listStyle: ListStyle.checklist,
|
||||
|
|
@ -189,14 +191,14 @@ void main() {
|
|||
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
|
||||
// ...and bumps the revision again so the open editor reflects the restore.
|
||||
expect(n.state.revision, greaterThan(revisionAfterClear));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('clearAllChecklists is a no-op when nothing is checked', () {
|
||||
final n = _notifier()..newDeck('D');
|
||||
final slide = Slide.create(SlideType.bullets).copyWith(
|
||||
listStyle: ListStyle.checklist,
|
||||
bullets: ['[ ] Open'],
|
||||
);
|
||||
final slide = Slide.create(
|
||||
SlideType.bullets,
|
||||
).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']);
|
||||
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
|
||||
expect(n.state.canUndo, isFalse);
|
||||
|
||||
|
|
@ -472,4 +474,31 @@ void main() {
|
|||
n.undo(); // één stap terug herstelt de hele vervanging
|
||||
expect(n.state.deck!.slides.first.title, 'Hallo wereld');
|
||||
});
|
||||
|
||||
test('find and replace covers the second column and both column titles', () {
|
||||
final n = _notifier();
|
||||
n.loadDeck(
|
||||
Deck(
|
||||
title: 'D',
|
||||
slides: [
|
||||
Slide.create(SlideType.twoBullets).copyWith(
|
||||
title: 'foo title',
|
||||
columnTitle1: 'foo left',
|
||||
columnTitle2: 'foo right',
|
||||
bullets: ['foo a'],
|
||||
bullets2: ['foo b', 'foo c'],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// title + columnTitle1 + columnTitle2 + bullets(1) + bullets2(2) = 6
|
||||
expect(n.countMatches('foo'), 6);
|
||||
expect(n.replaceAll('foo', 'bar'), 6);
|
||||
|
||||
final s = n.state.deck!.slides.first;
|
||||
expect(s.columnTitle1, 'bar left');
|
||||
expect(s.columnTitle2, 'bar right');
|
||||
expect(s.bullets2, ['bar b', 'bar c']);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/deck.dart';
|
||||
import 'package:ocideck/models/settings.dart';
|
||||
|
|
@ -62,4 +64,67 @@ void main() {
|
|||
expect(service.currentThemeProfile.logoPath, logo.path);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'importPackageBytes ignores path-traversal entries (zip slip)',
|
||||
() async {
|
||||
final temp = await Directory.systemTemp.createTemp('ocideck_zipslip_');
|
||||
addTearDown(() async {
|
||||
if (await temp.exists()) await temp.delete(recursive: true);
|
||||
});
|
||||
|
||||
final archive = Archive();
|
||||
final md = utf8.encode('---\nmarp: true\n---\n# Hi');
|
||||
archive.addFile(ArchiveFile('deck.md', md.length, md));
|
||||
final evil = utf8.encode('pwned');
|
||||
archive.addFile(ArchiveFile('../evil.txt', evil.length, evil));
|
||||
final zipBytes = ZipEncoder().encode(archive);
|
||||
|
||||
final service = FileService(
|
||||
MarkdownService(),
|
||||
ImageService(),
|
||||
() => const ThemeProfile(),
|
||||
);
|
||||
final mdPath = await service.importPackageBytes(zipBytes, temp.path);
|
||||
|
||||
// The traversal entry must not have escaped the extraction folder.
|
||||
expect(await File(p.join(temp.path, 'evil.txt')).exists(), isFalse);
|
||||
// The legitimate markdown landed inside it.
|
||||
expect(mdPath, isNotNull);
|
||||
expect(p.isWithin(temp.path, mdPath!), isTrue);
|
||||
expect(await File(mdPath).exists(), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'importFromUrl refuses non-web schemes and private/loopback hosts',
|
||||
() async {
|
||||
final temp = await Directory.systemTemp.createTemp('ocideck_ssrf_');
|
||||
addTearDown(() async {
|
||||
if (await temp.exists()) await temp.delete(recursive: true);
|
||||
});
|
||||
final service = FileService(
|
||||
MarkdownService(),
|
||||
ImageService(),
|
||||
() => const ThemeProfile(),
|
||||
);
|
||||
|
||||
// These are all rejected before any network access happens.
|
||||
for (final url in [
|
||||
'ftp://example.com/x', // non-web scheme
|
||||
'file:///etc/passwd', // non-web scheme
|
||||
'http://localhost:8080/x.ocideck', // loopback name
|
||||
'http://127.0.0.1/x', // loopback IP
|
||||
'http://192.168.1.5/x', // private IP
|
||||
'http://10.0.0.9/x', // private IP
|
||||
'http://169.254.1.1/x', // link-local IP
|
||||
]) {
|
||||
expect(
|
||||
await service.importFromUrl(url, temp.path),
|
||||
isNull,
|
||||
reason: 'should refuse $url',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,25 @@ void main() {}
|
|||
expect(html, contains(r'<\/script'));
|
||||
});
|
||||
|
||||
test('build() neutralises a mixed-case closing-script breakout', () async {
|
||||
final service = MarpHtmlService(loadAsset: _diskLoader);
|
||||
final html = await service.build('# X\n\nfoo </ScRiPt> bar');
|
||||
// Case tricks must not slip past the guard.
|
||||
expect(html, isNot(contains('</ScRiPt>')));
|
||||
expect(html, contains(r'<\/ScRiPt'));
|
||||
});
|
||||
|
||||
test(
|
||||
'build() bundles DOMPurify and sanitises the rendered markdown',
|
||||
() async {
|
||||
final service = MarpHtmlService(loadAsset: _diskLoader);
|
||||
final html = await service.build('# X');
|
||||
// The sanitiser is inlined and actually used before content hits the DOM.
|
||||
expect(html, contains('DOMPurify'));
|
||||
expect(html, contains('DOMPurify.sanitize('));
|
||||
},
|
||||
);
|
||||
|
||||
test('a theme colours the slides with the profile palette', () async {
|
||||
final service = MarpHtmlService(
|
||||
loadAsset: _diskLoader,
|
||||
|
|
@ -108,7 +127,10 @@ void main() {}
|
|||
codeTextColor: '#33FF33',
|
||||
codeFontFamily: 'Courier New',
|
||||
);
|
||||
final html = await service.build('```dart\nvoid main() {}\n```', theme: theme);
|
||||
final html = await service.build(
|
||||
'```dart\nvoid main() {}\n```',
|
||||
theme: theme,
|
||||
);
|
||||
|
||||
expect(html, contains('.slide pre{background:#000000;color:#33FF33'));
|
||||
expect(html, contains('.slide pre code{color:#33FF33'));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue