diff --git a/CHANGELOG.md b/CHANGELOG.md index 596568d..cfc2651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,12 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). - **Per-slide TLP classification** — each slide can carry its own Traffic Light Protocol level; slides classified stricter than the level the deck is shown at are withheld when presenting and exporting. +- **Export release ceiling** — an optional maximum TLP level that may be + exported. When set, a deck classified *above* it cannot be exported in any + format; the gate is enforced at the single export chokepoint and fails closed + (no file is written when blocked, and the export dialog explains why). + Classifying a deck stays optional — the ceiling only stops decks that exceed + it, and it is off by default. - **Dual-screen presenter** — on a second display the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync over method channels. diff --git a/README.md b/README.md index c911274..9b8c510 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Built with Flutter for macOS, Windows, and Linux. - **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel. - **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor. - **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math. -- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. +- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. An optional **export release ceiling** can block exporting any deck classified above a chosen level — enforced for every format, off by default. - **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview. - **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync. - **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar. diff --git a/SECURITY.md b/SECURITY.md index df4b11c..f68d026 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,6 +38,8 @@ OciDeck is an offline desktop application. Areas of particular interest: - Importing presentations from a URL. - The HTML export, which inlines third-party JavaScript (marked, highlight.js, mermaid, MathJax) to render offline. +- The export classification gate (`ClassificationPolicy`) — any way to export a + deck classified above the configured release ceiling. ## Supported versions diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1ae8ccd..97935d3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,9 +15,10 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md). ``` lib/ models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation - services/ # markdown, file, export, image, caption, description, - # image_dedup (md5 duplicates), image_reference (.md rewrites), - # recovery, rasterizer, marp_html, annotation_codec + services/ # markdown, file, export, classification_policy, image, caption, + # description, image_dedup (md5 duplicates), + # image_reference (.md rewrites), recovery, rasterizer, + # marp_html, annotation_codec state/ # Riverpod providers: deck, editor, settings, tabs, clipboard widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter l10n/ # AppLocalizations (8 languages) @@ -66,6 +67,15 @@ the key thing to understand before touching rendering: **SVG in Dart** here (no JS chart library). Fidelity differs from the in-app renderer by design. +Both worlds converge at one chokepoint: `services/export_service.dart` +(`ExportService.export()`) is the only place that writes an export, so the +**classification gate** lives there rather than in the export dialog. A +`ClassificationPolicy` enforces an optional *release ceiling* and refuses, +**fail-closed**, to export a deck classified above it — no format can bypass it. +The ceiling is stored in app settings (`maxReleaseExportTlpKey`, off by default); +the dialog also runs the same check up front so a blocked export is explained +before any work starts. + ## Presenter `widgets/presentation/fullscreen_presenter.dart` drives presenting: diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6a5a8bd..d3d8b99 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -114,6 +114,10 @@ A deck has an overall TLP level (shown as a marking on the slides). Each slide c deck can be shown safely to audiences with different clearances. Order, least to most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED. +Classifying a deck is **optional**. As an extra guardrail, an organisation can +set a **release ceiling** — a maximum level that may leave the machine; see +*Exporting* below. + ## Presenting Start the fullscreen presenter from the toolbar. See @@ -148,6 +152,11 @@ Export to: - **Portable package** (`.ocideck`) — a single zip with the Markdown and all assets, to hand the whole deck to someone else. +**Release ceiling (optional).** When a maximum TLP level is configured, exporting +a deck classified *above* it is blocked for every format, and the export dialog +explains why. The ceiling is off by default and classifying a deck stays +optional — it only stops decks that exceed the configured level. + ## Accessibility OciDeck aims for WCAG 2.1 in the editor: diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e491ce4..7c93f58 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -364,6 +364,12 @@ class AppSettings { final String selectedAppAppearanceProfileName; final List recentFiles; + /// Optioneel vrijgaveplafond voor de classificatie-gate, opgeslagen als + /// TLP-sleutel (zie `TlpLevelX.key`). `null` = geen plafond, alles mag worden + /// geëxporteerd (standaard). Classificeren blijft optioneel; dit plafond + /// blokkeert alleen decks die er bovenuit zijn geclassificeerd. + final String? maxReleaseExportTlpKey; + /// Scale factor for all interface text (1.0–2.0), on top of the system /// text scaling. The slide canvas itself is never scaled: slides are a /// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%. @@ -378,6 +384,7 @@ class AppSettings { this.appAppearanceProfiles = AppAppearanceProfile.builtIns, this.selectedAppAppearanceProfileName = 'Basic', this.recentFiles = const [], + this.maxReleaseExportTlpKey, this.uiTextScale = 1.0, }); @@ -430,9 +437,11 @@ class AppSettings { List? appAppearanceProfiles, String? selectedAppAppearanceProfileName, List? recentFiles, + String? maxReleaseExportTlpKey, double? uiTextScale, bool clearHomeDirectory = false, bool clearExportDirectory = false, + bool clearMaxReleaseExportTlp = false, }) { final nextProfiles = themeProfiles ?? this.themeProfiles; return AppSettings( @@ -464,6 +473,9 @@ class AppSettings { selectedAppAppearanceProfileName ?? this.selectedAppAppearanceProfileName, recentFiles: recentFiles ?? this.recentFiles, + maxReleaseExportTlpKey: clearMaxReleaseExportTlp + ? null + : (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey), uiTextScale: uiTextScale ?? this.uiTextScale, ); } diff --git a/lib/services/classification_policy.dart b/lib/services/classification_policy.dart new file mode 100644 index 0000000..b37287e --- /dev/null +++ b/lib/services/classification_policy.dart @@ -0,0 +1,58 @@ +import '../models/deck.dart'; + +/// Uitkomst van de export-gate: mag deze export door, en zo niet, waarom niet. +class ExportDecision { + /// Of de export is toegestaan. + final bool allowed; + + /// Reden waarom de export geweigerd is (`null` wanneer toegestaan). Bedoeld om + /// 1-op-1 aan de gebruiker te tonen. + final String? reason; + + const ExportDecision._(this.allowed, this.reason); + + const ExportDecision.allow() : this._(true, null); + const ExportDecision.block(String reason) : this._(false, reason); +} + +/// Centrale, pure beslisser voor classificatie bij export. +/// +/// Classificeren is **optioneel**: een deck zonder TLP-niveau ([TlpLevel.none]) +/// exporteert altijd. Maar zodra een organisatie een vrijgaveplafond instelt, is +/// dit de enige plek die bepaalt of een geclassificeerd deck naar buiten mag. +/// +/// De gate hangt aan het export-chokepoint ([ExportService.export]), zodat geen +/// enkel formaat (PDF/PPTX/HTML) eromheen kan. Fail-closed: bij twijfel weigert +/// de gate in plaats van stilletjes te exporteren. +class ClassificationPolicy { + /// Hoogste TLP-niveau dat geëxporteerd mag worden — het vrijgaveplafond. + /// + /// `null` = geen plafond, alles mag (standaard). Een deck dat híérboven is + /// geclassificeerd wordt geweigerd in plaats van naar buiten gebracht. Let op: + /// een plafond van [TlpLevel.none] staat alléén ongeclassificeerde decks toe. + final TlpLevel? maxReleaseLevel; + + const ClassificationPolicy({this.maxReleaseLevel}); + + /// Bouw het beleid uit de opgeslagen instelling: een TLP-sleutel (zie + /// [TlpLevelX.key]) of `null` wanneer er geen plafond is ingesteld. + factory ClassificationPolicy.fromKey(String? key) => ClassificationPolicy( + maxReleaseLevel: key == null ? null : TlpLevelX.fromKey(key), + ); + + /// Of er überhaupt een gate actief is. + bool get hasGate => maxReleaseLevel != null; + + /// Beoordeel of een deck met niveau [deckLevel] geëxporteerd mag worden. + ExportDecision evaluate(TlpLevel deckLevel) { + final ceiling = maxReleaseLevel; + if (ceiling != null && deckLevel.index > ceiling.index) { + return ExportDecision.block( + 'Export geblokkeerd door classificatiebeleid: dit deck is ' + '${deckLevel.label}, hoger dan het toegestane vrijgaveniveau ' + '${ceiling.label}.', + ); + } + return const ExportDecision.allow(); + } +} diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index dd5cf29..6c64e45 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -8,7 +8,9 @@ import 'package:path/path.dart' as p; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; +import '../models/deck.dart'; import '../models/settings.dart'; +import 'classification_policy.dart'; import 'marp_html_service.dart'; enum ExportFormat { pdf, pptx, html } @@ -103,7 +105,17 @@ class ExportService { List? notes, String? markdown, ThemeProfile? themeProfile, + TlpLevel tlp = TlpLevel.none, + ClassificationPolicy policy = const ClassificationPolicy(), }) async { + // Classificatie-gate. Dit is het enige chokepoint waar elk formaat + // (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de + // UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed — bij een + // weigering wordt er niets gebouwd of weggeschreven. + final decision = policy.evaluate(tlp); + if (!decision.allowed) { + return ExportResult.fail(decision.reason!); + } if (format == ExportFormat.html) { if (markdown == null || markdown.trim().isEmpty) { return ExportResult.fail('Geen inhoud om te exporteren.'); diff --git a/lib/state/settings_provider.dart b/lib/state/settings_provider.dart index 1e45fc6..493e677 100644 --- a/lib/state/settings_provider.dart +++ b/lib/state/settings_provider.dart @@ -54,10 +54,25 @@ class SettingsNotifier extends StateNotifier { ? selectedAppearance : 'Basic', recentFiles: prefs.getStringList('recentFiles') ?? [], + maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'), uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0), ); } + /// Stel het vrijgaveplafond voor de export-gate in (een TLP-sleutel), of + /// `null` om de gate uit te zetten. Persisteert in hetzelfde prefs-domein. + Future setMaxReleaseExportTlp(String? key) async { + state = key == null + ? state.copyWith(clearMaxReleaseExportTlp: true) + : state.copyWith(maxReleaseExportTlpKey: key); + final prefs = await SharedPreferences.getInstance(); + if (key == null) { + await prefs.remove('maxReleaseExportTlp'); + } else { + await prefs.setString('maxReleaseExportTlp', key); + } + } + Future setUiTextScale(double scale) async { final clamped = scale.clamp(1.0, 2.0).toDouble(); state = state.copyWith(uiTextScale: clamped); diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 36723fb..699621a 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -8,6 +8,7 @@ import '../models/deck.dart'; import '../models/slide.dart'; import '../services/caption_service.dart'; import '../services/description_service.dart'; +import '../services/classification_policy.dart'; import '../services/export_service.dart'; import '../services/recovery_service.dart'; import '../state/deck_provider.dart'; @@ -560,6 +561,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { projectPath: deck.projectPath, exportService: widget.exportService, tlp: deck.tlp, + policy: ClassificationPolicy.fromKey( + ref.read(settingsProvider).maxReleaseExportTlpKey, + ), exportDirectory: ref.read(settingsProvider).exportDirectory, // Inline chart data so the HTML export can render charts standalone, // even when a chart links an external CSV. diff --git a/lib/widgets/dialogs/export_dialog.dart b/lib/widgets/dialogs/export_dialog.dart index a9db743..a3711f1 100644 --- a/lib/widgets/dialogs/export_dialog.dart +++ b/lib/widgets/dialogs/export_dialog.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; +import '../../services/classification_policy.dart'; import '../../services/export_service.dart'; import '../../services/slide_rasterizer.dart'; import '../../l10n/app_localizations.dart'; @@ -18,6 +19,9 @@ class ExportDialog extends StatefulWidget { final ExportService exportService; final TlpLevel tlp; + /// Classificatie-gate. Standaard geen plafond (alles mag). + final ClassificationPolicy policy; + /// Folder all exports are written to. Null = next to the source deck. final String? exportDirectory; @@ -32,6 +36,7 @@ class ExportDialog extends StatefulWidget { required this.projectPath, required this.exportService, this.tlp = TlpLevel.none, + this.policy = const ClassificationPolicy(), this.exportDirectory, this.markdown = '', }); @@ -44,6 +49,7 @@ class ExportDialog extends StatefulWidget { required String? projectPath, required ExportService exportService, TlpLevel tlp = TlpLevel.none, + ClassificationPolicy policy = const ClassificationPolicy(), String? exportDirectory, String markdown = '', }) { @@ -57,6 +63,7 @@ class ExportDialog extends StatefulWidget { projectPath: projectPath, exportService: exportService, tlp: tlp, + policy: policy, exportDirectory: exportDirectory, markdown: markdown, ), @@ -131,6 +138,8 @@ class _ExportDialogState extends State { notes: [for (final s in widget.slides) s.notes], markdown: widget.markdown, themeProfile: widget.themeProfile, + tlp: widget.tlp, + policy: widget.policy, ); if (!mounted) return; @@ -231,6 +240,25 @@ class _ExportDialogState extends State { ); } + // Pre-flight classificatie-gate: blokkeert de export al vóór een poging, + // zodat de gebruiker meteen de reden ziet. De service handhaaft dezelfde + // regel nog eens als backstop, dus dit is puur UX — niet de beveiliging. + final decision = widget.policy.evaluate(widget.tlp); + if (!decision.allowed) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.block, color: Colors.red, size: 36), + const SizedBox(height: 12), + Text( + decision.reason!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: Colors.red[800]), + ), + ], + ); + } + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/test/classification_policy_test.dart b/test/classification_policy_test.dart new file mode 100644 index 0000000..f89f53f --- /dev/null +++ b/test/classification_policy_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/services/classification_policy.dart'; + +void main() { + group('ClassificationPolicy', () { + test( + 'without a ceiling every level is allowed (classificeren optioneel)', + () { + const policy = ClassificationPolicy(); + expect(policy.hasGate, isFalse); + for (final level in TlpLevel.values) { + expect(policy.evaluate(level).allowed, isTrue); + } + }, + ); + + test('a ceiling allows levels at or below it', () { + const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber); + expect(policy.hasGate, isTrue); + for (final level in [ + TlpLevel.none, + TlpLevel.clear, + TlpLevel.green, + TlpLevel.amber, + ]) { + expect(policy.evaluate(level).allowed, isTrue, reason: level.name); + } + }); + + test('a ceiling blocks levels above it, with a clear reason', () { + const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber); + for (final level in [TlpLevel.amberStrict, TlpLevel.red]) { + final decision = policy.evaluate(level); + expect(decision.allowed, isFalse, reason: level.name); + expect(decision.reason, contains(level.label)); + expect(decision.reason, contains(TlpLevel.amber.label)); + } + }); + + test('a ceiling of none only allows unclassified decks', () { + const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.none); + expect(policy.evaluate(TlpLevel.none).allowed, isTrue); + expect(policy.evaluate(TlpLevel.clear).allowed, isFalse); + }); + + group('fromKey', () { + test('null key means no gate', () { + final policy = ClassificationPolicy.fromKey(null); + expect(policy.hasGate, isFalse); + expect(policy.evaluate(TlpLevel.red).allowed, isTrue); + }); + + test('a TLP key sets the ceiling', () { + final policy = ClassificationPolicy.fromKey(TlpLevel.green.key); + expect(policy.maxReleaseLevel, TlpLevel.green); + expect(policy.evaluate(TlpLevel.green).allowed, isTrue); + expect(policy.evaluate(TlpLevel.amber).allowed, isFalse); + }); + }); + }); +} diff --git a/test/export_service_test.dart b/test/export_service_test.dart index f8008d5..6247d5c 100644 --- a/test/export_service_test.dart +++ b/test/export_service_test.dart @@ -5,6 +5,8 @@ import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/services/classification_policy.dart'; import 'package:ocideck/services/export_service.dart'; import 'package:ocideck/services/marp_html_service.dart'; import 'package:path/path.dart' as p; @@ -56,6 +58,41 @@ void main() { String deckPath() => p.join(tmp.path, 'deck.md'); + test( + 'classificatie-gate blocks an over-classified export, writes nothing', + () async { + const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green); + final r = await service.export( + deckPath(), + ExportFormat.pdf, + [_png()], + tlp: TlpLevel.red, + policy: policy, + ); + + expect(r.success, isFalse); + expect(r.outputPath, isNull); + expect(r.error, contains('classificatiebeleid')); + // Fail-closed: no file may be produced when the gate refuses. + final produced = tmp.listSync().whereType().where( + (f) => p.extension(f.path) == '.pdf', + ); + expect(produced, isEmpty); + }, + ); + + test('classificatie-gate allows an export at or below the ceiling', () async { + const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber); + final r = await service.export( + deckPath(), + ExportFormat.pdf, + [_png()], + tlp: TlpLevel.green, + policy: policy, + ); + expect(r.success, isTrue, reason: r.error); + }); + test('exports a PDF that starts with the PDF magic header', () async { final images = [_png(), _png()]; final r = await service.export(deckPath(), ExportFormat.pdf, images);