diff --git a/app/Http/Controllers/PlatformAccountsController.php b/app/Http/Controllers/PlatformAccountsController.php new file mode 100644 index 0000000..029f6fa --- /dev/null +++ b/app/Http/Controllers/PlatformAccountsController.php @@ -0,0 +1,85 @@ +orderBy('created_at', 'desc')->get(); + + return view('pages.platforms.index', compact('accounts')); + } + + public function create(): View + { + return view('pages.platforms.create'); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'platform' => 'required|in:lemmy,mastodon,reddit', + 'instance_url' => 'required|url', + 'username' => 'required|string|max:255', + 'password' => 'required|string', + 'settings' => 'nullable|array', + ]); + + $account = PlatformAccount::create($validated); + + // If this is the first account for this platform, make it active + if (! PlatformAccount::where('platform', $validated['platform'])->where('is_active', true)->exists()) { + $account->setAsActive(); + } + + return redirect()->route('platforms.index') + ->with('success', 'Platform account created successfully!'); + } + + public function edit(PlatformAccount $platformAccount): View + { + return view('pages.platforms.edit', compact('platformAccount')); + } + + public function update(Request $request, PlatformAccount $platformAccount): RedirectResponse + { + $validated = $request->validate([ + 'instance_url' => 'required|url', + 'username' => 'required|string|max:255', + 'password' => 'nullable|string', + 'settings' => 'nullable|array', + ]); + + // Don't update password if not provided + if (empty($validated['password'])) { + unset($validated['password']); + } + + $platformAccount->update($validated); + + return redirect()->route('platforms.index') + ->with('success', 'Platform account updated successfully!'); + } + + public function destroy(PlatformAccount $platformAccount): RedirectResponse + { + $platformAccount->delete(); + + return redirect()->route('platforms.index') + ->with('success', 'Platform account deleted successfully!'); + } + + public function setActive(PlatformAccount $platformAccount): RedirectResponse + { + $platformAccount->setAsActive(); + + return redirect()->route('platforms.index') + ->with('success', "Set $platformAccount->username@$platformAccount->instance_url as active for $platformAccount->platform!"); + } +} diff --git a/app/Jobs/PublishToLemmyJob.php b/app/Jobs/PublishToLemmyJob.php index 08341d9..116919c 100644 --- a/app/Jobs/PublishToLemmyJob.php +++ b/app/Jobs/PublishToLemmyJob.php @@ -8,6 +8,7 @@ use App\Services\Article\ArticleFetcher; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use RuntimeException; class PublishToLemmyJob implements ShouldQueue { @@ -37,7 +38,7 @@ public function handle(): void ]); try { - LemmyPublisher::fromConfig()->publish($this->article, $extractedData); + LemmyPublisher::fromActiveAccount()->publish($this->article, $extractedData); logger()->info('Article published successfully', [ 'article_id' => $this->article->id @@ -45,6 +46,12 @@ public function handle(): void } catch (PublishException $e) { $this->fail($e); + } catch (RuntimeException $e) { + logger()->warning('No active Lemmy accounts configured', [ + 'article_id' => $this->article->id, + 'error' => $e->getMessage() + ]); + $this->fail($e); } } } diff --git a/app/Models/PlatformAccount.php b/app/Models/PlatformAccount.php new file mode 100644 index 0000000..b4a5c4b --- /dev/null +++ b/app/Models/PlatformAccount.php @@ -0,0 +1,86 @@ + PlatformEnum::class, + 'settings' => 'array', + 'is_active' => 'boolean', + 'last_tested_at' => 'datetime' + ]; + + // Encrypt password when storing + protected function password(): Attribute + { + return Attribute::make( + get: fn ($value) => $value ? Crypt::decryptString($value) : null, + set: fn ($value) => $value ? Crypt::encryptString($value) : null, + ); + } + + // Encrypt API token when storing + protected function apiToken(): Attribute + { + return Attribute::make( + get: fn ($value) => $value ? Crypt::decryptString($value) : null, + set: fn ($value) => $value ? Crypt::encryptString($value) : null, + ); + } + + // Get the active accounts for a platform (returns collection) + public static function getActive(PlatformEnum $platform): Collection + { + return static::where('platform', $platform) + ->where('is_active', true) + ->get(); + } + + // Set this account as active (deactivates others for same platform) + public function setAsActive(): void + { + // Deactivate other accounts for this platform + static::where('platform', $this->platform) + ->where('id', '!=', $this->id) + ->update(['is_active' => false]); + + // Activate this account + $this->update(['is_active' => true]); + } +} diff --git a/app/Modules/Lemmy/Services/LemmyPublisher.php b/app/Modules/Lemmy/Services/LemmyPublisher.php index cd95374..a792a1f 100644 --- a/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -6,30 +6,32 @@ use App\Exceptions\PublishException; use App\Models\Article; use App\Models\ArticlePublication; +use App\Models\PlatformAccount; use App\Services\Auth\LemmyAuthService; use Exception; use Illuminate\Support\Facades\Cache; +use RuntimeException; class LemmyPublisher { private LemmyApiService $api; - private string $username; - private string $community; + private PlatformAccount $account; - public function __construct(string $instance, string $username, string $community) + public function __construct(PlatformAccount $account) { - $this->api = new LemmyApiService($instance); - $this->username = $username; - $this->community = $community; + $this->api = new LemmyApiService($account->instance_url); + $this->account = $account; } - public static function fromConfig(): self + public static function fromActiveAccount(): self { - return new self( - config('lemmy.instance'), - config('lemmy.username'), - config('lemmy.community') - ); + $accounts = PlatformAccount::getActive(PlatformEnum::LEMMY); + + if ($accounts->isEmpty()) { + throw new RuntimeException('No active Lemmy accounts configured'); + } + + return new self($accounts->first()); } /** @@ -38,7 +40,7 @@ public static function fromConfig(): self public function publish(Article $article, array $extractedData): ArticlePublication { try { - $token = LemmyAuthService::getToken(); + $token = LemmyAuthService::getToken($this->account); $communityId = $this->getCommunityId(); $languageId = $this->getLanguageIdForSource($article->url); @@ -59,16 +61,25 @@ public function publish(Article $article, array $extractedData): ArticlePublicat } } + /** + * @throws Exception + */ private function getCommunityId(): int { - $cacheKey = "lemmy_community_id_{$this->community}"; + $community = config('lemmy.community'); + + if (! $community) { + throw new RuntimeException('No community configured in config'); + } + + $cacheKey = "lemmy_community_id_$community"; $cachedId = Cache::get($cacheKey); if ($cachedId) { return $cachedId; } - $communityId = $this->api->getCommunityId($this->community); + $communityId = $this->api->getCommunityId($community); Cache::put($cacheKey, $communityId, 3600); return $communityId; @@ -80,7 +91,7 @@ private function createPublicationRecord(Article $article, array $postData, int 'article_id' => $article->id, 'post_id' => $postData['post_view']['post']['id'], 'community_id' => $communityId, - 'published_by' => $this->username, + 'published_by' => $this->account->username, 'published_at' => now(), 'platform' => 'lemmy', 'publication_data' => $postData, @@ -106,7 +117,7 @@ private function getLanguageIdForSource(string $url): ?int private function getLanguageId(string $languageCode): ?int { - $cacheKey = "lemmy_language_id_{$languageCode}"; + $cacheKey = "lemmy_language_id_$languageCode"; $cachedId = Cache::get($cacheKey); if ($cachedId !== null) { diff --git a/app/Services/Auth/LemmyAuthService.php b/app/Services/Auth/LemmyAuthService.php index d76aee8..1ee58b2 100644 --- a/app/Services/Auth/LemmyAuthService.php +++ b/app/Services/Auth/LemmyAuthService.php @@ -4,37 +4,38 @@ use App\Enums\PlatformEnum; use App\Exceptions\PlatformAuthException; +use App\Models\PlatformAccount; use App\Modules\Lemmy\Services\LemmyApiService; use Illuminate\Support\Facades\Cache; class LemmyAuthService { - public static function getToken(): string + /** + * @throws PlatformAuthException + */ + public static function getToken(PlatformAccount $account): string { - $cachedToken = Cache::get('lemmy_jwt_token'); - + $cacheKey = "lemmy_jwt_token_$account->id"; + $cachedToken = Cache::get($cacheKey); + if ($cachedToken) { return $cachedToken; } - $username = config('lemmy.username'); - $password = config('lemmy.password'); - $instance = config('lemmy.instance'); - - if (!$username || !$password || !$instance) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials or instance'); + if (! $account->username || ! $account->password || ! $account->instance_url) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); } - $api = new LemmyApiService($instance); - $token = $api->login($username, $password); - + $api = new LemmyApiService($account->instance_url); + $token = $api->login($account->username, $account->password); + if (!$token) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed'); + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); } // Cache for 50 minutes (3000 seconds) to allow buffer before token expires - Cache::put('lemmy_jwt_token', $token, config('lemmy.token_ttl', 3000)); - + Cache::put($cacheKey, $token, 3000); + return $token; } -} \ No newline at end of file +} diff --git a/database/migrations/2025_07_04_230000_create_platform_accounts_table.php b/database/migrations/2025_07_04_230000_create_platform_accounts_table.php new file mode 100644 index 0000000..51c7faa --- /dev/null +++ b/database/migrations/2025_07_04_230000_create_platform_accounts_table.php @@ -0,0 +1,31 @@ +id(); + $table->enum('platform', ['lemmy']); + $table->string('instance_url'); + $table->string('username'); + $table->string('password'); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(false); + $table->timestamp('last_tested_at')->nullable(); + $table->string('status')->default('untested'); + $table->timestamps(); + + $table->unique(['username', 'platform', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_accounts'); + } +}; \ No newline at end of file diff --git a/resources/views/pages/platforms/index.blade.php b/resources/views/pages/platforms/index.blade.php new file mode 100644 index 0000000..eaf71ca --- /dev/null +++ b/resources/views/pages/platforms/index.blade.php @@ -0,0 +1,103 @@ +@extends('layouts.app') + +@section('page-title', 'Platform Accounts') + +@section('content') +
+ + + Add Platform Account + +
+ +
+
+

