First Commit

This commit is contained in:
Esteban
2026-07-03 16:27:34 +02:00
commit 7a7440daab
8955 changed files with 1117958 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
FROM php:8.4-apache
WORKDIR /var/www/html
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libzip-dev \
libonig-dev \
libxml2-dev \
default-mysql-client \
&& docker-php-ext-install mysqli pdo pdo_mysql zip \
&& a2enmod rewrite \
&& rm -rf /var/lib/apt/lists/*
COPY . /var/www/html/
COPY docker/000-default.conf /etc/apache2/sites-available/000-default.conf
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENV COMPOSER_MEMORY_LIMIT=-1
RUN composer install --no-dev --optimize-autoloader --no-interaction
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
&& chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+83
View File
@@ -0,0 +1,83 @@
<p align="center"><img src="public/images/logo.png" width="360" alt="BloomFeed"></p>
<p align="center">
Un agrégateur RSS personnel et auto-hébergé, avec lecture approfondie intégrée.
</p>
<p align="center"><img src="docs/screenshots/home-light.png" width="800" alt="Page d'accueil de BloomFeed"></p>
## Qu'est-ce que BloomFeed ?
BloomFeed centralise vos flux RSS/Atom préférés dans une interface simple. Quand un article vous
intéresse, BloomFeed récupère la page source, en extrait le contenu principal et l'affiche
directement dans l'application — plus besoin d'ouvrir le site d'origine.
BloomFeed est pensé pour être **auto-hébergé par une seule personne ou un foyer** : une instance
n'a qu'un seul compte propriétaire, créé une seule fois au premier lancement.
## Fonctionnalités
- Ajout de flux RSS/Atom, suivi des non-lus, favoris.
- Lecture approfondie intégrée : extraction du contenu principal (via
[Readability](https://github.com/fivefilters/readability.php)) converti en Markdown, sans
quitter BloomFeed.
- Récupération périodique des flux via une tâche planifiée.
- Thème clair/sombre.
- Inscription fermée après la création du compte propriétaire — pas de SaaS ouvert.
## Aperçu
| Tableau de bord | Lecteur intégré |
|---|---|
| ![Tableau de bord](docs/screenshots/dashboard-light.png) | ![Lecteur intégré](docs/screenshots/reader-light.png) |
| Mode sombre | Connexion |
|---|---|
| ![Mode sombre](docs/screenshots/dashboard-dark.png) | ![Connexion](docs/screenshots/login-light.png) |
## Stack technique
- [Laravel 13](https://laravel.com) (PHP 8.4)
- MariaDB
- CSS/JS écrits à la main, sans étape de build (pas de Node/Vite/Tailwind)
- Docker Compose pour le déploiement
## Développement local
Prérequis : PHP 8.3+ et [Composer](https://getcomposer.org).
```bash
composer install
cp .env.example .env
# Passer DB_CONNECTION=sqlite dans .env pour un dev local sans MariaDB
touch database/database.sqlite
php artisan key:generate
php artisan migrate
php artisan serve
```
## Auto-hébergement
BloomFeed est fait pour tourner sur votre propre serveur via Docker Compose (application,
planificateur, MariaDB). Voir la page **[/self-hosting](/self-hosting)** une fois
l'application lancée, ou directement :
```bash
git clone https://git.eldorianet.work/esteban/RSS-Eldoria-Network bloomfeed
cd bloomfeed
bash scriptsite.sh
```
Le script crée automatiquement un `.env` avec des secrets générés aléatoirement s'il n'existe
pas encore, construit les images et démarre la stack. L'application écoute par défaut sur le
port `7081`.
Pour désinstaller proprement (avec confirmation avant toute suppression de données) :
```bash
bash uninstall.sh
```
## Licence
MIT.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use App\Models\Feed;
use App\Services\FeedFetcherService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
#[Signature('feeds:fetch {--feed=* : Limit to specific feed ID(s)} {--user=* : Limit to a user\'s feeds}')]
#[Description('Fetch RSS/Atom feeds and store new articles')]
class FetchFeeds extends Command
{
public function handle(FeedFetcherService $service): int
{
$query = Feed::query();
if ($feedIds = $this->option('feed')) {
$query->whereIn('id', $feedIds);
} elseif ($userIds = $this->option('user')) {
$query->whereIn('user_id', $userIds);
}
$feeds = $query->get();
$totalNew = 0;
foreach ($feeds as $feed) {
try {
$result = $service->fetchFeed($feed);
$totalNew += $result['new'];
if ($result['error'] !== null) {
$this->warn("[{$feed->id}] {$feed->url} : {$result['error']}");
} else {
$this->info("[{$feed->id}] {$feed->url} : {$result['new']} nouvel(aux) article(s)");
}
} catch (Throwable $e) {
$this->error("[{$feed->id}] {$feed->url} : {$e->getMessage()}");
}
}
Log::info('feeds:fetch completed', ['feeds' => $feeds->count(), 'new_articles' => $totalNew]);
return self::SUCCESS;
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use App\Services\ArticleReaderService;
use App\Support\MarkdownRenderer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ArticleController extends Controller
{
public function show(Article $article, ArticleReaderService $reader): View
{
$this->authorize('view', $article);
if (! $article->is_read) {
$article->update(['is_read' => true]);
}
if ($article->full_content_markdown === null && $article->full_content_error === null) {
$reader->fetchFullContent($article);
}
return view('articles.show', [
'article' => $article,
'contentHtml' => MarkdownRenderer::toHtml($article->full_content_markdown),
]);
}
public function retry(Article $article, ArticleReaderService $reader): RedirectResponse
{
$this->authorize('update', $article);
$article->full_content_error = null;
$article->save();
$reader->fetchFullContent($article);
return redirect()->route('articles.show', $article);
}
public function markRead(Article $article): JsonResponse|RedirectResponse
{
$this->authorize('update', $article);
$article->update(['is_read' => true]);
return request()->wantsJson()
? response()->json(['is_read' => true])
: back();
}
public function toggleFavorite(Article $article): JsonResponse|RedirectResponse
{
$this->authorize('update', $article);
$article->update(['is_favorite' => ! $article->is_favorite]);
return request()->wantsJson()
? response()->json(['is_favorite' => $article->is_favorite])
: back();
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class AuthController extends Controller
{
public function showRegister(): View|RedirectResponse
{
if (User::query()->exists()) {
return redirect()->route('login')
->with('status', 'Les inscriptions sont fermées sur cette instance. Connectez-vous.');
}
return view('auth.register');
}
public function register(Request $request): RedirectResponse
{
if (User::query()->exists()) {
return redirect()->route('login')
->with('status', 'Les inscriptions sont fermées sur cette instance. Connectez-vous.');
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', 'min:8'],
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('dashboard');
}
public function showLogin(): View
{
return view('auth.login');
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
return back()->withErrors([
'email' => 'Ces identifiants ne correspondent à aucun compte.',
])->onlyInput('email');
}
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(Request $request): View
{
$user = $request->user();
$query = Article::forUser($user->id)->with('feed');
if ($request->boolean('unread')) {
$query->unread();
}
if ($feedId = $request->integer('feed')) {
$query->where('feed_id', $feedId);
}
$articles = $query->orderByDesc('published_at')->paginate(20)->withQueryString();
$feeds = $user->feeds()->withCount(['articles as unread_count' => fn ($q) => $q->unread()])
->orderBy('title')
->get();
return view('dashboard', [
'articles' => $articles,
'feeds' => $feeds,
]);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use App\Models\Feed;
use App\Services\FeedFetcherService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FeedController extends Controller
{
public function index(Request $request): View
{
$feeds = $request->user()->feeds()->withCount('articles')->orderBy('title')->get();
return view('feeds.index', ['feeds' => $feeds]);
}
public function store(Request $request, FeedFetcherService $service): RedirectResponse
{
$validated = $request->validate([
'url' => ['required', 'url', 'max:2048'],
'title' => ['nullable', 'string', 'max:255'],
]);
$feed = $request->user()->feeds()->create([
'url' => $validated['url'],
'title' => $validated['title'] ?? null,
]);
$result = $service->fetchFeed($feed);
if ($result['error'] !== null) {
return back()->with('status', "Flux ajouté, mais la première récupération a échoué : {$result['error']}");
}
return back()->with('status', "Flux ajouté avec {$result['new']} article(s).");
}
public function destroy(Feed $feed): RedirectResponse
{
$this->authorize('delete', $feed);
$feed->delete();
return back()->with('status', 'Flux supprimé.');
}
public function refresh(Feed $feed, FeedFetcherService $service): RedirectResponse
{
$this->authorize('update', $feed);
$result = $service->fetchFeed($feed);
if ($result['error'] !== null) {
return back()->with('status', "Échec de la récupération : {$result['error']}");
}
return back()->with('status', "{$result['new']} nouvel(aux) article(s).");
}
public function refreshAll(Request $request, FeedFetcherService $service): RedirectResponse
{
$feeds = $request->user()->feeds;
$totalNew = 0;
foreach ($feeds as $feed) {
$totalNew += $service->fetchFeed($feed)['new'];
}
return back()->with('status', "{$totalNew} nouvel(aux) article(s) sur {$feeds->count()} flux.");
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class PageController extends Controller
{
public function home(): View|RedirectResponse
{
if (auth()->check()) {
return redirect()->route('dashboard');
}
return view('home', [
'hasOwner' => User::query()->exists(),
]);
}
public function selfHosting(): View
{
return view('self-hosting');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?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;
#[Fillable([
'guid', 'title', 'link', 'excerpt', 'content', 'published_at', 'is_read', 'is_favorite',
'full_content_markdown', 'full_content_fetched_at', 'full_content_error',
])]
class Article extends Model
{
protected function casts(): array
{
return [
'published_at' => 'datetime',
'is_read' => 'boolean',
'is_favorite' => 'boolean',
'full_content_fetched_at' => 'datetime',
];
}
public function feed(): BelongsTo
{
return $this->belongsTo(Feed::class);
}
public function scopeForUser(Builder $query, int $userId): Builder
{
return $query->whereHas('feed', fn (Builder $q) => $q->where('user_id', $userId));
}
public function scopeUnread(Builder $query): Builder
{
return $query->where('is_read', false);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['url', 'title', 'site_title', 'last_fetched_at', 'last_fetch_status'])]
class Feed extends Model
{
protected function casts(): array
{
return [
'last_fetched_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
public function displayTitle(): string
{
return $this->title ?: ($this->site_title ?: $this->url);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
public function feeds(): HasMany
{
return $this->hasMany(Feed::class);
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\Article;
use App\Models\User;
class ArticlePolicy
{
public function view(User $user, Article $article): bool
{
return $user->id === $article->feed->user_id;
}
public function update(User $user, Article $article): bool
{
return $user->id === $article->feed->user_id;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Policies;
use App\Models\Feed;
use App\Models\User;
class FeedPolicy
{
public function view(User $user, Feed $feed): bool
{
return $user->id === $feed->user_id;
}
public function update(User $user, Feed $feed): bool
{
return $user->id === $feed->user_id;
}
public function delete(User $user, Feed $feed): bool
{
return $user->id === $feed->user_id;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
if (str_starts_with((string) config('app.url'), 'https://')) {
URL::forceScheme('https');
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Services;
use App\Models\Article;
use fivefilters\Readability\Configuration as ReadabilityConfig;
use fivefilters\Readability\Readability;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
use RuntimeException;
use Throwable;
class ArticleReaderService
{
public function fetchFullContent(Article $article): void
{
try {
$response = Http::timeout(20)
->withHeaders(['User-Agent' => 'BloomFeed/1.0 (+https://bloomfeed.local)'])
->get($article->link);
if ($response->failed()) {
throw new RuntimeException("HTTP {$response->status()}");
}
$config = new ReadabilityConfig();
$config->setFixRelativeURLs(true);
$config->setOriginalURL($article->link);
$config->setSubstituteEntities(true);
$readability = new Readability($config);
$readability->parse($response->body());
$extracted = $readability->getContent();
if (! $extracted) {
throw new RuntimeException("Impossible d'extraire le contenu de cette page.");
}
$converter = new HtmlConverter([
'strip_tags' => true,
'remove_nodes' => 'script style noscript iframe form nav footer',
'hard_break' => true,
]);
$markdown = trim($converter->convert($extracted));
if ($markdown === '') {
throw new RuntimeException('Le contenu extrait est vide.');
}
$article->full_content_markdown = $markdown;
$article->full_content_fetched_at = now();
$article->full_content_error = null;
$article->save();
} catch (Throwable $e) {
$article->full_content_error = Str::limit($e->getMessage(), 250, '');
$article->full_content_fetched_at = now();
$article->save();
}
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Services;
use App\Models\Article;
use App\Models\Feed;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
class FeedFetcherService
{
/**
* Fetch, parse and store new articles for a single feed.
*
* @return array{new: int, error: string|null}
*/
public function fetchFeed(Feed $feed): array
{
try {
$response = Http::timeout(15)
->withHeaders(['User-Agent' => 'BloomFeed/1.0 (+https://bloomfeed.local)'])
->get($feed->url);
if ($response->failed()) {
throw new RuntimeException("HTTP {$response->status()}");
}
$items = $this->parse($response->body(), $feed);
} catch (Throwable $e) {
$feed->last_fetch_status = 'error: '.Str::limit($e->getMessage(), 200, '');
$feed->last_fetched_at = now();
$feed->save();
return ['new' => 0, 'error' => $e->getMessage()];
}
$newCount = 0;
foreach ($items as $item) {
$key = $item['guid'] !== '' ? $item['guid'] : $item['link'];
if ($key === '') {
continue;
}
$article = Article::firstOrNew([
'feed_id' => $feed->id,
'guid' => Str::limit($key, 512, ''),
]);
if ($article->exists) {
continue;
}
$article->fill([
'title' => Str::limit($item['title'] !== '' ? $item['title'] : '(Sans titre)', 512, ''),
'link' => Str::limit($item['link'], 2048, ''),
'excerpt' => $item['excerpt'],
'content' => $item['content'],
'published_at' => $item['published_at'],
]);
$article->feed()->associate($feed);
$article->save();
$newCount++;
}
$feed->last_fetched_at = now();
$feed->last_fetch_status = 'ok';
$feed->save();
return ['new' => $newCount, 'error' => null];
}
/**
* Parse an RSS 2.0 or Atom feed body into a normalized list of items.
* May set $feed->site_title as a side effect (persisted by the caller).
*
* @return list<array{guid: string, title: string, link: string, excerpt: string, content: string, published_at: ?Carbon}>
*/
private function parse(string $xml, Feed $feed): array
{
libxml_use_internal_errors(true);
$doc = simplexml_load_string($xml);
libxml_clear_errors();
if ($doc === false) {
throw new RuntimeException('Flux XML invalide.');
}
$namespaces = $doc->getNamespaces(true);
$items = [];
if (isset($doc->channel)) {
$channel = $doc->channel;
if ($feed->site_title === null && isset($channel->title)) {
$feed->site_title = Str::limit((string) $channel->title, 255, '');
}
foreach ($channel->item as $item) {
$content = isset($namespaces['content']) ? $item->children($namespaces['content']) : null;
$dc = isset($namespaces['dc']) ? $item->children($namespaces['dc']) : null;
$description = (string) ($item->description ?? '');
$items[] = [
'guid' => trim((string) ($item->guid ?? $item->link ?? '')),
'title' => trim((string) ($item->title ?? '')),
'link' => trim((string) ($item->link ?? '')),
'excerpt' => $this->cleanExcerpt($description),
'content' => ($content !== null && isset($content->encoded)) ? (string) $content->encoded : $description,
'published_at' => $this->parseDate((string) ($item->pubDate ?? ($dc->date ?? ''))),
];
}
} elseif ($doc->getName() === 'feed') {
if ($feed->site_title === null && isset($doc->title)) {
$feed->site_title = Str::limit((string) $doc->title, 255, '');
}
foreach ($doc->entry as $entry) {
$link = $this->extractAtomLink($entry);
$summary = (string) ($entry->summary ?? '');
$content = (string) ($entry->content ?? '');
$body = $content !== '' ? $content : $summary;
$items[] = [
'guid' => trim((string) ($entry->id ?? $link)),
'title' => trim((string) ($entry->title ?? '')),
'link' => $link,
'excerpt' => $this->cleanExcerpt($summary !== '' ? $summary : $body),
'content' => $body,
'published_at' => $this->parseDate((string) ($entry->published ?? ($entry->updated ?? ''))),
];
}
} else {
throw new RuntimeException('Format de flux non reconnu (ni RSS ni Atom).');
}
return $items;
}
private function extractAtomLink(SimpleXMLElement $entry): string
{
foreach ($entry->link as $link) {
$attrs = $link->attributes();
$rel = isset($attrs['rel']) ? (string) $attrs['rel'] : 'alternate';
if ($rel === 'alternate') {
return (string) ($attrs['href'] ?? '');
}
}
$first = $entry->link[0] ?? null;
return $first !== null ? (string) ($first->attributes()['href'] ?? '') : '';
}
private function cleanExcerpt(string $html): string
{
return Str::limit(trim(strip_tags($html)), 500);
}
private function parseDate(string $date): ?Carbon
{
if ($date === '') {
return null;
}
try {
return Carbon::parse($date);
} catch (Throwable) {
return null;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Support;
use League\CommonMark\CommonMarkConverter;
class MarkdownRenderer
{
public static function toHtml(?string $markdown): string
{
if (! $markdown) {
return '';
}
static $converter;
// html_input=strip: the source markdown comes from scraped third-party pages
// (or LLM output echoing back scraped content), so raw HTML must never be
// passed through as-is.
$converter ??= new CommonMarkConverter([
'html_input' => 'strip',
'max_nesting_level' => 100,
]);
return (string) $converter->convert($markdown);
}
}
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
BIN
View File
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "eldoria/bloomfeed",
"type": "project",
"description": "BloomFeed - agrégateur RSS personnel avec résumés IA.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"fivefilters/readability.php": "^3.3",
"laravel/framework": "^13.8",
"laravel/tinker": "^3.0",
"league/commonmark": "^2.8",
"league/html-to-markdown": "^5.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "8.4.1"
},
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated
+8519
View File
File diff suppressed because it is too large Load Diff
Executable
BIN
View File
Binary file not shown.
+126
View File
@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+136
View File
@@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];
+184
View File
@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+132
View File
@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
+38
View File
@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];
+233
View File
@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];
+1
View File
@@ -0,0 +1 @@
*.sqlite*
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -0,0 +1,49 @@
<?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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?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('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,59 @@
<?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('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,35 @@
<?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('feeds', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('url', 2048);
$table->string('title')->nullable();
$table->string('site_title')->nullable();
$table->timestamp('last_fetched_at')->nullable();
$table->string('last_fetch_status')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('feeds');
}
};
@@ -0,0 +1,41 @@
<?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('articles', function (Blueprint $table) {
$table->id();
$table->foreignId('feed_id')->constrained()->cascadeOnDelete();
$table->string('guid', 512);
$table->string('title', 512)->default('');
$table->string('link', 2048);
$table->text('excerpt')->nullable();
$table->longText('content')->nullable();
$table->text('ai_summary')->nullable();
$table->timestamp('published_at')->nullable();
$table->boolean('is_read')->default(false);
$table->boolean('is_favorite')->default(false);
$table->timestamps();
$table->unique(['feed_id', 'guid']);
$table->index(['feed_id', 'published_at']);
$table->index(['feed_id', 'is_read']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('articles');
}
};
@@ -0,0 +1,36 @@
<?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::table('articles', function (Blueprint $table) {
$table->dropColumn('ai_summary');
$table->longText('full_content_markdown')->nullable();
$table->timestamp('full_content_fetched_at')->nullable();
$table->string('full_content_error')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->text('ai_summary')->nullable();
$table->dropColumn([
'full_content_markdown',
'full_content_fetched_at',
'full_content_error',
]);
});
}
};
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}
+62
View File
@@ -0,0 +1,62 @@
services:
app:
build: .
container_name: bloomfeed
ports:
- "7081:80"
env_file:
- .env
environment:
DB_HOST: db
DB_PORT: 3306
volumes:
- app_storage:/var/www/html/storage
depends_on:
db:
condition: service_healthy
networks: [bloomfeed]
restart: unless-stopped
scheduler:
build: .
container_name: bloomfeed-scheduler
entrypoint: ["sh", "-c"]
command: ["while true; do php artisan schedule:run --no-interaction; sleep 60; done"]
env_file:
- .env
environment:
DB_HOST: db
DB_PORT: 3306
volumes:
- scheduler_storage:/var/www/html/storage
depends_on:
db:
condition: service_healthy
networks: [bloomfeed]
restart: unless-stopped
db:
image: mariadb:11
container_name: bloomfeed-db
environment:
MARIADB_DATABASE: ${DB_DATABASE:-bloomfeed}
MARIADB_USER: ${DB_USERNAME:-bloomfeed}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- db_data:/var/lib/mysql
networks: [bloomfeed]
restart: unless-stopped
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
networks:
bloomfeed: {}
volumes:
app_storage: {}
scheduler_storage: {}
db_data: {}
+11
View File
@@ -0,0 +1,11 @@
<VirtualHost *:80>
DocumentRoot /var/www/html/public
<Directory /var/www/html/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
echo "==> Attente de la base de données (${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"
break
fi
echo "==> DB indisponible, nouvelle tentative dans 2s ($i/30)"
sleep 2
done
echo "==> Exécution des migrations"
php artisan migrate --force
echo "==> Démarrage d'Apache"
exec apache2-foreground
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
+1024
View File
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
:root {
/* Palette — jaune moderne (tournesol) */
--sun: #f0a90a;
--sun-deep: #b07908;
--sun-bright: #fbc02d;
--sun-glow: rgba(240, 169, 10, 0.35);
--bg: #faf9f6;
--bg-elevated: #ffffff;
--surface: #ffffff;
--surface-hover: #fdfaf1;
--surface-alt: #f4f0e4;
--border: #eae4d2;
--border-strong: #d9cfb2;
--text: #1d1a12;
--text-muted: #6f6752;
--text-faint: #a09879;
--accent: var(--sun);
--accent-strong: #8f6206;
--accent-contrast: #241c04;
--accent-soft: #fdf3d7;
--accent-soft-strong: #f7e2a4;
--gold: #f0a90a;
--green: #3f8a5c;
--green-soft: #e3f1e7;
--danger: #c1462f;
--danger-soft: #fbe6e1;
/* Géométrie */
--radius-sm: 10px;
--radius: 16px;
--radius-lg: 24px;
--radius-full: 999px;
/* Ombres */
--shadow-sm: 0 1px 2px rgba(29, 26, 18, 0.05), 0 1px 4px rgba(29, 26, 18, 0.04);
--shadow-md: 0 8px 24px rgba(29, 26, 18, 0.09), 0 2px 8px rgba(29, 26, 18, 0.05);
--shadow-lg: 0 24px 64px rgba(29, 26, 18, 0.16), 0 8px 24px rgba(29, 26, 18, 0.08);
--shadow-accent: 0 8px 28px var(--sun-glow);
--ring: 0 0 0 4px rgba(240, 169, 10, 0.20);
/* Dégradés */
--gradient-accent: linear-gradient(135deg, #f0a90a 0%, #fbc02d 55%, #d18e07 100%);
--gradient-hero:
radial-gradient(110% 130% at 12% -8%, rgba(240, 169, 10, 0.13) 0%, transparent 55%),
radial-gradient(90% 110% at 98% 4%, rgba(63, 138, 92, 0.08) 0%, transparent 55%);
--gradient-text: linear-gradient(120deg, #b07908, #f0a90a 45%, #8f6206 90%);
--navbar-glass: rgba(255, 255, 255, 0.72);
/* Typo */
--font-xs: 0.75rem;
--font-sm: 0.8125rem;
--font-base: 0.9375rem;
--font-md: 1.0625rem;
--font-lg: 1.3125rem;
--font-xl: 1.75rem;
--font-2xl: 2.375rem;
--font-3xl: 3.25rem;
/* Espacement */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 80px;
/* Mouvement */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--t-fast: 150ms var(--ease-out);
--t-base: 280ms var(--ease-out);
--t-slow: 500ms var(--ease-out);
}
[data-theme="dark"] {
--sun-bright: #ffd54f;
--sun-glow: rgba(251, 192, 45, 0.28);
--bg: #141310;
--bg-elevated: #1d1b15;
--surface: #1d1b15;
--surface-hover: #26231a;
--surface-alt: #221f17;
--border: #383321;
--border-strong: #4b4429;
--text: #f4f1e6;
--text-muted: #b3a884;
--text-faint: #7d7660;
--accent: #fbc02d;
--accent-strong: #ffd54f;
--accent-contrast: #241c04;
--accent-soft: #352c10;
--accent-soft-strong: #4b3d14;
--gold: #fbc02d;
--green: #6fbb8d;
--green-soft: #1c2c22;
--danger: #e2725a;
--danger-soft: #3a231d;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 10px 28px rgba(0, 0, 0, 0.5), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 28px 72px rgba(0, 0, 0, 0.6), 0 10px 28px rgba(0, 0, 0, 0.4);
--shadow-accent: 0 8px 28px var(--sun-glow);
--ring: 0 0 0 4px rgba(251, 192, 45, 0.22);
--gradient-accent: linear-gradient(135deg, #e8a812 0%, #fbc02d 55%, #c78f0a 100%);
--gradient-hero:
radial-gradient(110% 130% at 12% -8%, rgba(251, 192, 45, 0.10) 0%, transparent 55%),
radial-gradient(90% 110% at 98% 4%, rgba(111, 187, 141, 0.07) 0%, transparent 55%);
--gradient-text: linear-gradient(120deg, #fbc02d, #ffe082 45%, #f0a90a 90%);
--navbar-glass: rgba(20, 19, 16, 0.72);
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

+20
View File
@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
+34
View File
@@ -0,0 +1,34 @@
(function () {
function csrfToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function post(url) {
return fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'Accept': 'application/json',
},
});
}
document.addEventListener('click', function (event) {
var favoriteBtn = event.target.closest('[data-favorite-url]');
if (favoriteBtn) {
event.preventDefault();
post(favoriteBtn.getAttribute('data-favorite-url')).then(function (res) {
return res.json();
}).then(function (data) {
favoriteBtn.classList.toggle('is-favorite', !!data.is_favorite);
});
return;
}
var deleteForm = event.target.closest('[data-confirm]');
if (deleteForm && !confirm(deleteForm.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
})();
+21
View File
@@ -0,0 +1,21 @@
(function () {
var STORAGE_KEY = 'bloomfeed-theme';
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.querySelector('[data-theme-toggle]');
if (!toggle) {
return;
}
toggle.addEventListener('click', function () {
var current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
var next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
localStorage.setItem(STORAGE_KEY, next);
});
});
})();
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.app', ['title' => $article->title.' — BloomFeed'])
@section('content')
<div class="reader-shell">
@if (session('status'))
<div class="form-status">{{ session('status') }}</div>
@endif
<a href="{{ url()->previous(route('dashboard')) }}" class="back-link">&larr; Retour aux articles</a>
<article class="card reader-article">
<h1 class="reader-title">{{ $article->title }}</h1>
<div class="reader-meta">
<span>{{ $article->feed->displayTitle() }}</span>
@if ($article->published_at)
<span>&middot; {{ $article->published_at->diffForHumans() }}</span>
@endif
<a href="{{ $article->link }}" target="_blank" rel="noopener" class="reader-source-link">Voir la source originale &#8599;</a>
</div>
@if ($article->full_content_error)
<div class="reader-error">
<p>Impossible de récupérer le contenu complet de cet article ({{ $article->full_content_error }}).</p>
<form method="POST" action="{{ route('articles.retry', $article) }}">
@csrf
<button type="submit" class="btn btn-ghost btn-sm">Réessayer</button>
</form>
@if ($article->excerpt)
<p class="mt-2">{{ $article->excerpt }}</p>
@endif
</div>
@elseif ($contentHtml)
<div class="prose">{!! $contentHtml !!}</div>
@else
<p class="text-muted">Récupération du contenu en cours…</p>
@endif
</article>
</div>
@endsection
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.guest', ['title' => 'Connexion — BloomFeed'])
@section('content')
<div class="auth-card">
<div class="logo-row">
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
</div>
<h1>Connexion</h1>
<p class="subtitle">Accédez à vos flux et à votre lecture approfondie</p>
@if (session('status'))
<div class="form-status">{{ session('status') }}</div>
@endif
@if ($errors->any())
<div class="form-error mt-1">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('login') }}" class="mt-2">
@csrf
<div class="form-group">
<label for="email">Adresse e-mail</label>
<input type="email" name="email" id="email" class="form-input" value="{{ old('email') }}" required autofocus>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" name="password" id="password" class="form-input" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Se connecter</button>
</form>
@unless (\App\Models\User::query()->exists())
<div class="switch-link">
Pas encore de compte ? <a href="{{ route('register') }}">Créer un compte</a>
</div>
@endunless
</div>
@endsection
+41
View File
@@ -0,0 +1,41 @@
@extends('layouts.guest', ['title' => 'Créer un compte — BloomFeed'])
@section('content')
<div class="auth-card">
<div class="logo-row">
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
</div>
<h1>Configurer votre instance</h1>
<p class="subtitle">Ce compte sera le seul compte propriétaire de cette instance BloomFeed</p>
@if ($errors->any())
<div class="form-error mt-1">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('register') }}" class="mt-2">
@csrf
<div class="form-group">
<label for="name">Nom</label>
<input type="text" name="name" id="name" class="form-input" value="{{ old('name') }}" required autofocus>
</div>
<div class="form-group">
<label for="email">Adresse e-mail</label>
<input type="email" name="email" id="email" class="form-input" value="{{ old('email') }}" required>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" name="password" id="password" class="form-input" required>
</div>
<div class="form-group">
<label for="password_confirmation">Confirmer le mot de passe</label>
<input type="password" name="password_confirmation" id="password_confirmation" class="form-input" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Créer mon compte</button>
</form>
<div class="switch-link">
Déjà un compte ? <a href="{{ route('login') }}">Se connecter</a>
</div>
</div>
@endsection
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.app', ['title' => 'Articles — BloomFeed'])
@section('content')
<div class="sidebar">
@include('partials.feed-sidebar')
</div>
<div class="main-column">
@if (session('status'))
<div class="form-status">{{ session('status') }}</div>
@endif
<div class="flex-between">
<h2 style="margin:0;">Derniers articles</h2>
<div class="flex" style="gap:8px;">
<a href="{{ route('dashboard', array_filter(['feed' => request('feed'), 'unread' => request('unread') ? null : 1])) }}"
class="btn btn-ghost btn-sm">
{{ request('unread') ? 'Tout afficher' : 'Non lus uniquement' }}
</a>
</div>
</div>
@forelse ($articles as $article)
@include('partials.article-card', ['article' => $article])
@empty
<div class="card empty-state">
@if ($feeds->isEmpty())
Ajoutez votre premier flux RSS depuis la barre latérale pour commencer.
@else
Aucun article pour le moment. Essayez d'actualiser vos flux.
@endif
</div>
@endforelse
<div class="pagination">
{{ $articles->links() }}
</div>
</div>
@endsection
+55
View File
@@ -0,0 +1,55 @@
@extends('layouts.app', ['title' => 'Mes flux — BloomFeed'])
@section('content')
<div class="main-column" style="width:100%;">
@if (session('status'))
<div class="form-status">{{ session('status') }}</div>
@endif
<div class="card">
<h2>Ajouter un flux</h2>
<form method="POST" action="{{ route('feeds.store') }}" class="inline-form">
@csrf
<input type="url" name="url" class="form-input" placeholder="https://exemple.com/rss.xml" required>
<input type="text" name="title" class="form-input" placeholder="Titre personnalisé (optionnel)" style="max-width:220px;">
<button type="submit" class="btn btn-primary">Ajouter</button>
</form>
@error('url')
<div class="form-error mt-1">{{ $message }}</div>
@enderror
</div>
<h2 class="mt-3">Mes flux</h2>
@forelse ($feeds as $feed)
<div class="card flex-between">
<div>
<div style="font-weight:600;">{{ $feed->displayTitle() }}</div>
<div class="text-sm text-muted">{{ $feed->url }}</div>
<div class="text-sm text-muted mt-1">
{{ $feed->articles_count }} article(s)
@if ($feed->last_fetched_at)
&middot; actualisé {{ $feed->last_fetched_at->diffForHumans() }}
@endif
@if ($feed->last_fetch_status && $feed->last_fetch_status !== 'ok')
&middot; <span style="color:var(--danger);">{{ $feed->last_fetch_status }}</span>
@endif
</div>
</div>
<div class="flex" style="gap:8px;">
<form method="POST" action="{{ route('feeds.refresh', $feed) }}">
@csrf
<button type="submit" class="btn btn-ghost btn-sm">Actualiser</button>
</form>
<form method="POST" action="{{ route('feeds.destroy', $feed) }}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger btn-sm" data-confirm="Supprimer ce flux et tous ses articles ?">Supprimer</button>
</form>
</div>
</div>
@empty
<div class="card empty-state">Vous n'avez pas encore ajouté de flux.</div>
@endforelse
</div>
@endsection
+40
View File
@@ -0,0 +1,40 @@
@extends('layouts.marketing', ['title' => 'BloomFeed — votre agrégateur RSS auto-hébergé'])
@section('content')
<section class="hero">
<div class="hero-inner">
<span class="eyebrow">Auto-hébergé &middot; Vie privée</span>
<h1>Vos flux RSS, lus en profondeur, sans jamais quitter BloomFeed.</h1>
<p class="hero-lede">
BloomFeed centralise vos flux préférés et en extrait le contenu complet à la demande,
directement sur votre propre serveur. Aucune donnée envoyée à des tiers.
</p>
<div class="hero-cta">
@if ($hasOwner)
<a href="{{ route('login') }}" class="btn btn-primary btn-lg">Se connecter</a>
@else
<a href="{{ route('register') }}" class="btn btn-primary btn-lg">Créer un compte</a>
@endif
<a href="{{ route('self-hosting') }}" class="btn btn-ghost btn-lg">Comment l'auto-héberger ?</a>
</div>
</div>
</section>
<section class="feature-grid">
<div class="feature-card">
<div class="feature-icon">📰</div>
<h3>Flux personnalisés</h3>
<p>Ajoutez n'importe quel flux RSS ou Atom, organisez-les et suivez vos non-lus en un coup d'œil.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📖</div>
<h3>Lecture approfondie intégrée</h3>
<p>BloomFeed récupère la page source, en extrait le contenu principal et l'affiche proprement plus besoin d'ouvrir dix onglets.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔒</div>
<h3>Chez vous, pour vous</h3>
<p>Une instance BloomFeed = un propriétaire. Pas d'inscriptions publiques, pas de tracking, votre serveur, vos données.</p>
</div>
</section>
@endsection
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'BloomFeed' }}</title>
<link rel="icon" href="{{ asset('images/icon.png') }}">
<link rel="apple-touch-icon" href="{{ asset('images/icon.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ asset('css/theme.css') }}">
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script>
(function () {
var stored = localStorage.getItem('bloomfeed-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
@include('partials.navbar')
<div class="app-shell">
@yield('content')
</div>
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
<script src="{{ asset('js/app.js') }}" defer></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'BloomFeed' }}</title>
<link rel="icon" href="{{ asset('images/icon.png') }}">
<link rel="apple-touch-icon" href="{{ asset('images/icon.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ asset('css/theme.css') }}">
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script>
(function () {
var stored = localStorage.getItem('bloomfeed-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<div class="centered-shell">
@yield('content')
</div>
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
</body>
</html>
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'BloomFeed' }}</title>
<meta name="description" content="BloomFeed est un agrégateur RSS personnel et auto-hébergé, avec lecture approfondie intégrée.">
<link rel="icon" href="{{ asset('images/icon.png') }}">
<link rel="apple-touch-icon" href="{{ asset('images/icon.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ asset('css/theme.css') }}">
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script>
(function () {
var stored = localStorage.getItem('bloomfeed-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<nav class="navbar navbar-marketing">
<a href="{{ route('home') }}" class="navbar-brand">
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
</a>
<div class="navbar-links">
<a href="{{ route('self-hosting') }}" class="{{ request()->routeIs('self-hosting') ? 'active' : '' }}">Self-hosting</a>
<button type="button" class="theme-toggle" data-theme-toggle title="Changer de thème"></button>
@if (\App\Models\User::query()->exists())
<a href="{{ route('login') }}" class="btn btn-primary btn-sm">Se connecter</a>
@else
<a href="{{ route('register') }}" class="btn btn-primary btn-sm">Créer un compte</a>
@endif
</div>
</nav>
<main>
@yield('content')
</main>
<footer class="site-footer">
<span>BloomFeed projet open-source, auto-hébergé.</span>
<a href="https://git.eldorianet.work/esteban/RSS-Eldoria-Network" target="_blank" rel="noopener">Code source</a>
</footer>
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
</body>
</html>
@@ -0,0 +1,22 @@
<article class="article-card {{ $article->is_read ? 'is-read' : '' }}">
<div class="article-card-top">
<div>
<a href="{{ route('articles.show', $article) }}" class="article-title">
{{ $article->title }}
</a>
<div class="article-meta">
{{ $article->feed->displayTitle() }}
@if ($article->published_at)
&middot; {{ $article->published_at->diffForHumans() }}
@endif
</div>
</div>
<button type="button" class="favorite-btn {{ $article->is_favorite ? 'is-favorite' : '' }}" data-favorite-url="{{ route('articles.toggleFavorite', $article) }}" title="Favori">
</button>
</div>
@if ($article->excerpt)
<p class="article-excerpt">{{ $article->excerpt }}</p>
@endif
</article>
@@ -0,0 +1,38 @@
<div class="card">
<div class="sidebar-title">Flux</div>
<ul class="feed-list">
<li>
<a href="{{ route('dashboard') }}" class="{{ ! request('feed') ? 'active' : '' }}">
Tous les articles
</a>
</li>
@foreach ($feeds as $feed)
<li>
<a href="{{ route('dashboard', ['feed' => $feed->id]) }}" class="{{ request('feed') == $feed->id ? 'active' : '' }}">
<span>{{ $feed->displayTitle() }}</span>
@if ($feed->unread_count > 0)
<span class="pill">{{ $feed->unread_count }}</span>
@endif
</a>
</li>
@endforeach
</ul>
<form method="POST" action="{{ route('feeds.store') }}" class="mt-2">
@csrf
<div class="form-group">
<label for="sidebar-url">Ajouter un flux</label>
<input type="url" name="url" id="sidebar-url" class="form-input" placeholder="https://exemple.com/rss.xml" required>
</div>
<button type="submit" class="btn btn-primary btn-block btn-sm">Ajouter</button>
</form>
<form method="POST" action="{{ route('feeds.refreshAll') }}" class="mt-2">
@csrf
<button type="submit" class="btn btn-ghost btn-block btn-sm">Tout actualiser</button>
</form>
<a href="{{ route('feeds.index') }}" class="text-sm text-muted mt-1" style="display:block; text-align:center;">
Gérer mes flux
</a>
</div>
+15
View File
@@ -0,0 +1,15 @@
<nav class="navbar">
<a href="{{ route('dashboard') }}" class="navbar-brand">
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
</a>
<div class="navbar-links">
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'active' : '' }}">Articles</a>
<a href="{{ route('feeds.index') }}" class="{{ request()->routeIs('feeds.*') ? 'active' : '' }}">Mes flux</a>
<button type="button" class="theme-toggle" data-theme-toggle title="Changer de thème"></button>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="btn btn-ghost btn-sm">Déconnexion</button>
</form>
</div>
</nav>
+58
View File
@@ -0,0 +1,58 @@
@extends('layouts.marketing', ['title' => 'Auto-héberger BloomFeed'])
@section('content')
<section class="docs-shell">
<h1>Auto-héberger BloomFeed</h1>
<p class="hero-lede">
BloomFeed est pensé pour être installé sur votre propre serveur, pour vous (ou votre foyer).
Une instance = un seul compte propriétaire. Tout tourne dans Docker : l'application
et la base de données MariaDB.
</p>
<div class="card mt-3">
<h2>Prérequis</h2>
<ul class="docs-list">
<li>Un serveur Linux avec <strong>Docker</strong> et le plugin <strong>Docker Compose</strong>.</li>
<li>Le port <strong>7081</strong> libre (modifiable dans <code>docker-compose.yml</code>).</li>
<li>Optionnel : un nom de domaine et un reverse proxy si vous voulez du HTTPS.</li>
</ul>
</div>
<div class="card mt-2">
<h2>Installation</h2>
<ol class="docs-list docs-list-numbered">
<li>Clonez le dépôt sur votre serveur.</li>
<li>
Lancez le script de déploiement :
<pre class="code-block">bash scriptsite.sh</pre>
Il crée automatiquement un fichier <code>.env</code> (à partir de <code>.env.example</code>) et y génère
des secrets aléatoires (<code>APP_KEY</code>, mots de passe de base de données) s'ils sont absents
ils ne sont jamais régénérés lors des déploiements suivants.
</li>
<li>Le script construit les images et démarre les conteneurs (application, planificateur, MariaDB).</li>
<li>Ouvrez <code>http://votre-serveur:7081</code> et créez le compte propriétaire cette option disparaît ensuite définitivement.</li>
</ol>
</div>
<div class="card mt-2">
<h2>Mettre à jour</h2>
<p>Relancez simplement <code>bash scriptsite.sh</code> : il récupère les derniers changements et reconstruit la stack sans toucher à vos données.</p>
</div>
<div class="card mt-2">
<h2>Désinstaller</h2>
<p>
Le script <code>uninstall.sh</code> arrête et supprime proprement la stack Docker.
Il demande une confirmation explicite avant de supprimer vos données (base de données)
et ne supprime jamais le code source sans confirmation séparée.
</p>
</div>
<div class="card mt-2">
<h2>Code source</h2>
<p>
<a href="https://git.eldorianet.work/esteban/RSS-Eldoria-Network" target="_blank" rel="noopener">git.eldorianet.work/esteban/RSS-Eldoria-Network</a>
</p>
</div>
</section>
@endsection
+11
View File
@@ -0,0 +1,11 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('feeds:fetch')->everyFifteenMinutes()->withoutOverlapping();
+35
View File
@@ -0,0 +1,35 @@
<?php
use App\Http\Controllers\ArticleController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\FeedController;
use App\Http\Controllers\PageController;
use Illuminate\Support\Facades\Route;
Route::get('/', [PageController::class, 'home'])->name('home');
Route::get('/self-hosting', [PageController::class, 'selfHosting'])->name('self-hosting');
Route::middleware('guest')->group(function () {
Route::get('/register', [AuthController::class, 'showRegister'])->name('register');
Route::post('/register', [AuthController::class, 'register']);
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
Route::post('/login', [AuthController::class, 'login']);
});
Route::middleware('auth')->group(function () {
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
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');
Route::post('/feeds/{feed}/refresh', [FeedController::class, 'refresh'])->name('feeds.refresh');
Route::post('/feeds/refresh-all', [FeedController::class, 'refreshAll'])->name('feeds.refreshAll');
Route::get('/articles/{article}', [ArticleController::class, 'show'])->name('articles.show');
Route::post('/articles/{article}/read', [ArticleController::class, 'markRead'])->name('articles.markRead');
Route::post('/articles/{article}/favorite', [ArticleController::class, 'toggleFavorite'])->name('articles.toggleFavorite');
Route::post('/articles/{article}/retry', [ArticleController::class, 'retry'])->name('articles.retry');
});
Executable
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_URL="https://git.eldorianet.work/esteban/RSS-Eldoria-Network"
APP_DIR="/opt/bloomfeed"
HOST_PORT="7081"
echo "==> Kill forcé de tout process qui écoute sur le port ${HOST_PORT}"
PIDS="$(sudo lsof -ti tcp:${HOST_PORT} || true)"
if [[ -n "${PIDS}" ]]; then
echo "PIDs trouvés sur ${HOST_PORT}: ${PIDS}"
sudo kill -9 ${PIDS} || true
else
echo "Aucun process actif sur ${HOST_PORT}"
fi
if [[ -d "${APP_DIR}/.git" ]]; then
echo "==> Mise à jour du dépôt existant"
sudo git -C "${APP_DIR}" fetch --all
sudo git -C "${APP_DIR}" reset --hard origin/main
else
echo "==> Suppression complète de ${APP_DIR} et clone propre du dépôt"
sudo rm -rf "${APP_DIR}"
sudo mkdir -p "${APP_DIR}"
sudo git clone "${REPO_URL}" "${APP_DIR}"
fi
cd "${APP_DIR}"
if [[ ! -f ".env" ]]; then
echo "==> Aucun .env trouvé, création à partir de .env.example"
cp .env.example .env
fi
echo "==> Génération des secrets manquants dans .env (ne régénère jamais un secret déjà présent)"
if grep -q '^APP_KEY=$' .env; then
sed -i "s#^APP_KEY=.*#APP_KEY=base64:$(openssl rand -base64 32)#" .env
echo " - APP_KEY généré"
fi
if grep -q '^DB_PASSWORD=$' .env; then
sed -i "s#^DB_PASSWORD=.*#DB_PASSWORD=$(openssl rand -hex 24)#" .env
echo " - DB_PASSWORD généré"
fi
if grep -q '^DB_ROOT_PASSWORD=$' .env; then
sed -i "s#^DB_ROOT_PASSWORD=.*#DB_ROOT_PASSWORD=$(openssl rand -hex 24)#" .env
echo " - DB_ROOT_PASSWORD généré"
fi
chmod 600 .env
echo "==> Arrêt de l'ancienne stack Docker Compose si présente"
sudo docker compose down --remove-orphans || true
echo "==> Build des images Docker (app + scheduler)"
sudo docker compose build --pull
echo "==> Lancement de la stack (app, scheduler, db)"
sudo docker compose up -d
echo "==> Attente de la disponibilité de la base de données"
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 "==> Base de données prête"
break
fi
echo "==> DB pas encore prête, nouvelle tentative ($i/30)"
sleep 3
done
echo "==> Migrations (filet de sécurité, l'entrypoint les exécute déjà au démarrage)"
sudo docker compose exec -T app php artisan migrate --force
echo "==> Vérification des conteneurs"
sudo docker compose ps
echo "==> Vérification du port ${HOST_PORT}"
sudo lsof -nP -iTCP:${HOST_PORT} -sTCP:LISTEN || true
echo "==> Test HTTP de l'application"
curl -sf -o /dev/null -w "HTTP %{http_code}\n" "http://localhost:${HOST_PORT}/login" || echo "L'application ne répond pas encore, vérifiez les logs."
echo "==> Logs récents"
sudo docker compose logs --tail 50 app || true
echo "==> Déploiement BloomFeed terminé — http://<ip-serveur>:${HOST_PORT}"
+4
View File
@@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+9
View File
@@ -0,0 +1,9 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json
+3
View File
@@ -0,0 +1,3 @@
*
!data/
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}

Some files were not shown because too many files have changed in this diff Show More