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
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);
}
}