Platform Accounts

+

Manage your social media platform accounts for posting

+
+ + @if($accounts->isEmpty()) +
+ +

No platform accounts configured

+

Add your first platform account to start posting articles

+ + + Add Platform Account + +
+ @else +
+ + + + + + + + + + + + @foreach($accounts as $account) + + + + + + + + @endforeach + +
PlatformAccountStatusLast TestedActions
+
+ + {{ $account->platform }} + @if($account->is_active) + + Active + + @endif +
+
+
{{ $account->username }}
+
{{ $account->instance_url }}
+
+ + {{ ucfirst($account->status) }} + + + {{ $account->last_tested_at ? $account->last_tested_at->format('Y-m-d H:i') : 'Never' }} + +
+ @if(!$account->is_active) +
+ @csrf + +
+ @endif + + + Edit + + +
+ @csrf + @method('DELETE') + +
+
+
+
+ @endif +
+@endsection \ No newline at end of file diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index b1a67fe..bc504ed 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -10,6 +10,10 @@ Articles + + + Platforms + Logs diff --git a/routes/web.php b/routes/web.php index 3c7b998..f45c4f1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -20,3 +20,6 @@ Route::get('/articles', ArticlesController::class)->name('articles'); Route::get('/logs', LogsController::class)->name('logs'); + +Route::resource('platforms', App\Http\Controllers\PlatformAccountsController::class)->names('platforms'); +Route::post('/platforms/{platformAccount}/set-active', [App\Http\Controllers\PlatformAccountsController::class, 'setActive'])->name('platforms.set-active');