From 16801d39c67d46647aca172b4a8f381a49c78dea Mon Sep 17 00:00:00 2001 From: nicolas Date: Mon, 14 Sep 2026 11:47:58 +0200 Subject: [PATCH] feat(security & backup): double guard permissions in app, restrict news creation to admin, and add one-click firestore backup tool --- .gitignore | 1 + backup_firestore.bat | 9 +++++ firestore.rules | 6 ++-- lib/screens/post_list_screen.dart | 41 +++++++++++++++++++--- scripts/backup_firestore.js | 57 +++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 backup_firestore.bat create mode 100644 scripts/backup_firestore.js diff --git a/.gitignore b/.gitignore index 68a2d21..109d73a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ app.*.map.json release-apks/ gitea-runner/data/ gitea-runner/.env +backups/ diff --git a/backup_firestore.bat b/backup_firestore.bat new file mode 100644 index 0000000..c10cf91 --- /dev/null +++ b/backup_firestore.bat @@ -0,0 +1,9 @@ +@echo off +echo ======================================================== +echo Oggauer Gemeindetrommler - Firestore Backup Tool +echo ======================================================== +echo. +node scripts\backup_firestore.js +echo. +echo Backup abgeschlossen. Taste druecken zum Schliessen... +pause >nul diff --git a/firestore.rules b/firestore.rules index dded8d6..3654914 100644 --- a/firestore.rules +++ b/firestore.rules @@ -34,8 +34,10 @@ service cloud.firestore { // Jeder darf News, Mängel & Ideen öffentlich lesen allow read: if collection in ['News', 'Issues', 'CitizensForum']; - // Erstellen darf jeder freigeschaltete/angemeldete Nutzer - allow create: if isAuthenticated() && collection in ['News', 'Issues', 'CitizensForum']; + // Erstellen: News nur Admin; Mängel und Ideen für angemeldete Nutzer + allow create: if collection == 'News' + ? isAdmin() + : (isAuthenticated() && collection in ['Issues', 'CitizensForum']); // Ändern/Löschen nur der eigene Autor oder Admin allow update, delete: if isAuthorOrAdmin() && collection in ['News', 'Issues', 'CitizensForum']; diff --git a/lib/screens/post_list_screen.dart b/lib/screens/post_list_screen.dart index ffbd643..b3882d9 100644 --- a/lib/screens/post_list_screen.dart +++ b/lib/screens/post_list_screen.dart @@ -58,6 +58,21 @@ class _PostListScreenState extends State { } Future _deletePost(PostItem item) async { + final auth = Provider.of(context, listen: false); + final currentUser = auth.user; + final isAuthor = currentUser != null && item.authorUid == currentUser.uid; + final canDelete = isAuthor || auth.isAdmin; + + if (!canDelete) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Keine Berechtigung zum Löschen dieses Beitrags.'), + backgroundColor: Colors.red, + ), + ); + return; + } + try { for (final url in item.imageUrls) { await _storageService.deleteFileByUrl(url); @@ -78,6 +93,18 @@ class _PostListScreenState extends State { Future _onAddNewPressed() async { final auth = Provider.of(context, listen: false); + // News are official announcements and can only be created by Admins + if (widget.collectionName == 'News' && !auth.isAdmin) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Nur Administratoren können Neuigkeiten veröffentlichen.'), + backgroundColor: Colors.amber, + ), + ); + return; + } + if (!auth.isCommunityUnlocked && !auth.isAuthenticated) { await AuthDialog.show(context); if (!mounted) return; @@ -410,11 +437,15 @@ class _PostListScreenState extends State { ); }, ), - floatingActionButton: FloatingActionButton( - onPressed: _onAddNewPressed, - tooltip: 'Neuen Eintrag erstellen', - child: const Icon(Icons.add), - ), + floatingActionButton: (widget.collectionName == 'News' && !auth.isAdmin) + ? null + : FloatingActionButton( + onPressed: _onAddNewPressed, + tooltip: widget.collectionName == 'News' + ? 'Nachricht verfassen' + : 'Neuen Eintrag erstellen', + child: const Icon(Icons.add), + ), ); } } diff --git a/scripts/backup_firestore.js b/scripts/backup_firestore.js new file mode 100644 index 0000000..1b050ef --- /dev/null +++ b/scripts/backup_firestore.js @@ -0,0 +1,57 @@ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const PROJECT_ID = 'gemeinde-trommler'; +const COLLECTIONS = ['News', 'Issues', 'CitizensForum', 'ContactMessages', 'Admins']; + +function fetchCollection(collection) { + return new Promise((resolve) => { + const url = `https://firestore.googleapis.com/v1/projects/${PROJECT_ID}/databases/(default)/documents/${collection}?pageSize=300`; + https.get(url, (res) => { + let raw = ''; + res.on('data', (chunk) => raw += chunk); + res.on('end', () => { + try { + const json = JSON.parse(raw); + if (json.documents) { + resolve({ collection, status: 'OK', count: json.documents.length, documents: json.documents }); + } else if (json.error) { + resolve({ collection, status: 'PERMISSION_OR_EMPTY', error: json.error.message, documents: [] }); + } else { + resolve({ collection, status: 'EMPTY', count: 0, documents: [] }); + } + } catch (e) { + resolve({ collection, status: 'PARSE_ERROR', error: e.message, documents: [] }); + } + }); + }).on('error', (err) => { + resolve({ collection, status: 'NETWORK_ERROR', error: err.message, documents: [] }); + }); + }); +} + +async function runBackup() { + console.log(`[Backup] Starte Sicherung fuer Firebase Projekt: ${PROJECT_ID}...`); + const results = {}; + const backupDir = path.join(__dirname, '..', 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + + for (const col of COLLECTIONS) { + process.stdout.write(`[Backup] Lade ${col}... `); + const res = await fetchCollection(col); + results[col] = res; + console.log(`${res.status} (${res.documents ? res.documents.length : 0} Dokumente)`); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `firestore_backup_${timestamp}.json`; + const filePath = path.join(backupDir, filename); + + fs.writeFileSync(filePath, JSON.stringify(results, null, 2), 'utf-8'); + console.log(`\n[Backup] Erfolgreich gespeichert in: ${filePath}`); +} + +runBackup();