First Commit
@@ -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"]
|
||||||
@@ -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é |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Mode sombre | Connexion |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppServiceProvider::class,
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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')),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 630 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 464 KiB |
|
After Width: | Height: | Size: 538 KiB |
@@ -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());
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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">← 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>· {{ $article->published_at->diffForHumans() }}</span>
|
||||||
|
@endif
|
||||||
|
<a href="{{ $article->link }}" target="_blank" rel="noopener" class="reader-source-link">Voir la source originale ↗</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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
· actualisé {{ $feed->last_fetched_at->diffForHumans() }}
|
||||||
|
@endif
|
||||||
|
@if ($feed->last_fetch_status && $feed->last_fetch_status !== 'ok')
|
||||||
|
· <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
|
||||||
@@ -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é · 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
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
· {{ $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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
*
|
||||||
|
!private/
|
||||||
|
!public/
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
compiled.php
|
||||||
|
config.php
|
||||||
|
down
|
||||||
|
events.scanned.php
|
||||||
|
maintenance.php
|
||||||
|
routes.php
|
||||||
|
routes.scanned.php
|
||||||
|
schedule-*
|
||||||
|
services.json
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!data/
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
|
|
||||||
|
abstract class TestCase extends BaseTestCase
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||