feat(security & backup): double guard permissions in app, restrict news creation to admin, and add one-click firestore backup tool
This commit is contained in:
@@ -47,3 +47,4 @@ app.*.map.json
|
|||||||
release-apks/
|
release-apks/
|
||||||
gitea-runner/data/
|
gitea-runner/data/
|
||||||
gitea-runner/.env
|
gitea-runner/.env
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -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
|
||||||
+4
-2
@@ -34,8 +34,10 @@ service cloud.firestore {
|
|||||||
// Jeder darf News, Mängel & Ideen öffentlich lesen
|
// Jeder darf News, Mängel & Ideen öffentlich lesen
|
||||||
allow read: if collection in ['News', 'Issues', 'CitizensForum'];
|
allow read: if collection in ['News', 'Issues', 'CitizensForum'];
|
||||||
|
|
||||||
// Erstellen darf jeder freigeschaltete/angemeldete Nutzer
|
// Erstellen: News nur Admin; Mängel und Ideen für angemeldete Nutzer
|
||||||
allow create: if isAuthenticated() && collection in ['News', 'Issues', 'CitizensForum'];
|
allow create: if collection == 'News'
|
||||||
|
? isAdmin()
|
||||||
|
: (isAuthenticated() && collection in ['Issues', 'CitizensForum']);
|
||||||
|
|
||||||
// Ändern/Löschen nur der eigene Autor oder Admin
|
// Ändern/Löschen nur der eigene Autor oder Admin
|
||||||
allow update, delete: if isAuthorOrAdmin() && collection in ['News', 'Issues', 'CitizensForum'];
|
allow update, delete: if isAuthorOrAdmin() && collection in ['News', 'Issues', 'CitizensForum'];
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ class _PostListScreenState extends State<PostListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deletePost(PostItem item) async {
|
Future<void> _deletePost(PostItem item) async {
|
||||||
|
final auth = Provider.of<AuthService>(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 {
|
try {
|
||||||
for (final url in item.imageUrls) {
|
for (final url in item.imageUrls) {
|
||||||
await _storageService.deleteFileByUrl(url);
|
await _storageService.deleteFileByUrl(url);
|
||||||
@@ -78,6 +93,18 @@ class _PostListScreenState extends State<PostListScreen> {
|
|||||||
Future<void> _onAddNewPressed() async {
|
Future<void> _onAddNewPressed() async {
|
||||||
final auth = Provider.of<AuthService>(context, listen: false);
|
final auth = Provider.of<AuthService>(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) {
|
if (!auth.isCommunityUnlocked && !auth.isAuthenticated) {
|
||||||
await AuthDialog.show(context);
|
await AuthDialog.show(context);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -410,11 +437,15 @@ class _PostListScreenState extends State<PostListScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: (widget.collectionName == 'News' && !auth.isAdmin)
|
||||||
onPressed: _onAddNewPressed,
|
? null
|
||||||
tooltip: 'Neuen Eintrag erstellen',
|
: FloatingActionButton(
|
||||||
child: const Icon(Icons.add),
|
onPressed: _onAddNewPressed,
|
||||||
),
|
tooltip: widget.collectionName == 'News'
|
||||||
|
? 'Nachricht verfassen'
|
||||||
|
: 'Neuen Eintrag erstellen',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user