From d48e2b7d9d00b5968ff86b58428a32b446ed3b62 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 10 Apr 2026 23:11:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v0.2.0=20=E2=80=94=20backup=20system,?= =?UTF-8?q?=20self-update=20with=20Ed25519=20signing,=20column=20pickers,?= =?UTF-8?q?=20import=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major features: - Full backup & restore system (JSON snapshots of all 20 tables + settings) - Web UI, REST API, OCC CLI commands, scheduled background job - Self-update from Gitea releases with Ed25519 signature verification - Configurable column visibility on all data tables (persisted via localStorage) Fixes: - NC admin group fallback for PermissionService (IGroupManager) - Bundle import inline error correction (editable error rows) New files: BackupService, BackupSettingsService, BackupController, BackupJob, SelfUpdateService, 4 OCC commands, ColumnPicker component, Backup.vue, Ed25519 signing scripts, signature verification tests (18 tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 + Makefile | 61 ++- appinfo/info.xml | 9 +- appinfo/routes.php | 14 + lib/BackgroundJob/BackupJob.php | 76 ++++ lib/Command/AppUpdateCommand.php | 96 ++++ lib/Command/BackupCreateCommand.php | 69 +++ lib/Command/BackupListCommand.php | 68 +++ lib/Command/BackupRestoreCommand.php | 82 ++++ lib/Controller/BackupController.php | 200 +++++++++ lib/Controller/ImportController.php | 7 +- lib/Middleware/AuthorizationMiddleware.php | 2 + lib/Service/BackupService.php | 490 ++++++++++++++++++++ lib/Service/BackupSettingsService.php | 92 ++++ lib/Service/BundleImportService.php | 23 +- lib/Service/PermissionService.php | 24 +- lib/Service/SelfUpdateService.php | 413 +++++++++++++++++ scripts/generate-keypair.php | 56 +++ scripts/sign-release.php | 67 +++ src/App.vue | 8 + src/components/ColumnPicker.vue | 113 +++++ src/main.js | 2 +- src/router.js | 5 + src/stores/backup.js | 153 +++++++ src/stores/import.js | 5 + src/utils/useColumnVisibility.js | 42 ++ src/views/AuditLog.vue | 290 ++++-------- src/views/Backup.vue | 311 +++++++++++++ src/views/FamilyList.vue | 171 +++---- src/views/FeeOverview.vue | 498 ++++++--------------- src/views/Import.vue | 94 +++- src/views/InjuryList.vue | 67 +-- src/views/LagerList.vue | 50 ++- src/views/MemberList.vue | 353 +++++---------- src/views/QueryBuilder.vue | 56 ++- tests/Unit/PermissionServiceTest.php | 8 +- tests/Unit/SelfUpdateSignatureTest.php | 382 ++++++++++++++++ webpack.config.js | 2 +- 38 files changed, 3449 insertions(+), 1014 deletions(-) create mode 100644 lib/BackgroundJob/BackupJob.php create mode 100644 lib/Command/AppUpdateCommand.php create mode 100644 lib/Command/BackupCreateCommand.php create mode 100644 lib/Command/BackupListCommand.php create mode 100644 lib/Command/BackupRestoreCommand.php create mode 100644 lib/Controller/BackupController.php create mode 100644 lib/Service/BackupService.php create mode 100644 lib/Service/BackupSettingsService.php create mode 100644 lib/Service/SelfUpdateService.php create mode 100644 scripts/generate-keypair.php create mode 100644 scripts/sign-release.php create mode 100644 src/components/ColumnPicker.vue create mode 100644 src/stores/backup.js create mode 100644 src/utils/useColumnVisibility.js create mode 100644 src/views/Backup.vue create mode 100644 tests/Unit/SelfUpdateSignatureTest.php diff --git a/.gitignore b/.gitignore index 66a25f9..b9e543e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,9 @@ package-lock.json # Test artifacts .playwright-mcp/ +.phpunit.cache/ screenshots/ test-results/ + +# Release artifacts +artifacts/ diff --git a/Makefile b/Makefile index 2cd922c..2745ca4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build deps up down setup deploy redeploy logs clean +.PHONY: build deps up down setup deploy redeploy logs clean package release # Install dependencies (composer via Docker since PHP may not be local) deps: @@ -70,6 +70,65 @@ down: logs: docker compose logs -f nextcloud +# Create a distributable tar.gz for production installation +package: build + $(eval VERSION := $(shell grep '' appinfo/info.xml | sed 's/.*\(.*\)<\/version>.*/\1/')) + @echo "Packaging mitgliederverwaltung v$(VERSION)..." + mkdir -p artifacts + rm -rf /tmp/mitgliederverwaltung + mkdir -p /tmp/mitgliederverwaltung + cp -a appinfo lib templates js vendor img /tmp/mitgliederverwaltung/ + @for f in LICENSE README.md CHANGELOG.md; do \ + [ -f "$$f" ] && cp "$$f" /tmp/mitgliederverwaltung/ || true; \ + done + cd /tmp && tar -czf mitgliederverwaltung-$(VERSION).tar.gz mitgliederverwaltung + mv /tmp/mitgliederverwaltung-$(VERSION).tar.gz artifacts/ + rm -rf /tmp/mitgliederverwaltung + @# Sign the tarball with Ed25519 + @if [ -f "$$HOME/.mv-release-key" ]; then \ + docker run --rm -v "$$(pwd):/app" -v "$$HOME/.mv-release-key:$$HOME/.mv-release-key:ro" -e HOME="$$HOME" -w /app php:8.1-cli php scripts/sign-release.php artifacts/mitgliederverwaltung-$(VERSION).tar.gz; \ + else \ + echo "WARNING: ~/.mv-release-key not found — tarball NOT signed."; \ + echo " Run: php scripts/generate-keypair.php"; \ + fi + @echo "" + @echo "Package created: artifacts/mitgliederverwaltung-$(VERSION).tar.gz" + @echo "Size: $$(du -h artifacts/mitgliederverwaltung-$(VERSION).tar.gz | cut -f1)" + +# Build, package, tag, and create a Gitea release with the tarball attached +release: package + $(eval VERSION := $(shell grep '' appinfo/info.xml | sed 's/.*\(.*\)<\/version>.*/\1/')) + @echo "Creating release v$(VERSION) on Gitea..." + git tag -a "v$(VERSION)" -m "Release v$(VERSION)" 2>/dev/null || echo "Tag v$(VERSION) already exists, skipping" + git push origin "v$(VERSION)" 2>/dev/null || echo "Tag already pushed" + @# Create Gitea release via API + @RELEASE_ID=$$(curl -s -X POST \ + "https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases" \ + -H "Content-Type: application/json" \ + -H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \ + -d '{"tag_name": "v$(VERSION)", "name": "v$(VERSION)", "body": "Release v$(VERSION)", "draft": false, "prerelease": false}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) && \ + if [ -n "$$RELEASE_ID" ] && [ "$$RELEASE_ID" != "" ]; then \ + echo "Uploading tarball to release $$RELEASE_ID..."; \ + curl -s -X POST \ + "https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases/$$RELEASE_ID/assets?name=mitgliederverwaltung-$(VERSION).tar.gz" \ + -H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \ + -F "attachment=@artifacts/mitgliederverwaltung-$(VERSION).tar.gz" \ + >/dev/null && echo " Tarball uploaded."; \ + if [ -f "artifacts/mitgliederverwaltung-$(VERSION).tar.gz.sig" ]; then \ + curl -s -X POST \ + "https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases/$$RELEASE_ID/assets?name=mitgliederverwaltung-$(VERSION).tar.gz.sig" \ + -H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \ + -F "attachment=@artifacts/mitgliederverwaltung-$(VERSION).tar.gz.sig" \ + >/dev/null && echo " Signature uploaded."; \ + else \ + echo " WARNING: No .sig file found — release is UNSIGNED."; \ + fi; \ + echo "Release v$(VERSION) created."; \ + else \ + echo "WARNING: Could not create Gitea release (missing ~/.gitea-token?). Tarball is in artifacts/."; \ + fi + # Remove volumes (full reset) clean: docker compose down -v diff --git a/appinfo/info.xml b/appinfo/info.xml index 32db8bc..fd9b2da 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ Mitgliederverwaltung Mitgliederverwaltung für Pfadfindervereine - 0.1.5 + 0.2.0 agpl shahondin1624 Mitgliederverwaltung @@ -19,7 +19,14 @@ OCA\Mitgliederverwaltung\BackgroundJob\SyncQueueJob OCA\Mitgliederverwaltung\BackgroundJob\CalendarFullSyncJob OCA\Mitgliederverwaltung\BackgroundJob\ContactsFullSyncJob + OCA\Mitgliederverwaltung\BackgroundJob\BackupJob + + OCA\Mitgliederverwaltung\Command\BackupCreateCommand + OCA\Mitgliederverwaltung\Command\BackupRestoreCommand + OCA\Mitgliederverwaltung\Command\BackupListCommand + OCA\Mitgliederverwaltung\Command\AppUpdateCommand + Mitgliederverwaltung diff --git a/appinfo/routes.php b/appinfo/routes.php index 2c27273..3dc2537 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -158,6 +158,20 @@ return [ ['name' => 'query#destroy', 'url' => '/api/v1/queries/{id}', 'verb' => 'DELETE'], ['name' => 'query#executeSaved', 'url' => '/api/v1/queries/{id}/execute', 'verb' => 'POST'], + // ── Backup & Restore (admin-only) ───────────────────────── + ['name' => 'backup#getSettings', 'url' => '/api/v1/backups/settings', 'verb' => 'GET'], + ['name' => 'backup#updateSettings', 'url' => '/api/v1/backups/settings', 'verb' => 'PUT'], + ['name' => 'backup#index', 'url' => '/api/v1/backups', 'verb' => 'GET'], + ['name' => 'backup#create', 'url' => '/api/v1/backups', 'verb' => 'POST'], + ['name' => 'backup#show', 'url' => '/api/v1/backups/{filename}', 'verb' => 'GET'], + ['name' => 'backup#download', 'url' => '/api/v1/backups/{filename}/download', 'verb' => 'GET'], + ['name' => 'backup#restore', 'url' => '/api/v1/backups/{filename}/restore', 'verb' => 'POST'], + ['name' => 'backup#destroy', 'url' => '/api/v1/backups/{filename}', 'verb' => 'DELETE'], + + // ── Self-update (admin-only) ─────────────────────────────── + ['name' => 'backup#checkUpdate', 'url' => '/api/v1/update/check', 'verb' => 'GET'], + ['name' => 'backup#installUpdate', 'url' => '/api/v1/update/install', 'verb' => 'POST'], + // ── Files integration (NC Files browser) ──────────────────── ['name' => 'file#getSettings', 'url' => '/api/v1/files/settings', 'verb' => 'GET'], ['name' => 'file#updateSettings', 'url' => '/api/v1/files/settings', 'verb' => 'PUT'], diff --git a/lib/BackgroundJob/BackupJob.php b/lib/BackgroundJob/BackupJob.php new file mode 100644 index 0000000..2341ab2 --- /dev/null +++ b/lib/BackgroundJob/BackupJob.php @@ -0,0 +1,76 @@ +backupService = $backupService; + $this->settingsService = $settingsService; + $this->logger = $logger; + + // Check every 24 hours + $this->setInterval(24 * 60 * 60); + } + + protected function run($argument): void { + $schedule = $this->settingsService->getSchedule(); + + if ($schedule === 'disabled') { + return; + } + + // For weekly schedule, only run if 7+ days since last backup + if ($schedule === 'weekly') { + $backups = $this->backupService->listBackups(); + if (!empty($backups)) { + $lastCreated = strtotime($backups[0]['createdAt']); + $daysSince = (time() - $lastCreated) / 86400; + if ($daysSince < 7) { + return; + } + } + } + + try { + $password = $this->settingsService->getBackupPassword(); + $result = $this->backupService->createBackup($password, 'scheduled'); + $deleted = $this->backupService->rotateBackups(); + + $this->logger->info('Scheduled backup completed', [ + 'filename' => $result['filename'], + 'rotated' => $deleted, + 'app' => 'mitgliederverwaltung', + ]); + } catch (\Exception $e) { + $this->logger->error('Scheduled backup failed', [ + 'exception' => $e, + 'app' => 'mitgliederverwaltung', + ]); + } + } +} diff --git a/lib/Command/AppUpdateCommand.php b/lib/Command/AppUpdateCommand.php new file mode 100644 index 0000000..c11bc76 --- /dev/null +++ b/lib/Command/AppUpdateCommand.php @@ -0,0 +1,96 @@ +updateService = $updateService; + } + + protected function configure(): void { + $this->setName('mitgliederverwaltung:app:update') + ->setDescription('Check for and install updates from Gitea releases') + ->addOption('check', 'c', InputOption::VALUE_NONE, 'Only check for updates, do not install') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Install even if version is not newer') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $checkOnly = $input->getOption('check'); + + $output->writeln('Checking for updates...'); + $info = $this->updateService->checkForUpdate(); + + $output->writeln(' Current version: v' . $info['currentVersion']); + + if (isset($info['error'])) { + $output->writeln('Update-Check fehlgeschlagen: ' . $info['error'] . ''); + return Command::FAILURE; + } + + if ($info['latestVersion'] === null) { + $output->writeln(' No releases found on Gitea.'); + return Command::SUCCESS; + } + + $output->writeln(' Latest release: v' . $info['latestVersion']); + $output->writeln(' Signed: ' . (($info['signed'] ?? false) ? 'Yes' : 'No')); + + if (!$info['available']) { + $output->writeln('Already up to date.'); + if (!$checkOnly && !$input->getOption('force')) { + return Command::SUCCESS; + } + } else { + $output->writeln('Update available!'); + if ($info['releaseName']) { + $output->writeln(' Release: ' . $info['releaseName']); + } + } + + if ($checkOnly) { + return Command::SUCCESS; + } + + // Confirmation + if (!$input->getOption('yes')) { + $output->writeln(''); + $output->writeln('This will replace the app files with the new version.'); + $output->writeln('Run "occ upgrade" afterwards to apply database migrations.'); + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Install update? [y/N] ', false); + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Update cancelled.'); + return Command::SUCCESS; + } + } + + $output->writeln(''); + $output->writeln('Downloading and installing...'); + + $result = $this->updateService->performUpdate($input->getOption('force')); + + if ($result['success']) { + $output->writeln('' . $result['message'] . ''); + $output->writeln(''); + $output->writeln('Next step: run occ upgrade to apply database migrations.'); + return Command::SUCCESS; + } else { + $output->writeln('' . $result['message'] . ''); + return Command::FAILURE; + } + } +} diff --git a/lib/Command/BackupCreateCommand.php b/lib/Command/BackupCreateCommand.php new file mode 100644 index 0000000..7a60193 --- /dev/null +++ b/lib/Command/BackupCreateCommand.php @@ -0,0 +1,69 @@ +backupService = $backupService; + } + + protected function configure(): void { + $this->setName('mitgliederverwaltung:backup:create') + ->setDescription('Create a full backup of Mitgliederverwaltung data') + ->addOption('password', 'p', InputOption::VALUE_OPTIONAL, 'Password for ZIP encryption'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $password = $input->getOption('password'); + + $output->writeln('Creating backup...'); + + try { + $result = $this->backupService->createBackup($password, 'cli'); + + $output->writeln(''); + $output->writeln('Backup created successfully!'); + $output->writeln(' Filename: ' . $result['filename']); + $output->writeln(' Size: ' . $this->formatBytes($result['size'])); + $output->writeln(' Created: ' . $result['createdAt']); + $output->writeln(''); + + $totalRows = 0; + foreach ($result['tables'] as $table => $count) { + if ($count > 0) { + $output->writeln(" {$table}: {$count} rows"); + } + $totalRows += $count; + } + $output->writeln(''); + $output->writeln(" Total: {$totalRows} rows across " . count($result['tables']) . ' tables'); + + return Command::SUCCESS; + } catch (\Exception $e) { + $output->writeln('Backup failed: ' . $e->getMessage() . ''); + return Command::FAILURE; + } + } + + private function formatBytes(int $bytes): string { + if ($bytes >= 1048576) { + return round($bytes / 1048576, 2) . ' MB'; + } + if ($bytes >= 1024) { + return round($bytes / 1024, 2) . ' KB'; + } + return $bytes . ' B'; + } +} diff --git a/lib/Command/BackupListCommand.php b/lib/Command/BackupListCommand.php new file mode 100644 index 0000000..288b6cf --- /dev/null +++ b/lib/Command/BackupListCommand.php @@ -0,0 +1,68 @@ +backupService = $backupService; + } + + protected function configure(): void { + $this->setName('mitgliederverwaltung:backup:list') + ->setDescription('List all available backups'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $backups = $this->backupService->listBackups(); + + if (empty($backups)) { + $output->writeln('No backups found.'); + return Command::SUCCESS; + } + + $table = new Table($output); + $table->setHeaders(['Filename', 'Created', 'Size', 'Tables', 'Rows', 'Triggered By']); + + foreach ($backups as $backup) { + $totalRows = 0; + foreach ($backup['tables'] as $count) { + $totalRows += $count; + } + + $table->addRow([ + $backup['filename'], + $backup['createdAt'], + $this->formatBytes($backup['size']), + count($backup['tables']), + $totalRows, + $backup['triggeredBy'] ?? '-', + ]); + } + + $table->render(); + + return Command::SUCCESS; + } + + private function formatBytes(int $bytes): string { + if ($bytes >= 1048576) { + return round($bytes / 1048576, 2) . ' MB'; + } + if ($bytes >= 1024) { + return round($bytes / 1024, 2) . ' KB'; + } + return $bytes . ' B'; + } +} diff --git a/lib/Command/BackupRestoreCommand.php b/lib/Command/BackupRestoreCommand.php new file mode 100644 index 0000000..4c67b3d --- /dev/null +++ b/lib/Command/BackupRestoreCommand.php @@ -0,0 +1,82 @@ +backupService = $backupService; + } + + protected function configure(): void { + $this->setName('mitgliederverwaltung:backup:restore') + ->setDescription('Restore Mitgliederverwaltung from a backup file') + ->addArgument('filename', InputArgument::REQUIRED, 'Backup filename') + ->addOption('password', 'p', InputOption::VALUE_OPTIONAL, 'Password if backup is encrypted') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $filename = $input->getArgument('filename'); + $password = $input->getOption('password'); + $skipConfirm = $input->getOption('yes'); + + // Show backup info + $info = $this->backupService->getBackupInfo($filename); + if ($info === null) { + $output->writeln('Backup not found: ' . $filename . ''); + return Command::FAILURE; + } + + $output->writeln('Backup info:'); + $output->writeln(' Filename: ' . $info['filename']); + $output->writeln(' Created: ' . $info['createdAt']); + $output->writeln(' Version: ' . ($info['appVersion'] ?? 'unknown')); + $output->writeln(' Tables: ' . count($info['tables'])); + + $totalRows = 0; + foreach ($info['tables'] as $count) { + $totalRows += $count; + } + $output->writeln(' Total rows: ' . $totalRows); + $output->writeln(''); + + // Confirmation + if (!$skipConfirm) { + $output->writeln('WARNING: This will DELETE ALL existing data and replace it with the backup.'); + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Continue? [y/N] ', false); + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Restore cancelled.'); + return Command::SUCCESS; + } + } + + $output->writeln('Restoring...'); + + try { + $result = $this->backupService->restoreBackup($filename, $password); + $output->writeln(''); + $output->writeln('Restore completed successfully!'); + $output->writeln(' Tables restored: ' . $result['restoredTables']); + $output->writeln(' Total rows: ' . $result['totalRows']); + return Command::SUCCESS; + } catch (\Exception $e) { + $output->writeln('Restore failed: ' . $e->getMessage() . ''); + return Command::FAILURE; + } + } +} diff --git a/lib/Controller/BackupController.php b/lib/Controller/BackupController.php new file mode 100644 index 0000000..633a30e --- /dev/null +++ b/lib/Controller/BackupController.php @@ -0,0 +1,200 @@ +backupService = $backupService; + $this->settingsService = $settingsService; + $this->updateService = $updateService; + $this->logger = $logger; + } + + /** + * List all available backups. + * + * GET /api/v1/backups + */ + public function index(): JSONResponse { + return $this->handleAction(function () { + return new JSONResponse($this->backupService->listBackups()); + }, 'Backup-Liste'); + } + + /** + * Create a new backup. + * + * POST /api/v1/backups + * Body: { password?: string } + */ + public function create(): JSONResponse { + return $this->handleAction(function () { + $data = $this->getRequestData(); + $password = $data['password'] ?? null; + $result = $this->backupService->createBackup($password, 'manual'); + return new JSONResponse($result, Http::STATUS_CREATED); + }, 'Backup erstellen'); + } + + /** + * Get backup info (manifest). + * + * GET /api/v1/backups/{filename} + */ + public function show(string $filename): JSONResponse { + return $this->handleAction(function () use ($filename) { + $info = $this->backupService->getBackupInfo($filename); + if ($info === null) { + return new JSONResponse( + ['error' => 'Backup nicht gefunden'], + Http::STATUS_NOT_FOUND + ); + } + return new JSONResponse($info); + }, 'Backup-Info'); + } + + /** + * Download a backup ZIP file. + * + * GET /api/v1/backups/{filename}/download + */ + public function download(string $filename): DataDownloadResponse|JSONResponse { + try { + $filePath = $this->backupService->getBackupFilePath($filename); + $content = file_get_contents($filePath); + return new DataDownloadResponse( + $content, + basename($filename), + 'application/zip' + ); + } catch (\Exception $e) { + $this->logger->error('Backup download failed', [ + 'filename' => $filename, + 'exception' => $e, + 'app' => 'mitgliederverwaltung', + ]); + return new JSONResponse( + ['error' => 'Download fehlgeschlagen'], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } + + /** + * Restore from a backup. + * + * POST /api/v1/backups/{filename}/restore + * Body: { password?: string, confirm: true } + */ + public function restore(string $filename): JSONResponse { + return $this->handleAction(function () use ($filename) { + $data = $this->getRequestData(); + if (empty($data['confirm'])) { + return new JSONResponse( + ['error' => 'Bestaetigung erforderlich (confirm: true)'], + Http::STATUS_BAD_REQUEST + ); + } + $password = $data['password'] ?? null; + $result = $this->backupService->restoreBackup($filename, $password); + return new JSONResponse($result); + }, 'Backup wiederherstellen'); + } + + /** + * Delete a backup. + * + * DELETE /api/v1/backups/{filename} + */ + public function destroy(string $filename): JSONResponse { + return $this->handleAction(function () use ($filename) { + $this->backupService->deleteBackup($filename); + return new JSONResponse(['status' => 'deleted']); + }, 'Backup loeschen'); + } + + /** + * Get backup settings. + * + * GET /api/v1/backups/settings + */ + public function getSettings(): JSONResponse { + return new JSONResponse($this->settingsService->getAllSettings()); + } + + /** + * Update backup settings. + * + * PUT /api/v1/backups/settings + * Body: { schedule?, retentionCount?, password? } + */ + public function updateSettings(): JSONResponse { + return $this->handleAction(function () { + $data = $this->getRequestData(); + $this->settingsService->updateSettings($data); + return new JSONResponse($this->settingsService->getAllSettings()); + }, 'Backup-Einstellungen aktualisieren'); + } + + // ── Self-update endpoints ─────────────────────────────────── + + /** + * Check for available updates from Gitea. + * + * GET /api/v1/update/check + */ + public function checkUpdate(): JSONResponse { + return $this->handleAction(function () { + return new JSONResponse($this->updateService->checkForUpdate()); + }, 'Update-Check'); + } + + /** + * Download and install the latest update. + * + * POST /api/v1/update/install + * Body: { force?: bool } + */ + public function installUpdate(): JSONResponse { + return $this->handleAction(function () { + $data = $this->getRequestData(); + $force = !empty($data['force']); + $result = $this->updateService->performUpdate($force); + return new JSONResponse($result); + }, 'Update installieren'); + } +} diff --git a/lib/Controller/ImportController.php b/lib/Controller/ImportController.php index d9beab2..1b9bf5d 100644 --- a/lib/Controller/ImportController.php +++ b/lib/Controller/ImportController.php @@ -534,7 +534,9 @@ class ImportController extends ApiController { ); } - $result = $this->bundleImportService->previewBundle($decoded); + $corrections = $data['corrections'] ?? []; + + $result = $this->bundleImportService->previewBundle($decoded, $corrections); return new JSONResponse($result); } catch (\Exception $e) { $this->logger->error('Failed to preview bundle', [ @@ -574,8 +576,9 @@ class ImportController extends ApiController { } $overrides = $data['overrides'] ?? []; + $corrections = $data['corrections'] ?? []; - $result = $this->bundleImportService->executeBundle($decoded, $overrides); + $result = $this->bundleImportService->executeBundle($decoded, $overrides, $corrections); return new JSONResponse($result); } catch (\Exception $e) { $this->logger->error('Failed to execute bundle import', [ diff --git a/lib/Middleware/AuthorizationMiddleware.php b/lib/Middleware/AuthorizationMiddleware.php index ec072df..8509b3e 100644 --- a/lib/Middleware/AuthorizationMiddleware.php +++ b/lib/Middleware/AuthorizationMiddleware.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace OCA\Mitgliederverwaltung\Middleware; use OCA\Mitgliederverwaltung\Controller\AuditController; +use OCA\Mitgliederverwaltung\Controller\BackupController; use OCA\Mitgliederverwaltung\Controller\DsgvoController; use OCA\Mitgliederverwaltung\Controller\ExportController; use OCA\Mitgliederverwaltung\Controller\FeeController; @@ -89,6 +90,7 @@ class AuthorizationMiddleware extends Middleware { || $controller instanceof PermissionController || $controller instanceof AuditController || $controller instanceof DsgvoController + || $controller instanceof BackupController ) { if (!$this->permissionService->isAdmin($userId)) { throw new \RuntimeException('Authorization: admin required'); diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php new file mode 100644 index 0000000..dd1e79a --- /dev/null +++ b/lib/Service/BackupService.php @@ -0,0 +1,490 @@ +db = $db; + $this->config = $config; + $this->userSession = $userSession; + $this->backupSettings = $backupSettings; + $this->logger = $logger; + } + + /** + * Create a full backup ZIP containing all tables + settings as JSON. + * + * @param string|null $password Optional password for ZIP encryption + * @param string $triggeredBy 'manual' or 'scheduled' + * @return array{filename: string, size: int, tables: array} + */ + public function createBackup(?string $password = null, string $triggeredBy = 'manual'): array { + $timestamp = date('Y-m-d_His'); + $filename = "backup_mitgliederverwaltung_{$timestamp}.zip"; + + $tmpFile = tempnam(sys_get_temp_dir(), 'mv_backup_'); + if ($tmpFile === false) { + throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.'); + } + chmod($tmpFile, 0600); + + try { + $zip = new \ZipArchive(); + $result = $zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + if ($result !== true) { + throw new \RuntimeException('ZIP konnte nicht erstellt werden. Fehlercode: ' . $result); + } + + if ($password !== null && $password !== '') { + $zip->setPassword($password); + } + + // Dump each table + $tableCounts = []; + foreach (self::TABLE_RESTORE_ORDER as $table) { + $rows = $this->exportTable($table); + $tableCounts[$table] = count($rows); + $json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $entryName = "tables/{$table}.json"; + $zip->addFromString($entryName, $json); + if ($password !== null && $password !== '') { + $zip->setEncryptionName($entryName, \ZipArchive::EM_AES_256); + } + } + + // Settings + $settings = $this->exportAppSettings(); + $settingsJson = json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $zip->addFromString('settings.json', $settingsJson); + if ($password !== null && $password !== '') { + $zip->setEncryptionName('settings.json', \ZipArchive::EM_AES_256); + } + + // Manifest + $userId = $this->userSession->getUser()?->getUID() ?? 'system'; + $manifest = [ + 'appVersion' => $this->config->getAppValue(self::APP_ID, 'installed_version', '0.0.0'), + 'schemaVersion' => self::SCHEMA_VERSION, + 'createdAt' => date('c'), + 'triggeredBy' => $triggeredBy, + 'userId' => $userId, + 'tables' => $tableCounts, + 'settingsIncluded' => true, + 'auditLogIncluded' => true, + ]; + $manifestJson = json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $zip->addFromString('manifest.json', $manifestJson); + + $zip->close(); + + // Move to backup storage + $storageDir = $this->getBackupStoragePath(); + $targetPath = $storageDir . '/' . $filename; + if (!rename($tmpFile, $targetPath)) { + throw new \RuntimeException('Backup konnte nicht in den Speicher verschoben werden.'); + } + chmod($targetPath, 0600); + $tmpFile = null; // Prevent cleanup + + $this->logger->info('Backup created', [ + 'filename' => $filename, + 'triggeredBy' => $triggeredBy, + 'tables' => $tableCounts, + 'app' => self::APP_ID, + ]); + + return [ + 'filename' => $filename, + 'size' => filesize($targetPath), + 'tables' => $tableCounts, + 'createdAt' => $manifest['createdAt'], + ]; + } finally { + if ($tmpFile !== null && file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } + + /** + * Restore from a backup ZIP. All-or-nothing via transaction. + * + * @return array{restoredTables: int, totalRows: int} + */ + public function restoreBackup(string $filename, ?string $password = null): array { + $filePath = $this->getBackupStoragePath() . '/' . basename($filename); + if (!file_exists($filePath)) { + throw new ValidationException('Backup-Datei nicht gefunden: ' . $filename); + } + + $zip = new \ZipArchive(); + $result = $zip->open($filePath); + if ($result !== true) { + throw new \RuntimeException('ZIP konnte nicht geoeffnet werden. Fehlercode: ' . $result); + } + + if ($password !== null && $password !== '') { + $zip->setPassword($password); + } + + try { + // Read manifest + $manifestJson = $zip->getFromName('manifest.json'); + if ($manifestJson === false) { + throw new ValidationException('Ungueltige Backup-Datei: manifest.json fehlt'); + } + $manifest = json_decode($manifestJson, true); + if (!is_array($manifest) || !isset($manifest['schemaVersion'])) { + throw new ValidationException('Ungueltige manifest.json'); + } + if ($manifest['schemaVersion'] > self::SCHEMA_VERSION) { + throw new ValidationException( + 'Backup-Schema-Version ' . $manifest['schemaVersion'] . + ' ist neuer als unterstuetzte Version ' . self::SCHEMA_VERSION + ); + } + + // Load all table data into memory first to validate before truncating + $tableData = []; + foreach (self::TABLE_RESTORE_ORDER as $table) { + $json = $zip->getFromName("tables/{$table}.json"); + if ($json === false) { + // Table may not exist in older backups — skip + $tableData[$table] = []; + continue; + } + $rows = json_decode($json, true); + if (!is_array($rows)) { + throw new ValidationException("Ungueltiges JSON fuer Tabelle {$table}"); + } + $tableData[$table] = $rows; + } + + // Load settings + $settingsJson = $zip->getFromName('settings.json'); + $settings = $settingsJson !== false ? json_decode($settingsJson, true) : null; + + $zip->close(); + + // Execute restore in transaction + $this->db->beginTransaction(); + try { + // Truncate in reverse dependency order + $reversed = array_reverse(self::TABLE_RESTORE_ORDER); + foreach ($reversed as $table) { + $this->truncateTable($table); + } + + // Insert in forward dependency order + $totalRows = 0; + $restoredTables = 0; + foreach (self::TABLE_RESTORE_ORDER as $table) { + $rows = $tableData[$table]; + if (!empty($rows)) { + $this->insertRows($table, $rows); + $totalRows += count($rows); + $restoredTables++; + } + } + + // Restore settings + if (is_array($settings)) { + $this->restoreAppSettings($settings); + } + + $this->db->commit(); + } catch (\Exception $e) { + $this->db->rollBack(); + throw $e; + } + + $this->logger->info('Backup restored', [ + 'filename' => $filename, + 'restoredTables' => $restoredTables, + 'totalRows' => $totalRows, + 'app' => self::APP_ID, + ]); + + return [ + 'restoredTables' => $restoredTables, + 'totalRows' => $totalRows, + ]; + } catch (\Exception $e) { + if ($zip->count() > 0) { + $zip->close(); + } + throw $e; + } + } + + /** + * List all available backup files. + * + * @return array[] Each: {filename, size, createdAt, tables} + */ + public function listBackups(): array { + $dir = $this->getBackupStoragePath(); + $files = glob($dir . '/backup_mitgliederverwaltung_*.zip'); + if ($files === false) { + return []; + } + + $backups = []; + foreach ($files as $file) { + $info = $this->getBackupInfoFromFile($file); + if ($info !== null) { + $backups[] = $info; + } + } + + // Sort newest first + usort($backups, fn($a, $b) => strcmp($b['createdAt'], $a['createdAt'])); + + return $backups; + } + + /** + * Get info about a single backup (from manifest). + */ + public function getBackupInfo(string $filename): ?array { + $filePath = $this->getBackupStoragePath() . '/' . basename($filename); + if (!file_exists($filePath)) { + return null; + } + return $this->getBackupInfoFromFile($filePath); + } + + /** + * Get the full filesystem path for downloading a backup. + */ + public function getBackupFilePath(string $filename): string { + $filePath = $this->getBackupStoragePath() . '/' . basename($filename); + if (!file_exists($filePath)) { + throw new ValidationException('Backup-Datei nicht gefunden: ' . $filename); + } + return $filePath; + } + + /** + * Delete a backup file. + */ + public function deleteBackup(string $filename): void { + $filePath = $this->getBackupStoragePath() . '/' . basename($filename); + if (file_exists($filePath)) { + unlink($filePath); + } + } + + /** + * Delete oldest backups beyond the configured retention count. + * + * @return int Number of deleted backups + */ + public function rotateBackups(): int { + $retention = $this->backupSettings->getRetentionCount(); + $backups = $this->listBackups(); + + if (count($backups) <= $retention) { + return 0; + } + + $toDelete = array_slice($backups, $retention); + foreach ($toDelete as $backup) { + $this->deleteBackup($backup['filename']); + } + + return count($toDelete); + } + + // ── Private helpers ───────────────────────────────────────────── + + private function getBackupStoragePath(): string { + $dataDir = $this->config->getSystemValue('datadirectory', '/var/www/html/data'); + $instanceId = $this->config->getSystemValue('instanceid', ''); + $path = $dataDir . '/appdata_' . $instanceId . '/' . self::APP_ID . '/' . self::BACKUP_DIR; + + if (!is_dir($path)) { + mkdir($path, 0750, true); + } + + return $path; + } + + /** + * Export all rows from a table as an array of associative arrays. + */ + private function exportTable(string $tableName): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($tableName); + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } + + /** + * Export all app config keys for this app. + */ + private function exportAppSettings(): array { + $settings = []; + foreach (self::SETTINGS_KEYS as $key) { + $val = $this->config->getAppValue(self::APP_ID, $key, ''); + if ($val !== '') { + $settings[$key] = $val; + } + } + return $settings; + } + + /** + * Delete all rows from a table. + */ + private function truncateTable(string $tableName): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($tableName); + $qb->executeStatement(); + } + + /** + * Batch-insert rows into a table. + */ + private function insertRows(string $tableName, array $rows): void { + if (empty($rows)) { + return; + } + + $columns = array_keys($rows[0]); + + // Insert in batches for performance + $batchSize = 200; + foreach (array_chunk($rows, $batchSize) as $batch) { + foreach ($batch as $row) { + $qb = $this->db->getQueryBuilder(); + $qb->insert($tableName); + foreach ($columns as $col) { + $value = $row[$col] ?? null; + $qb->setValue($col, $qb->createNamedParameter($value)); + } + $qb->executeStatement(); + } + } + } + + /** + * Restore app config settings. + */ + private function restoreAppSettings(array $settings): void { + foreach ($settings as $key => $value) { + // Only restore known keys to avoid injecting unexpected config + if (in_array($key, self::SETTINGS_KEYS, true)) { + $this->config->setAppValue(self::APP_ID, $key, $value); + } + } + } + + /** + * Read backup info from a ZIP file's manifest. + */ + private function getBackupInfoFromFile(string $filePath): ?array { + $zip = new \ZipArchive(); + if ($zip->open($filePath, \ZipArchive::RDONLY) !== true) { + return null; + } + + $manifestJson = $zip->getFromName('manifest.json'); + $zip->close(); + + if ($manifestJson === false) { + return [ + 'filename' => basename($filePath), + 'size' => filesize($filePath), + 'createdAt' => date('c', filemtime($filePath)), + 'tables' => [], + ]; + } + + $manifest = json_decode($manifestJson, true); + return [ + 'filename' => basename($filePath), + 'size' => filesize($filePath), + 'createdAt' => $manifest['createdAt'] ?? date('c', filemtime($filePath)), + 'appVersion' => $manifest['appVersion'] ?? null, + 'schemaVersion' => $manifest['schemaVersion'] ?? null, + 'triggeredBy' => $manifest['triggeredBy'] ?? null, + 'userId' => $manifest['userId'] ?? null, + 'tables' => $manifest['tables'] ?? [], + 'auditLogIncluded' => $manifest['auditLogIncluded'] ?? false, + ]; + } +} diff --git a/lib/Service/BackupSettingsService.php b/lib/Service/BackupSettingsService.php new file mode 100644 index 0000000..9a16717 --- /dev/null +++ b/lib/Service/BackupSettingsService.php @@ -0,0 +1,92 @@ +config = $config; + $this->crypto = $crypto; + } + + public function getSchedule(): string { + return $this->config->getAppValue(self::APP_ID, 'backup_schedule', 'disabled'); + } + + public function setSchedule(string $schedule): void { + $valid = ['daily', 'weekly', 'disabled']; + if (!in_array($schedule, $valid, true)) { + throw new ValidationException('Ungueltiger Zeitplan: ' . $schedule); + } + $this->config->setAppValue(self::APP_ID, 'backup_schedule', $schedule); + } + + public function getRetentionCount(): int { + return (int) $this->config->getAppValue(self::APP_ID, 'backup_retention_count', '10'); + } + + public function setRetentionCount(int $count): void { + if ($count < 1) { + throw new ValidationException('Aufbewahrungsanzahl muss mindestens 1 sein'); + } + $this->config->setAppValue(self::APP_ID, 'backup_retention_count', (string) $count); + } + + public function getBackupPassword(): ?string { + $encrypted = $this->config->getAppValue(self::APP_ID, 'backup_password', ''); + if ($encrypted === '') { + return null; + } + try { + return $this->crypto->decrypt($encrypted); + } catch (\Exception $e) { + return null; + } + } + + public function setBackupPassword(?string $password): void { + if ($password === null || $password === '') { + $this->config->setAppValue(self::APP_ID, 'backup_password', ''); + } else { + $this->config->setAppValue(self::APP_ID, 'backup_password', $this->crypto->encrypt($password)); + } + } + + public function getAllSettings(): array { + return [ + 'schedule' => $this->getSchedule(), + 'retentionCount' => $this->getRetentionCount(), + 'hasPassword' => $this->getBackupPassword() !== null, + ]; + } + + public function updateSettings(array $data): void { + if (isset($data['schedule'])) { + $this->setSchedule($data['schedule']); + } + if (isset($data['retentionCount'])) { + $this->setRetentionCount((int) $data['retentionCount']); + } + if (array_key_exists('password', $data)) { + $this->setBackupPassword($data['password']); + } + } +} diff --git a/lib/Service/BundleImportService.php b/lib/Service/BundleImportService.php index ba65c38..684d4dd 100644 --- a/lib/Service/BundleImportService.php +++ b/lib/Service/BundleImportService.php @@ -136,7 +136,7 @@ class BundleImportService { * @param array $entityOverrides Optional type overrides: {filename: type} * @return array{results: array[], idMap: array} */ - public function executeBundle(string $zipContent, array $entityOverrides = []): array { + public function executeBundle(string $zipContent, array $entityOverrides = [], array $corrections = []): array { $csvFiles = $this->extractCsvFiles($zipContent); $analysis = $this->analyzeBundle($zipContent); @@ -188,12 +188,16 @@ class BundleImportService { $parsed = $this->entityImportService->parseFile($remappedContent, ';', 'UTF-8'); $mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']); + $entityCorrections = $corrections[$type] ?? []; + $result = $this->entityImportService->execute( $type, $remappedContent, $mapping, ';', - 'UTF-8' + 'UTF-8', + false, + $entityCorrections ); // Collect ID mappings from created entities @@ -236,9 +240,11 @@ class BundleImportService { /** * Preview bundle import (dry-run for all entities). * + * @param string $zipContent Raw ZIP binary content + * @param array $corrections Per-entity corrections: { entityType: { rowNum: { fieldKey: value } } } * @return array{results: array[]} */ - public function previewBundle(string $zipContent): array { + public function previewBundle(string $zipContent, array $corrections = []): array { $csvFiles = $this->extractCsvFiles($zipContent); $analysis = $this->analyzeBundle($zipContent); $results = []; @@ -262,14 +268,21 @@ class BundleImportService { $parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8'); $mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']); + $entityCorrections = $corrections[$type] ?? []; + $preview = $this->entityImportService->preview( $type, $content, $mapping, ';', - 'UTF-8' + 'UTF-8', + false, + $entityCorrections ); + // Include target fields so the frontend can render editable inputs + $schema = $this->entityImportService->getImportSchema($type); + $results[] = [ 'type' => $type, 'label' => $entityEntry['label'], @@ -277,6 +290,7 @@ class BundleImportService { 'fkWarnings' => $preview['fkWarnings'], 'errorCount' => count($preview['errors']), 'errors' => array_slice($preview['errors'], 0, 10), + 'targetFields' => $schema['targetFields'], ]; } catch (\Exception $e) { $results[] = [ @@ -286,6 +300,7 @@ class BundleImportService { 'fkWarnings' => [], 'errorCount' => 1, 'errors' => [['_rowIndex' => 0, '_errors' => [$e->getMessage()]]], + 'targetFields' => [], ]; } } diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 4e6d709..0ca846a 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -9,6 +9,7 @@ use OCA\Mitgliederverwaltung\Db\Permission; use OCA\Mitgliederverwaltung\Db\PermissionMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\DB\Exception; +use OCP\IGroupManager; /** * Permission checking logic for all access levels. @@ -26,13 +27,16 @@ class PermissionService { private PermissionMapper $permissionMapper; private MemberMapper $memberMapper; + private IGroupManager $groupManager; public function __construct( PermissionMapper $permissionMapper, - MemberMapper $memberMapper + MemberMapper $memberMapper, + IGroupManager $groupManager ) { $this->permissionMapper = $permissionMapper; $this->memberMapper = $memberMapper; + $this->groupManager = $groupManager; } /** @@ -54,6 +58,9 @@ class PermissionService { * @throws Exception */ public function canAccess(string $userId): bool { + if ($this->groupManager->isInGroup($userId, 'admin')) { + return true; + } $perm = $this->getUserPermission($userId); return $perm !== null && $perm->getLevel() !== 'none'; } @@ -64,6 +71,9 @@ class PermissionService { * @throws Exception */ public function canRead(string $userId): bool { + if ($this->groupManager->isInGroup($userId, 'admin')) { + return true; + } $perm = $this->getUserPermission($userId); if ($perm === null) { return false; @@ -79,6 +89,9 @@ class PermissionService { * @throws Exception */ public function canWrite(string $userId, ?int $memberId = null): bool { + if ($this->groupManager->isInGroup($userId, 'admin')) { + return true; + } $perm = $this->getUserPermission($userId); if ($perm === null) { return false; @@ -136,7 +149,11 @@ class PermissionService { */ public function isAdmin(string $userId): bool { $perm = $this->getUserPermission($userId); - return $perm !== null && $perm->getLevel() === 'admin'; + if ($perm !== null && $perm->getLevel() === 'admin') { + return true; + } + // Fallback: Nextcloud system admins are always app admins + return $this->groupManager->isInGroup($userId, 'admin'); } /** @@ -145,6 +162,9 @@ class PermissionService { * @throws Exception */ public function canSeeBanking(string $userId): bool { + if ($this->groupManager->isInGroup($userId, 'admin')) { + return true; + } $perm = $this->getUserPermission($userId); return $perm !== null && $perm->getCanSeeBanking(); } diff --git a/lib/Service/SelfUpdateService.php b/lib/Service/SelfUpdateService.php new file mode 100644 index 0000000..8a5ddde --- /dev/null +++ b/lib/Service/SelfUpdateService.php @@ -0,0 +1,413 @@ +config = $config; + $this->clientService = $clientService; + $this->logger = $logger; + } + + /** + * Get the currently installed app version. + */ + public function getCurrentVersion(): string { + return $this->config->getAppValue(self::APP_ID, 'installed_version', '0.0.0'); + } + + /** + * Check Gitea for the latest release. + * + * @return array{available: bool, currentVersion: string, latestVersion: string|null, releaseUrl: string|null, publishedAt: string|null} + */ + public function checkForUpdate(): array { + $currentVersion = $this->getCurrentVersion(); + + try { + $release = $this->fetchLatestRelease(); + } catch (\Exception $e) { + $this->logger->warning('Failed to check for updates', [ + 'exception' => $e, + 'app' => self::APP_ID, + ]); + return [ + 'available' => false, + 'currentVersion' => $currentVersion, + 'latestVersion' => null, + 'releaseUrl' => null, + 'publishedAt' => null, + 'error' => $e->getMessage(), + ]; + } + + if ($release === null) { + return [ + 'available' => false, + 'currentVersion' => $currentVersion, + 'latestVersion' => null, + 'releaseUrl' => null, + 'publishedAt' => null, + ]; + } + + $latestVersion = ltrim($release['tag_name'], 'v'); + $isNewer = version_compare($latestVersion, $currentVersion, '>'); + $assets = $this->findReleaseAssets($release); + + return [ + 'available' => $isNewer, + 'currentVersion' => $currentVersion, + 'latestVersion' => $latestVersion, + 'releaseUrl' => $release['html_url'] ?? null, + 'publishedAt' => $release['published_at'] ?? null, + 'releaseName' => $release['name'] ?? null, + 'releaseBody' => $release['body'] ?? null, + 'signed' => $assets['sigUrl'] !== null, + ]; + } + + /** + * Download and install the latest release. + * + * @param bool $force Install even if version is not newer + * @return array{success: bool, oldVersion: string, newVersion: string, message: string} + */ + public function performUpdate(bool $force = false): array { + $currentVersion = $this->getCurrentVersion(); + $release = $this->fetchLatestRelease(); + + if ($release === null) { + return [ + 'success' => false, + 'oldVersion' => $currentVersion, + 'newVersion' => $currentVersion, + 'message' => 'Kein Release auf Gitea gefunden.', + ]; + } + + $latestVersion = ltrim($release['tag_name'], 'v'); + + if (!$force && !version_compare($latestVersion, $currentVersion, '>')) { + return [ + 'success' => false, + 'oldVersion' => $currentVersion, + 'newVersion' => $currentVersion, + 'message' => "Bereits auf dem neuesten Stand (v{$currentVersion}).", + ]; + } + + // Find release assets (tarball + signature) + $assets = $this->findReleaseAssets($release); + if ($assets['tarballUrl'] === null) { + return [ + 'success' => false, + 'oldVersion' => $currentVersion, + 'newVersion' => $latestVersion, + 'message' => 'Kein .tar.gz Asset im Release gefunden.', + ]; + } + if ($assets['sigUrl'] === null) { + return [ + 'success' => false, + 'oldVersion' => $currentVersion, + 'newVersion' => $latestVersion, + 'message' => 'Keine Signatur (.sig) im Release gefunden — Update abgelehnt.', + ]; + } + + // Download both files + $tmpTarball = $this->downloadAsset($assets['tarballUrl']); + $tmpSig = null; + + try { + $tmpSig = $this->downloadAsset($assets['sigUrl']); + + // Verify signature BEFORE extraction + $this->verifySignature($tmpTarball, $tmpSig); + + $this->logger->info('Release signature verified', [ + 'version' => $latestVersion, + 'app' => self::APP_ID, + ]); + + // Extract over the installed app + $this->extractUpdate($tmpTarball); + + $this->logger->info('App updated successfully', [ + 'from' => $currentVersion, + 'to' => $latestVersion, + 'app' => self::APP_ID, + ]); + + return [ + 'success' => true, + 'oldVersion' => $currentVersion, + 'newVersion' => $latestVersion, + 'message' => "Update von v{$currentVersion} auf v{$latestVersion} erfolgreich (Signatur geprueft). Bitte occ upgrade ausfuehren.", + ]; + } finally { + if (file_exists($tmpTarball)) { + unlink($tmpTarball); + } + if ($tmpSig !== null && file_exists($tmpSig)) { + unlink($tmpSig); + } + } + } + + /** + * Fetch the latest non-draft, non-prerelease from Gitea API. + */ + private function fetchLatestRelease(): ?array { + $url = self::GITEA_BASE . '/api/v1/repos/' . self::GITEA_OWNER . '/' . self::GITEA_REPO . '/releases?limit=5'; + + $client = $this->clientService->newClient(); + $response = $client->get($url, [ + 'timeout' => 15, + 'headers' => ['Accept' => 'application/json'], + ]); + + $releases = json_decode($response->getBody(), true); + if (!is_array($releases)) { + return null; + } + + // Find latest non-draft, non-prerelease + foreach ($releases as $release) { + if (!($release['draft'] ?? false) && !($release['prerelease'] ?? false)) { + return $release; + } + } + + return null; + } + + /** + * Find the .tar.gz and .tar.gz.sig asset URLs from a release. + * + * @return array{tarballUrl: string|null, sigUrl: string|null} + */ + private function findReleaseAssets(array $release): array { + $tarballUrl = null; + $sigUrl = null; + $assets = $release['assets'] ?? []; + + foreach ($assets as $asset) { + $name = strtolower($asset['name'] ?? ''); + $url = $asset['browser_download_url'] ?? null; + if ($url === null) { + continue; + } + if (str_ends_with($name, '.tar.gz.sig')) { + $sigUrl = $url; + } elseif (str_ends_with($name, '.tar.gz')) { + $tarballUrl = $url; + } + } + + return ['tarballUrl' => $tarballUrl, 'sigUrl' => $sigUrl]; + } + + /** + * Verify Ed25519 signature of a downloaded tarball. + * + * @throws \RuntimeException if verification fails + */ + private function verifySignature(string $tarballPath, string $signaturePath): void { + $publicKey = base64_decode(self::PUBLIC_KEY, true); + if ($publicKey === false || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + throw new \RuntimeException('Ungueltiger eingebetteter oeffentlicher Schluessel.'); + } + + $signature = file_get_contents($signaturePath); + if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) { + throw new \RuntimeException('Ungueltige Signaturdatei (erwartet: ' . SODIUM_CRYPTO_SIGN_BYTES . ' Bytes).'); + } + + $content = file_get_contents($tarballPath); + if ($content === false) { + throw new \RuntimeException('Tarball konnte nicht gelesen werden.'); + } + + $valid = sodium_crypto_sign_verify_detached($signature, $content, $publicKey); + + if (!$valid) { + $this->logger->error('Release signature verification FAILED', [ + 'tarball' => basename($tarballPath), + 'app' => self::APP_ID, + ]); + throw new \RuntimeException('Signaturpruefung fehlgeschlagen — Update abgebrochen. Die Datei wurde moeglicherweise manipuliert.'); + } + } + + /** + * Download an asset to a temp file. + */ + private function downloadAsset(string $url): string { + $tmpFile = tempnam(sys_get_temp_dir(), 'mv_update_'); + if ($tmpFile === false) { + throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.'); + } + chmod($tmpFile, 0600); + + $client = $this->clientService->newClient(); + $response = $client->get($url, ['timeout' => 120, 'sink' => $tmpFile]); + + if (filesize($tmpFile) < 1024) { + unlink($tmpFile); + throw new \RuntimeException('Download fehlgeschlagen oder Datei zu klein.'); + } + + return $tmpFile; + } + + /** + * Extract a tarball over the installed app directory. + * + * The tarball is expected to contain a top-level `mitgliederverwaltung/` directory + * with appinfo/, lib/, templates/, js/, vendor/, img/. + */ + private function extractUpdate(string $tarballPath): void { + $appPath = $this->getAppInstallPath(); + + // Extract to temp dir first to validate structure + $tmpDir = sys_get_temp_dir() . '/mv_update_extract_' . uniqid(); + mkdir($tmpDir, 0750, true); + + try { + // Extract tarball + $phar = new \PharData($tarballPath); + $phar->extractTo($tmpDir, null, true); + + // Find the extracted app dir (should be mitgliederverwaltung/) + $extractedApp = $tmpDir . '/' . self::APP_ID; + if (!is_dir($extractedApp)) { + // Maybe it extracted without the wrapper dir + $extractedApp = $tmpDir; + } + + // Validate it has appinfo/info.xml + if (!file_exists($extractedApp . '/appinfo/info.xml')) { + throw new \RuntimeException('Ungueltiges Update-Paket: appinfo/info.xml fehlt.'); + } + + // Copy each directory over the existing installation + $dirs = ['appinfo', 'lib', 'templates', 'js', 'vendor', 'img']; + foreach ($dirs as $dir) { + $source = $extractedApp . '/' . $dir; + $target = $appPath . '/' . $dir; + if (is_dir($source)) { + // Remove old dir and replace + if (is_dir($target)) { + $this->removeDirectory($target); + } + $this->copyDirectory($source, $target); + } + } + + // Copy top-level files if present + foreach (['LICENSE', 'README.md', 'CHANGELOG.md'] as $file) { + $source = $extractedApp . '/' . $file; + if (file_exists($source)) { + copy($source, $appPath . '/' . $file); + } + } + } finally { + $this->removeDirectory($tmpDir); + } + } + + /** + * Determine where the app is installed. + */ + private function getAppInstallPath(): string { + // Check custom_apps first (writable), then apps + $paths = json_decode($this->config->getSystemValue('apps_paths', '[]'), true); + + if (is_array($paths)) { + foreach ($paths as $pathConfig) { + if (($pathConfig['writable'] ?? false) === true) { + $candidate = $pathConfig['path'] . '/' . self::APP_ID; + if (is_dir($candidate)) { + return $candidate; + } + } + } + } + + // Fallback: standard custom_apps location + $serverRoot = defined('OC_SERVERROOT') ? OC_SERVERROOT : '/var/www/html'; + $fallback = $serverRoot . '/custom_apps/' . self::APP_ID; + if (is_dir($fallback)) { + return $fallback; + } + + throw new \RuntimeException('App-Installationspfad konnte nicht ermittelt werden.'); + } + + private function removeDirectory(string $dir): void { + if (!is_dir($dir)) { + return; + } + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($items as $item) { + if ($item->isDir()) { + rmdir($item->getRealPath()); + } else { + unlink($item->getRealPath()); + } + } + rmdir($dir); + } + + private function copyDirectory(string $source, string $dest): void { + mkdir($dest, 0750, true); + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($items as $item) { + $target = $dest . '/' . $items->getSubPathname(); + if ($item->isDir()) { + if (!is_dir($target)) { + mkdir($target, 0750, true); + } + } else { + copy($item->getRealPath(), $target); + } + } + } +} diff --git a/scripts/generate-keypair.php b/scripts/generate-keypair.php new file mode 100644 index 0000000..ca08e45 --- /dev/null +++ b/scripts/generate-keypair.php @@ -0,0 +1,56 @@ +#!/usr/bin/env php + + * + * Reads the private key from ~/.mv-release-key (base64-encoded). + * Produces .sig (raw 64-byte Ed25519 signature). + */ + +if ($argc < 2) { + fwrite(STDERR, "Usage: php scripts/sign-release.php \n"); + exit(1); +} + +$tarballPath = $argv[1]; + +if (!file_exists($tarballPath)) { + fwrite(STDERR, "ERROR: File not found: $tarballPath\n"); + exit(1); +} + +if (!function_exists('sodium_crypto_sign_detached')) { + fwrite(STDERR, "ERROR: sodium extension is required.\n"); + exit(1); +} + +// Read private key +$keyFile = $_SERVER['HOME'] . '/.mv-release-key'; +if (!file_exists($keyFile)) { + fwrite(STDERR, "ERROR: Private key not found at $keyFile\n"); + fwrite(STDERR, "Run: php scripts/generate-keypair.php\n"); + exit(1); +} + +$secretKeyBase64 = trim(file_get_contents($keyFile)); +$secretKey = base64_decode($secretKeyBase64, true); +if ($secretKey === false || strlen($secretKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { + fwrite(STDERR, "ERROR: Invalid private key in $keyFile\n"); + exit(1); +} + +// Read tarball +$content = file_get_contents($tarballPath); +if ($content === false) { + fwrite(STDERR, "ERROR: Could not read $tarballPath\n"); + exit(1); +} + +// Sign +$signature = sodium_crypto_sign_detached($content, $secretKey); + +// Write signature +$sigPath = $tarballPath . '.sig'; +file_put_contents($sigPath, $signature); + +echo "Signed: $sigPath (" . strlen($signature) . " bytes)\n"; + +// Verify as sanity check +$publicKey = sodium_crypto_sign_publickey_from_secretkey($secretKey); +if (sodium_crypto_sign_verify_detached($signature, $content, $publicKey)) { + echo "Verification: OK\n"; +} else { + fwrite(STDERR, "ERROR: Self-verification failed!\n"); + exit(1); +} diff --git a/src/App.vue b/src/App.vue index 808243c..656a46f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -65,6 +65,13 @@ + + + @@ -97,6 +104,7 @@ import DatabaseSearch from 'vue-material-design-icons/DatabaseSearch.vue' import SwapVertical from 'vue-material-design-icons/SwapVertical.vue' import MedicalBag from 'vue-material-design-icons/MedicalBag.vue' import Tent from 'vue-material-design-icons/Campfire.vue' +import BackupRestore from 'vue-material-design-icons/BackupRestore.vue' import SearchBar from './components/SearchBar.vue' const route = useRoute() diff --git a/src/components/ColumnPicker.vue b/src/components/ColumnPicker.vue new file mode 100644 index 0000000..c28ace4 --- /dev/null +++ b/src/components/ColumnPicker.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/main.js b/src/main.js index 620886f..bb29102 100644 --- a/src/main.js +++ b/src/main.js @@ -31,7 +31,7 @@ app.use(router) // @nextcloud/vue v9 reads appName/appVersion via Vue's inject(), // not via webpack DefinePlugin globals. app.provide('appName', 'mitgliederverwaltung') -app.provide('appVersion', '0.1.5') +app.provide('appVersion', '0.2.0') app.mount('#mitgliederverwaltung') diff --git a/src/router.js b/src/router.js index 9c8c9ca..094bd4a 100644 --- a/src/router.js +++ b/src/router.js @@ -73,6 +73,11 @@ const routes = [ name: 'settings', component: () => import('./views/Settings.vue'), }, + { + path: '/backup', + name: 'backup', + component: () => import('./views/Backup.vue'), + }, ] const router = createRouter({ diff --git a/src/stores/backup.js b/src/stores/backup.js new file mode 100644 index 0000000..72f57ea --- /dev/null +++ b/src/stores/backup.js @@ -0,0 +1,153 @@ +/** + * Pinia store for backup & restore state management. + */ +import { defineStore } from 'pinia' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +export const useBackupStore = defineStore('backup', { + state: () => ({ + backups: [], + settings: { schedule: 'disabled', retentionCount: 10, hasPassword: false }, + loading: false, + creating: false, + restoring: false, + updating: false, + updateInfo: null, + error: null, + success: null, + }), + + actions: { + async fetchBackups() { + this.loading = true + this.error = null + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/backups') + const response = await axios.get(url) + this.backups = response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Laden der Backups' + } finally { + this.loading = false + } + }, + + async createBackup(password) { + this.creating = true + this.error = null + this.success = null + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/backups') + const response = await axios.post(url, { password: password || undefined }) + this.success = `Backup "${response.data.filename}" erstellt` + await this.fetchBackups() + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Erstellen des Backups' + throw err + } finally { + this.creating = false + } + }, + + async restoreBackup(filename, password) { + this.restoring = true + this.error = null + this.success = null + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/backups/${filename}/restore`) + const response = await axios.post(url, { + confirm: true, + password: password || undefined, + }) + this.success = `Backup wiederhergestellt: ${response.data.totalRows} Zeilen in ${response.data.restoredTables} Tabellen` + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Wiederherstellen' + throw err + } finally { + this.restoring = false + } + }, + + async deleteBackup(filename) { + this.error = null + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/backups/${filename}`) + await axios.delete(url) + this.backups = this.backups.filter(b => b.filename !== filename) + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Loeschen' + } + }, + + downloadBackup(filename) { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/backups/${filename}/download`) + window.location.href = url + }, + + async fetchSettings() { + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/backups/settings') + const response = await axios.get(url) + this.settings = response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Laden der Einstellungen' + } + }, + + async updateSettings(data) { + this.error = null + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/backups/settings') + const response = await axios.put(url, data) + this.settings = response.data + this.success = 'Backup-Einstellungen gespeichert' + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Speichern der Einstellungen' + } + }, + + async checkForUpdate() { + this.error = null + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/update/check') + const response = await axios.get(url) + this.updateInfo = response.data + } catch (err) { + this.error = err.response?.data?.error || 'Update-Check fehlgeschlagen' + } + }, + + async installUpdate(force) { + this.updating = true + this.error = null + this.success = null + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/update/install') + const response = await axios.post(url, { force: force || false }) + if (response.data.success) { + this.success = response.data.message + this.updateInfo = null + } else { + this.error = response.data.message + } + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Update fehlgeschlagen' + throw err + } finally { + this.updating = false + } + }, + + clearError() { + this.error = null + }, + + clearSuccess() { + this.success = null + }, + }, +}) diff --git a/src/stores/import.js b/src/stores/import.js index 10b26c9..5a25fbf 100644 --- a/src/stores/import.js +++ b/src/stores/import.js @@ -69,6 +69,8 @@ export const useImportStore = defineStore('import', { bundlePreviewResult: null, /** @type {Object|null} Bundle execution result */ bundleExecuteResult: null, + /** @type {Object} Per-entity corrections for bundle errors: { entityType: { rowIndex: { fieldKey: value } } } */ + bundleCorrections: {}, }), getters: { @@ -305,6 +307,7 @@ export const useImportStore = defineStore('import', { const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/preview') const response = await axios.post(url, { content: this.fileContent, + corrections: this.bundleCorrections, }) this.bundlePreviewResult = response.data @@ -328,6 +331,7 @@ export const useImportStore = defineStore('import', { const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/execute') const response = await axios.post(url, { content: this.fileContent, + corrections: this.bundleCorrections, }) this.bundleExecuteResult = response.data @@ -366,6 +370,7 @@ export const useImportStore = defineStore('import', { this.bundleAnalysis = null this.bundlePreviewResult = null this.bundleExecuteResult = null + this.bundleCorrections = {} }, /** diff --git a/src/utils/useColumnVisibility.js b/src/utils/useColumnVisibility.js new file mode 100644 index 0000000..d9ecb01 --- /dev/null +++ b/src/utils/useColumnVisibility.js @@ -0,0 +1,42 @@ +import { ref, computed, watch } from 'vue' + +/** + * Composable for persisted column visibility. + * + * @param {string} storageKey - localStorage key + * @param {Array} allColumns - Column definitions with { key, label, ... } + * @param {string[]} defaultVisible - Keys visible by default + * @returns {{ visibleKeys, visibleColumns, setVisibleKeys }} + */ +export function useColumnVisibility(storageKey, allColumns, defaultVisible) { + function load() { + try { + const stored = localStorage.getItem(storageKey) + if (stored) { + const parsed = JSON.parse(stored) + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed + } + } + } catch { + // ignore + } + return [...defaultVisible] + } + + const visibleKeys = ref(load()) + + const visibleColumns = computed(() => + allColumns.filter(col => visibleKeys.value.includes(col.key)), + ) + + watch(visibleKeys, (val) => { + localStorage.setItem(storageKey, JSON.stringify(val)) + }, { deep: true }) + + function setVisibleKeys(newKeys) { + visibleKeys.value = newKeys + } + + return { visibleKeys, visibleColumns, setVisibleKeys } +} diff --git a/src/views/AuditLog.vue b/src/views/AuditLog.vue index 3d9ce3f..5a346b8 100644 --- a/src/views/AuditLog.vue +++ b/src/views/AuditLog.vue @@ -1,6 +1,11 @@