diff --git a/README.md b/README.md index 07349ca..0215afd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ comptes) ou ouvrir les inscriptions publiques depuis le menu Paramètres. - Catalogue de flux façon "market" : une sélection de flux embarquée avec l'application ([resources/data/bloomflux.json](resources/data/bloomflux.json)), à ajouter en un clic. - Multi-comptes : le propriétaire crée des comptes ou ouvre/ferme les inscriptions d'un clic. +- Notifications personnelles : chaque utilisateur configure ses propres alertes **Discord** ou + **webhook générique**, pour tous ses flux ou seulement certains. - Interface disponible en **français, anglais, espagnol et allemand** (sélecteur en haut de page, préférence mémorisée par utilisateur). - Récupération périodique des flux via une tâche planifiée. @@ -37,9 +39,13 @@ comptes) ou ouvrir les inscriptions publiques depuis le menu Paramètres. |---|---| | ![Tableau de bord](docs/screenshots/dashboard-light.png) | ![Lecteur intégré](docs/screenshots/reader-light.png) | -| Catalogue de flux | Mode sombre | +| Catalogue de flux | Notifieurs | |---|---| -| ![Catalogue de flux](docs/screenshots/catalog-light.png) | ![Mode sombre](docs/screenshots/dashboard-dark.png) | +| ![Catalogue de flux](docs/screenshots/catalog-light.png) | ![Notifieurs](docs/screenshots/notifiers-light.png) | + +| Mode sombre | +|---| +| ![Mode sombre](docs/screenshots/dashboard-dark.png) | ## Stack technique @@ -82,6 +88,16 @@ publiques se referment automatiquement après. Relancez simplement `bash scriptsite.sh` : il récupère les derniers changements et reconstruit la stack sans toucher à vos données. +### Réinitialisation + +Pour repartir d'une instance vierge (efface comptes, flux, articles et notifieurs) sans toucher +au code, au `.env` ni aux images Docker : + +```bash +bash reset.sh # confirmation interactive requise +bash reset.sh --yes # sans confirmation +``` + ### Désinstallation ```bash @@ -126,6 +142,18 @@ l'enrichir, éditez le fichier et redéployez — aucun service externe n'est n { "title": "Nom du flux", "url": "https://exemple.com/rss.xml", "category": "Tech", "lang": "fr", "description": "Courte description." } ``` +### Notifications (Discord / webhook) + +Chaque utilisateur configure ses propres notifieurs depuis le menu **Notifieurs** : + +- **Discord** : collez l'URL d'un webhook de salon Discord (Paramètres du salon → Intégrations → + Webhooks) ; BloomFeed envoie un embed (titre, lien, extrait) à chaque nouvel article. +- **Webhook générique** : n'importe quelle URL recevant un `POST` JSON + `{ "event": "article.created", "feed": "...", "article": { "title", "url", "excerpt", "published_at" } }`. + +Chaque notifieur a une **portée** : tous les flux de l'utilisateur, ou une sélection précise de +flux. Un bouton "Tester" envoie une notification factice pour valider l'URL avant de compter dessus. + ## Développement local Prérequis : PHP 8.3+ et [Composer](https://getcomposer.org). diff --git a/app/Http/Controllers/NotifierController.php b/app/Http/Controllers/NotifierController.php new file mode 100644 index 0000000..b91274f --- /dev/null +++ b/app/Http/Controllers/NotifierController.php @@ -0,0 +1,103 @@ +user()->notifiers()->with('feeds')->orderBy('created_at')->get(); + $feeds = $request->user()->feeds()->orderBy('title')->get(); + + return view('notifiers', [ + 'notifiers' => $notifiers, + 'feeds' => $feeds, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $this->validatePayload($request); + + $notifier = $request->user()->notifiers()->create([ + 'type' => $validated['type'], + 'name' => $validated['name'], + 'url' => $validated['url'], + 'scope' => $validated['scope'], + ]); + + if ($validated['scope'] === Notifier::SCOPE_FEEDS) { + $notifier->feeds()->sync($validated['feed_ids'] ?? []); + } + + return redirect()->route('notifiers')->with('status', __('app.notifier_created')); + } + + public function update(Request $request, Notifier $notifier): RedirectResponse + { + $this->authorize('update', $notifier); + + $validated = $this->validatePayload($request); + + $notifier->update([ + 'type' => $validated['type'], + 'name' => $validated['name'], + 'url' => $validated['url'], + 'scope' => $validated['scope'], + ]); + + $notifier->feeds()->sync($validated['scope'] === Notifier::SCOPE_FEEDS ? ($validated['feed_ids'] ?? []) : []); + + return redirect()->route('notifiers')->with('status', __('app.notifier_updated')); + } + + public function toggle(Request $request, Notifier $notifier): RedirectResponse + { + $this->authorize('update', $notifier); + + $notifier->update(['enabled' => ! $notifier->enabled]); + + return redirect()->route('notifiers'); + } + + public function test(Notifier $notifier, NotifierDispatchService $dispatcher): RedirectResponse + { + $this->authorize('update', $notifier); + + $ok = $dispatcher->sendTest($notifier); + + return redirect()->route('notifiers') + ->with('status', $ok ? __('app.notifier_test_ok') : __('app.notifier_test_failed')); + } + + public function destroy(Notifier $notifier): RedirectResponse + { + $this->authorize('delete', $notifier); + + $notifier->delete(); + + return redirect()->route('notifiers')->with('status', __('app.notifier_deleted')); + } + + private function validatePayload(Request $request): array + { + return $request->validate([ + 'type' => ['required', 'in:'.Notifier::TYPE_DISCORD.','.Notifier::TYPE_WEBHOOK], + 'name' => ['required', 'string', 'max:255'], + 'url' => ['required', 'url', 'max:2048'], + 'scope' => ['required', 'in:'.Notifier::SCOPE_ALL.','.Notifier::SCOPE_FEEDS], + 'feed_ids' => ['array'], + 'feed_ids.*' => [ + 'integer', + Rule::exists('feeds', 'id')->where('user_id', $request->user()->id), + ], + ]); + } +} diff --git a/app/Models/Notifier.php b/app/Models/Notifier.php new file mode 100644 index 0000000..eed3f9c --- /dev/null +++ b/app/Models/Notifier.php @@ -0,0 +1,55 @@ + 'boolean', + 'last_triggered_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function feeds(): BelongsToMany + { + return $this->belongsToMany(Feed::class); + } + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('enabled', true); + } + + public function scopeForFeed(Builder $query, int $feedId): Builder + { + return $query->where(function (Builder $q) use ($feedId) { + $q->where('scope', self::SCOPE_ALL) + ->orWhere(function (Builder $q2) use ($feedId) { + $q2->where('scope', self::SCOPE_FEEDS) + ->whereHas('feeds', fn (Builder $f) => $f->where('feeds.id', $feedId)); + }); + }); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2bef9f0..234a74e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -23,6 +23,11 @@ class User extends Authenticatable return $this->hasMany(Feed::class); } + public function notifiers(): HasMany + { + return $this->hasMany(Notifier::class); + } + /** * Get the attributes that should be cast. * diff --git a/app/Policies/NotifierPolicy.php b/app/Policies/NotifierPolicy.php new file mode 100644 index 0000000..2dfa3fa --- /dev/null +++ b/app/Policies/NotifierPolicy.php @@ -0,0 +1,19 @@ +id === $notifier->user_id; + } + + public function delete(User $user, Notifier $notifier): bool + { + return $user->id === $notifier->user_id; + } +} diff --git a/app/Services/FeedFetcherService.php b/app/Services/FeedFetcherService.php index 983aacc..a3aecf8 100644 --- a/app/Services/FeedFetcherService.php +++ b/app/Services/FeedFetcherService.php @@ -13,6 +13,10 @@ use Throwable; class FeedFetcherService { + public function __construct(private readonly NotifierDispatchService $notifier) + { + } + /** * Fetch, parse and store new articles for a single feed. * @@ -66,6 +70,8 @@ class FeedFetcherService $article->feed()->associate($feed); $article->save(); + $this->notifier->notifyNewArticle($article); + $newCount++; } diff --git a/app/Services/NotifierDispatchService.php b/app/Services/NotifierDispatchService.php new file mode 100644 index 0000000..59191ab --- /dev/null +++ b/app/Services/NotifierDispatchService.php @@ -0,0 +1,116 @@ +feed; + + $notifiers = Notifier::query() + ->where('user_id', $feed->user_id) + ->enabled() + ->forFeed($feed->id) + ->get(); + + foreach ($notifiers as $notifier) { + $this->send($notifier, $article); + } + } + + public function sendTest(Notifier $notifier): bool + { + $payload = $notifier->type === Notifier::TYPE_DISCORD + ? $this->discordTestPayload() + : $this->webhookTestPayload(); + + return $this->deliver($notifier, $payload); + } + + private function send(Notifier $notifier, Article $article): void + { + $payload = $notifier->type === Notifier::TYPE_DISCORD + ? $this->discordPayload($notifier, $article) + : $this->webhookPayload($notifier, $article); + + $this->deliver($notifier, $payload); + } + + private function deliver(Notifier $notifier, array $payload): bool + { + try { + $response = Http::timeout(6)->post($notifier->url, $payload); + + if ($response->failed()) { + $notifier->update([ + 'last_error' => Str::limit("HTTP {$response->status()}", 250, ''), + ]); + + return false; + } + + $notifier->update(['last_triggered_at' => now(), 'last_error' => null]); + + return true; + } catch (Throwable $e) { + Log::warning('Notifier delivery failed', [ + 'notifier_id' => $notifier->id, + 'error' => $e->getMessage(), + ]); + + $notifier->update(['last_error' => Str::limit($e->getMessage(), 250, '')]); + + return false; + } + } + + private function discordPayload(Notifier $notifier, Article $article): array + { + return [ + 'embeds' => [[ + 'title' => Str::limit($article->title, 250), + 'url' => $article->link, + 'description' => $article->excerpt ? Str::limit($article->excerpt, 300) : null, + 'color' => 15768330, // amber accent + 'footer' => ['text' => $article->feed->displayTitle()], + 'timestamp' => $article->published_at?->toIso8601String() ?? now()->toIso8601String(), + ]], + ]; + } + + private function webhookPayload(Notifier $notifier, Article $article): array + { + return [ + 'event' => 'article.created', + 'feed' => $article->feed->displayTitle(), + 'article' => [ + 'title' => $article->title, + 'url' => $article->link, + 'excerpt' => $article->excerpt, + 'published_at' => $article->published_at?->toIso8601String(), + ], + ]; + } + + private function discordTestPayload(): array + { + return ['content' => '🔔 BloomFeed test notification — your Discord webhook is correctly configured.']; + } + + private function webhookTestPayload(): array + { + return ['event' => 'test', 'message' => 'BloomFeed test notification — your webhook is correctly configured.']; + } +} diff --git a/database/migrations/2026_07_04_092809_create_notifiers_table.php b/database/migrations/2026_07_04_092809_create_notifiers_table.php new file mode 100644 index 0000000..fbe9f8a --- /dev/null +++ b/database/migrations/2026_07_04_092809_create_notifiers_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type'); // 'discord' | 'webhook' + $table->string('name'); + $table->string('url', 2048); + $table->string('scope')->default('all'); // 'all' | 'feeds' + $table->boolean('enabled')->default(true); + $table->timestamp('last_triggered_at')->nullable(); + $table->string('last_error')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'enabled']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifiers'); + } +}; diff --git a/database/migrations/2026_07_04_092810_create_feed_notifier_table.php b/database/migrations/2026_07_04_092810_create_feed_notifier_table.php new file mode 100644 index 0000000..d615281 --- /dev/null +++ b/database/migrations/2026_07_04_092810_create_feed_notifier_table.php @@ -0,0 +1,30 @@ +foreignId('notifier_id')->constrained()->cascadeOnDelete(); + $table->foreignId('feed_id')->constrained()->cascadeOnDelete(); + $table->primary(['notifier_id', 'feed_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('feed_notifier'); + } +}; diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index eb940d6..4ee122f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,18 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -echo "==> Attente de la base de données (${DB_HOST:-db}:${DB_PORT:-3306})" +echo "==> Waiting for the database (${DB_HOST:-db}:${DB_PORT:-3306})" for i in $(seq 1 30); do if php -r "exit(@fsockopen(getenv('DB_HOST') ?: 'db', (int) (getenv('DB_PORT') ?: 3306)) ? 0 : 1);"; then - echo "==> Base de données disponible" + echo "==> Database available" break fi - echo "==> DB indisponible, nouvelle tentative dans 2s ($i/30)" + echo "==> Database not available yet, retrying in 2s ($i/30)" sleep 2 done -echo "==> Exécution des migrations" +echo "==> Running migrations" php artisan migrate --force -echo "==> Démarrage d'Apache" +echo "==> Starting Apache" exec apache2-foreground diff --git a/docs/screenshots/notifiers-light.png b/docs/screenshots/notifiers-light.png new file mode 100644 index 0000000..e2bac6a Binary files /dev/null and b/docs/screenshots/notifiers-light.png differ diff --git a/lang/de/app.php b/lang/de/app.php index 1cbb324..a0bab3a 100644 --- a/lang/de/app.php +++ b/lang/de/app.php @@ -143,4 +143,39 @@ return [ 'source_removed' => 'Quelle entfernt.', 'source_invalid' => 'Diese Quelle konnte nicht gelesen werden (gültige bloomflux.json-Datei erwartet).', 'no_sources' => 'Noch keine externen Quellen.', + + // Benachrichtigungen + 'notifiers' => 'Benachrichtigungen', + 'notifiers_lede' => 'Erhalte eine Discord-Benachrichtigung oder einen generischen Webhook-Aufruf, sobald ein neuer Artikel in deinen Feeds erscheint. Jeder Nutzer verwaltet seine eigenen Benachrichtigungen.', + 'new_notifier' => 'Neue Benachrichtigung', + 'notifier_type' => 'Typ', + 'generic_webhook' => 'Generischer Webhook', + 'notifier_name' => 'Name', + 'notifier_name_placeholder' => 'z. B. Discord #news', + 'notifier_url' => 'Webhook-URL', + 'notifier_scope' => 'Umfang', + 'scope_all' => 'Alle meine Feeds', + 'scope_feeds' => 'Bestimmte Feeds', + 'scope_feeds_pick' => 'Feeds auswählen', + 'scope_feeds_list' => 'Feeds: :feeds', + 'no_feeds_selected' => 'kein Feed ausgewählt', + 'add_notifier' => 'Benachrichtigung hinzufügen', + 'your_notifiers' => 'Deine Benachrichtigungen', + 'no_notifiers_yet' => 'Du hast noch keine Benachrichtigung eingerichtet.', + 'disabled' => 'deaktiviert', + 'send_test' => 'Testen', + 'enable' => 'Aktivieren', + 'disable' => 'Deaktivieren', + 'confirm_delete_notifier' => 'Diese Benachrichtigung löschen?', + 'edit' => 'Bearbeiten', + 'save' => 'Speichern', + 'last_triggered' => 'ausgelöst :time', + 'notifier_created' => 'Benachrichtigung erstellt.', + 'notifier_updated' => 'Benachrichtigung aktualisiert.', + 'notifier_deleted' => 'Benachrichtigung gelöscht.', + 'notifier_test_ok' => 'Testbenachrichtigung erfolgreich gesendet.', + 'notifier_test_failed' => 'Testbenachrichtigung konnte nicht gesendet werden — URL prüfen.', + + 'reset_title' => 'Zurücksetzen', + 'reset_text' => 'Das Skript reset.sh setzt die Instanz auf einen leeren Zustand zurück (Konten, Feeds, Artikel, Benachrichtigungen), ohne Quellcode, .env oder Docker-Images anzutasten — nützlich, um mit einer sauberen Instanz neu zu starten.', ]; diff --git a/lang/en/app.php b/lang/en/app.php index fafdec0..0dcf721 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -143,4 +143,39 @@ return [ 'source_removed' => 'Source removed.', 'source_invalid' => 'Could not read this source (a valid bloomflux.json file is expected).', 'no_sources' => 'No external sources yet.', + + // Notifiers + 'notifiers' => 'Notifiers', + 'notifiers_lede' => 'Get a Discord alert or a generic webhook call whenever a new article arrives on your feeds. Each user manages their own notifiers.', + 'new_notifier' => 'New notifier', + 'notifier_type' => 'Type', + 'generic_webhook' => 'Generic webhook', + 'notifier_name' => 'Name', + 'notifier_name_placeholder' => 'e.g. Discord #news', + 'notifier_url' => 'Webhook URL', + 'notifier_scope' => 'Scope', + 'scope_all' => 'All my feeds', + 'scope_feeds' => 'Specific feeds', + 'scope_feeds_pick' => 'Choose feeds', + 'scope_feeds_list' => 'Feeds: :feeds', + 'no_feeds_selected' => 'no feed selected', + 'add_notifier' => 'Add notifier', + 'your_notifiers' => 'Your notifiers', + 'no_notifiers_yet' => "You haven't configured any notifier yet.", + 'disabled' => 'disabled', + 'send_test' => 'Test', + 'enable' => 'Enable', + 'disable' => 'Disable', + 'confirm_delete_notifier' => 'Delete this notifier?', + 'edit' => 'Edit', + 'save' => 'Save', + 'last_triggered' => 'triggered :time', + 'notifier_created' => 'Notifier created.', + 'notifier_updated' => 'Notifier updated.', + 'notifier_deleted' => 'Notifier deleted.', + 'notifier_test_ok' => 'Test notification sent successfully.', + 'notifier_test_failed' => 'Failed to send the test notification — check the URL.', + + 'reset_title' => 'Reset', + 'reset_text' => 'The reset.sh script wipes the instance back to a blank state (accounts, feeds, articles, notifiers) without touching the source code, .env or Docker images — useful to start over on a clean instance.', ]; diff --git a/lang/es/app.php b/lang/es/app.php index 2cfb782..7abd201 100644 --- a/lang/es/app.php +++ b/lang/es/app.php @@ -143,4 +143,39 @@ return [ 'source_removed' => 'Fuente eliminada.', 'source_invalid' => 'No se pudo leer esta fuente (se espera un archivo bloomflux.json válido).', 'no_sources' => 'Ninguna fuente externa por ahora.', + + // Notificadores + 'notifiers' => 'Notificadores', + 'notifiers_lede' => 'Recibe una alerta de Discord o una llamada webhook genérica cuando llegue un nuevo artículo a tus fuentes. Cada usuario gestiona sus propios notificadores.', + 'new_notifier' => 'Nuevo notificador', + 'notifier_type' => 'Tipo', + 'generic_webhook' => 'Webhook genérico', + 'notifier_name' => 'Nombre', + 'notifier_name_placeholder' => 'Ej: Discord #noticias', + 'notifier_url' => 'URL del webhook', + 'notifier_scope' => 'Alcance', + 'scope_all' => 'Todas mis fuentes', + 'scope_feeds' => 'Fuentes específicas', + 'scope_feeds_pick' => 'Elegir fuentes', + 'scope_feeds_list' => 'Fuentes: :feeds', + 'no_feeds_selected' => 'ninguna fuente seleccionada', + 'add_notifier' => 'Añadir notificador', + 'your_notifiers' => 'Tus notificadores', + 'no_notifiers_yet' => 'Todavía no has configurado ningún notificador.', + 'disabled' => 'desactivado', + 'send_test' => 'Probar', + 'enable' => 'Activar', + 'disable' => 'Desactivar', + 'confirm_delete_notifier' => '¿Eliminar este notificador?', + 'edit' => 'Editar', + 'save' => 'Guardar', + 'last_triggered' => 'activado :time', + 'notifier_created' => 'Notificador creado.', + 'notifier_updated' => 'Notificador actualizado.', + 'notifier_deleted' => 'Notificador eliminado.', + 'notifier_test_ok' => 'Notificación de prueba enviada con éxito.', + 'notifier_test_failed' => 'No se pudo enviar la notificación de prueba — comprueba la URL.', + + 'reset_title' => 'Reiniciar', + 'reset_text' => 'El script reset.sh restablece la instancia a un estado en blanco (cuentas, fuentes, artículos, notificadores) sin tocar el código fuente, el .env ni las imágenes Docker — útil para empezar de cero.', ]; diff --git a/lang/fr/app.php b/lang/fr/app.php index 870aef7..843e46e 100644 --- a/lang/fr/app.php +++ b/lang/fr/app.php @@ -143,4 +143,39 @@ return [ 'source_removed' => 'Source retirée.', 'source_invalid' => 'Impossible de lire cette source (fichier bloomflux.json valide attendu).', 'no_sources' => 'Aucune source externe pour le moment.', + + // Notifieurs + 'notifiers' => 'Notifieurs', + 'notifiers_lede' => 'Recevez une alerte Discord ou un webhook générique quand un nouvel article arrive sur vos flux. Chaque utilisateur gère ses propres notifieurs.', + 'new_notifier' => 'Nouveau notifieur', + 'notifier_type' => 'Type', + 'generic_webhook' => 'Webhook générique', + 'notifier_name' => 'Nom', + 'notifier_name_placeholder' => 'Ex: Discord #actus', + 'notifier_url' => "URL du webhook", + 'notifier_scope' => 'Portée', + 'scope_all' => 'Tous mes flux', + 'scope_feeds' => 'Flux spécifiques', + 'scope_feeds_pick' => 'Choisir les flux', + 'scope_feeds_list' => 'Flux : :feeds', + 'no_feeds_selected' => 'aucun flux sélectionné', + 'add_notifier' => 'Ajouter le notifieur', + 'your_notifiers' => 'Vos notifieurs', + 'no_notifiers_yet' => "Vous n'avez pas encore configuré de notifieur.", + 'disabled' => 'désactivé', + 'send_test' => 'Tester', + 'enable' => 'Activer', + 'disable' => 'Désactiver', + 'confirm_delete_notifier' => 'Supprimer ce notifieur ?', + 'edit' => 'Modifier', + 'save' => 'Enregistrer', + 'last_triggered' => 'déclenché :time', + 'notifier_created' => 'Notifieur créé.', + 'notifier_updated' => 'Notifieur mis à jour.', + 'notifier_deleted' => 'Notifieur supprimé.', + 'notifier_test_ok' => 'Notification de test envoyée avec succès.', + 'notifier_test_failed' => "Échec de l'envoi de la notification de test — vérifiez l'URL.", + + 'reset_title' => 'Réinitialiser', + 'reset_text' => "Le script reset.sh remet l'instance à zéro (comptes, flux, articles, notifieurs) sans toucher au code, au .env ni aux images Docker — utile pour repartir sur une instance vierge.", ]; diff --git a/public/css/app.css b/public/css/app.css index 6efbc14..63d0336 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -1195,6 +1195,40 @@ h1, h2, h3 { z-index: 30; } +/* ---------- Notifiers ---------- */ + +.notifier-form .form-group { + margin-bottom: var(--space-3); +} + +.notifier-feed-picker { + background: var(--surface-alt); + border-radius: var(--radius-sm); + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: 6px; + max-height: 180px; + overflow-y: auto; +} + +.notifier-feed-picker[hidden] { + display: none; +} + +.notifier-feed-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--font-sm); + font-weight: 400; + cursor: pointer; +} + +.notifier-card summary::-webkit-details-marker { + color: var(--text-muted); +} + /* ---------- Responsive ---------- */ @media (max-width: 720px) { diff --git a/public/js/app.js b/public/js/app.js index 8b8d85b..51ea710 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -83,4 +83,16 @@ applyFilters(); }); }); + + // Notifiers: show/hide the per-feed picker depending on the chosen scope + document.addEventListener('change', function (event) { + var select = event.target.closest('[data-scope-select]'); + if (!select) { + return; + } + var picker = select.closest('form').querySelector('[data-scope-feeds]'); + if (picker) { + picker.hidden = select.value !== 'feeds'; + } + }); })(); diff --git a/reset.sh b/reset.sh new file mode 100755 index 0000000..be5eab6 --- /dev/null +++ b/reset.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Resets a BloomFeed instance to a fresh, empty state: wipes the database and +# application storage, then restarts the stack so the first user to register +# becomes the new owner. Source code, .env and Docker images are untouched — +# use uninstall.sh instead if you want to remove those too. + +APP_DIR="/opt/bloomfeed" +PROJECT_NAME="bloomfeed" + +ASSUME_YES=false + +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=true ;; + --help|-h) + echo "Usage: bash reset.sh [--yes]" + echo " --yes Skip the interactive confirmation" + exit 0 + ;; + *) + echo "Unknown option: $arg (use --help)" + exit 1 + ;; + esac +done + +if [[ ! -d "${APP_DIR}" ]]; then + echo "ERROR: ${APP_DIR} not found — nothing to reset." + exit 1 +fi + +cd "${APP_DIR}" + +echo "WARNING: this will PERMANENTLY delete all data on this instance:" +echo " - the MariaDB database (all accounts, feeds, articles, notifiers)" +echo " - the application storage files" +echo "The source code, .env and Docker images are kept." + +if [[ "${ASSUME_YES}" != true ]]; then + read -r -p "Type RESET to confirm: " CONFIRM + if [[ "${CONFIRM}" != "RESET" ]]; then + echo "Cancelled. Nothing was changed." + exit 0 + fi +fi + +echo "==> Stopping the stack" +sudo docker compose down --remove-orphans + +echo "==> Removing data volumes" +sudo docker volume rm \ + "${PROJECT_NAME}_db_data" \ + "${PROJECT_NAME}_app_storage" \ + "${PROJECT_NAME}_scheduler_storage" \ + 2>/dev/null || true + +echo "==> Starting a fresh stack" +sudo docker compose up -d + +echo "==> Waiting for the database to be ready" +for i in $(seq 1 30); do + if sudo docker compose exec -T db healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then + echo "==> Database ready" + break + fi + echo "==> Database not ready yet, retrying ($i/30)" + sleep 3 +done + +echo "==> Waiting for the entrypoint migrations to finish" +for i in $(seq 1 30); do + if curl -sf -o /dev/null "http://localhost:7081/up"; then + echo "==> Application up" + break + fi + echo "==> Application not ready yet, retrying ($i/30)" + sleep 3 +done + +echo +echo "==> Reset finished. Visit the instance and create the new owner account." diff --git a/resources/views/notifiers.blade.php b/resources/views/notifiers.blade.php new file mode 100644 index 0000000..1ad0cf4 --- /dev/null +++ b/resources/views/notifiers.blade.php @@ -0,0 +1,142 @@ +@extends('layouts.app', ['title' => __('app.notifiers').' — BloomFeed']) + +@section('content') +
+ @if (session('status')) +
{{ session('status') }}
+ @endif + +

