From 198da00ecd45853370aa50a5ca1ca6de3c69921b Mon Sep 17 00:00:00 2001 From: Akiba So Date: Wed, 5 Aug 2026 19:04:48 +0800 Subject: [PATCH] feat: vault-aligned server v1 + UX polish Redesign the optional FastAPI companion around vault files (manifest / PUT/GET/DELETE + OCR jobs) instead of legacy strokes_json notes. Wire a client Server settings panel for health/login. Polish shell UX: l10n for settings/home/board, sticky-board empty state, and a narrow-screen diagnostics FAB. Co-authored-by: Cursor --- README.md | 2 + lib/diagnostics/diagnostic_chrome.dart | 7 +- lib/l10n/app_en.arb | 40 ++- lib/l10n/app_localizations.dart | 168 +++++++++++++ lib/l10n/app_localizations_en.dart | 99 ++++++++ lib/l10n/app_localizations_zh.dart | 96 ++++++++ lib/l10n/app_zh.arb | 40 ++- lib/screens/app_shell.dart | 35 ++- lib/screens/board_screen.dart | 41 +++- lib/screens/home_screen.dart | 17 +- lib/screens/settings_screen.dart | 231 ++++++++++++++++-- lib/services/badnote_server_client.dart | 139 +++++++++++ server/README.md | 82 +++++-- server/badnote_server/config.py | 3 + server/badnote_server/main.py | 62 ++++- .../badnote_server/routers/v1_ocr_router.py | 74 ++++++ .../badnote_server/routers/v1_vault_router.py | 63 +++++ server/badnote_server/vault_store.py | 124 ++++++++++ server/tests/conftest.py | 4 + server/tests/test_vault_v1.py | 88 +++++++ 20 files changed, 1325 insertions(+), 90 deletions(-) create mode 100644 lib/services/badnote_server_client.dart create mode 100644 server/badnote_server/routers/v1_ocr_router.py create mode 100644 server/badnote_server/routers/v1_vault_router.py create mode 100644 server/badnote_server/vault_store.py create mode 100644 server/tests/test_vault_v1.py diff --git a/README.md b/README.md index 565fbec..4e9c1a5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ All notes, documents, search, and OCR run on your device. No server is required - Infinite sticky board with `[[wikilinks]]` / backlinks - Full-text search over note titles, typed text, and OCR results - Always-on diagnostics + one-click diagnostic pack export (Settings) +- Optional self-hosted **BadNote Server** (`/api/v1`: vault assist + OCR jobs) — see [server/README.md](server/README.md) - **Local OCR** — ONNX when bundled, else Windows WinRT +- WebDAV vault sync (NAS) ## Build (Windows) diff --git a/lib/diagnostics/diagnostic_chrome.dart b/lib/diagnostics/diagnostic_chrome.dart index bc6cf40..1aa3180 100644 --- a/lib/diagnostics/diagnostic_chrome.dart +++ b/lib/diagnostics/diagnostic_chrome.dart @@ -6,6 +6,7 @@ import 'diagnostic_export.dart'; import '../editor/canvas/input_diagnostics.dart'; import '../editor/input/diagnostic_logger.dart'; import '../editor/input/pen_input_service.dart'; +import '../l10n/app_localizations.dart'; /// Shared diagnostics chrome: overlay readout + export action. /// Mount on any document surface (note / PDF / PPT / board). @@ -54,7 +55,7 @@ class DiagnosticChromeState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - '诊断包已导出 (${result.bytes} bytes)\n路径已复制到剪贴板', + AppLocalizations.of(context).diagExported(result.bytes), ), duration: const Duration(seconds: 5), ), @@ -65,7 +66,9 @@ class DiagnosticChromeState extends State { }); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('导出失败: $e')), + SnackBar( + content: Text(AppLocalizations.of(context).diagExportFail('$e')), + ), ); } finally { if (mounted) setState(() => _exporting = false); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3ab37c9..18da9bf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -199,5 +199,43 @@ }, "syncAuto": "Sync automatically on launch", "syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.", - "syncNotConfigured": "Enter a server URL to enable sync." + "syncNotConfigured": "Enter a server URL to enable sync.", + "settingsDefaults": "Defaults", + "settingsAppearance": "Appearance", + "settingsAbout": "About", + "settingsDefaultTool": "Default tool", + "settingsDefaultColor": "Default color", + "settingsDefaultWidth": "Default stroke width", + "settingsPressureCurve": "Pressure curve", + "settingsClearConfirmBody": "This resets pen defaults and appearance. Notes and documents are not affected.", + "serverSection": "BadNote Server", + "serverUrl": "Server URL", + "serverUrlHint": "http://192.168.1.10:8080", + "serverUsername": "Username", + "serverPassword": "Password", + "serverSave": "Save & sign in", + "serverTest": "Test connection", + "serverTestOk": "Connected · API {version}", + "@serverTestOk": { + "placeholders": { "version": { "type": "String" } } + }, + "serverTestFail": "Connection failed: {error}", + "@serverTestFail": { + "placeholders": { "error": { "type": "String" } } + }, + "serverLoggedIn": "Signed in", + "serverHint": "Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.", + "boardEmptyTitle": "No sticky notes yet", + "boardEmptyBody": "Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.", + "relativeJustNow": "Just now", + "relativeMinutesAgo": "{n}m ago", + "@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } }, + "relativeHoursAgo": "{n}h ago", + "@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } }, + "relativeYesterday": "Yesterday", + "diagExported": "Diagnostic pack exported ({bytes} bytes)\nPath copied", + "@diagExported": { "placeholders": { "bytes": { "type": "int" } } }, + "diagExportFail": "Export failed: {error}", + "@diagExportFail": { "placeholders": { "error": { "type": "String" } } }, + "processingOcr": "Processing OCR…" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ad0c68f..ce94ec5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -985,6 +985,174 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Enter a server URL to enable sync.'** String get syncNotConfigured; + + /// No description provided for @settingsDefaults. + /// + /// In en, this message translates to: + /// **'Defaults'** + String get settingsDefaults; + + /// No description provided for @settingsAppearance. + /// + /// In en, this message translates to: + /// **'Appearance'** + String get settingsAppearance; + + /// No description provided for @settingsAbout. + /// + /// In en, this message translates to: + /// **'About'** + String get settingsAbout; + + /// No description provided for @settingsDefaultTool. + /// + /// In en, this message translates to: + /// **'Default tool'** + String get settingsDefaultTool; + + /// No description provided for @settingsDefaultColor. + /// + /// In en, this message translates to: + /// **'Default color'** + String get settingsDefaultColor; + + /// No description provided for @settingsDefaultWidth. + /// + /// In en, this message translates to: + /// **'Default stroke width'** + String get settingsDefaultWidth; + + /// No description provided for @settingsPressureCurve. + /// + /// In en, this message translates to: + /// **'Pressure curve'** + String get settingsPressureCurve; + + /// No description provided for @settingsClearConfirmBody. + /// + /// In en, this message translates to: + /// **'This resets pen defaults and appearance. Notes and documents are not affected.'** + String get settingsClearConfirmBody; + + /// No description provided for @serverSection. + /// + /// In en, this message translates to: + /// **'BadNote Server'** + String get serverSection; + + /// No description provided for @serverUrl. + /// + /// In en, this message translates to: + /// **'Server URL'** + String get serverUrl; + + /// No description provided for @serverUrlHint. + /// + /// In en, this message translates to: + /// **'http://192.168.1.10:8080'** + String get serverUrlHint; + + /// No description provided for @serverUsername. + /// + /// In en, this message translates to: + /// **'Username'** + String get serverUsername; + + /// No description provided for @serverPassword. + /// + /// In en, this message translates to: + /// **'Password'** + String get serverPassword; + + /// No description provided for @serverSave. + /// + /// In en, this message translates to: + /// **'Save & sign in'** + String get serverSave; + + /// No description provided for @serverTest. + /// + /// In en, this message translates to: + /// **'Test connection'** + String get serverTest; + + /// No description provided for @serverTestOk. + /// + /// In en, this message translates to: + /// **'Connected · API {version}'** + String serverTestOk(String version); + + /// No description provided for @serverTestFail. + /// + /// In en, this message translates to: + /// **'Connection failed: {error}'** + String serverTestFail(String error); + + /// No description provided for @serverLoggedIn. + /// + /// In en, this message translates to: + /// **'Signed in'** + String get serverLoggedIn; + + /// No description provided for @serverHint. + /// + /// In en, this message translates to: + /// **'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.'** + String get serverHint; + + /// No description provided for @boardEmptyTitle. + /// + /// In en, this message translates to: + /// **'No sticky notes yet'** + String get boardEmptyTitle; + + /// No description provided for @boardEmptyBody. + /// + /// In en, this message translates to: + /// **'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.'** + String get boardEmptyBody; + + /// No description provided for @relativeJustNow. + /// + /// In en, this message translates to: + /// **'Just now'** + String get relativeJustNow; + + /// No description provided for @relativeMinutesAgo. + /// + /// In en, this message translates to: + /// **'{n}m ago'** + String relativeMinutesAgo(int n); + + /// No description provided for @relativeHoursAgo. + /// + /// In en, this message translates to: + /// **'{n}h ago'** + String relativeHoursAgo(int n); + + /// No description provided for @relativeYesterday. + /// + /// In en, this message translates to: + /// **'Yesterday'** + String get relativeYesterday; + + /// No description provided for @diagExported. + /// + /// In en, this message translates to: + /// **'Diagnostic pack exported ({bytes} bytes)\nPath copied'** + String diagExported(int bytes); + + /// No description provided for @diagExportFail. + /// + /// In en, this message translates to: + /// **'Export failed: {error}'** + String diagExportFail(String error); + + /// No description provided for @processingOcr. + /// + /// In en, this message translates to: + /// **'Processing OCR…'** + String get processingOcr; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9d6bdfe..e9b70d0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -489,4 +489,103 @@ class AppLocalizationsEn extends AppLocalizations { @override String get syncNotConfigured => 'Enter a server URL to enable sync.'; + + @override + String get settingsDefaults => 'Defaults'; + + @override + String get settingsAppearance => 'Appearance'; + + @override + String get settingsAbout => 'About'; + + @override + String get settingsDefaultTool => 'Default tool'; + + @override + String get settingsDefaultColor => 'Default color'; + + @override + String get settingsDefaultWidth => 'Default stroke width'; + + @override + String get settingsPressureCurve => 'Pressure curve'; + + @override + String get settingsClearConfirmBody => + 'This resets pen defaults and appearance. Notes and documents are not affected.'; + + @override + String get serverSection => 'BadNote Server'; + + @override + String get serverUrl => 'Server URL'; + + @override + String get serverUrlHint => 'http://192.168.1.10:8080'; + + @override + String get serverUsername => 'Username'; + + @override + String get serverPassword => 'Password'; + + @override + String get serverSave => 'Save & sign in'; + + @override + String get serverTest => 'Test connection'; + + @override + String serverTestOk(String version) { + return 'Connected · API $version'; + } + + @override + String serverTestFail(String error) { + return 'Connection failed: $error'; + } + + @override + String get serverLoggedIn => 'Signed in'; + + @override + String get serverHint => + 'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.'; + + @override + String get boardEmptyTitle => 'No sticky notes yet'; + + @override + String get boardEmptyBody => + 'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.'; + + @override + String get relativeJustNow => 'Just now'; + + @override + String relativeMinutesAgo(int n) { + return '${n}m ago'; + } + + @override + String relativeHoursAgo(int n) { + return '${n}h ago'; + } + + @override + String get relativeYesterday => 'Yesterday'; + + @override + String diagExported(int bytes) { + return 'Diagnostic pack exported ($bytes bytes)\nPath copied'; + } + + @override + String diagExportFail(String error) { + return 'Export failed: $error'; + } + + @override + String get processingOcr => 'Processing OCR…'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 23fce96..44e8552 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -484,4 +484,100 @@ class AppLocalizationsZh extends AppLocalizations { @override String get syncNotConfigured => '请输入服务器地址以启用同步。'; + + @override + String get settingsDefaults => '默认笔迹'; + + @override + String get settingsAppearance => '外观'; + + @override + String get settingsAbout => '关于'; + + @override + String get settingsDefaultTool => '默认工具'; + + @override + String get settingsDefaultColor => '默认颜色'; + + @override + String get settingsDefaultWidth => '默认线宽'; + + @override + String get settingsPressureCurve => '压感曲线'; + + @override + String get settingsClearConfirmBody => '将重置笔默认值与外观设置。笔记和文档不会受影响。'; + + @override + String get serverSection => 'BadNote 服务器'; + + @override + String get serverUrl => '服务器地址'; + + @override + String get serverUrlHint => 'http://192.168.1.10:8080'; + + @override + String get serverUsername => '用户名'; + + @override + String get serverPassword => '密码'; + + @override + String get serverSave => '保存并登录'; + + @override + String get serverTest => '测试连接'; + + @override + String serverTestOk(String version) { + return '连接成功 · API $version'; + } + + @override + String serverTestFail(String error) { + return '连接失败:$error'; + } + + @override + String get serverLoggedIn => '已登录'; + + @override + String get serverHint => '可选。用于自托管 vault 协助同步与延迟 OCR;日常笔记仍完全离线。'; + + @override + String get boardEmptyTitle => '还没有便利贴'; + + @override + String get boardEmptyBody => '点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。'; + + @override + String get relativeJustNow => '刚刚'; + + @override + String relativeMinutesAgo(int n) { + return '$n 分钟前'; + } + + @override + String relativeHoursAgo(int n) { + return '$n 小时前'; + } + + @override + String get relativeYesterday => '昨天'; + + @override + String diagExported(int bytes) { + return '诊断包已导出($bytes 字节)\n路径已复制'; + } + + @override + String diagExportFail(String error) { + return '导出失败:$error'; + } + + @override + String get processingOcr => '正在识别文字…'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c9018de..700cfe2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -169,5 +169,43 @@ }, "syncAuto": "启动时自动同步", "syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。", - "syncNotConfigured": "请输入服务器地址以启用同步。" + "syncNotConfigured": "请输入服务器地址以启用同步。", + "settingsDefaults": "默认笔迹", + "settingsAppearance": "外观", + "settingsAbout": "关于", + "settingsDefaultTool": "默认工具", + "settingsDefaultColor": "默认颜色", + "settingsDefaultWidth": "默认线宽", + "settingsPressureCurve": "压感曲线", + "settingsClearConfirmBody": "将重置笔默认值与外观设置。笔记和文档不会受影响。", + "serverSection": "BadNote 服务器", + "serverUrl": "服务器地址", + "serverUrlHint": "http://192.168.1.10:8080", + "serverUsername": "用户名", + "serverPassword": "密码", + "serverSave": "保存并登录", + "serverTest": "测试连接", + "serverTestOk": "连接成功 · API {version}", + "@serverTestOk": { + "placeholders": { "version": { "type": "String" } } + }, + "serverTestFail": "连接失败:{error}", + "@serverTestFail": { + "placeholders": { "error": { "type": "String" } } + }, + "serverLoggedIn": "已登录", + "serverHint": "可选。用于自托管 vault 协助同步与延迟 OCR;日常笔记仍完全离线。", + "boardEmptyTitle": "还没有便利贴", + "boardEmptyBody": "点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。", + "relativeJustNow": "刚刚", + "relativeMinutesAgo": "{n} 分钟前", + "@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } }, + "relativeHoursAgo": "{n} 小时前", + "@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } }, + "relativeYesterday": "昨天", + "diagExported": "诊断包已导出({bytes} 字节)\n路径已复制", + "@diagExported": { "placeholders": { "bytes": { "type": "int" } } }, + "diagExportFail": "导出失败:{error}", + "@diagExportFail": { "placeholders": { "error": { "type": "String" } } }, + "processingOcr": "正在识别文字…" } diff --git a/lib/screens/app_shell.dart b/lib/screens/app_shell.dart index 25f3a49..b0582de 100644 --- a/lib/screens/app_shell.dart +++ b/lib/screens/app_shell.dart @@ -115,18 +115,29 @@ class _AppShellState extends ConsumerState { return Scaffold( body: body, - bottomNavigationBar: NavigationBar( - selectedIndex: _index, - onDestinationSelected: _select, - destinations: [ - for (final d in destinations) - NavigationDestination( - icon: Icon(d.icon), - selectedIcon: Icon(d.selectedIcon), - label: d.label, - ), + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + NavigationBar( + selectedIndex: _index, + onDestinationSelected: _select, + destinations: [ + for (final d in destinations) + NavigationDestination( + icon: Icon(d.icon), + selectedIcon: Icon(d.selectedIcon), + label: d.label, + ), + ], + ), ], ), + floatingActionButton: FloatingActionButton.small( + heroTag: 'diag_fab', + tooltip: AppLocalizations.of(context).diagnosticsSection, + onPressed: () => _diagKey.currentState?.toggle(), + child: const Icon(Icons.bug_report_outlined), + ), ); } @@ -150,14 +161,14 @@ Future exportDiagnosticPack(BuildContext context) async { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('诊断包: ${result.zipPath}'), + content: Text(AppLocalizations.of(context).diagExported(result.bytes)), duration: const Duration(seconds: 5), ), ); } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('导出失败: $e')), + SnackBar(content: Text(AppLocalizations.of(context).diagExportFail('$e'))), ); } } diff --git a/lib/screens/board_screen.dart b/lib/screens/board_screen.dart index 68dd18b..284b7bb 100644 --- a/lib/screens/board_screen.dart +++ b/lib/screens/board_screen.dart @@ -134,6 +134,7 @@ class _BoardScreenState extends ConsumerState { final selected = _selectedId != null ? _board.cardById(_selectedId!) : null; final backlinks = selected != null ? _board.backlinksOf(selected.id) : {}; + final isEmpty = _board.length == 0; return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, @@ -153,7 +154,43 @@ class _BoardScreenState extends ConsumerState { ), ], ), - body: Row( + body: isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.sticky_note_2_outlined, + size: 72, + color: AppTokens.copper.withValues(alpha: 0.85), + ), + const SizedBox(height: 20), + Text( + l.boardEmptyTitle, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l.boardEmptyBody, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTokens.inkMuted, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _addCard, + icon: const Icon(Icons.add), + label: Text(l.boardAddCard), + ), + ], + ), + ), + ) + : Row( children: [ Expanded( child: InteractiveViewer( @@ -242,7 +279,7 @@ class _BoardScreenState extends ConsumerState { ), const SizedBox(height: 4), Text( - '在便利贴正文里写 [[另一张卡片id]] 建立双链', + l.boardEmptyBody, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.inkMuted, ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 813233f..b6f2175 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -20,15 +20,16 @@ import '../editor/canvas/pen_slide_screen.dart'; import 'search_screen.dart'; import 'settings_screen.dart'; -// [M1] Relative date helper — no new package dependencies. -String _formatDate(DateTime d) { +// Relative date helper — localized. +String _formatDate(BuildContext context, DateTime d) { + final l = AppLocalizations.of(context); final now = DateTime.now(); final diff = now.difference(d); - if (diff.inSeconds < 60) return 'Just now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inSeconds < 60) return l.relativeJustNow; + if (diff.inMinutes < 60) return l.relativeMinutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l.relativeHoursAgo(diff.inHours); if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) { - return 'Yesterday'; + return l.relativeYesterday; } return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}'; } @@ -411,7 +412,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> { Widget build(BuildContext context) { final note = widget.note; // [M1] Use relative date helper - final dateStr = _formatDate(note.updatedAt); + final dateStr = _formatDate(context, note.updatedAt); final ocrStatusMap = ref.watch(ocrStatusProvider); final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none; @@ -584,7 +585,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { Widget build(BuildContext context) { final document = widget.document; // [M1] Use relative date helper - final dateStr = _formatDate(document.updatedAt); + final dateStr = _formatDate(context, document.updatedAt); final isPdf = document.docType == 'pdf'; final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6a70a11..85ae159 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,6 +9,7 @@ import '../models/pen_tool.dart'; import '../models/pressure_curve.dart'; import '../providers/settings_provider.dart'; import '../screens/app_shell.dart' show exportDiagnosticPack; +import '../services/badnote_server_client.dart'; import '../services/vault_service.dart'; import '../services/webdav_sync_service.dart'; import '../utils/stroke_stabilizer.dart'; @@ -60,10 +61,7 @@ class SettingsScreen extends ConsumerWidget { context: context, builder: (ctx) => AlertDialog( title: Text(AppLocalizations.of(ctx).clearSettingsTitle), - content: const Text( - 'This will reset pen defaults and appearance settings. ' - 'Notes and documents are not affected.', - ), + content: Text(AppLocalizations.of(ctx).settingsClearConfirmBody), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), @@ -107,15 +105,18 @@ class SettingsScreen extends ConsumerWidget { onTap: () => exportDiagnosticPack(context), ), const Divider(), - _SectionHeader(title: 'Defaults', icon: Icons.tune), + _SectionHeader( + title: AppLocalizations.of(context).settingsDefaults, + icon: Icons.tune, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Default Tool', - style: TextStyle(fontWeight: FontWeight.w500), + Text( + AppLocalizations.of(context).settingsDefaultTool, + style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), DropdownButtonFormField( @@ -135,9 +136,9 @@ class SettingsScreen extends ConsumerWidget { }, ), const SizedBox(height: 16), - const Text( - 'Default Color', - style: TextStyle(fontWeight: FontWeight.w500), + Text( + AppLocalizations.of(context).settingsDefaultColor, + style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Row( @@ -170,9 +171,9 @@ class SettingsScreen extends ConsumerWidget { ], ), const SizedBox(height: 16), - const Text( - 'Default Stroke Width', - style: TextStyle(fontWeight: FontWeight.w500), + Text( + AppLocalizations.of(context).settingsDefaultWidth, + style: const TextStyle(fontWeight: FontWeight.w500), ), Slider( value: settings.defaultStrokeWidth, @@ -183,9 +184,9 @@ class SettingsScreen extends ConsumerWidget { onChanged: settings.setDefaultStrokeWidth, ), const SizedBox(height: 16), - const Text( - 'Pressure Curve', - style: TextStyle(fontWeight: FontWeight.w500), + Text( + AppLocalizations.of(context).settingsPressureCurve, + style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), DropdownButtonFormField( @@ -232,15 +233,18 @@ class SettingsScreen extends ConsumerWidget { ), ), const Divider(), - _SectionHeader(title: 'Appearance', icon: Icons.palette), + _SectionHeader( + title: AppLocalizations.of(context).settingsAppearance, + icon: Icons.palette, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Theme Mode', - style: TextStyle(fontWeight: FontWeight.w500), + Text( + AppLocalizations.of(context).settingsAppearance, + style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), SegmentedButton( @@ -267,9 +271,9 @@ class SettingsScreen extends ConsumerWidget { }, ), const SizedBox(height: 16), - const Text( - 'Color Scheme Seed', - style: TextStyle(fontWeight: FontWeight.w500), + Text( + AppLocalizations.of(context).seedColorDesc, + style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Row( @@ -302,7 +306,10 @@ class SettingsScreen extends ConsumerWidget { ), ), const Divider(), - _SectionHeader(title: 'Vault', icon: Icons.folder_special), + _SectionHeader( + title: AppLocalizations.of(context).vaultSection, + icon: Icons.folder_special, + ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: _VaultSettings(), @@ -317,7 +324,19 @@ class SettingsScreen extends ConsumerWidget { child: _SyncSettings(), ), const Divider(), - _SectionHeader(title: 'About', icon: Icons.info), + _SectionHeader( + title: AppLocalizations.of(context).serverSection, + icon: Icons.dns_outlined, + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: _ServerSettings(), + ), + const Divider(), + _SectionHeader( + title: AppLocalizations.of(context).settingsAbout, + icon: Icons.info, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( @@ -709,6 +728,166 @@ class _SyncSettingsState extends State<_SyncSettings> { } } +class _ServerSettings extends StatefulWidget { + const _ServerSettings(); + + @override + State<_ServerSettings> createState() => _ServerSettingsState(); +} + +class _ServerSettingsState extends State<_ServerSettings> { + final _urlCtrl = TextEditingController(); + final _userCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _busy = false; + bool _loggedIn = false; + String? _status; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _urlCtrl.dispose(); + _userCtrl.dispose(); + _passCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final cfg = await BadNoteServerConfig.load(prefs); + if (!mounted) return; + setState(() { + _urlCtrl.text = cfg.baseUrl; + _userCtrl.text = cfg.username; + _passCtrl.text = cfg.password; + _loggedIn = cfg.isLoggedIn; + }); + } + + Future _saveAndLogin() async { + final l = AppLocalizations.of(context); + setState(() => _busy = true); + try { + final prefs = await SharedPreferences.getInstance(); + var cfg = BadNoteServerConfig( + baseUrl: _urlCtrl.text.trim(), + username: _userCtrl.text.trim(), + password: _passCtrl.text, + ); + final client = BadNoteServerClient(cfg); + cfg = await client.registerOrLogin( + username: cfg.username, + password: cfg.password, + ); + await cfg.save(prefs); + client.close(); + if (!mounted) return; + setState(() { + _loggedIn = true; + _status = l.serverLoggedIn; + }); + } catch (e) { + if (!mounted) return; + setState(() => _status = l.serverTestFail('$e')); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _test() async { + final l = AppLocalizations.of(context); + setState(() => _busy = true); + try { + final cfg = BadNoteServerConfig(baseUrl: _urlCtrl.text.trim()); + final client = BadNoteServerClient(cfg); + final health = await client.health(); + client.close(); + if (!mounted) return; + setState(() { + _status = l.serverTestOk('${health['version'] ?? health['api']}'); + }); + } catch (e) { + if (!mounted) return; + setState(() => _status = l.serverTestFail('$e')); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.serverHint, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + TextField( + controller: _urlCtrl, + decoration: InputDecoration( + labelText: l.serverUrl, + hintText: l.serverUrlHint, + border: const OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 8), + TextField( + controller: _userCtrl, + decoration: InputDecoration( + labelText: l.serverUsername, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _passCtrl, + obscureText: true, + decoration: InputDecoration( + labelText: l.serverPassword, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton( + onPressed: _busy ? null : _saveAndLogin, + child: Text(l.serverSave), + ), + OutlinedButton( + onPressed: _busy ? null : _test, + child: Text(l.serverTest), + ), + if (_loggedIn) + Chip( + avatar: const Icon(Icons.check_circle, size: 16), + label: Text(l.serverLoggedIn), + ), + ], + ), + if (_status != null) ...[ + const SizedBox(height: 8), + Text(_status!, style: Theme.of(context).textTheme.bodySmall), + ], + ], + ); + } +} + class _SectionHeader extends StatelessWidget { final String title; final IconData icon; diff --git a/lib/services/badnote_server_client.dart b/lib/services/badnote_server_client.dart new file mode 100644 index 0000000..c6f5dbb --- /dev/null +++ b/lib/services/badnote_server_client.dart @@ -0,0 +1,139 @@ +// HTTP client for the optional self-hosted BadNote Server (/api/v1). +// WebDAV remains the primary NAS sync path; this client covers health, auth, +// vault manifest assist, and OCR job submit/poll. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class BadNoteServerConfig { + const BadNoteServerConfig({ + this.baseUrl = '', + this.username = '', + this.password = '', + this.token = '', + this.userId = '', + }); + + final String baseUrl; + final String username; + final String password; + final String token; + final String userId; + + bool get isConfigured => baseUrl.trim().isNotEmpty; + bool get isLoggedIn => token.isNotEmpty; + + Uri? uri(String path) { + final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), ''); + if (base.isEmpty) return null; + final p = path.startsWith('/') ? path : '/$path'; + return Uri.parse('$base$p'); + } + + BadNoteServerConfig copyWith({ + String? baseUrl, + String? username, + String? password, + String? token, + String? userId, + }) => + BadNoteServerConfig( + baseUrl: baseUrl ?? this.baseUrl, + username: username ?? this.username, + password: password ?? this.password, + token: token ?? this.token, + userId: userId ?? this.userId, + ); + + static const _kBase = 'badnote.server.baseUrl'; + static const _kUser = 'badnote.server.username'; + static const _kPass = 'badnote.server.password'; + static const _kToken = 'badnote.server.token'; + static const _kUid = 'badnote.server.userId'; + + static Future load(SharedPreferences prefs) async { + return BadNoteServerConfig( + baseUrl: prefs.getString(_kBase) ?? '', + username: prefs.getString(_kUser) ?? '', + password: prefs.getString(_kPass) ?? '', + token: prefs.getString(_kToken) ?? '', + userId: prefs.getString(_kUid) ?? '', + ); + } + + Future save(SharedPreferences prefs) async { + await prefs.setString(_kBase, baseUrl); + await prefs.setString(_kUser, username); + await prefs.setString(_kPass, password); + await prefs.setString(_kToken, token); + await prefs.setString(_kUid, userId); + } +} + +class BadNoteServerClient { + BadNoteServerClient(this.config, {http.Client? httpClient}) + : _http = httpClient ?? http.Client(); + + BadNoteServerConfig config; + final http.Client _http; + + Map get _authHeaders => { + if (config.token.isNotEmpty) 'Authorization': 'Bearer ${config.token}', + }; + + Future> health() async { + final uri = config.uri('/api/v1/health'); + if (uri == null) throw StateError('server URL not set'); + final res = await _http.get(uri).timeout(const Duration(seconds: 8)); + if (res.statusCode != 200) { + throw StateError('health ${res.statusCode}: ${res.body}'); + } + return jsonDecode(res.body) as Map; + } + + Future registerOrLogin({ + required String username, + required String password, + }) async { + final registerUri = config.uri('/api/v1/auth/register'); + if (registerUri == null) throw StateError('server URL not set'); + var res = await _http.post( + registerUri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'username': username, 'password': password}), + ); + if (res.statusCode == 409) { + final loginUri = config.uri('/api/v1/auth/login')!; + res = await _http.post( + loginUri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'username': username, 'password': password}), + ); + } + if (res.statusCode != 200 && res.statusCode != 201) { + throw StateError('auth ${res.statusCode}: ${res.body}'); + } + final body = jsonDecode(res.body) as Map; + config = config.copyWith( + username: username, + password: password, + token: body['token'] as String? ?? '', + userId: body['user_id'] as String? ?? '', + ); + return config; + } + + Future> vaultManifest() async { + final uri = config.uri('/api/v1/vault/manifest'); + if (uri == null) throw StateError('server URL not set'); + final res = await _http.get(uri, headers: _authHeaders); + if (res.statusCode != 200) { + throw StateError('manifest ${res.statusCode}: ${res.body}'); + } + return jsonDecode(res.body) as Map; + } + + void close() => _http.close(); +} diff --git a/server/README.md b/server/README.md index c658478..2a93bda 100644 --- a/server/README.md +++ b/server/README.md @@ -1,24 +1,38 @@ -# BadNote Server (Optional) +# BadNote Server (Self-hosted companion) -This directory contains an **optional** Python/FastAPI backend. The BadNote desktop app does **not** depend on it. +Optional FastAPI backend for multi-device vault assist and deferred OCR. +The Flutter app stays local-first: notes work fully offline. This server is +for **your NAS / VPS**, not a hosted cloud product. -The Flutter client is local-first: +## Architecture (v2 / API v1) -- Notes and documents are stored in SQLite on device -- OCR runs locally via Windows built-in OCR -- Full-text search uses on-device FTS5 +``` +Client vault (files + *.badnote.json) + │ + ├─ WebDAV (NAS) ───────────── file sync (existing) + │ + └─ BadNote Server /api/v1 ─── assist layer + ├─ /auth JWT register/login + ├─ /vault manifest + PUT/GET/DELETE (tombstones) + └─ /ocr upload ink PNG → job queue → EasyOCR worker +``` -## Why this exists +**Source of truth = vault files**, not the legacy `notes.strokes_json` tables. +Legacy routers remain under `/api/legacy/*` (and old `/api/notes` paths) for +experiments only — new clients must use `/api/v1`. -This server was an early experiment for: +### Storage layout -- Multi-device note sync (push/pull) -- Server-side OCR with EasyOCR -- JWT authentication +``` +data/ + badnote_server.db # users + .jwt_secret # if BADNOTE_JWT_SECRET unset + vaults//files/ # mirrors client vault + storage/ocr_blobs/… # uploaded ink rasters + queue/{pending,processing,done,failed}/ +``` -These features are **not wired into the current client**. The client previously had incomplete sync/OCR scaffolding that has been removed in favor of local processing. - -## Running (if you want to experiment) +## Run ```bash cd server @@ -28,25 +42,43 @@ pip install -r requirements.txt uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080 ``` -API docs: http://localhost:8080/docs +- Health: `GET /api/v1/health` +- OpenAPI: http://localhost:8080/docs -The OCR worker has heavy extra dependencies (EasyOCR + torch). Install them only -if you want to run it: +### OCR worker (optional, heavy) ```bash pip install -r requirements-ocr.txt python -m badnote_server.ocr.worker ``` -### Security notes +### Security -- Set `BADNOTE_JWT_SECRET` in production. If unset, a secret is generated once - and persisted to `/.jwt_secret` so tokens survive restarts. -- Restrict origins with `BADNOTE_CORS_ORIGINS` (comma-separated). The default is - permissive (`*`, without credentials) for local development. +- Set `BADNOTE_JWT_SECRET` in production. +- Restrict CORS with `BADNOTE_CORS_ORIGINS`. +- Prefer HTTPS reverse proxy (Caddy/Nginx) in front of uvicorn. + +### Env + +| Variable | Default | Meaning | +|----------|---------|---------| +| `BADNOTE_HOST` / `PORT` | `0.0.0.0` / `8080` | Bind | +| `BADNOTE_DB_PATH` | `./data/badnote_server.db` | Users DB | +| `BADNOTE_VAULT_PATH` | `./data/vaults` | Per-user vault trees | +| `BADNOTE_STORAGE_PATH` | `./data/storage` | Blobs | +| `BADNOTE_QUEUE_PATH` | `./data/queue` | OCR jobs | +| `BADNOTE_JWT_SECRET` | persisted file | Signing key | +| `BADNOTE_CORS_ORIGINS` | `*` | Allowed origins | + +## Client + +In BadNote → Settings → **BadNote Server**, set base URL (e.g. +`http://192.168.1.10:8080`), register/login, then **Test connection**. +Vault file sync via the API is additive to WebDAV; OCR upload is opt-in when +online/charging (future client job). ## Status -- Kept for reference and future optional sync work -- Not part of the primary development path -- No guarantee of API compatibility with future client versions +- **v1 vault + health + OCR enqueue**: implemented +- **Wiki / semantic search**: stubbed for later (`501` reserved) +- Legacy notes push/pull: deprecated, not used by current Flutter app diff --git a/server/badnote_server/config.py b/server/badnote_server/config.py index ae69529..cb15cd5 100644 --- a/server/badnote_server/config.py +++ b/server/badnote_server/config.py @@ -68,6 +68,9 @@ class Settings: jwt_secret: str = _resolve_jwt_secret() jwt_expiry_hours: int = int(os.environ.get("BADNOTE_JWT_EXPIRY_HOURS", "720")) cors_origins: list[str] = _resolve_cors_origins() + # Per-user vault trees (notebook folders + sidecars), independent of legacy + # notes.strokes_json storage. + vault_path: str = os.environ.get("BADNOTE_VAULT_PATH", "./data/vaults") settings = Settings() diff --git a/server/badnote_server/main.py b/server/badnote_server/main.py index 958ad65..65d4bbc 100644 --- a/server/badnote_server/main.py +++ b/server/badnote_server/main.py @@ -1,4 +1,12 @@ -"""BadNote FastAPI server — main application.""" +"""BadNote FastAPI server — main application. + +Architecture (v1): + - Vault files are the source of truth (same layout as the Flutter vault). + - ``/api/v1/vault/*`` assists multi-device sync (manifest + PUT/GET/DELETE). + - ``/api/v1/ocr/*`` accepts ink rasters for deferred server OCR. + - Legacy ``/api/notes`` etc. remain mounted under ``/api/legacy/*`` for + compatibility with old experiments; new clients must not use them. +""" import os from contextlib import asynccontextmanager @@ -13,12 +21,15 @@ from .routers.notes_router import router as notes_router from .routers.documents_router import router as documents_router from .routers.ocr_router import router as ocr_router from .routers.sync_router import router as sync_router +from .routers.v1_vault_router import router as v1_vault_router +from .routers.v1_ocr_router import router as v1_ocr_router @asynccontextmanager async def lifespan(app: FastAPI): """Startup: create directories and init DB. Shutdown: close DB.""" os.makedirs(settings.storage_path, exist_ok=True) + os.makedirs(settings.vault_path, exist_ok=True) for subdir in ("pending", "processing", "done", "failed"): os.makedirs(os.path.join(settings.queue_path, subdir), exist_ok=True) await init_db() @@ -26,12 +37,17 @@ async def lifespan(app: FastAPI): await close_db() -app = FastAPI(title="BadNote Server", version="1.0.0", lifespan=lifespan) +app = FastAPI( + title="BadNote Server", + version="2.0.0", + description=( + "Self-hosted companion for BadNote. " + "Primary API is /api/v1 (vault + OCR). " + "Legacy notes CRUD lives under /api/legacy." + ), + lifespan=lifespan, +) -# Authentication is Bearer-token based, so cookies/credentials are not needed. -# `allow_origins=["*"]` together with `allow_credentials=True` is an invalid and -# insecure combination, so we keep credentials disabled. Set BADNOTE_CORS_ORIGINS -# (comma-separated) to lock the API down to specific front-end origins. _cors_origins = settings.cors_origins or ["*"] app.add_middleware( @@ -42,14 +58,34 @@ app.add_middleware( allow_headers=["*"], ) +# --- Auth (shared by v1 + legacy) --- app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) -app.include_router(notes_router, prefix="/api/notes", tags=["notes"]) -app.include_router(documents_router, prefix="/api/documents", tags=["documents"]) -app.include_router(ocr_router, prefix="/api/ocr", tags=["ocr"]) -app.include_router(sync_router, prefix="/api/sync", tags=["sync"]) +app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth-v1"]) + +# --- Primary v1 API --- +app.include_router(v1_vault_router, prefix="/api/v1/vault", tags=["vault-v1"]) +app.include_router(v1_ocr_router, prefix="/api/v1/ocr", tags=["ocr-v1"]) + +# --- Legacy (deprecated) --- +app.include_router(notes_router, prefix="/api/legacy/notes", tags=["legacy-notes"]) +app.include_router(documents_router, prefix="/api/legacy/documents", tags=["legacy-documents"]) +app.include_router(ocr_router, prefix="/api/legacy/ocr", tags=["legacy-ocr"]) +app.include_router(sync_router, prefix="/api/legacy/sync", tags=["legacy-sync"]) +# Keep old paths temporarily so existing bookmarks to /docs experiments still work, +# but they are the same legacy routers. +app.include_router(notes_router, prefix="/api/notes", tags=["legacy-notes"], include_in_schema=False) +app.include_router(documents_router, prefix="/api/documents", tags=["legacy-documents"], include_in_schema=False) +app.include_router(ocr_router, prefix="/api/ocr", tags=["legacy-ocr"], include_in_schema=False) +app.include_router(sync_router, prefix="/api/sync", tags=["legacy-sync"], include_in_schema=False) @app.get("/api/ping") -async def ping() -> dict: - """Health check endpoint.""" - return {"status": "ok"} +@app.get("/api/v1/health") +async def health() -> dict: + """Liveness probe for clients and reverse proxies.""" + return { + "status": "ok", + "version": "2.0.0", + "api": "v1", + "features": ["vault", "ocr", "auth"], + } diff --git a/server/badnote_server/routers/v1_ocr_router.py b/server/badnote_server/routers/v1_ocr_router.py new file mode 100644 index 0000000..ad67d0c --- /dev/null +++ b/server/badnote_server/routers/v1_ocr_router.py @@ -0,0 +1,74 @@ +"""v1 OCR job API — upload ink raster, poll status, fetch text.""" + +from __future__ import annotations + +import os +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status + +from ..auth import get_current_user +from ..config import settings +from ..ocr import queue as ocr_queue + +router = APIRouter() + + +@router.post("/jobs", status_code=status.HTTP_202_ACCEPTED) +async def create_ocr_job( + image: UploadFile = File(...), + source_path: str = Form(""), + page_index: int = Form(0), + user_id: str = Depends(get_current_user), +) -> dict: + """Enqueue an OCR job from an uploaded PNG/JPEG of handwriting.""" + job_id = str(uuid.uuid4()) + blob_dir = os.path.join(settings.storage_path, "ocr_blobs", user_id) + os.makedirs(blob_dir, exist_ok=True) + ext = os.path.splitext(image.filename or "ink.png")[1] or ".png" + blob_path = os.path.join(blob_dir, f"{job_id}{ext}") + data = await image.read() + if not data: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty image") + with open(blob_path, "wb") as f: + f.write(data) + + ocr_queue.enqueue( + { + "id": job_id, + "user_id": user_id, + "source_path": source_path, + "note_id": source_path or job_id, + "page_index": page_index, + "image_path": blob_path, + "created_at": datetime.now(timezone.utc).isoformat(), + } + ) + + return { + "job_id": job_id, + "status": "pending", + "source_path": source_path, + "page_index": page_index, + } + + +@router.get("/jobs/{job_id}") +async def get_ocr_job( + job_id: str, + user_id: str = Depends(get_current_user), +) -> dict: + job = ocr_queue.get_status(job_id) + if job is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="job not found") + if job.get("user_id") not in (None, user_id): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="forbidden") + return { + "job_id": job_id, + "status": job.get("status"), + "result": job.get("result_text") or job.get("result") or job.get("text"), + "error": job.get("error_message") or job.get("error"), + "source_path": job.get("source_path"), + "page_index": job.get("page_index"), + } diff --git a/server/badnote_server/routers/v1_vault_router.py b/server/badnote_server/routers/v1_vault_router.py new file mode 100644 index 0000000..5caf597 --- /dev/null +++ b/server/badnote_server/routers/v1_vault_router.py @@ -0,0 +1,63 @@ +"""v1 vault sync-assist API — vault files are the source of truth.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi.responses import Response + +from ..auth import get_current_user +from .. import vault_store + +router = APIRouter() + + +@router.get("/manifest") +async def get_manifest(user_id: str = Depends(get_current_user)) -> dict: + """List all files in the user's vault with size/mtime/sha256.""" + return vault_store.build_manifest(user_id) + + +@router.get("/files/{file_path:path}") +async def download_file( + file_path: str, + user_id: str = Depends(get_current_user), +) -> Response: + try: + data = vault_store.read_file(user_id, file_path) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except FileNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="file not found") + return Response( + content=data, + media_type="application/octet-stream", + headers={"X-Vault-Path": file_path}, + ) + + +@router.put("/files/{file_path:path}") +async def upload_file( + file_path: str, + upload: UploadFile = File(...), + user_id: str = Depends(get_current_user), +) -> dict: + try: + data = await upload.read() + entry = vault_store.write_file(user_id, file_path, data) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + return entry.to_dict() + + +@router.delete("/files/{file_path:path}") +async def remove_file( + file_path: str, + user_id: str = Depends(get_current_user), +) -> dict: + try: + vault_store.delete_file(user_id, file_path, tombstone=True) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except FileNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="file not found") + return {"deleted": file_path, "tombstone": True} diff --git a/server/badnote_server/vault_store.py b/server/badnote_server/vault_store.py new file mode 100644 index 0000000..cbc8e90 --- /dev/null +++ b/server/badnote_server/vault_store.py @@ -0,0 +1,124 @@ +"""Per-user vault file store — mirrors the client vault layout.""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path + +from .config import settings + + +@dataclass +class VaultFileEntry: + path: str + size: int + mtime: float + sha256: str + + def to_dict(self) -> dict: + return { + "path": self.path, + "size": self.size, + "mtime": self.mtime, + "sha256": self.sha256, + } + + +def vault_root(user_id: str) -> Path: + root = Path(settings.vault_path) / user_id / "files" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _safe_relpath(rel: str) -> str: + """Normalize and reject path escape attempts.""" + cleaned = rel.replace("\\", "/").lstrip("/") + if not cleaned or cleaned.startswith("..") or "/../" in f"/{cleaned}/": + raise ValueError(f"invalid vault path: {rel!r}") + parts = Path(cleaned).parts + if ".." in parts: + raise ValueError(f"invalid vault path: {rel!r}") + return "/".join(parts) + + +def resolve_path(user_id: str, rel: str) -> Path: + rel_n = _safe_relpath(rel) + full = (vault_root(user_id) / rel_n).resolve() + root = vault_root(user_id).resolve() + if not str(full).startswith(str(root) + os.sep) and full != root: + raise ValueError(f"path escapes vault: {rel!r}") + return full + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def build_manifest(user_id: str) -> dict: + root = vault_root(user_id) + entries: list[VaultFileEntry] = [] + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + if path.name.startswith("."): + continue + rel = path.relative_to(root).as_posix() + st = path.stat() + entries.append( + VaultFileEntry( + path=rel, + size=st.st_size, + mtime=st.st_mtime, + sha256=_sha256_file(path), + ) + ) + return { + "user_id": user_id, + "generated_at": time.time(), + "files": [e.to_dict() for e in entries], + } + + +def write_file(user_id: str, rel: str, data: bytes) -> VaultFileEntry: + path = resolve_path(user_id, rel) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_bytes(data) + tmp.replace(path) + st = path.stat() + return VaultFileEntry( + path=_safe_relpath(rel), + size=st.st_size, + mtime=st.st_mtime, + sha256=_sha256_file(path), + ) + + +def read_file(user_id: str, rel: str) -> bytes: + path = resolve_path(user_id, rel) + if not path.is_file(): + raise FileNotFoundError(rel) + return path.read_bytes() + + +def delete_file(user_id: str, rel: str, *, tombstone: bool = True) -> None: + path = resolve_path(user_id, rel) + if not path.exists(): + raise FileNotFoundError(rel) + if tombstone: + tomb = path.parent / f".tombstone-{path.name}" + meta = { + "path": _safe_relpath(rel), + "deleted_at": time.time(), + "sha256": _sha256_file(path) if path.is_file() else None, + } + tomb.write_text(json.dumps(meta), encoding="utf-8") + path.unlink() diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 0ddb46a..cb891e7 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -14,6 +14,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) os.environ["BADNOTE_STORAGE_PATH"] = "/tmp/badnote_test_storage" os.environ["BADNOTE_QUEUE_PATH"] = "/tmp/badnote_test_queue" +os.environ["BADNOTE_VAULT_PATH"] = "/tmp/badnote_test_vaults" os.environ["BADNOTE_JWT_SECRET"] = "test-secret-key-for-testing-only" from badnote_server.config import settings # noqa: E402 @@ -26,10 +27,13 @@ async def setup_db(tmp_path): """Fresh DB and clean queue for each test.""" db_path = str(tmp_path / "test.db") settings.db_path = db_path + settings.vault_path = str(tmp_path / "vaults") # Clean the queue directory before each test queue_path = settings.queue_path if os.path.exists(queue_path): shutil.rmtree(queue_path) + if os.path.exists(settings.vault_path): + shutil.rmtree(settings.vault_path) await init_db() yield await close_db() diff --git a/server/tests/test_vault_v1.py b/server/tests/test_vault_v1.py new file mode 100644 index 0000000..1499c26 --- /dev/null +++ b/server/tests/test_vault_v1.py @@ -0,0 +1,88 @@ +"""Tests for v1 vault API (async, uses shared conftest).""" + +from __future__ import annotations + +import pytest + + +async def _token(client) -> str: + r = await client.post( + "/api/v1/auth/register", + json={"username": "vault_user", "password": "password123"}, + ) + if r.status_code == 409: + r = await client.post( + "/api/v1/auth/login", + json={"username": "vault_user", "password": "password123"}, + ) + assert r.status_code in (200, 201), r.text + return r.json()["token"] + + +@pytest.mark.asyncio +async def test_health(client): + r = await client.get("/api/v1/health") + assert r.status_code == 200 + body = r.json() + assert body["api"] == "v1" + assert "vault" in body["features"] + + +@pytest.mark.asyncio +async def test_vault_roundtrip(client, tmp_path, monkeypatch): + from badnote_server.config import settings + + monkeypatch.setattr(settings, "vault_path", str(tmp_path / "vaults")) + + token = await _token(client) + headers = {"Authorization": f"Bearer {token}"} + + files = {"upload": ("hello.txt", b"hello vault", "text/plain")} + r = await client.put( + "/api/v1/vault/files/NotebookA/hello.txt", + headers=headers, + files=files, + ) + assert r.status_code == 200, r.text + assert r.json()["path"] == "NotebookA/hello.txt" + assert r.json()["size"] == 11 + + r = await client.get("/api/v1/vault/manifest", headers=headers) + assert r.status_code == 200 + paths = [f["path"] for f in r.json()["files"]] + assert "NotebookA/hello.txt" in paths + + r = await client.get( + "/api/v1/vault/files/NotebookA/hello.txt", + headers=headers, + ) + assert r.status_code == 200 + assert r.content == b"hello vault" + + r = await client.delete( + "/api/v1/vault/files/NotebookA/hello.txt", + headers=headers, + ) + assert r.status_code == 200 + r = await client.get( + "/api/v1/vault/files/NotebookA/hello.txt", + headers=headers, + ) + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_path_escape_rejected(client, tmp_path, monkeypatch): + from badnote_server.config import settings + + monkeypatch.setattr(settings, "vault_path", str(tmp_path / "vaults")) + + token = await _token(client) + headers = {"Authorization": f"Bearer {token}"} + files = {"upload": ("x", b"nope", "application/octet-stream")} + r = await client.put( + "/api/v1/vault/files/../../etc/passwd", + headers=headers, + files=files, + ) + assert r.status_code in (400, 404)