Done
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-07-04 23:09:00 +10:00
parent 0485b85a59
commit 439139a057
50 changed files with 2405 additions and 548 deletions
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Models\Feed;
use App\Services\FeedFetcher;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('feeds:fetch')]
#[Description('Fetch feeds')]
class FetchFeeds extends Command
{
protected $signature = 'feeds:fetch';
public function handle(FeedFetcher $fetcher): void
{
Feed::query()->chunk(50, function ($feeds) use ($fetcher) {
foreach ($feeds as $feed) {
dispatch(fn () => $fetcher->fetch($feed))->onQueue('feeds');
}
});
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class UpDb extends Command
{
protected $signature = 'db:updb
{--no-backup : Skip saving a local backup before wiping}
{--force : Skip confirmation prompt}';
protected $description = 'Download the production database and replace the local database with it';
public function handle(): int
{
if (! $this->option('force') && ! $this->confirm('This will wipe your local database and replace it with production. Are you sure?')) {
$this->info('Aborted.');
return self::SUCCESS;
}
$local = $this->connectionConfig('local');
$production = $this->connectionConfig('production');
$timestamp = now()->format('Y_m_d_His');
$dumpPath = storage_path("app/private/db_dumps/production_{$timestamp}.dump");
$backupPath = storage_path("app/private/db_dumps/local_backup_{$timestamp}.dump");
if (! is_dir(storage_path('app/private/db_dumps'))) {
mkdir(storage_path('app/private/db_dumps'), 0755, true);
}
// Step 1: Dump production
$this->info('Dumping production database...');
$dumpResult = $this->pgDump($production, $dumpPath);
if ($dumpResult !== 0) {
$this->error('Failed to dump production database.');
return self::FAILURE;
}
$this->info("Production dump saved to: {$dumpPath}");
// Step 2: Back up local (unless skipped)
if (! $this->option('no-backup')) {
$this->info('Backing up local database...');
$backupResult = $this->pgDump($local, $backupPath);
if ($backupResult !== 0) {
$this->error('Failed to back up local database. Aborting to be safe. Use --no-backup to skip.');
return self::FAILURE;
}
$this->info("Local backup saved to: {$backupPath}");
}
// Step 3: Drop and recreate the local database
$this->info('Wiping local database...');
$this->dropAndRecreateLocalDatabase($local);
// Step 4: Restore production dump into local
$this->info('Restoring production dump to local database...');
$restoreResult = $this->pgRestore($local, $dumpPath);
if ($restoreResult !== 0) {
$this->error('Restore failed. Your local backup is at: '.($this->option('no-backup') ? 'N/A (backup was skipped)' : $backupPath));
return self::FAILURE;
}
$this->info('Done. Local database now mirrors production.');
return self::SUCCESS;
}
private function connectionConfig(string $type): array
{
$connection = $type === 'production' ? 'pgsql_production' : config('database.default');
return [
'host' => config("database.connections.{$connection}.host"),
'port' => config("database.connections.{$connection}.port"),
'database' => config("database.connections.{$connection}.database"),
'username' => config("database.connections.{$connection}.username"),
'password' => config("database.connections.{$connection}.password"),
];
}
private function pgDump(array $config, string $outputPath): int
{
$pgpass = $this->writePgPass($config);
$command = sprintf(
'pg_dump -Fc -h %s -p %s -U %s %s -f %s',
escapeshellarg($config['host']),
escapeshellarg($config['port']),
escapeshellarg($config['username']),
escapeshellarg($config['database']),
escapeshellarg($outputPath),
);
$resultCode = $this->runWithPgPass($pgpass, $command);
unlink($pgpass);
return $resultCode;
}
private function pgRestore(array $config, string $dumpPath): int
{
$pgpass = $this->writePgPass($config);
$command = sprintf(
'pg_restore -h %s -p %s -U %s -d %s --no-owner --no-privileges %s',
escapeshellarg($config['host']),
escapeshellarg($config['port']),
escapeshellarg($config['username']),
escapeshellarg($config['database']),
escapeshellarg($dumpPath),
);
$resultCode = $this->runWithPgPass($pgpass, $command);
unlink($pgpass);
return $resultCode;
}
/**
* Write a temporary pgpass file and return its path.
* This is the cross-platform alternative to PGPASSWORD which doesn't work on Windows.
*/
private function writePgPass(array $config): string
{
// Escape any colons or backslashes in the password per pgpass format rules
$password = str_replace(['\\', ':'], ['\\\\', '\\:'], $config['password']);
$content = "{$config['host']}:{$config['port']}:*:{$config['username']}:{$password}";
$path = tempnam(sys_get_temp_dir(), 'pgpass_');
file_put_contents($path, $content);
chmod($path, 0600);
return $path;
}
private function runWithPgPass(string $pgpassFile, string $command): int
{
// PGPASSFILE is supported on all platforms including Windows
putenv("PGPASSFILE={$pgpassFile}");
passthru($command, $resultCode);
putenv('PGPASSFILE');
return $resultCode;
}
private function dropAndRecreateLocalDatabase(array $config): void
{
$database = $config['database'];
// Connect to the postgres maintenance database to drop/recreate
$pdo = new \PDO(
"pgsql:host={$config['host']};port={$config['port']};dbname=postgres",
$config['username'],
$config['password'],
);
$pdo->exec('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '.$pdo->quote($database));
$pdo->exec('DROP DATABASE IF EXISTS '.'"'.$database.'"');
$pdo->exec('CREATE DATABASE '.'"'.$database.'"');
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Categories\StoreCategoryRequest;
use App\Http\Requests\Categories\UpdateCategoryRequest;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class CategoryController extends Controller
{
public function getAll(){
return Category::query()
->where('user_id', request()->user()->id)
->whereNull('parent_id')
->with(['children.feeds', 'feeds'])
->orderBy('title')
->get();
}
public function index(): Response
{
$categories = $this->getAll();
return Inertia::render('Settings/Index', [
'categories' => $categories,
]);
}
public function store(StoreCategoryRequest $request): RedirectResponse
{
DB::transaction(function () use ($request) {
$category = Category::create([
'user_id' => $request->user()->id,
'parent_id' => $request->input('parent_id'),
'title' => $request->input('title'),
'description' => $request->input('description'),
]);
$category->feeds()->createMany($request->input('feeds', []));
});
return back();
}
public function update(UpdateCategoryRequest $request, Category $category): RedirectResponse
{
DB::transaction(function () use ($request, $category) {
$category->update([
'parent_id' => $request->input('parent_id'),
'title' => $request->input('title'),
'description' => $request->input('description'),
]);
$category->feeds()->delete();
$category->feeds()->createMany($request->input('feeds', []));
});
return back();
}
public function destroy(Category $category): RedirectResponse
{
abort_unless($category->user_id === request()->user()->id, 403);
$category->delete();
return back();
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Services\ArticleSelector;
use Illuminate\Http\Request;
use Inertia\Inertia;
class FrontPageController extends Controller
{
public function __invoke(ArticleSelector $selector)
{
$categories = Category::whereNull('parent_id')->get()->map(function ($category) use ($selector) {
return [
'label' => $category->title,
'hero' => $category->slug === 'headlines', // or a boolean column on Category
'items' => $selector->forCategory($category)->map(fn ($article) => [
'source' => $article->feed->title ?? parse_url($article->feed->url, PHP_URL_HOST),
'time' => $article->published_at?->diffForHumans(),
'headline' => $article->title,
'deck' => \Illuminate\Support\Str::limit($article->description, 140),
'image' => $article->image_url,
'link' => $article->link,
]),
];
});
return Inertia::render('Index', ['categories' => $categories]);
}
}
+14 -1
View File
@@ -2,8 +2,10 @@
namespace App\Http\Middleware;
use App\Models\Category;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Tighten\Ziggy\Ziggy;
class HandleInertiaRequests extends Middleware
{
@@ -41,7 +43,18 @@ class HandleInertiaRequests extends Middleware
'auth' => [
'user' => $request->user(),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'navigation_categories' => fn () => $request->user()
? Category::query()
->where('user_id', $request->user()->id)
->whereNull('parent_id')
->with('children:id,parent_id,title')
->orderBy('title')
->get(['id', 'parent_id', 'title'])
: [],
'ziggy' => fn () => [
...new Ziggy()->toArray(),
'location' => $request->url(),
]
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Categories;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'parent_id' => [
'nullable',
Rule::exists('categories', 'id')->where('user_id', $this->user()->id),
],
'feeds' => ['array'],
'feeds.*.url' => ['required', 'url', 'max:2048'],
'feeds.*.type' => ['required', Rule::in(['rss'])],
'feeds.*.paywall' => ['boolean'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Categories;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return $this->route('category')->user_id === $this->user()->id;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'parent_id' => [
'nullable',
Rule::exists('categories', 'id')->where('user_id', $this->user()->id),
Rule::notIn([$this->route('category')->id]),
],
'feeds' => ['array'],
'feeds.*.url' => ['required', 'url', 'max:2048'],
'feeds.*.type' => ['required', Rule::in(['rss'])],
'feeds.*.paywall' => ['boolean'],
];
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Article extends Model
{
protected $fillable = ['feed_id', 'guid', 'title', 'description', 'link', 'author', 'image_url', 'published_at'];
protected $casts = ['published_at' => 'datetime'];
public function feed(): BelongsTo
{
return $this->belongsTo(Feed::class);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'parent_id',
'title',
'description',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(Category::class, 'parent_id');
}
public function feeds(): HasMany
{
return $this->hasMany(Feed::class);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Feed extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'type',
'url',
'paywall'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Services;
use App\Models\Category;
use Illuminate\Support\Collection;
class ArticleSelector
{
public function forCategory(Category $category, int $limit = 10): Collection
{
$feeds = $category->feeds()->with([
'articles' => fn ($q) => $q->orderByDesc('published_at')->limit($limit),
])->get();
// Round robin: take article[0] from every feed, then article[1] from every feed, etc.
$buckets = $feeds->map(fn ($feed) => $feed->articles->values());
$maxDepth = $buckets->max(fn ($b) => $b->count()) ?? 0;
$selected = collect();
for ($i = 0; $i < $maxDepth && $selected->count() < $limit; $i++) {
foreach ($buckets as $bucket) {
if ($selected->count() >= $limit) break;
if ($bucket->has($i)) $selected->push($bucket[$i]);
}
}
return $selected;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Services;
use App\Models\Article;
use App\Models\Feed;
use SimplePie\SimplePie;
class FeedFetcher
{
public function __construct(private OgImageResolver $ogResolver) {}
public function fetch(Feed $feed): void
{
$simplepie = new SimplePie();
$simplepie->set_feed_url($feed->url);
$simplepie->enable_cache(false); // you're doing your own caching in the DB
$simplepie->init();
if ($simplepie->error()) {
report(new \RuntimeException("Feed fetch failed for {$feed->url}: {$simplepie->error()}"));
return;
}
foreach ($simplepie->get_items() as $item) {
$imageUrl = $this->extractImage($item);
$attributes = [
'title' => strip_tags($item->get_title()),
'description' => strip_tags($item->get_description() ?? ''),
'link' => $item->get_permalink(),
'author' => $item->get_author()?->get_name(),
'published_at' => $item->get_date('Y-m-d H:i:s'),
];
if ($imageUrl) {
$attributes['image_url'] = $imageUrl;
}
$article = Article::updateOrCreate(
['feed_id' => $feed->id, 'guid' => $item->get_id()],
$attributes
);
if (! $article->image_url && ! $article->image_checked_at) {
$og = $this->ogResolver->resolve($article->link);
$article->update([
'image_url' => $og,
'image_checked_at' => now(),
]);
}
}
}
private function extractImage($item): ?string
{
$mediaContents = $item->get_item_tags('http://search.yahoo.com/mrss/', 'content');
if (empty($mediaContents)) {
$enclosure = $item->get_enclosure();
$link = $enclosure?->get_thumbnail() ?? $enclosure?->get_link();
return $link ? html_entity_decode($link) : null;
}
usort($mediaContents, fn ($a, $b) =>
(int) ($b['attribs']['']['width'] ?? 0) <=> (int) ($a['attribs']['']['width'] ?? 0)
);
return $mediaContents[0]['attribs']['']['url'] ?? null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class OgImageResolver
{
public function resolve(string $pageUrl): ?string
{
try {
$response = Http::withHeaders([
'User-Agent' => 'Mozilla/5.0 (compatible; DredgeNewsBot/1.0)',
])->timeout(8)->get($pageUrl);
if (! $response->successful()) {
return null;
}
libxml_use_internal_errors(true); // suppress warnings from imperfect HTML
$dom = new \DOMDocument();
$dom->loadHTML($response->body());
$xpath = new \DOMXPath($dom);
foreach (['og:image', 'twitter:image', 'twitter:image:src'] as $property) {
$node = $xpath->query("//meta[@property='{$property}']/@content")->item(0)
?? $xpath->query("//meta[@name='{$property}']/@content")->item(0);
if ($node && filled($node->nodeValue)) {
return $node->nodeValue;
}
}
return null;
} catch (\Throwable $e) {
report($e);
return null;
}
}
}