{{ __('app.notifiers') }}

+

{{ __('app.notifiers_lede') }}

+ +
+

{{ __('app.new_notifier') }}

+ + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +

{{ __('app.your_notifiers') }}

+ + @forelse ($notifiers as $notifier) +
+
+
+ {{ $notifier->type === 'discord' ? 'Discord' : __('app.generic_webhook') }} + {{ $notifier->name }} + @unless ($notifier->enabled) + ({{ __('app.disabled') }}) + @endunless +
+
+
+ @csrf + +
+
+ @csrf + +
+
+ @csrf + @method('DELETE') + +
+
+
+ +

+ {{ $notifier->scope === 'all' ? __('app.scope_all') : __('app.scope_feeds_list', ['feeds' => $notifier->feeds->map->displayTitle()->join(', ') ?: __('app.no_feeds_selected')]) }} + @if ($notifier->last_triggered_at) + · {{ __('app.last_triggered', ['time' => $notifier->last_triggered_at->diffForHumans()]) }} + @endif + @if ($notifier->last_error) + · {{ $notifier->last_error }} + @endif +

+ +
+ {{ __('app.edit') }} +
+ @csrf + @method('PUT') +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
scope !== 'feeds') hidden @endif> + + @php($subscribed = $notifier->feeds->pluck('id')->all()) + @foreach ($feeds as $feed) + + @endforeach +
+ +
+
+
+ @empty +
{{ __('app.no_notifiers_yet') }}
+ @endforelse +
+@endsection diff --git a/resources/views/partials/navbar.blade.php b/resources/views/partials/navbar.blade.php index 102e7ce..74fdb35 100644 --- a/resources/views/partials/navbar.blade.php +++ b/resources/views/partials/navbar.blade.php @@ -7,6 +7,7 @@ {{ __('app.articles') }} {{ __('app.catalog') }} {{ __('app.my_feeds') }} + {{ __('app.notifiers') }} @if (auth()->user()?->is_owner) {{ __('app.settings') }} @endif diff --git a/resources/views/self-hosting.blade.php b/resources/views/self-hosting.blade.php index 1a8c314..72f945e 100644 --- a/resources/views/self-hosting.blade.php +++ b/resources/views/self-hosting.blade.php @@ -47,6 +47,12 @@ cd bloomfeed
bash scriptsite.sh
+
+

