BloomFeed Addons v0.22
This commit is contained in:
@@ -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.
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Catalogue de flux | Mode sombre |
|
||||
| Catalogue de flux | Notifieurs |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Mode sombre |
|
||||
|---|
|
||||
|  |
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Notifier;
|
||||
use App\Services\NotifierDispatchService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotifierController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$notifiers = $request->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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Fillable(['type', 'name', 'url', 'scope', 'enabled', 'last_triggered_at', 'last_error'])]
|
||||
class Notifier extends Model
|
||||
{
|
||||
public const TYPE_DISCORD = 'discord';
|
||||
|
||||
public const TYPE_WEBHOOK = 'webhook';
|
||||
|
||||
public const SCOPE_ALL = 'all';
|
||||
|
||||
public const SCOPE_FEEDS = 'feeds';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => '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));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Notifier;
|
||||
use App\Models\User;
|
||||
|
||||
class NotifierPolicy
|
||||
{
|
||||
public function update(User $user, Notifier $notifier): bool
|
||||
{
|
||||
return $user->id === $notifier->user_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Notifier $notifier): bool
|
||||
{
|
||||
return $user->id === $notifier->user_id;
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Notifier;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class NotifierDispatchService
|
||||
{
|
||||
/**
|
||||
* Notify every enabled notifier of the article's owner that matches this
|
||||
* article's feed (scope "all", or scope "feeds" including this feed).
|
||||
*/
|
||||
public function notifyNewArticle(Article $article): void
|
||||
{
|
||||
$feed = $article->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.'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifiers', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Table name follows Laravel's belongsToMany convention: alphabetical
|
||||
// order of the two model names ("feed" < "notifier").
|
||||
Schema::create('feed_notifier', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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.",
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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."
|
||||
@@ -0,0 +1,142 @@
|
||||
@extends('layouts.app', ['title' => __('app.notifiers').' — BloomFeed'])
|
||||
|
||||
@section('content')
|
||||
<div class="main-column" style="width:100%; max-width:760px; margin:0 auto;">
|
||||
@if (session('status'))
|
||||
<div class="form-status">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<h2 style="margin:0;">{{ __('app.notifiers') }}</h2>
|
||||
<p class="text-muted text-sm" style="margin:4px 0 0;">{{ __('app.notifiers_lede') }}</p>
|
||||
|
||||
<div class="card mt-2">
|
||||
<h3>{{ __('app.new_notifier') }}</h3>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="form-error mt-1">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('notifiers.store') }}" class="notifier-form mt-2">
|
||||
@csrf
|
||||
<div class="form-group">
|
||||
<label for="new-type">{{ __('app.notifier_type') }}</label>
|
||||
<select name="type" id="new-type" class="form-input">
|
||||
<option value="discord">Discord</option>
|
||||
<option value="webhook">{{ __('app.generic_webhook') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-name">{{ __('app.notifier_name') }}</label>
|
||||
<input type="text" name="name" id="new-name" class="form-input" placeholder="{{ __('app.notifier_name_placeholder') }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-url">{{ __('app.notifier_url') }}</label>
|
||||
<input type="url" name="url" id="new-url" class="form-input" placeholder="https://discord.com/api/webhooks/…" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-scope">{{ __('app.notifier_scope') }}</label>
|
||||
<select name="scope" id="new-scope" class="form-input" data-scope-select>
|
||||
<option value="all">{{ __('app.scope_all') }}</option>
|
||||
<option value="feeds">{{ __('app.scope_feeds') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group notifier-feed-picker" data-scope-feeds hidden>
|
||||
<label>{{ __('app.scope_feeds_pick') }}</label>
|
||||
@forelse ($feeds as $feed)
|
||||
<label class="notifier-feed-checkbox">
|
||||
<input type="checkbox" name="feed_ids[]" value="{{ $feed->id }}">
|
||||
{{ $feed->displayTitle() }}
|
||||
</label>
|
||||
@empty
|
||||
<p class="text-muted text-sm">{{ __('app.no_feeds_yet') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ __('app.add_notifier') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-3">{{ __('app.your_notifiers') }}</h3>
|
||||
|
||||
@forelse ($notifiers as $notifier)
|
||||
<div class="card notifier-card">
|
||||
<div class="flex-between">
|
||||
<div>
|
||||
<span class="pill">{{ $notifier->type === 'discord' ? 'Discord' : __('app.generic_webhook') }}</span>
|
||||
<strong style="margin-left:8px;">{{ $notifier->name }}</strong>
|
||||
@unless ($notifier->enabled)
|
||||
<span class="text-muted text-sm">({{ __('app.disabled') }})</span>
|
||||
@endunless
|
||||
</div>
|
||||
<div class="flex" style="gap:8px;">
|
||||
<form method="POST" action="{{ route('notifiers.test', $notifier) }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ __('app.send_test') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('notifiers.toggle', $notifier) }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ $notifier->enabled ? __('app.disable') : __('app.enable') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('notifiers.destroy', $notifier) }}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-danger btn-sm" data-confirm="{{ __('app.confirm_delete_notifier') }}">{{ __('app.delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted text-sm mt-1">
|
||||
{{ $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)
|
||||
· <span style="color:var(--danger);">{{ $notifier->last_error }}</span>
|
||||
@endif
|
||||
</p>
|
||||
|
||||
<details class="mt-1">
|
||||
<summary class="text-sm text-muted" style="cursor:pointer;">{{ __('app.edit') }}</summary>
|
||||
<form method="POST" action="{{ route('notifiers.update', $notifier) }}" class="notifier-form mt-2">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div class="form-group">
|
||||
<label>{{ __('app.notifier_type') }}</label>
|
||||
<select name="type" class="form-input">
|
||||
<option value="discord" @selected($notifier->type === 'discord')>Discord</option>
|
||||
<option value="webhook" @selected($notifier->type === 'webhook')>{{ __('app.generic_webhook') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ __('app.notifier_name') }}</label>
|
||||
<input type="text" name="name" class="form-input" value="{{ $notifier->name }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ __('app.notifier_url') }}</label>
|
||||
<input type="url" name="url" class="form-input" value="{{ $notifier->url }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ __('app.notifier_scope') }}</label>
|
||||
<select name="scope" class="form-input" data-scope-select>
|
||||
<option value="all" @selected($notifier->scope === 'all')>{{ __('app.scope_all') }}</option>
|
||||
<option value="feeds" @selected($notifier->scope === 'feeds')>{{ __('app.scope_feeds') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group notifier-feed-picker" data-scope-feeds @if ($notifier->scope !== 'feeds') hidden @endif>
|
||||
<label>{{ __('app.scope_feeds_pick') }}</label>
|
||||
@php($subscribed = $notifier->feeds->pluck('id')->all())
|
||||
@foreach ($feeds as $feed)
|
||||
<label class="notifier-feed-checkbox">
|
||||
<input type="checkbox" name="feed_ids[]" value="{{ $feed->id }}" @checked(in_array($feed->id, $subscribed))>
|
||||
{{ $feed->displayTitle() }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ __('app.save') }}</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
@empty
|
||||
<div class="card empty-state">{{ __('app.no_notifiers_yet') }}</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endsection
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'active' : '' }}">{{ __('app.articles') }}</a>
|
||||
<a href="{{ route('catalog') }}" class="{{ request()->routeIs('catalog') ? 'active' : '' }}">{{ __('app.catalog') }}</a>
|
||||
<a href="{{ route('feeds.index') }}" class="{{ request()->routeIs('feeds.*') ? 'active' : '' }}">{{ __('app.my_feeds') }}</a>
|
||||
<a href="{{ route('notifiers') }}" class="{{ request()->routeIs('notifiers') ? 'active' : '' }}">{{ __('app.notifiers') }}</a>
|
||||
@if (auth()->user()?->is_owner)
|
||||
<a href="{{ route('settings') }}" class="{{ request()->routeIs('settings') ? 'active' : '' }}">{{ __('app.settings') }}</a>
|
||||
@endif
|
||||
|
||||
@@ -47,6 +47,12 @@ cd bloomfeed</pre>
|
||||
<pre class="code-block">bash scriptsite.sh</pre>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<h2>{{ __('app.reset_title') }}</h2>
|
||||
<p>{{ __('app.reset_text') }}</p>
|
||||
<pre class="code-block">bash reset.sh</pre>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<h2>{{ __('app.uninstall_title') }}</h2>
|
||||
<p>{{ __('app.uninstall_text') }}</p>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user