Done
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user