{{ __('app.reset_title') }}

+

{{ __('app.reset_text') }}

+
bash reset.sh
+
+

{{ __('app.uninstall_title') }}

{{ __('app.uninstall_text') }}

diff --git a/routes/web.php b/routes/web.php index a63c87b..59eab71 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\CatalogController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\FeedController; +use App\Http\Controllers\NotifierController; use App\Http\Controllers\PageController; use App\Http\Controllers\SettingsController; use Illuminate\Support\Facades\Route; @@ -33,6 +34,13 @@ Route::middleware('auth')->group(function () { Route::post('/settings/sources', [SettingsController::class, 'addCatalogSource'])->name('settings.addSource'); Route::delete('/settings/sources', [SettingsController::class, 'removeCatalogSource'])->name('settings.removeSource'); + Route::get('/notifiers', [NotifierController::class, 'index'])->name('notifiers'); + Route::post('/notifiers', [NotifierController::class, 'store'])->name('notifiers.store'); + Route::put('/notifiers/{notifier}', [NotifierController::class, 'update'])->name('notifiers.update'); + Route::post('/notifiers/{notifier}/toggle', [NotifierController::class, 'toggle'])->name('notifiers.toggle'); + Route::post('/notifiers/{notifier}/test', [NotifierController::class, 'test'])->name('notifiers.test'); + Route::delete('/notifiers/{notifier}', [NotifierController::class, 'destroy'])->name('notifiers.destroy'); + Route::get('/feeds', [FeedController::class, 'index'])->name('feeds.index'); Route::post('/feeds', [FeedController::class, 'store'])->name('feeds.store'); Route::delete('/feeds/{feed}', [FeedController::class, 'destroy'])->name('feeds.destroy');