From 83126fa721c58bc49b8d5d776984270f02dbe67e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 7 Aug 2025 21:19:19 +0200 Subject: [PATCH 01/51] Increase test coverage to 78 --- .../Controllers/Api/V1/AuthController.php | 112 ----- backend/app/Jobs/SyncChannelPostsJob.php | 4 +- .../Modules/Lemmy/Services/LemmyPublisher.php | 4 +- .../app/Services/Auth/LemmyAuthService.php | 2 +- .../Services/OnboardingRedirectService.php | 20 - .../Services/Parsers/VrtArticlePageParser.php | 2 +- .../app/Services/RoutingValidationService.php | 2 +- backend/routes/api.php | 13 +- .../Commands/FetchNewArticlesCommandTest.php | 16 +- .../Commands/FetchNewArticlesCommandTest.php | 127 ++++++ .../Commands/SyncChannelPostsCommandTest.php | 145 ++++++ .../Unit/Exceptions/PublishExceptionTest.php | 104 +++++ .../Unit/Modules/Lemmy/LemmyRequestTest.php | 173 ++++++++ .../Lemmy/Services/LemmyApiServiceTest.php | 417 ++++++++++++++++++ .../Lemmy/Services/LemmyPublisherTest.php | 393 +++++++++++++++++ .../Unit/Services/ArticleFetcherTest.php | 9 + .../Services/Auth/LemmyAuthServiceTest.php | 20 +- .../Parsers/VrtArticlePageParserTest.php | 325 ++++++++++++++ .../Services/RoutingValidationServiceTest.php | 202 +++++++++ .../Unit/Services/ValidationServiceTest.php | 9 + 20 files changed, 1928 insertions(+), 171 deletions(-) delete mode 100644 backend/app/Http/Controllers/Api/V1/AuthController.php delete mode 100644 backend/app/Services/OnboardingRedirectService.php create mode 100644 backend/tests/Unit/Console/Commands/FetchNewArticlesCommandTest.php create mode 100644 backend/tests/Unit/Console/Commands/SyncChannelPostsCommandTest.php create mode 100644 backend/tests/Unit/Exceptions/PublishExceptionTest.php create mode 100644 backend/tests/Unit/Modules/Lemmy/LemmyRequestTest.php create mode 100644 backend/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php create mode 100644 backend/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php create mode 100644 backend/tests/Unit/Services/Parsers/VrtArticlePageParserTest.php create mode 100644 backend/tests/Unit/Services/RoutingValidationServiceTest.php diff --git a/backend/app/Http/Controllers/Api/V1/AuthController.php b/backend/app/Http/Controllers/Api/V1/AuthController.php deleted file mode 100644 index 8f336e0..0000000 --- a/backend/app/Http/Controllers/Api/V1/AuthController.php +++ /dev/null @@ -1,112 +0,0 @@ -validate([ - 'email' => 'required|email', - 'password' => 'required', - ]); - - $user = User::where('email', $request->email)->first(); - - if (!$user || !Hash::check($request->password, $user->password)) { - return $this->sendError('Invalid credentials', [], 401); - } - - $token = $user->createToken('api-token')->plainTextToken; - - return $this->sendResponse([ - 'user' => [ - 'id' => $user->id, - 'name' => $user->name, - 'email' => $user->email, - ], - 'token' => $token, - 'token_type' => 'Bearer', - ], 'Login successful'); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Login failed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Register a new user - */ - public function register(Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|string|email|max:255|unique:users', - 'password' => 'required|string|min:8|confirmed', - ]); - - $user = User::create([ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'password' => Hash::make($validated['password']), - ]); - - $token = $user->createToken('api-token')->plainTextToken; - - return $this->sendResponse([ - 'user' => [ - 'id' => $user->id, - 'name' => $user->name, - 'email' => $user->email, - ], - 'token' => $token, - 'token_type' => 'Bearer', - ], 'Registration successful', 201); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Registration failed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Logout user (revoke token) - */ - public function logout(Request $request): JsonResponse - { - try { - $request->user()->currentAccessToken()->delete(); - - return $this->sendResponse(null, 'Logged out successfully'); - } catch (\Exception $e) { - return $this->sendError('Logout failed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Get current authenticated user - */ - public function me(Request $request): JsonResponse - { - return $this->sendResponse([ - 'user' => [ - 'id' => $request->user()->id, - 'name' => $request->user()->name, - 'email' => $request->user()->email, - ], - ], 'User retrieved successfully'); - } -} \ No newline at end of file diff --git a/backend/app/Jobs/SyncChannelPostsJob.php b/backend/app/Jobs/SyncChannelPostsJob.php index 5f40503..4707b83 100644 --- a/backend/app/Jobs/SyncChannelPostsJob.php +++ b/backend/app/Jobs/SyncChannelPostsJob.php @@ -29,8 +29,8 @@ public static function dispatchForAllActiveChannels(): void { PlatformChannel::with(['platformInstance', 'platformAccounts']) ->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY)) - ->whereHas('platformAccounts', fn ($query) => $query->where('is_active', true)) - ->where('is_active', true) + ->whereHas('platformAccounts', fn ($query) => $query->where('platform_accounts.is_active', true)) + ->where('platform_channels.is_active', true) ->get() ->each(function (PlatformChannel $channel) { self::dispatch($channel); diff --git a/backend/app/Modules/Lemmy/Services/LemmyPublisher.php b/backend/app/Modules/Lemmy/Services/LemmyPublisher.php index 68a2651..5b153d0 100644 --- a/backend/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/backend/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -28,7 +28,7 @@ public function __construct(PlatformAccount $account) */ public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array { - $token = LemmyAuthService::getToken($this->account); + $token = resolve(LemmyAuthService::class)->getToken($this->account); // Use the language ID from extracted data (should be set during validation) $languageId = $extractedData['language_id'] ?? null; @@ -37,7 +37,7 @@ public function publishToChannel(Article $article, array $extractedData, Platfor $token, $extractedData['title'] ?? 'Untitled', $extractedData['description'] ?? '', - $channel->channel_id, + (int) $channel->channel_id, $article->url, $extractedData['thumbnail'] ?? null, $languageId diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php index 1ee58b2..65ed4c6 100644 --- a/backend/app/Services/Auth/LemmyAuthService.php +++ b/backend/app/Services/Auth/LemmyAuthService.php @@ -13,7 +13,7 @@ class LemmyAuthService /** * @throws PlatformAuthException */ - public static function getToken(PlatformAccount $account): string + public function getToken(PlatformAccount $account): string { $cacheKey = "lemmy_jwt_token_$account->id"; $cachedToken = Cache::get($cacheKey); diff --git a/backend/app/Services/OnboardingRedirectService.php b/backend/app/Services/OnboardingRedirectService.php deleted file mode 100644 index 6382fbc..0000000 --- a/backend/app/Services/OnboardingRedirectService.php +++ /dev/null @@ -1,20 +0,0 @@ -input('redirect_to'); - - if ($redirectTo) { - return redirect($redirectTo)->with('success', $successMessage); - } - - return redirect()->route($defaultRoute)->with('success', $successMessage); - } -} \ No newline at end of file diff --git a/backend/app/Services/Parsers/VrtArticlePageParser.php b/backend/app/Services/Parsers/VrtArticlePageParser.php index 323713d..36e152d 100644 --- a/backend/app/Services/Parsers/VrtArticlePageParser.php +++ b/backend/app/Services/Parsers/VrtArticlePageParser.php @@ -12,7 +12,7 @@ public static function extractTitle(string $html): ?string } // Try h1 tag - if (preg_match('/]*>([^<]+)<\/h1>/i', $html, $matches)) { + if (preg_match('/]*>(.*?)<\/h1>/is', $html, $matches)) { return html_entity_decode(strip_tags($matches[1]), ENT_QUOTES, 'UTF-8'); } diff --git a/backend/app/Services/RoutingValidationService.php b/backend/app/Services/RoutingValidationService.php index 7ddff2a..beb6520 100644 --- a/backend/app/Services/RoutingValidationService.php +++ b/backend/app/Services/RoutingValidationService.php @@ -24,7 +24,7 @@ public function validateLanguageCompatibility(Feed $feed, Collection $channels): continue; } - if ($feed->language !== $channel->language) { + if ($feed->language->id !== $channel->language->id) { throw new RoutingMismatchException($feed, $channel); } } diff --git a/backend/routes/api.php b/backend/routes/api.php index 4b3b990..b67b885 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,7 +1,6 @@ group(function () { - // Public authentication routes - Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login'); - Route::post('/auth/register', [AuthController::class, 'register'])->name('api.auth.register'); - - // Protected authentication routes - Route::middleware('auth:sanctum')->group(function () { - Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout'); - Route::get('/auth/me', [AuthController::class, 'me'])->name('api.auth.me'); - }); - - // For demo purposes, making most endpoints public. In production, wrap in auth:sanctum middleware + // All endpoints are public for demo purposes // Route::middleware('auth:sanctum')->group(function () { // Dashboard stats Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats'); diff --git a/backend/tests/Feature/Http/Console/Commands/FetchNewArticlesCommandTest.php b/backend/tests/Feature/Http/Console/Commands/FetchNewArticlesCommandTest.php index e02d822..ea6e8d6 100644 --- a/backend/tests/Feature/Http/Console/Commands/FetchNewArticlesCommandTest.php +++ b/backend/tests/Feature/Http/Console/Commands/FetchNewArticlesCommandTest.php @@ -15,58 +15,48 @@ class FetchNewArticlesCommandTest extends TestCase public function test_command_runs_successfully_when_feeds_exist(): void { - // Arrange + Queue::fake(); + Feed::factory()->create(['is_active' => true]); - // Act & Assert /** @var PendingCommand $exitCode */ $exitCode = $this->artisan('article:refresh'); $exitCode->assertSuccessful(); - // The command should complete without the "no feeds" message $exitCode->assertExitCode(0); } public function test_command_does_not_dispatch_jobs_when_no_active_feeds_exist(): void { - // Arrange Queue::fake(); - // No active feeds created - // Act /** @var PendingCommand $exitCode */ $exitCode = $this->artisan('article:refresh'); - // Assert $exitCode->assertSuccessful(); Queue::assertNotPushed(ArticleDiscoveryJob::class); } public function test_command_does_not_dispatch_jobs_when_only_inactive_feeds_exist(): void { - // Arrange Queue::fake(); + Feed::factory()->create(['is_active' => false]); - // Act /** @var PendingCommand $exitCode */ $exitCode = $this->artisan('article:refresh'); - // Assert $exitCode->assertSuccessful(); Queue::assertNotPushed(ArticleDiscoveryJob::class); } public function test_command_logs_when_no_feeds_available(): void { - // Arrange Queue::fake(); - // Act /** @var PendingCommand $exitCode */ $exitCode = $this->artisan('article:refresh'); - // Assert $exitCode->assertSuccessful(); $exitCode->expectsOutput('No active feeds found. Article discovery skipped.'); } diff --git a/backend/tests/Unit/Console/Commands/FetchNewArticlesCommandTest.php b/backend/tests/Unit/Console/Commands/FetchNewArticlesCommandTest.php new file mode 100644 index 0000000..80c2a07 --- /dev/null +++ b/backend/tests/Unit/Console/Commands/FetchNewArticlesCommandTest.php @@ -0,0 +1,127 @@ +create([ + 'key' => 'article_processing_enabled', + 'value' => 'false' + ]); + + $this->artisan('article:refresh') + ->expectsOutput('Article processing is disabled. Article discovery skipped.') + ->assertExitCode(0); + + Queue::assertNotPushed(ArticleDiscoveryJob::class); + } + + public function test_command_skips_when_no_active_feeds(): void + { + Queue::fake(); + + // Enable article processing + Setting::factory()->create([ + 'key' => 'article_processing_enabled', + 'value' => 'true' + ]); + + // Ensure no active feeds exist + Feed::factory()->create(['is_active' => false]); + + $this->artisan('article:refresh') + ->expectsOutput('No active feeds found. Article discovery skipped.') + ->assertExitCode(0); + + Queue::assertNotPushed(ArticleDiscoveryJob::class); + } + + public function test_command_dispatches_job_when_conditions_met(): void + { + Queue::fake(); + + // Enable article processing + Setting::factory()->create([ + 'key' => 'article_processing_enabled', + 'value' => 'true' + ]); + + // Create at least one active feed + Feed::factory()->create(['is_active' => true]); + + $this->artisan('article:refresh') + ->assertExitCode(0); + + Queue::assertPushed(ArticleDiscoveryJob::class); + } + + public function test_command_has_correct_signature(): void + { + $command = new FetchNewArticlesCommand(); + + $this->assertEquals('article:refresh', $command->getName()); + } + + public function test_command_has_correct_description(): void + { + $command = new FetchNewArticlesCommand(); + + $this->assertEquals('Fetches latest articles', $command->getDescription()); + } + + public function test_command_with_multiple_active_feeds_still_dispatches_once(): void + { + Queue::fake(); + + // Enable article processing + Setting::factory()->create([ + 'key' => 'article_processing_enabled', + 'value' => 'true' + ]); + + // Create multiple active feeds + Feed::factory()->count(3)->create(['is_active' => true]); + + $command = new FetchNewArticlesCommand(); + $result = $command->handle(); + + $this->assertEquals(0, $result); + Queue::assertPushed(ArticleDiscoveryJob::class, 1); + } + + public function test_command_ignores_inactive_feeds(): void + { + Queue::fake(); + + // Enable article processing + Setting::factory()->create([ + 'key' => 'article_processing_enabled', + 'value' => 'true' + ]); + + // Create mix of active and inactive feeds, but ensure at least one active + Feed::factory()->create(['is_active' => true]); + Feed::factory()->count(2)->create(['is_active' => false]); + + $command = new FetchNewArticlesCommand(); + $result = $command->handle(); + + $this->assertEquals(0, $result); + Queue::assertPushed(ArticleDiscoveryJob::class); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Console/Commands/SyncChannelPostsCommandTest.php b/backend/tests/Unit/Console/Commands/SyncChannelPostsCommandTest.php new file mode 100644 index 0000000..0138e80 --- /dev/null +++ b/backend/tests/Unit/Console/Commands/SyncChannelPostsCommandTest.php @@ -0,0 +1,145 @@ +create([ + 'platform' => PlatformEnum::LEMMY, + 'url' => 'https://lemmy.test' + ]); + + $account = PlatformAccount::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'is_active' => true + ]); + + $channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $instance->id, + 'is_active' => true + ]); + + // Link the account to the channel + $account->channels()->attach($channel->id); + } + + public function test_command_has_correct_signature(): void + { + $command = new SyncChannelPostsCommand(); + + $this->assertEquals('channel:sync', $command->getName()); + } + + public function test_command_has_correct_description(): void + { + $command = new SyncChannelPostsCommand(); + + $this->assertEquals('Manually sync channel posts for a platform', $command->getDescription()); + } + + public function test_command_syncs_lemmy_by_default(): void + { + $this->createTestChannelData(); + + $this->artisan('channel:sync') + ->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels') + ->assertExitCode(0); + + Queue::assertPushed(SyncChannelPostsJob::class); + } + + public function test_command_syncs_lemmy_when_explicitly_specified(): void + { + $this->createTestChannelData(); + + $this->artisan('channel:sync lemmy') + ->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels') + ->assertExitCode(0); + + Queue::assertPushed(SyncChannelPostsJob::class); + } + + public function test_command_fails_with_unsupported_platform(): void + { + $this->artisan('channel:sync twitter') + ->expectsOutput('Unsupported platform: twitter') + ->assertExitCode(1); + + Queue::assertNotPushed(SyncChannelPostsJob::class); + } + + public function test_command_fails_with_invalid_platform(): void + { + $this->artisan('channel:sync invalid') + ->expectsOutput('Unsupported platform: invalid') + ->assertExitCode(1); + + Queue::assertNotPushed(SyncChannelPostsJob::class); + } + + public function test_command_handles_empty_platform_argument(): void + { + $this->createTestChannelData(); + + // When no platform is provided, it defaults to 'lemmy' per the signature + $this->artisan('channel:sync') + ->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels') + ->assertExitCode(0); + + Queue::assertPushed(SyncChannelPostsJob::class); + } + + public function test_sync_lemmy_returns_success_code(): void + { + $this->createTestChannelData(); + + $this->artisan('channel:sync lemmy') + ->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels') + ->assertExitCode(0); + + Queue::assertPushed(SyncChannelPostsJob::class); + } + + public function test_command_signature_accepts_platform_argument(): void + { + $command = new SyncChannelPostsCommand(); + + // Check that the command definition includes the platform argument + $definition = $command->getDefinition(); + $this->assertTrue($definition->hasArgument('platform')); + + $platformArg = $definition->getArgument('platform'); + $this->assertEquals('lemmy', $platformArg->getDefault()); + } + + public function test_private_sync_lemmy_method_calls_job(): void + { + $this->createTestChannelData(); + + $this->artisan('channel:sync lemmy') + ->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels') + ->assertExitCode(0); + + Queue::assertPushed(SyncChannelPostsJob::class); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Exceptions/PublishExceptionTest.php b/backend/tests/Unit/Exceptions/PublishExceptionTest.php new file mode 100644 index 0000000..e2abed0 --- /dev/null +++ b/backend/tests/Unit/Exceptions/PublishExceptionTest.php @@ -0,0 +1,104 @@ +create(['id' => 123]); + + $exception = new PublishException($article, PlatformEnum::LEMMY); + + $this->assertSame($article, $exception->getArticle()); + $this->assertEquals(123, $exception->getArticle()->id); + } + + public function test_get_platform_returns_correct_platform(): void + { + $article = Article::factory()->create(); + + $exception = new PublishException($article, PlatformEnum::LEMMY); + + $this->assertSame(PlatformEnum::LEMMY, $exception->getPlatform()); + } + + public function test_get_platform_returns_null_when_no_platform(): void + { + $article = Article::factory()->create(); + + $exception = new PublishException($article, null); + + $this->assertNull($exception->getPlatform()); + } + + public function test_constructor_creates_message_with_article_id_and_platform(): void + { + $article = Article::factory()->create(['id' => 456]); + + $exception = new PublishException($article, PlatformEnum::LEMMY); + + $this->assertEquals('Failed to publish article #456 to lemmy', $exception->getMessage()); + } + + public function test_constructor_creates_message_with_article_id_only(): void + { + $article = Article::factory()->create(['id' => 789]); + + $exception = new PublishException($article, null); + + $this->assertEquals('Failed to publish article #789', $exception->getMessage()); + } + + public function test_constructor_includes_previous_exception_message(): void + { + $article = Article::factory()->create(['id' => 321]); + $previousException = new Exception('Original error message'); + + $exception = new PublishException($article, PlatformEnum::LEMMY, $previousException); + + $this->assertEquals('Failed to publish article #321 to lemmy: Original error message', $exception->getMessage()); + $this->assertSame($previousException, $exception->getPrevious()); + } + + public function test_constructor_includes_previous_exception_message_without_platform(): void + { + $article = Article::factory()->create(['id' => 654]); + $previousException = new Exception('Another error'); + + $exception = new PublishException($article, null, $previousException); + + $this->assertEquals('Failed to publish article #654: Another error', $exception->getMessage()); + $this->assertSame($previousException, $exception->getPrevious()); + } + + public function test_exception_properties_are_immutable(): void + { + $article = Article::factory()->create(); + + $exception = new PublishException($article, PlatformEnum::LEMMY); + + // These methods should return the same instances every time + $this->assertSame($exception->getArticle(), $exception->getArticle()); + $this->assertSame($exception->getPlatform(), $exception->getPlatform()); + } + + public function test_works_with_different_platform_enum_values(): void + { + $article = Article::factory()->create(); + + // Test with different platform values (if more are available) + $exception = new PublishException($article, PlatformEnum::LEMMY); + $this->assertSame(PlatformEnum::LEMMY, $exception->getPlatform()); + $this->assertStringContainsString('lemmy', $exception->getMessage()); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Modules/Lemmy/LemmyRequestTest.php b/backend/tests/Unit/Modules/Lemmy/LemmyRequestTest.php new file mode 100644 index 0000000..07aaff4 --- /dev/null +++ b/backend/tests/Unit/Modules/Lemmy/LemmyRequestTest.php @@ -0,0 +1,173 @@ +assertInstanceOf(LemmyRequest::class, $request); + } + + public function test_constructor_sets_instance_without_token(): void + { + $instance = 'lemmy.test'; + + $request = new LemmyRequest($instance); + + $this->assertInstanceOf(LemmyRequest::class, $request); + } + + public function test_get_makes_request_without_token(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/test' => Http::response(['success' => true], 200) + ]); + + $request = new LemmyRequest('lemmy.test'); + $response = $request->get('test'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->successful()); + + Http::assertSent(function ($request) { + return $request->url() === 'https://lemmy.test/api/v3/test' && + !$request->hasHeader('Authorization'); + }); + } + + public function test_get_makes_request_with_token(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/test' => Http::response(['success' => true], 200) + ]); + + $request = new LemmyRequest('lemmy.test', 'test-token'); + $response = $request->get('test'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->successful()); + + Http::assertSent(function ($request) { + return $request->url() === 'https://lemmy.test/api/v3/test' && + $request->hasHeader('Authorization', 'Bearer test-token'); + }); + } + + public function test_get_includes_parameters(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/test*' => Http::response(['success' => true], 200) + ]); + + $request = new LemmyRequest('lemmy.test'); + $params = ['param1' => 'value1', 'param2' => 'value2']; + $response = $request->get('test', $params); + + $this->assertTrue($response->successful()); + + Http::assertSent(function ($request) use ($params) { + $query = parse_url($request->url(), PHP_URL_QUERY); + parse_str($query, $queryParams); + return $queryParams === $params; + }); + } + + public function test_post_makes_request_without_token(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/test' => Http::response(['success' => true], 200) + ]); + + $request = new LemmyRequest('lemmy.test'); + $response = $request->post('test'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->successful()); + + Http::assertSent(function ($request) { + return $request->url() === 'https://lemmy.test/api/v3/test' && + $request->method() === 'POST' && + !$request->hasHeader('Authorization'); + }); + } + + public function test_post_makes_request_with_token(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/test' => Http::response(['success' => true], 200) + ]); + + $request = new LemmyRequest('lemmy.test', 'test-token'); + $response = $request->post('test'); + + $this->assertTrue($response->successful()); + + Http::assertSent(function ($request) { + return $request->url() === 'https://lemmy.test/api/v3/test' && + $request->method() === 'POST' && + $request->hasHeader('Authorization', 'Bearer test-token'); + }); + } + + public function test_post_includes_data(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/test' => Http::response(['success' => true], 200) + ]); + + $request = new LemmyRequest('lemmy.test'); + $data = ['field1' => 'value1', 'field2' => 'value2']; + $response = $request->post('test', $data); + + $this->assertTrue($response->successful()); + + Http::assertSent(function ($request) use ($data) { + return $request->data() === $data; + }); + } + + public function test_with_token_returns_self_and_updates_token(): void + { + $request = new LemmyRequest('lemmy.test'); + $result = $request->withToken('new-token'); + + $this->assertSame($request, $result); + + // Verify token is used in subsequent requests + Http::fake([ + 'https://lemmy.test/api/v3/test' => Http::response(['success' => true], 200) + ]); + + $request->get('test'); + + Http::assertSent(function ($request) { + return $request->hasHeader('Authorization', 'Bearer new-token'); + }); + } + + public function test_requests_have_timeout(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/test' => Http::response(['success' => true], 200) + ]); + + $request = new LemmyRequest('lemmy.test'); + $request->get('test'); + + // This tests that timeout is set - actual timeout value is implementation detail + Http::assertSent(function ($request) { + return str_contains($request->url(), 'lemmy.test'); + }); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php b/backend/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php new file mode 100644 index 0000000..fe74ec9 --- /dev/null +++ b/backend/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php @@ -0,0 +1,417 @@ +assertInstanceOf(LemmyApiService::class, $service); + } + + public function test_login_successful(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/user/login' => Http::response([ + 'jwt' => 'test-jwt-token' + ], 200) + ]); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->login('testuser', 'testpass'); + + $this->assertEquals('test-jwt-token', $result); + + Http::assertSent(function ($request) { + return $request->url() === 'https://lemmy.test/api/v3/user/login' && + $request->method() === 'POST' && + $request->data() === [ + 'username_or_email' => 'testuser', + 'password' => 'testpass' + ]; + }); + } + + public function test_login_failed_response(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/user/login' => Http::response(['error' => 'Invalid credentials'], 401) + ]); + + Log::shouldReceive('error') + ->once() + ->with('Lemmy login failed', Mockery::type('array')); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->login('testuser', 'wrongpass'); + + $this->assertNull($result); + } + + public function test_login_missing_jwt_in_response(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/user/login' => Http::response(['success' => true], 200) + ]); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->login('testuser', 'testpass'); + + $this->assertNull($result); + } + + public function test_login_exception_handling(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/user/login' => function () { + throw new Exception('Network error'); + } + ]); + + Log::shouldReceive('error') + ->once() + ->with('Lemmy login exception', ['error' => 'Network error']); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->login('testuser', 'testpass'); + + $this->assertNull($result); + } + + public function test_get_community_id_successful(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/community*' => Http::response([ + 'community_view' => [ + 'community' => [ + 'id' => 123 + ] + ] + ], 200) + ]); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->getCommunityId('testcommunity', 'test-token'); + + $this->assertEquals(123, $result); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'community') && + $request->hasHeader('Authorization', 'Bearer test-token'); + }); + } + + public function test_get_community_id_failed_response(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/community*' => Http::response(['error' => 'Not found'], 404) + ]); + + Log::shouldReceive('error') + ->once() + ->with('Community lookup failed', Mockery::type('array')); + + $service = new LemmyApiService('lemmy.test'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to fetch community: 404'); + + $service->getCommunityId('nonexistent', 'test-token'); + } + + public function test_get_community_id_missing_data(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/community*' => Http::response([ + 'community_view' => ['community' => []] + ], 200) + ]); + + Log::shouldReceive('error') + ->once() + ->with('Community lookup failed', Mockery::type('array')); + + $service = new LemmyApiService('lemmy.test'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Community not found'); + + $service->getCommunityId('testcommunity', 'test-token'); + } + + public function test_sync_channel_posts_successful(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/post/list*' => Http::response([ + 'posts' => [ + [ + 'post' => [ + 'id' => 1, + 'name' => 'Test Post 1', + 'url' => 'https://example.com/1', + 'published' => '2023-01-01T00:00:00Z' + ] + ], + [ + 'post' => [ + 'id' => 2, + 'name' => 'Test Post 2', + 'published' => '2023-01-02T00:00:00Z' + ] + ] + ] + ], 200) + ]); + + Log::shouldReceive('info') + ->once() + ->with('Synced channel posts', [ + 'platform_channel_id' => 123, + 'posts_count' => 2 + ]); + + $service = new LemmyApiService('lemmy.test'); + $service->syncChannelPosts('test-token', 123, 'testcommunity'); + + // Verify posts were stored + $this->assertDatabaseCount('platform_channel_posts', 2); + $this->assertDatabaseHas('platform_channel_posts', [ + 'platform' => PlatformEnum::LEMMY, + 'channel_id' => '123', + 'post_id' => '1' + ]); + } + + public function test_sync_channel_posts_failed_response(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/post/list*' => Http::response(['error' => 'Unauthorized'], 401) + ]); + + Log::shouldReceive('warning') + ->once() + ->with('Failed to sync channel posts', [ + 'status' => 401, + 'platform_channel_id' => 123 + ]); + + $service = new LemmyApiService('lemmy.test'); + $service->syncChannelPosts('test-token', 123, 'testcommunity'); + + // Verify no posts were stored + $this->assertDatabaseCount('platform_channel_posts', 0); + } + + public function test_sync_channel_posts_exception_handling(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/post/list*' => function () { + throw new Exception('Network error'); + } + ]); + + Log::shouldReceive('error') + ->once() + ->with('Exception while syncing channel posts', [ + 'error' => 'Network error', + 'platform_channel_id' => 123 + ]); + + $service = new LemmyApiService('lemmy.test'); + $service->syncChannelPosts('test-token', 123, 'testcommunity'); + + $this->assertDatabaseCount('platform_channel_posts', 0); + } + + public function test_create_post_successful_minimal_data(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/post' => Http::response([ + 'post_view' => [ + 'post' => [ + 'id' => 456, + 'name' => 'Test Title' + ] + ] + ], 200) + ]); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->createPost('test-token', 'Test Title', 'Test Body', 123); + + $this->assertIsArray($result); + $this->assertEquals(456, $result['post_view']['post']['id']); + + Http::assertSent(function ($request) { + return $request->url() === 'https://lemmy.test/api/v3/post' && + $request->method() === 'POST' && + $request->data() === [ + 'name' => 'Test Title', + 'body' => 'Test Body', + 'community_id' => 123 + ]; + }); + } + + public function test_create_post_successful_with_all_optional_data(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/post' => Http::response([ + 'post_view' => ['post' => ['id' => 456]] + ], 200) + ]); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->createPost( + 'test-token', + 'Test Title', + 'Test Body', + 123, + 'https://example.com', + 'https://example.com/thumb.jpg', + 2 + ); + + $this->assertIsArray($result); + + Http::assertSent(function ($request) { + return $request->data() === [ + 'name' => 'Test Title', + 'body' => 'Test Body', + 'community_id' => 123, + 'url' => 'https://example.com', + 'custom_thumbnail' => 'https://example.com/thumb.jpg', + 'language_id' => 2 + ]; + }); + } + + public function test_create_post_failed_response(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/post' => Http::response(['error' => 'Validation failed'], 400) + ]); + + Log::shouldReceive('error') + ->once() + ->with('Post creation failed', Mockery::type('array')); + + $service = new LemmyApiService('lemmy.test'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to create post: 400'); + + $service->createPost('test-token', 'Test Title', 'Test Body', 123); + } + + public function test_create_post_exception_handling(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/post' => function () { + throw new Exception('Network error'); + } + ]); + + Log::shouldReceive('error') + ->once() + ->with('Post creation failed', ['error' => 'Network error']); + + $service = new LemmyApiService('lemmy.test'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Network error'); + + $service->createPost('test-token', 'Test Title', 'Test Body', 123); + } + + public function test_get_languages_successful(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/site' => Http::response([ + 'all_languages' => [ + ['id' => 0, 'code' => 'und', 'name' => 'Undetermined'], + ['id' => 1, 'code' => 'en', 'name' => 'English'], + ['id' => 2, 'code' => 'es', 'name' => 'Spanish'] + ] + ], 200) + ]); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->getLanguages(); + + $this->assertIsArray($result); + $this->assertCount(3, $result); + $this->assertEquals('English', $result[1]['name']); + } + + public function test_get_languages_failed_response(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/site' => Http::response(['error' => 'Server error'], 500) + ]); + + Log::shouldReceive('warning') + ->once() + ->with('Failed to fetch site languages', ['status' => 500]); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->getLanguages(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function test_get_languages_missing_data(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/site' => Http::response(['site_view' => []], 200) + ]); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->getLanguages(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function test_get_languages_exception_handling(): void + { + Http::fake([ + 'https://lemmy.test/api/v3/site' => function () { + throw new Exception('Network error'); + } + ]); + + Log::shouldReceive('error') + ->once() + ->with('Exception while fetching languages', ['error' => 'Network error']); + + $service = new LemmyApiService('lemmy.test'); + $result = $service->getLanguages(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php b/backend/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php new file mode 100644 index 0000000..e02cd49 --- /dev/null +++ b/backend/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php @@ -0,0 +1,393 @@ +create([ + 'platform' => PlatformEnum::LEMMY, + 'instance_url' => 'https://lemmy.test' + ]); + + $publisher = new LemmyPublisher($account); + + $this->assertInstanceOf(LemmyPublisher::class, $publisher); + } + + public function test_publish_to_channel_with_minimal_data(): void + { + $account = PlatformAccount::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'instance_url' => 'https://lemmy.test', + 'username' => 'testuser', + 'password' => 'testpass' + ]); + + $article = Article::factory()->create([ + 'url' => 'https://example.com/article' + ]); + + $instance = PlatformInstance::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'url' => 'https://lemmy.test' + ]); + + $channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $instance->id, + 'channel_id' => 123, + 'name' => 'testcommunity' + ]); + + $extractedData = [ + 'title' => 'Test Article', + 'description' => 'Test Description' + ]; + + $expectedResult = [ + 'post_view' => [ + 'post' => [ + 'id' => 456, + 'name' => 'Test Article' + ] + ] + ]; + + // Mock LemmyAuthService + $this->mock(LemmyAuthService::class, function ($mock) use ($account) { + $mock->shouldReceive('getToken') + ->once() + ->with($account) + ->andReturn('test-jwt-token'); + }); + + // Mock LemmyApiService + $apiServiceMock = Mockery::mock(LemmyApiService::class); + $apiServiceMock->shouldReceive('createPost') + ->once() + ->with( + 'test-jwt-token', + 'Test Article', + 'Test Description', + 123, + 'https://example.com/article', + null, + null + ) + ->andReturn($expectedResult); + + // Use reflection to replace the api service in the publisher + $publisher = new LemmyPublisher($account); + $reflection = new \ReflectionClass($publisher); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($publisher, $apiServiceMock); + + $result = $publisher->publishToChannel($article, $extractedData, $channel); + + $this->assertEquals($expectedResult, $result); + } + + public function test_publish_to_channel_with_all_optional_data(): void + { + $account = PlatformAccount::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'instance_url' => 'https://lemmy.test', + 'username' => 'testuser', + 'password' => 'testpass' + ]); + + $article = Article::factory()->create([ + 'url' => 'https://example.com/article' + ]); + + $instance = PlatformInstance::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'url' => 'https://lemmy.test' + ]); + + $channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $instance->id, + 'channel_id' => 123, + 'name' => 'testcommunity' + ]); + + $extractedData = [ + 'title' => 'Test Article', + 'description' => 'Test Description', + 'thumbnail' => 'https://example.com/thumb.jpg', + 'language_id' => 2 + ]; + + $expectedResult = [ + 'post_view' => [ + 'post' => [ + 'id' => 456, + 'name' => 'Test Article' + ] + ] + ]; + + // Mock LemmyAuthService + $this->mock(LemmyAuthService::class, function ($mock) use ($account) { + $mock->shouldReceive('getToken') + ->once() + ->with($account) + ->andReturn('test-jwt-token'); + }); + + // Mock LemmyApiService + $apiServiceMock = Mockery::mock(LemmyApiService::class); + $apiServiceMock->shouldReceive('createPost') + ->once() + ->with( + 'test-jwt-token', + 'Test Article', + 'Test Description', + 123, + 'https://example.com/article', + 'https://example.com/thumb.jpg', + 2 + ) + ->andReturn($expectedResult); + + // Use reflection to replace the api service in the publisher + $publisher = new LemmyPublisher($account); + $reflection = new \ReflectionClass($publisher); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($publisher, $apiServiceMock); + + $result = $publisher->publishToChannel($article, $extractedData, $channel); + + $this->assertEquals($expectedResult, $result); + } + + public function test_publish_to_channel_with_missing_title_uses_default(): void + { + $this->expectNotToPerformAssertions(); + + $account = PlatformAccount::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'instance_url' => 'https://lemmy.test', + 'username' => 'testuser', + 'password' => 'testpass' + ]); + + $article = Article::factory()->create([ + 'url' => 'https://example.com/article' + ]); + + $instance = PlatformInstance::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'url' => 'https://lemmy.test' + ]); + + $channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $instance->id, + 'channel_id' => 123, + 'name' => 'testcommunity' + ]); + + $extractedData = [ + 'description' => 'Test Description' + ]; + + // Mock LemmyAuthService + $this->mock(LemmyAuthService::class, function ($mock) use ($account) { + $mock->shouldReceive('getToken') + ->once() + ->with($account) + ->andReturn('test-jwt-token'); + }); + + // Mock LemmyApiService + $apiServiceMock = Mockery::mock(LemmyApiService::class); + $apiServiceMock->shouldReceive('createPost') + ->once() + ->with( + 'test-jwt-token', + 'Untitled', + 'Test Description', + 123, + 'https://example.com/article', + null, + null + ) + ->andReturn([]); + + // Use reflection to replace the api service in the publisher + $publisher = new LemmyPublisher($account); + $reflection = new \ReflectionClass($publisher); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($publisher, $apiServiceMock); + + $publisher->publishToChannel($article, $extractedData, $channel); + } + + public function test_publish_to_channel_with_missing_description_uses_default(): void + { + $this->expectNotToPerformAssertions(); + + $account = PlatformAccount::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'instance_url' => 'https://lemmy.test', + 'username' => 'testuser', + 'password' => 'testpass' + ]); + + $article = Article::factory()->create([ + 'url' => 'https://example.com/article' + ]); + + $instance = PlatformInstance::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'url' => 'https://lemmy.test' + ]); + + $channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $instance->id, + 'channel_id' => 123, + 'name' => 'testcommunity' + ]); + + $extractedData = [ + 'title' => 'Test Title' + ]; + + // Mock LemmyAuthService + $this->mock(LemmyAuthService::class, function ($mock) use ($account) { + $mock->shouldReceive('getToken') + ->once() + ->with($account) + ->andReturn('test-jwt-token'); + }); + + // Mock LemmyApiService + $apiServiceMock = Mockery::mock(LemmyApiService::class); + $apiServiceMock->shouldReceive('createPost') + ->once() + ->with( + 'test-jwt-token', + 'Test Title', + '', + 123, + 'https://example.com/article', + null, + null + ) + ->andReturn([]); + + // Use reflection to replace the api service in the publisher + $publisher = new LemmyPublisher($account); + $reflection = new \ReflectionClass($publisher); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($publisher, $apiServiceMock); + + $publisher->publishToChannel($article, $extractedData, $channel); + } + + public function test_publish_to_channel_throws_platform_auth_exception_when_auth_fails(): void + { + $account = PlatformAccount::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'instance_url' => 'https://lemmy.test', + 'username' => 'testuser', + 'password' => 'testpass' + ]); + + $article = Article::factory()->create(); + $channel = PlatformChannel::factory()->create(); + $extractedData = ['title' => 'Test', 'description' => 'Test']; + + // Mock LemmyAuthService to throw exception + $this->mock(LemmyAuthService::class, function ($mock) use ($account) { + $mock->shouldReceive('getToken') + ->once() + ->with($account) + ->andThrow(new PlatformAuthException(PlatformEnum::LEMMY, 'Auth failed')); + }); + + $publisher = new LemmyPublisher($account); + + $this->expectException(PlatformAuthException::class); + $this->expectExceptionMessage('Auth failed'); + + $publisher->publishToChannel($article, $extractedData, $channel); + } + + public function test_publish_to_channel_throws_exception_when_create_post_fails(): void + { + $account = PlatformAccount::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'instance_url' => 'https://lemmy.test', + 'username' => 'testuser', + 'password' => 'testpass' + ]); + + $article = Article::factory()->create(); + $channel = PlatformChannel::factory()->create(); + $extractedData = ['title' => 'Test', 'description' => 'Test']; + + // Mock LemmyAuthService + $this->mock(LemmyAuthService::class, function ($mock) use ($account) { + $mock->shouldReceive('getToken') + ->once() + ->with($account) + ->andReturn('test-jwt-token'); + }); + + // Mock LemmyApiService to throw exception + $apiServiceMock = Mockery::mock(LemmyApiService::class); + $apiServiceMock->shouldReceive('createPost') + ->once() + ->andThrow(new Exception('Post creation failed')); + + // Use reflection to replace the api service in the publisher + $publisher = new LemmyPublisher($account); + $reflection = new \ReflectionClass($publisher); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($publisher, $apiServiceMock); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Post creation failed'); + + $publisher->publishToChannel($article, $extractedData, $channel); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/ArticleFetcherTest.php b/backend/tests/Unit/Services/ArticleFetcherTest.php index 77a63ad..d32528c 100644 --- a/backend/tests/Unit/Services/ArticleFetcherTest.php +++ b/backend/tests/Unit/Services/ArticleFetcherTest.php @@ -11,12 +11,21 @@ use App\Services\Log\LogSaver; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Mockery; class ArticleFetcherTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + Http::fake([ + '*' => Http::response('', 200) + ]); + } + public function test_get_articles_from_feed_returns_collection(): void { $feed = Feed::factory()->create([ diff --git a/backend/tests/Unit/Services/Auth/LemmyAuthServiceTest.php b/backend/tests/Unit/Services/Auth/LemmyAuthServiceTest.php index a5691f8..903f697 100644 --- a/backend/tests/Unit/Services/Auth/LemmyAuthServiceTest.php +++ b/backend/tests/Unit/Services/Auth/LemmyAuthServiceTest.php @@ -38,7 +38,8 @@ public function test_get_token_returns_cached_token_when_available(): void ->with($cacheKey) ->andReturn($cachedToken); - $result = LemmyAuthService::getToken($account); + $service = new LemmyAuthService(); + $result = $service->getToken($account); $this->assertEquals($cachedToken, $result); } @@ -68,7 +69,8 @@ public function test_get_token_throws_exception_when_username_missing(): void $this->expectException(PlatformAuthException::class); $this->expectExceptionMessage('Missing credentials for account: '); - LemmyAuthService::getToken($account); + $service = new LemmyAuthService(); + $service->getToken($account); } public function test_get_token_throws_exception_when_password_missing(): void @@ -96,7 +98,8 @@ public function test_get_token_throws_exception_when_password_missing(): void $this->expectException(PlatformAuthException::class); $this->expectExceptionMessage('Missing credentials for account: testuser'); - LemmyAuthService::getToken($account); + $service = new LemmyAuthService(); + $service->getToken($account); } public function test_get_token_throws_exception_when_instance_url_missing(): void @@ -124,7 +127,8 @@ public function test_get_token_throws_exception_when_instance_url_missing(): voi $this->expectException(PlatformAuthException::class); $this->expectExceptionMessage('Missing credentials for account: testuser'); - LemmyAuthService::getToken($account); + $service = new LemmyAuthService(); + $service->getToken($account); } public function test_get_token_successfully_authenticates_and_caches_token(): void @@ -169,8 +173,9 @@ public function test_get_token_uses_account_specific_cache_key(): void ->with($cacheKey2) ->andReturn('token2'); - $result1 = LemmyAuthService::getToken($account1); - $result2 = LemmyAuthService::getToken($account2); + $service = new LemmyAuthService(); + $result1 = $service->getToken($account1); + $result2 = $service->getToken($account2); $this->assertEquals('token1', $result1); $this->assertEquals('token2', $result2); @@ -199,7 +204,8 @@ public function test_platform_auth_exception_contains_correct_platform(): void ->andReturn(null); try { - LemmyAuthService::getToken($account); + $service = new LemmyAuthService(); + $service->getToken($account); $this->fail('Expected PlatformAuthException to be thrown'); } catch (PlatformAuthException $e) { $this->assertEquals(PlatformEnum::LEMMY, $e->getPlatform()); diff --git a/backend/tests/Unit/Services/Parsers/VrtArticlePageParserTest.php b/backend/tests/Unit/Services/Parsers/VrtArticlePageParserTest.php new file mode 100644 index 0000000..b6a5c12 --- /dev/null +++ b/backend/tests/Unit/Services/Parsers/VrtArticlePageParserTest.php @@ -0,0 +1,325 @@ +'; + + $result = VrtArticlePageParser::extractTitle($html); + + $this->assertEquals('VRT News Article Title', $result); + } + + public function test_extract_title_returns_h1_when_og_title_not_present(): void + { + $html = '

Main Article Heading

'; + + $result = VrtArticlePageParser::extractTitle($html); + + $this->assertEquals('Main Article Heading', $result); + } + + public function test_extract_title_returns_title_tag_when_og_title_and_h1_not_present(): void + { + $html = 'Page Title'; + + $result = VrtArticlePageParser::extractTitle($html); + + $this->assertEquals('Page Title', $result); + } + + public function test_extract_title_decodes_html_entities(): void + { + $html = ''; + + $result = VrtArticlePageParser::extractTitle($html); + + $this->assertEquals('Title with & special "chars"', $result); + } + + public function test_extract_title_handles_h1_content_with_attributes(): void + { + $html = '

Simple H1 Title

'; + + $result = VrtArticlePageParser::extractTitle($html); + + $this->assertEquals('Simple H1 Title', $result); + } + + public function test_extract_title_handles_h1_with_nested_tags(): void + { + // Should extract content from h1 and strip nested tags + $html = '

Title with nested tags

'; + + $result = VrtArticlePageParser::extractTitle($html); + + // Should extract and strip tags to get clean text + $this->assertEquals('Title with nested tags', $result); + } + + public function test_extract_title_returns_null_when_none_found(): void + { + $html = '

No title tags here

'; + + $result = VrtArticlePageParser::extractTitle($html); + + $this->assertNull($result); + } + + public function test_extract_description_returns_og_description_when_present(): void + { + $html = ''; + + $result = VrtArticlePageParser::extractDescription($html); + + $this->assertEquals('This is the article description', $result); + } + + public function test_extract_description_returns_first_paragraph_when_og_description_not_present(): void + { + $html = '

This is the first paragraph content.

Second paragraph.

'; + + $result = VrtArticlePageParser::extractDescription($html); + + $this->assertEquals('This is the first paragraph content.', $result); + } + + public function test_extract_description_decodes_html_entities(): void + { + $html = ''; + + $result = VrtArticlePageParser::extractDescription($html); + + $this->assertEquals('Description with & entities ', $result); + } + + public function test_extract_description_strips_tags_from_paragraph(): void + { + $html = '

Paragraph with bold and italic text.

'; + + $result = VrtArticlePageParser::extractDescription($html); + + $this->assertEquals('Paragraph with bold and italic text.', $result); + } + + public function test_extract_description_returns_null_when_none_found(): void + { + $html = '
No paragraphs or meta description
'; + + $result = VrtArticlePageParser::extractDescription($html); + + $this->assertNull($result); + } + + public function test_extract_full_article_returns_all_paragraphs(): void + { + $html = ' +

First paragraph content.

+

Second paragraph with more text.

+

Third paragraph here.

+ '; + + $result = VrtArticlePageParser::extractFullArticle($html); + + $expected = "First paragraph content.\n\nSecond paragraph with more text.\n\nThird paragraph here."; + $this->assertEquals($expected, $result); + } + + public function test_extract_full_article_removes_script_and_style_tags(): void + { + $html = ' + + +

Actual content paragraph.

+ '; + + $result = VrtArticlePageParser::extractFullArticle($html); + + $this->assertEquals('Actual content paragraph.', $result); + } + + public function test_extract_full_article_strips_tags_from_paragraphs(): void + { + $html = ' +

Paragraph with bold and link tags.

+ '; + + $result = VrtArticlePageParser::extractFullArticle($html); + + $this->assertEquals('Paragraph with bold and link tags.', $result); + } + + public function test_extract_full_article_filters_out_empty_paragraphs(): void + { + $html = ' +

First paragraph.

+

+

+

Second paragraph.

+ '; + + $result = VrtArticlePageParser::extractFullArticle($html); + + $this->assertEquals("First paragraph.\n\nSecond paragraph.", $result); + } + + public function test_extract_full_article_decodes_html_entities(): void + { + $html = ' +

Text with & entities and "quotes".

+ '; + + $result = VrtArticlePageParser::extractFullArticle($html); + + $this->assertEquals('Text with & entities and "quotes".', $result); + } + + public function test_extract_full_article_returns_null_when_no_paragraphs(): void + { + $html = '
No paragraph tags
'; + + $result = VrtArticlePageParser::extractFullArticle($html); + + $this->assertNull($result); + } + + public function test_extract_thumbnail_returns_og_image_when_present(): void + { + $html = ''; + + $result = VrtArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://example.com/image.jpg', $result); + } + + public function test_extract_thumbnail_returns_first_img_src_when_og_image_not_present(): void + { + $html = 'Photo'; + + $result = VrtArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://example.com/photo.png', $result); + } + + public function test_extract_thumbnail_returns_null_when_none_found(): void + { + $html = '
No images here
'; + + $result = VrtArticlePageParser::extractThumbnail($html); + + $this->assertNull($result); + } + + public function test_extract_data_returns_all_extracted_fields(): void + { + $html = ' + + + + + + +

First paragraph of article.

+

Second paragraph of article.

+ + '; + + $result = VrtArticlePageParser::extractData($html); + + $this->assertIsArray($result); + $this->assertEquals('Article Title', $result['title']); + $this->assertEquals('Article Description', $result['description']); + $this->assertEquals("First paragraph of article.\n\nSecond paragraph of article.", $result['full_article']); + $this->assertEquals('https://example.com/thumb.jpg', $result['thumbnail']); + } + + public function test_extract_data_handles_missing_elements(): void + { + $html = '
Minimal content
'; + + $result = VrtArticlePageParser::extractData($html); + + $this->assertIsArray($result); + $this->assertArrayHasKey('title', $result); + $this->assertArrayHasKey('description', $result); + $this->assertArrayHasKey('full_article', $result); + $this->assertArrayHasKey('thumbnail', $result); + + $this->assertNull($result['title']); + $this->assertNull($result['description']); + $this->assertNull($result['full_article']); + $this->assertNull($result['thumbnail']); + } + + public function test_extract_data_with_partial_content(): void + { + $html = ' + Just Title +

Single paragraph

+ '; + + $result = VrtArticlePageParser::extractData($html); + + $this->assertEquals('Just Title', $result['title']); + $this->assertEquals('Single paragraph', $result['description']); + $this->assertEquals('Single paragraph', $result['full_article']); + $this->assertNull($result['thumbnail']); + } + + public function test_extract_title_prioritizes_og_title_over_h1_and_title(): void + { + $html = ' + + Page Title + + +

H1 Title

+ '; + + $result = VrtArticlePageParser::extractTitle($html); + + $this->assertEquals('OG Title', $result); + } + + public function test_extract_title_prioritizes_h1_over_title_when_no_og_title(): void + { + $html = ' + Page Title +

H1 Title

+ '; + + $result = VrtArticlePageParser::extractTitle($html); + + $this->assertEquals('H1 Title', $result); + } + + public function test_extract_description_prioritizes_og_description_over_paragraph(): void + { + $html = ' + +

First paragraph content

+ '; + + $result = VrtArticlePageParser::extractDescription($html); + + $this->assertEquals('OG Description', $result); + } + + public function test_extract_thumbnail_prioritizes_og_image_over_img_src(): void + { + $html = ' + + Image + '; + + $result = VrtArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://example.com/og-image.jpg', $result); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/RoutingValidationServiceTest.php b/backend/tests/Unit/Services/RoutingValidationServiceTest.php new file mode 100644 index 0000000..6f519cd --- /dev/null +++ b/backend/tests/Unit/Services/RoutingValidationServiceTest.php @@ -0,0 +1,202 @@ +service = new RoutingValidationService(); + } + + public function test_validate_language_compatibility_passes_when_feed_has_no_language(): void + { + $feed = Feed::factory()->create(['language_id' => null]); + + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $esLanguage = Language::factory()->create(['short_code' => 'es']); + $instance = PlatformInstance::factory()->create(); + + $channels = collect([ + PlatformChannel::factory()->create(['language_id' => $enLanguage->id, 'platform_instance_id' => $instance->id]), + PlatformChannel::factory()->create(['language_id' => $esLanguage->id, 'platform_instance_id' => $instance->id]), + ]); + + $this->service->validateLanguageCompatibility($feed, $channels); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + public function test_validate_language_compatibility_passes_when_channels_have_no_language(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + + $instance = PlatformInstance::factory()->create(); + $channels = collect([ + PlatformChannel::factory()->create(['language_id' => null, 'platform_instance_id' => $instance->id]), + PlatformChannel::factory()->create(['language_id' => null, 'platform_instance_id' => $instance->id]), + ]); + + $this->service->validateLanguageCompatibility($feed, $channels); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + public function test_validate_language_compatibility_passes_when_languages_match(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + + $instance = PlatformInstance::factory()->create(); + $channels = collect([ + PlatformChannel::factory()->create(['language_id' => $enLanguage->id, 'platform_instance_id' => $instance->id]), + PlatformChannel::factory()->create(['language_id' => $enLanguage->id, 'platform_instance_id' => $instance->id]), + ]); + + $this->service->validateLanguageCompatibility($feed, $channels); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + public function test_validate_language_compatibility_passes_with_mixed_null_and_matching_languages(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + + $instance = PlatformInstance::factory()->create(); + $channels = collect([ + PlatformChannel::factory()->create(['language_id' => $enLanguage->id, 'platform_instance_id' => $instance->id]), + PlatformChannel::factory()->create(['language_id' => null, 'platform_instance_id' => $instance->id]), + PlatformChannel::factory()->create(['language_id' => $enLanguage->id, 'platform_instance_id' => $instance->id]), + ]); + + $this->service->validateLanguageCompatibility($feed, $channels); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + public function test_validate_language_compatibility_throws_exception_when_languages_mismatch(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $esLanguage = Language::factory()->create(['short_code' => 'es']); + + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + + $instance = PlatformInstance::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $esLanguage->id, 'platform_instance_id' => $instance->id]); + $channels = collect([$channel]); + + $this->expectException(RoutingMismatchException::class); + + $this->service->validateLanguageCompatibility($feed, $channels); + } + + public function test_validate_language_compatibility_throws_exception_on_first_mismatch(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $esLanguage = Language::factory()->create(['short_code' => 'es']); + + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + + $instance = PlatformInstance::factory()->create(); + $mismatchChannel = PlatformChannel::factory()->create(['language_id' => $esLanguage->id, 'platform_instance_id' => $instance->id]); + $matchingChannel = PlatformChannel::factory()->create(['language_id' => $enLanguage->id, 'platform_instance_id' => $instance->id]); + + $channels = collect([$mismatchChannel, $matchingChannel]); + + $this->expectException(RoutingMismatchException::class); + + $this->service->validateLanguageCompatibility($feed, $channels); + } + + public function test_validate_language_compatibility_with_empty_collection(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + $channels = collect([]); + + $this->service->validateLanguageCompatibility($feed, $channels); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + public function test_validate_language_compatibility_exception_contains_correct_feed_and_channel(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $esLanguage = Language::factory()->create(['short_code' => 'es']); + + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + + $instance = PlatformInstance::factory()->create(); + $channel = PlatformChannel::factory()->create(['language_id' => $esLanguage->id, 'platform_instance_id' => $instance->id]); + $channels = collect([$channel]); + + try { + $this->service->validateLanguageCompatibility($feed, $channels); + $this->fail('Expected RoutingMismatchException to be thrown'); + } catch (RoutingMismatchException $e) { + // Exception should contain the feed and channel that caused the mismatch + $this->assertInstanceOf(RoutingMismatchException::class, $e); + } + } + + public function test_validate_language_compatibility_with_multiple_mismatching_channels(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $esLanguage = Language::factory()->create(['short_code' => 'es']); + $frLanguage = Language::factory()->create(['short_code' => 'fr']); + + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + + $instance = PlatformInstance::factory()->create(); + $channels = collect([ + PlatformChannel::factory()->create(['language_id' => $esLanguage->id, 'platform_instance_id' => $instance->id]), // This should trigger exception + PlatformChannel::factory()->create(['language_id' => $frLanguage->id, 'platform_instance_id' => $instance->id]), // This won't be reached + ]); + + $this->expectException(RoutingMismatchException::class); + + $this->service->validateLanguageCompatibility($feed, $channels); + } + + public function test_validate_language_compatibility_ignores_channels_after_first_mismatch(): void + { + $enLanguage = Language::factory()->create(['short_code' => 'en']); + $esLanguage = Language::factory()->create(['short_code' => 'es']); + $frLanguage = Language::factory()->create(['short_code' => 'fr']); + + $feed = Feed::factory()->create(['language_id' => $enLanguage->id]); + + $instance = PlatformInstance::factory()->create(); + $channels = collect([ + PlatformChannel::factory()->create(['language_id' => null, 'platform_instance_id' => $instance->id]), // Should be skipped + PlatformChannel::factory()->create(['language_id' => $esLanguage->id, 'platform_instance_id' => $instance->id]), // Should cause exception + PlatformChannel::factory()->create(['language_id' => $frLanguage->id, 'platform_instance_id' => $instance->id]), // Should not be reached + ]); + + $this->expectException(RoutingMismatchException::class); + + $this->service->validateLanguageCompatibility($feed, $channels); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/ValidationServiceTest.php b/backend/tests/Unit/Services/ValidationServiceTest.php index b104585..e282266 100644 --- a/backend/tests/Unit/Services/ValidationServiceTest.php +++ b/backend/tests/Unit/Services/ValidationServiceTest.php @@ -7,11 +7,20 @@ use App\Models\Feed; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; class ValidationServiceTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + Http::fake([ + '*' => Http::response('', 200) + ]); + } + public function test_validate_returns_article_with_validation_status(): void { $feed = Feed::factory()->create(); -- 2.45.2 From c17a858e639351e0690f2fecac73f209f43c8a73 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 8 Aug 2025 21:54:22 +0200 Subject: [PATCH 02/51] Remove previous onboarding implementation --- .../Services/OnboardingRedirectService.php | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 backend/app/Services/OnboardingRedirectService.php diff --git a/backend/app/Services/OnboardingRedirectService.php b/backend/app/Services/OnboardingRedirectService.php deleted file mode 100644 index 6382fbc..0000000 --- a/backend/app/Services/OnboardingRedirectService.php +++ /dev/null @@ -1,20 +0,0 @@ -input('redirect_to'); - - if ($redirectTo) { - return redirect($redirectTo)->with('success', $successMessage); - } - - return redirect()->route($defaultRoute)->with('success', $successMessage); - } -} \ No newline at end of file -- 2.45.2 From 17320ad05a441790c819f0ed3518d53dc7aa9774 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 9 Aug 2025 00:03:45 +0200 Subject: [PATCH 03/51] Add onboarding --- .../Api/V1/OnboardingController.php | 222 ++++++++++++++++++ .../app/Services/Auth/LemmyAuthService.php | 33 +++ backend/routes/api.php | 33 +-- frontend/src/App.tsx | 51 +++- frontend/src/contexts/OnboardingContext.tsx | 68 ++++++ frontend/src/lib/api.ts | 83 +++++++ .../src/pages/onboarding/OnboardingLayout.tsx | 17 ++ .../src/pages/onboarding/OnboardingWizard.tsx | 25 ++ .../pages/onboarding/steps/ChannelStep.tsx | 178 ++++++++++++++ .../pages/onboarding/steps/CompleteStep.tsx | 86 +++++++ .../src/pages/onboarding/steps/FeedStep.tsx | 193 +++++++++++++++ .../pages/onboarding/steps/PlatformStep.tsx | 138 +++++++++++ .../pages/onboarding/steps/WelcomeStep.tsx | 43 ++++ 13 files changed, 1145 insertions(+), 25 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/V1/OnboardingController.php create mode 100644 frontend/src/contexts/OnboardingContext.tsx create mode 100644 frontend/src/pages/onboarding/OnboardingLayout.tsx create mode 100644 frontend/src/pages/onboarding/OnboardingWizard.tsx create mode 100644 frontend/src/pages/onboarding/steps/ChannelStep.tsx create mode 100644 frontend/src/pages/onboarding/steps/CompleteStep.tsx create mode 100644 frontend/src/pages/onboarding/steps/FeedStep.tsx create mode 100644 frontend/src/pages/onboarding/steps/PlatformStep.tsx create mode 100644 frontend/src/pages/onboarding/steps/WelcomeStep.tsx diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php new file mode 100644 index 0000000..a1c3b1b --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php @@ -0,0 +1,222 @@ +exists(); + $hasFeed = Feed::where('is_active', true)->exists(); + $hasChannel = PlatformChannel::where('is_active', true)->exists(); + + $needsOnboarding = !$hasPlatformAccount || !$hasFeed || !$hasChannel; + + // Determine current step + $currentStep = null; + if ($needsOnboarding) { + if (!$hasPlatformAccount) { + $currentStep = 'platform'; + } elseif (!$hasFeed) { + $currentStep = 'feed'; + } elseif (!$hasChannel) { + $currentStep = 'channel'; + } + } + + return $this->sendResponse([ + 'needs_onboarding' => $needsOnboarding, + 'current_step' => $currentStep, + 'has_platform_account' => $hasPlatformAccount, + 'has_feed' => $hasFeed, + 'has_channel' => $hasChannel, + ], 'Onboarding status retrieved successfully.'); + } + + /** + * Get onboarding options (languages, platform instances) + */ + public function options(): JsonResponse + { + $languages = Language::where('is_active', true) + ->orderBy('name') + ->get(['id', 'short_code', 'name', 'native_name', 'is_active']); + + $platformInstances = PlatformInstance::where('is_active', true) + ->orderBy('name') + ->get(['id', 'platform', 'url', 'name', 'description', 'is_active']); + + return $this->sendResponse([ + 'languages' => $languages, + 'platform_instances' => $platformInstances, + ], 'Onboarding options retrieved successfully.'); + } + + /** + * Create platform account for onboarding + */ + public function createPlatform(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'instance_url' => 'required|url|max:255', + 'username' => 'required|string|max:255', + 'password' => 'required|string|min:6', + 'platform' => 'required|in:lemmy', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $validated = $validator->validated(); + + try { + // Create or get platform instance + $platformInstance = PlatformInstance::firstOrCreate([ + 'url' => $validated['instance_url'], + 'platform' => $validated['platform'], + ], [ + 'name' => parse_url($validated['instance_url'], PHP_URL_HOST) ?? 'Lemmy Instance', + 'is_active' => true, + ]); + + // Authenticate with Lemmy API + $authResponse = $this->lemmyAuthService->authenticate( + $validated['instance_url'], + $validated['username'], + $validated['password'] + ); + + // Create platform account with the current schema + $platformAccount = PlatformAccount::create([ + 'platform' => $validated['platform'], + 'instance_url' => $validated['instance_url'], + 'username' => $validated['username'], + 'password' => $validated['password'], + 'api_token' => $authResponse['jwt'] ?? null, + 'settings' => [ + 'display_name' => $authResponse['person_view']['person']['display_name'] ?? null, + 'description' => $authResponse['person_view']['person']['bio'] ?? null, + 'person_id' => $authResponse['person_view']['person']['id'] ?? null, + 'platform_instance_id' => $platformInstance->id, + ], + 'is_active' => true, + 'status' => 'active', + ]); + + return $this->sendResponse( + new PlatformAccountResource($platformAccount), + 'Platform account created successfully.' + ); + + } catch (\Exception $e) { + return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 422); + } + } + + /** + * Create feed for onboarding + */ + public function createFeed(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'url' => 'required|url|max:500', + 'type' => 'required|in:website,rss', + 'language_id' => 'required|exists:languages,id', + 'description' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $validated = $validator->validated(); + + $feed = Feed::create([ + 'name' => $validated['name'], + 'url' => $validated['url'], + 'type' => $validated['type'], + 'language_id' => $validated['language_id'], + 'description' => $validated['description'] ?? null, + 'is_active' => true, + ]); + + return $this->sendResponse( + new FeedResource($feed->load('language')), + 'Feed created successfully.' + ); + } + + /** + * Create channel for onboarding + */ + public function createChannel(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'platform_instance_id' => 'required|exists:platform_instances,id', + 'language_id' => 'required|exists:languages,id', + 'description' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $validated = $validator->validated(); + + $channel = PlatformChannel::create([ + 'platform_instance_id' => $validated['platform_instance_id'], + 'channel_id' => $validated['name'], // For Lemmy, this is the community name + 'name' => $validated['name'], + 'display_name' => ucfirst($validated['name']), + 'description' => $validated['description'] ?? null, + 'language_id' => $validated['language_id'], + 'is_active' => true, + ]); + + return $this->sendResponse( + new PlatformChannelResource($channel->load(['platformInstance', 'language'])), + 'Channel created successfully.' + ); + } + + /** + * Mark onboarding as complete + */ + public function complete(): JsonResponse + { + // In a real implementation, you might want to update a user preference + // or create a setting that tracks onboarding completion + // For now, we'll just return success since the onboarding status + // is determined by the existence of platform accounts, feeds, and channels + + return $this->sendResponse( + ['completed' => true], + 'Onboarding completed successfully.' + ); + } +} \ No newline at end of file diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php index 1ee58b2..6c0afda 100644 --- a/backend/app/Services/Auth/LemmyAuthService.php +++ b/backend/app/Services/Auth/LemmyAuthService.php @@ -6,6 +6,7 @@ use App\Exceptions\PlatformAuthException; use App\Models\PlatformAccount; use App\Modules\Lemmy\Services\LemmyApiService; +use Exception; use Illuminate\Support\Facades\Cache; class LemmyAuthService @@ -38,4 +39,36 @@ public static function getToken(PlatformAccount $account): string return $token; } + + /** + * Authenticate with Lemmy API and return user data with JWT + * @throws PlatformAuthException + */ + public function authenticate(string $instanceUrl, string $username, string $password): array + { + try { + $api = new LemmyApiService($instanceUrl); + $token = $api->login($username, $password); + + if (!$token) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for user: ' . $username); + } + + // Get user info with the token + // For now, we'll return a basic response structure + // In a real implementation, you might want to fetch user details + return [ + 'jwt' => $token, + 'person_view' => [ + 'person' => [ + 'id' => 0, // Would need API call to get actual user info + 'display_name' => null, + 'bio' => null, + ] + ] + ]; + } catch (Exception $e) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Authentication failed: ' . $e->getMessage()); + } + } } diff --git a/backend/routes/api.php b/backend/routes/api.php index 4b3b990..f89d445 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -5,11 +5,11 @@ use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\FeedsController; use App\Http\Controllers\Api\V1\LogsController; +use App\Http\Controllers\Api\V1\OnboardingController; use App\Http\Controllers\Api\V1\PlatformAccountsController; use App\Http\Controllers\Api\V1\PlatformChannelsController; use App\Http\Controllers\Api\V1\RoutingController; use App\Http\Controllers\Api\V1\SettingsController; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; /* @@ -27,23 +27,29 @@ // Public authentication routes Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login'); Route::post('/auth/register', [AuthController::class, 'register'])->name('api.auth.register'); - + // Protected authentication routes Route::middleware('auth:sanctum')->group(function () { Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout'); Route::get('/auth/me', [AuthController::class, 'me'])->name('api.auth.me'); }); - // For demo purposes, making most endpoints public. In production, wrap in auth:sanctum middleware - // Route::middleware('auth:sanctum')->group(function () { + // Onboarding + Route::get('/onboarding/status', [OnboardingController::class, 'status'])->name('api.onboarding.status'); + Route::get('/onboarding/options', [OnboardingController::class, 'options'])->name('api.onboarding.options'); + Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform'); + Route::post('/onboarding/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed'); + Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel'); + Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete'); + // Dashboard stats Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats'); - + // Articles Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index'); Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('api.articles.approve'); Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('api.articles.reject'); - + // Platform Accounts Route::apiResource('platform-accounts', PlatformAccountsController::class)->names([ 'index' => 'api.platform-accounts.index', @@ -54,7 +60,7 @@ ]); Route::post('/platform-accounts/{platformAccount}/set-active', [PlatformAccountsController::class, 'setActive']) ->name('api.platform-accounts.set-active'); - + // Platform Channels Route::apiResource('platform-channels', PlatformChannelsController::class)->names([ 'index' => 'api.platform-channels.index', @@ -65,7 +71,7 @@ ]); Route::post('/platform-channels/{channel}/toggle', [PlatformChannelsController::class, 'toggle']) ->name('api.platform-channels.toggle'); - + // Feeds Route::apiResource('feeds', FeedsController::class)->names([ 'index' => 'api.feeds.index', @@ -75,7 +81,7 @@ 'destroy' => 'api.feeds.destroy', ]); Route::post('/feeds/{feed}/toggle', [FeedsController::class, 'toggle'])->name('api.feeds.toggle'); - + // Routing Route::get('/routing', [RoutingController::class, 'index'])->name('api.routing.index'); Route::post('/routing', [RoutingController::class, 'store'])->name('api.routing.store'); @@ -83,14 +89,11 @@ Route::put('/routing/{feed}/{channel}', [RoutingController::class, 'update'])->name('api.routing.update'); Route::delete('/routing/{feed}/{channel}', [RoutingController::class, 'destroy'])->name('api.routing.destroy'); Route::post('/routing/{feed}/{channel}/toggle', [RoutingController::class, 'toggle'])->name('api.routing.toggle'); - + // Settings Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index'); Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update'); - + // Logs Route::get('/logs', [LogsController::class, 'index'])->name('api.logs.index'); - - // Close the auth:sanctum middleware group when ready - // }); -}); \ No newline at end of file +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d523794..04a0e4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,19 +5,50 @@ import Dashboard from './pages/Dashboard'; import Articles from './pages/Articles'; import Feeds from './pages/Feeds'; import Settings from './pages/Settings'; +import OnboardingWizard from './pages/onboarding/OnboardingWizard'; +import { OnboardingProvider, useOnboarding } from './contexts/OnboardingContext'; + +const AppContent: React.FC = () => { + const { isLoading } = useOnboarding(); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + return ( + + {/* Onboarding routes - outside of main layout */} + } /> + + {/* Main app routes - with layout */} + + + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + ); +}; const App: React.FC = () => { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - - + + + ); }; diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx new file mode 100644 index 0000000..47b9287 --- /dev/null +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -0,0 +1,68 @@ +import React, { createContext, useContext, type ReactNode } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { apiClient, type OnboardingStatus } from '../lib/api'; + +interface OnboardingContextValue { + onboardingStatus: OnboardingStatus | undefined; + isLoading: boolean; + needsOnboarding: boolean; +} + +const OnboardingContext = createContext(null); + +interface OnboardingProviderProps { + children: ReactNode; +} + +export const OnboardingProvider: React.FC = ({ children }) => { + const navigate = useNavigate(); + const location = useLocation(); + + const { data: onboardingStatus, isLoading } = useQuery({ + queryKey: ['onboarding-status'], + queryFn: () => apiClient.getOnboardingStatus(), + retry: 1, + }); + + const needsOnboarding = onboardingStatus?.needs_onboarding ?? false; + const isOnOnboardingPage = location.pathname.startsWith('/onboarding'); + + // Redirect logic + React.useEffect(() => { + if (isLoading) return; + + if (needsOnboarding && !isOnOnboardingPage) { + // User needs onboarding but is not on onboarding pages + const targetStep = onboardingStatus?.current_step; + if (targetStep) { + navigate(`/onboarding/${targetStep}`, { replace: true }); + } else { + navigate('/onboarding', { replace: true }); + } + } else if (!needsOnboarding && isOnOnboardingPage) { + // User doesn't need onboarding but is on onboarding pages + navigate('/dashboard', { replace: true }); + } + }, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]); + + const value: OnboardingContextValue = { + onboardingStatus, + isLoading, + needsOnboarding, + }; + + return ( + + {children} + + ); +}; + +export const useOnboarding = () => { + const context = useContext(OnboardingContext); + if (!context) { + throw new Error('useOnboarding must be used within an OnboardingProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1404909..b2fd143 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -127,6 +127,59 @@ export interface DashboardStats { current_period: string; } +// Onboarding types +export interface Language { + id: number; + short_code: string; + name: string; + native_name: string; + is_active: boolean; +} + +export interface PlatformInstance { + id: number; + platform: 'lemmy'; + url: string; + name: string; + description: string | null; + is_active: boolean; +} + +export interface OnboardingStatus { + needs_onboarding: boolean; + current_step: 'platform' | 'feed' | 'channel' | 'complete' | null; + has_platform_account: boolean; + has_feed: boolean; + has_channel: boolean; +} + +export interface OnboardingOptions { + languages: Language[]; + platform_instances: PlatformInstance[]; +} + +export interface PlatformAccountRequest { + instance_url: string; + username: string; + password: string; + platform: 'lemmy'; +} + +export interface FeedRequest { + name: string; + url: string; + type: 'rss' | 'website'; + language_id: number; + description?: string; +} + +export interface ChannelRequest { + name: string; + platform_instance_id: number; + language_id: number; + description?: string; +} + // API Client class class ApiClient { constructor() { @@ -205,6 +258,36 @@ class ApiClient { const response = await axios.put>('/settings', data); return response.data.data; } + + // Onboarding endpoints + async getOnboardingStatus(): Promise { + const response = await axios.get>('/onboarding/status'); + return response.data.data; + } + + async getOnboardingOptions(): Promise { + const response = await axios.get>('/onboarding/options'); + return response.data.data; + } + + async createPlatformAccount(data: PlatformAccountRequest): Promise { + const response = await axios.post>('/onboarding/platform', data); + return response.data.data; + } + + async createFeedForOnboarding(data: FeedRequest): Promise { + const response = await axios.post>('/onboarding/feed', data); + return response.data.data; + } + + async createChannelForOnboarding(data: ChannelRequest): Promise { + const response = await axios.post>('/onboarding/channel', data); + return response.data.data; + } + + async completeOnboarding(): Promise { + await axios.post('/onboarding/complete'); + } } export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/frontend/src/pages/onboarding/OnboardingLayout.tsx b/frontend/src/pages/onboarding/OnboardingLayout.tsx new file mode 100644 index 0000000..4985823 --- /dev/null +++ b/frontend/src/pages/onboarding/OnboardingLayout.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface OnboardingLayoutProps { + children: React.ReactNode; +} + +const OnboardingLayout: React.FC = ({ children }) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default OnboardingLayout; \ No newline at end of file diff --git a/frontend/src/pages/onboarding/OnboardingWizard.tsx b/frontend/src/pages/onboarding/OnboardingWizard.tsx new file mode 100644 index 0000000..b8379bb --- /dev/null +++ b/frontend/src/pages/onboarding/OnboardingWizard.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import OnboardingLayout from './OnboardingLayout'; +import WelcomeStep from './steps/WelcomeStep'; +import PlatformStep from './steps/PlatformStep'; +import FeedStep from './steps/FeedStep'; +import ChannelStep from './steps/ChannelStep'; +import CompleteStep from './steps/CompleteStep'; + +const OnboardingWizard: React.FC = () => { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +}; + +export default OnboardingWizard; \ No newline at end of file diff --git a/frontend/src/pages/onboarding/steps/ChannelStep.tsx b/frontend/src/pages/onboarding/steps/ChannelStep.tsx new file mode 100644 index 0000000..b15b244 --- /dev/null +++ b/frontend/src/pages/onboarding/steps/ChannelStep.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api'; + +const ChannelStep: React.FC = () => { + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + name: '', + platform_instance_id: 0, + language_id: 0, + description: '' + }); + const [errors, setErrors] = useState>({}); + + // Get onboarding options (languages, platform instances) + const { data: options, isLoading: optionsLoading } = useQuery({ + queryKey: ['onboarding-options'], + queryFn: () => apiClient.getOnboardingOptions() + }); + + const createChannelMutation = useMutation({ + mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data), + onSuccess: () => { + navigate('/onboarding/complete'); + }, + onError: (error: any) => { + if (error.response?.data?.errors) { + setErrors(error.response.data.errors); + } else { + setErrors({ general: [error.response?.data?.message || 'An error occurred'] }); + } + } + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + createChannelMutation.mutate(formData); + }; + + const handleChange = (field: keyof ChannelRequest, value: string | number) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear field error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: [] })); + } + }; + + if (optionsLoading) { + return
Loading...
; + } + + return ( +
+

Configure Your Channel

+

+ Set up a Lemmy community where articles will be posted +

+ + {/* Progress indicator */} +
+
โœ“
+
โœ“
+
3
+
4
+
+ +
+ {errors.general && ( +
+

{errors.general[0]}

+
+ )} + +
+ + handleChange('name', e.target.value)} + placeholder="technology" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> +

Enter the community name (without the @ or instance)

+ {errors.name && ( +

{errors.name[0]}

+ )} +
+ +
+ + + {errors.platform_instance_id && ( +

{errors.platform_instance_id[0]}

+ )} +
+ +
+ + + {errors.language_id && ( +

{errors.language_id[0]}

+ )} +
+ +
+ + + @error('feedDescription')

{{ $message }}

@enderror +
+ +
+ + +
+
+
+ @endif + + {{-- Step 4: Channel --}} + @if ($step === 4) +
+

Configure Your Channel

+

+ Set up a Lemmy community where articles will be posted +

+ + {{-- Progress indicator --}} +
+
โœ“
+
โœ“
+
3
+
4
+
+ +
+ @if (!empty($errors['general'])) +
+

{{ $errors['general'] }}

+
+ @endif + +
+ + +

Enter the community name (without the @ or instance)

+ @error('channelName')

{{ $message }}

@enderror +
+ +
+ + + @error('platformInstanceId')

{{ $message }}

@enderror +
+ +
+ + + @error('channelLanguageId')

{{ $message }}

@enderror +
+ +
+ + + @error('channelDescription')

{{ $message }}

@enderror +
+ +
+ + +
+
+
+ @endif + + {{-- Step 5: Route --}} + @if ($step === 5) +
+

Create Your First Route

+

+ Connect your feed to a channel by creating a route. This tells FFR which articles to post where. +

+ + {{-- Progress indicator --}} +
+
โœ“
+
โœ“
+
โœ“
+
4
+
5
+
+ +
+ @if (!empty($errors['general'])) +
+

{{ $errors['general'] }}

+
+ @endif + +
+ + + @error('routeFeedId')

{{ $message }}

@enderror +
+ +
+ + + @if ($channels->isEmpty()) +

+ No channels available. Please create a channel first. +

+ @endif + @error('routeChannelId')

{{ $message }}

@enderror +
+ +
+ + +

+ Higher priority routes are processed first (default: 50) +

+ @error('routePriority')

{{ $message }}

@enderror +
+ +
+ + +
+
+
+ @endif + + {{-- Step 6: Complete --}} + @if ($step === 6) +
+
+
+ + + +
+

Setup Complete!

+

+ Great! You've successfully configured FFR. Your feeds will now be monitored and articles will be automatically posted to your configured channels. +

+
+ + {{-- Progress indicator --}} +
+
โœ“
+
โœ“
+
โœ“
+
โœ“
+
+ +
+
+

What happens next?

+
    +
  • โ€ข Your feeds will be checked regularly for new articles
  • +
  • โ€ข New articles will be automatically posted to your channels
  • +
  • โ€ข You can monitor activity in the Articles and other sections
  • +
+
+ +
+

Want more control?

+

+ You can add more feeds, channels, and configure settings from the dashboard. +

+
+
+ +
+ + +
+ View Articles + โ€ข + Manage Feeds + โ€ข + Settings +
+
+
+ @endif + + diff --git a/resources/views/livewire/routes.blade.php b/resources/views/livewire/routes.blade.php new file mode 100644 index 0000000..364bbfa --- /dev/null +++ b/resources/views/livewire/routes.blade.php @@ -0,0 +1,380 @@ +
+
+
+

Routes

+

+ Manage connections between your feeds and channels +

+
+ +
+ +
+ @forelse ($routes as $route) +
+
+
+
+

+ {{ $route->feed?->name }} โ†’ {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }} +

+ @if ($route->is_active) + + + + + Active + + @else + + + + + Inactive + + @endif +
+
+ Priority: {{ $route->priority }} + + Feed: {{ $route->feed?->name }} + + Channel: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }} + + Created: {{ $route->created_at->format('M d, Y') }} +
+ @if ($route->platformChannel?->description) +

+ {{ $route->platformChannel->description }} +

+ @endif + @if ($route->keywords->isNotEmpty()) +
+
+ + + + + Keywords +
+
+ @foreach ($route->keywords as $keyword) + + {{ $keyword->keyword }} + + @endforeach +
+
+ @else +
+ No keyword filters - matches all articles +
+ @endif +
+
+ + + +
+
+
+ @empty +
+ + + +

No routes

+

+ Get started by creating a new route to connect feeds with channels. +

+
+ +
+
+ @endforelse +
+ + + @if ($showCreateModal) + + @endif + + + @if ($editingRoute) + + @endif +
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php new file mode 100644 index 0000000..96b050c --- /dev/null +++ b/resources/views/livewire/settings.blade.php @@ -0,0 +1,90 @@ +
+
+

Settings

+

+ Configure your system preferences +

+
+ +
+ +
+
+

+ + + + + Article Processing +

+

+ Control how articles are processed and handled +

+
+
+
+
+

+ Article Processing Enabled +

+

+ Enable automatic fetching and processing of articles from feeds +

+
+ +
+ +
+
+

+ Publishing Approvals Required +

+

+ Require manual approval before articles are published to platforms +

+
+ +
+
+
+ + + @if ($successMessage) +
+

{{ $successMessage }}

+
+ @endif + + @if ($errorMessage) +
+

{{ $errorMessage }}

+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 74bb7ca..b7fa3da 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,16 +1,36 @@ route('dashboard'); }); -Route::get('/dashboard', function () { - return view('dashboard'); -})->middleware(['auth', 'verified'])->name('dashboard'); +// Onboarding routes (protected by auth, but need incomplete onboarding) +Route::middleware(['auth', 'onboarding.incomplete'])->group(function () { + Route::get('/onboarding', Onboarding::class)->name('onboarding'); +}); +// Main app routes (protected by auth and require completed onboarding) +Route::middleware(['auth', 'onboarding.complete'])->group(function () { + Route::get('/dashboard', Dashboard::class)->name('dashboard'); + Route::get('/articles', Articles::class)->name('articles'); + Route::get('/feeds', Feeds::class)->name('feeds'); + Route::get('/channels', Channels::class)->name('channels'); + Route::get('/routes', Routes::class)->name('routes'); + Route::get('/settings', Settings::class)->name('settings'); +}); + +// Profile routes (auth protected, no onboarding check needed) Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); -- 2.45.2 From 4e0f0bb0728e2b6afe46cf4f0a11c710b0f17d48 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 23 Jan 2026 00:08:32 +0100 Subject: [PATCH 47/51] 73 - Fix dev environment --- Dockerfile.dev | 127 +++++++++++++++++++++++++ docker/dev/docker-compose.yml | 82 ++++++++++++++++ docker/dev/podman/.env.dev | 62 ------------ docker/dev/podman/Dockerfile | 53 ----------- docker/dev/podman/container-start.sh | 73 -------------- docker/dev/podman/docker-compose.yml | 76 --------------- docker/dev/podman/nginx.conf | 87 ----------------- docker/dev/podman/podman-sail-alias.sh | 52 ---------- docker/dev/podman/start-dev.sh | 81 ---------------- shell.nix | 109 +++++++++++++++++++++ vite.config.js | 9 ++ 11 files changed, 327 insertions(+), 484 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 docker/dev/docker-compose.yml delete mode 100644 docker/dev/podman/.env.dev delete mode 100644 docker/dev/podman/Dockerfile delete mode 100755 docker/dev/podman/container-start.sh delete mode 100644 docker/dev/podman/docker-compose.yml delete mode 100644 docker/dev/podman/nginx.conf delete mode 100755 docker/dev/podman/podman-sail-alias.sh delete mode 100755 docker/dev/podman/start-dev.sh create mode 100644 shell.nix diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..124ace1 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,127 @@ +# Development Dockerfile with FrankenPHP +FROM dunglas/frankenphp:latest-php8.3-alpine + +# Install system dependencies + development tools +RUN apk add --no-cache \ + nodejs \ + npm \ + git \ + mysql-client \ + vim \ + bash \ + nano + +# Install PHP extensions including xdebug for development +RUN install-php-extensions \ + pdo_mysql \ + opcache \ + zip \ + gd \ + intl \ + bcmath \ + redis \ + pcntl \ + xdebug + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Configure PHP for development +RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" + +# Configure Xdebug (disabled by default to reduce noise) +RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ + && echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ + && echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ + && echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + +# Configure Caddy for development (simpler, no worker mode) +RUN cat > /etc/caddy/Caddyfile < /start.sh <<'EOF' +#!/bin/sh +set -e + +# Create .env file if it doesn't exist +if [ ! -f ".env" ]; then + echo "Creating .env file from .env.example..." + cp .env.example .env +fi + +# Install dependencies if volumes are empty +if [ ! -f "vendor/autoload.php" ]; then + echo "Installing composer dependencies..." + composer install +fi + +# Always reinstall node_modules in container to get correct native binaries for Alpine/musl +echo "Installing npm dependencies..." +rm -rf node_modules 2>/dev/null || true +rm -rf /app/.npm 2>/dev/null || true +npm install --cache /tmp/.npm + +# Clear Laravel caches +php artisan config:clear || true +php artisan cache:clear || true + +# Wait for database and run migrations +echo "Waiting for database..." +sleep 5 +php artisan migrate --force || echo "Migration failed or not needed" + +# Run seeders +echo "Running seeders..." +php artisan db:seed --force || echo "Seeding skipped or already done" + +# Generate app key if not set +if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then + echo "Generating application key..." + php artisan key:generate +fi + +# Start Vite dev server in background +npm run dev & + +# Start Horizon (queue worker) in background +php artisan horizon & + +# Start FrankenPHP +exec frankenphp run --config /etc/caddy/Caddyfile +EOF + +RUN chmod +x /start.sh + +# Expose ports +EXPOSE 8000 5173 + +# Use the startup script +CMD ["/start.sh"] diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml new file mode 100644 index 0000000..fcb03bb --- /dev/null +++ b/docker/dev/docker-compose.yml @@ -0,0 +1,82 @@ +# =================== +# FFR Development Services +# =================== +# Port allocation: +# App: 8000 (frankenphp), 5173 (vite) +# DB: 3307 (mysql) +# Redis: 6380 + +services: + app: + build: + context: ../.. + dockerfile: Dockerfile.dev + container_name: ffr_dev_app + restart: unless-stopped + ports: + - "8000:8000" + - "5173:5173" + volumes: + - ../..:/app + environment: + APP_NAME: "FFR" + APP_ENV: "${APP_ENV:-local}" + APP_DEBUG: "${APP_DEBUG:-true}" + APP_URL: "${APP_URL:-http://localhost:8000}" + DB_CONNECTION: mysql + DB_HOST: db + DB_PORT: 3306 + DB_DATABASE: "${DB_DATABASE:-ffr_dev}" + DB_USERNAME: "${DB_USERNAME:-ffr}" + DB_PASSWORD: "${DB_PASSWORD:-ffr}" + REDIS_HOST: redis + REDIS_PORT: 6379 + SESSION_DRIVER: redis + CACHE_STORE: redis + QUEUE_CONNECTION: redis + VITE_HOST: "0.0.0.0" + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + networks: + - ffr-network + + db: + image: mariadb:11 + container_name: ffr_dev_db + restart: unless-stopped + ports: + - "3307:3306" + environment: + MYSQL_DATABASE: "${DB_DATABASE:-ffr_dev}" + MYSQL_USER: "${DB_USERNAME:-ffr}" + MYSQL_PASSWORD: "${DB_PASSWORD:-ffr}" + MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-ffr_root_dev}" + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - ffr-network + + redis: + image: redis:7-alpine + container_name: ffr_dev_redis + restart: unless-stopped + ports: + - "6380:6379" + networks: + - ffr-network + +networks: + ffr-network: + driver: bridge + +volumes: + db_data: diff --git a/docker/dev/podman/.env.dev b/docker/dev/podman/.env.dev deleted file mode 100644 index f81be47..0000000 --- a/docker/dev/podman/.env.dev +++ /dev/null @@ -1,62 +0,0 @@ -APP_NAME="FFR Development" -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_TIMEZONE=UTC -APP_URL=http://localhost:8000 - -APP_LOCALE=en -APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US - -APP_MAINTENANCE_DRIVER=file -APP_MAINTENANCE_STORE=database - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug - -DB_CONNECTION=mysql -DB_HOST=db -DB_PORT=3306 -DB_DATABASE=ffr_dev -DB_USERNAME=ffr_user -DB_PASSWORD=ffr_password - -SESSION_DRIVER=redis -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=redis - -CACHE_STORE=redis -CACHE_PREFIX= - -REDIS_CLIENT=phpredis -REDIS_HOST=redis -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -VITE_APP_NAME="${APP_NAME}" \ No newline at end of file diff --git a/docker/dev/podman/Dockerfile b/docker/dev/podman/Dockerfile deleted file mode 100644 index 3708dfe..0000000 --- a/docker/dev/podman/Dockerfile +++ /dev/null @@ -1,53 +0,0 @@ -FROM docker.io/library/php:8.4-fpm - -# Install system dependencies including nginx -RUN apt-get update && apt-get install -y \ - git \ - curl \ - libpng-dev \ - libonig-dev \ - libxml2-dev \ - zip \ - unzip \ - nginx \ - default-mysql-client \ - && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd \ - && pecl install redis xdebug \ - && docker-php-ext-enable redis xdebug - -# Install Node.js 22.x LTS (latest LTS version) -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y nodejs - -# Install Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# Set working directory -WORKDIR /var/www/html - -# Copy application code -COPY . . - -# Install PHP dependencies in backend -WORKDIR /var/www/html/backend -RUN composer install --optimize-autoloader --no-scripts - -# Build React frontend -WORKDIR /var/www/html/frontend -RUN npm install && npm run build - -# Back to main directory -WORKDIR /var/www/html - -# Set permissions -RUN chown -R www-data:www-data /var/www/html \ - && chmod -R 755 /var/www/html/backend/storage \ - && chmod -R 755 /var/www/html/backend/bootstrap/cache - -# Copy and set up container start script -COPY docker/dev/podman/container-start.sh /usr/local/bin/container-start.sh -RUN chmod +x /usr/local/bin/container-start.sh - -EXPOSE 80 - -CMD ["/usr/local/bin/container-start.sh"] \ No newline at end of file diff --git a/docker/dev/podman/container-start.sh b/docker/dev/podman/container-start.sh deleted file mode 100755 index 2f95f04..0000000 --- a/docker/dev/podman/container-start.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash - -# Copy development environment configuration to backend -cp /var/www/html/docker/dev/podman/.env.dev /var/www/html/backend/.env - -# Setup nginx configuration for development -cp /var/www/html/docker/dev/podman/nginx.conf /etc/nginx/sites-available/default - -# Install/update dependencies -echo "Installing PHP dependencies..." -cd /var/www/html/backend -composer install --no-interaction - -# Ensure APP_KEY is set in backend/.env -ENV_APP_KEY="${APP_KEY}" -if [ -n "$ENV_APP_KEY" ]; then - echo "Using APP_KEY from environment" - sed -i "s|^APP_KEY=.*|APP_KEY=${ENV_APP_KEY}|" /var/www/html/backend/.env || true -fi - -# Generate application key if still missing -CURRENT_APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2) -if [ -z "$CURRENT_APP_KEY" ]; then - echo "Generating application key..." - php artisan key:generate --force -fi - -# Verify APP_KEY -APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2) -if [ -n "$APP_KEY" ]; then - echo "โœ… APP_KEY successfully set." -else - echo "โŒ ERROR: APP_KEY not set!" -fi - -# Wait for database to be ready -echo "Waiting for database..." -while ! mysql -h db -u ffr_user -pffr_password --connect-timeout=2 -e "SELECT 1" >/dev/null 2>&1; do - echo "Database not ready, waiting..." - sleep 1 -done -echo "Database connection established!" - -# Run migrations and seeders -php artisan migrate --force -php artisan db:seed --force - -# Build frontend if not already built -cd /var/www/html/frontend -if [ ! -d "dist" ]; then - echo "Building React frontend..." - npm run build -fi - -# Start services -echo "Starting services..." - -# Start React dev server -cd /var/www/html/frontend -npm run dev -- --host 0.0.0.0 --port 5173 & - -# Start Laravel backend -cd /var/www/html/backend -php artisan serve --host=127.0.0.1 --port=8000 & - -# Start Horizon (manages queue workers in dev) -php artisan horizon & - -# Start nginx -nginx -g "daemon off;" & - -# Wait for background processes -wait diff --git a/docker/dev/podman/docker-compose.yml b/docker/dev/podman/docker-compose.yml deleted file mode 100644 index a7e86d3..0000000 --- a/docker/dev/podman/docker-compose.yml +++ /dev/null @@ -1,76 +0,0 @@ -services: - app: - build: - context: ../../.. - dockerfile: docker/dev/podman/Dockerfile - container_name: ffr-dev-app - restart: unless-stopped - working_dir: /var/www/html - environment: - - APP_ENV=local - - APP_DEBUG=true - - APP_KEY=base64:5VABFQKtzx6flRFn7rQUQYI/G8xLnkUSYPVaYz2s/4M= - - DB_CONNECTION=mysql - - DB_HOST=db - - DB_PORT=3306 - - DB_DATABASE=ffr_dev - - DB_USERNAME=ffr_user - - DB_PASSWORD=ffr_password - - REDIS_HOST=redis - - REDIS_PORT=6379 - - QUEUE_CONNECTION=redis - - CACHE_DRIVER=redis - - SESSION_DRIVER=redis - - VITE_PORT=5173 - volumes: - - ../../../:/var/www/html:Z - - /var/www/html/node_modules - - /var/www/html/vendor - ports: - - "8000:80" - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - networks: - - ffr-dev-network - - db: - image: docker.io/library/mysql:8.4 - container_name: ffr-dev-db - restart: unless-stopped - environment: - - MYSQL_DATABASE=ffr_dev - - MYSQL_USER=ffr_user - - MYSQL_PASSWORD=ffr_password - - MYSQL_ROOT_PASSWORD=root_password - volumes: - - db_data:/var/lib/mysql - ports: - - "3307:3306" - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "ffr_user", "-pffr_password"] - timeout: 5s - retries: 5 - interval: 3s - start_period: 30s - networks: - - ffr-dev-network - - redis: - image: docker.io/library/redis:7-alpine - container_name: ffr-dev-redis - restart: unless-stopped - ports: - - "6380:6379" - networks: - - ffr-dev-network - -networks: - ffr-dev-network: - driver: bridge - -volumes: - db_data: - driver: local \ No newline at end of file diff --git a/docker/dev/podman/nginx.conf b/docker/dev/podman/nginx.conf deleted file mode 100644 index ef692f5..0000000 --- a/docker/dev/podman/nginx.conf +++ /dev/null @@ -1,87 +0,0 @@ -server { - listen 80; - server_name localhost; - - # Proxy API requests to Laravel backend - location /api/ { - proxy_pass http://127.0.0.1:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_redirect off; - } - - # Serve Laravel public assets (images, etc.) - location /images/ { - alias /var/www/html/backend/public/images/; - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # Proxy Vite dev server assets - location /@vite/ { - proxy_pass http://127.0.0.1:5173; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_redirect off; - } - - # Proxy Vite HMR WebSocket - location /@vite/client { - proxy_pass http://127.0.0.1:5173; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_redirect off; - } - - # Proxy node_modules for Vite deps - location /node_modules/ { - proxy_pass http://127.0.0.1:5173; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_redirect off; - } - - # Proxy /src/ for Vite source files - location /src/ { - proxy_pass http://127.0.0.1:5173; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_redirect off; - } - - # Proxy React dev server for development (catch-all) - location / { - proxy_pass http://127.0.0.1:5173; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # WebSocket support for HMR - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_redirect off; - } - - # Security headers - add_header X-Content-Type-Options nosniff; - add_header X-Frame-Options DENY; - add_header X-XSS-Protection "1; mode=block"; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; -} \ No newline at end of file diff --git a/docker/dev/podman/podman-sail-alias.sh b/docker/dev/podman/podman-sail-alias.sh deleted file mode 100755 index 07b90ed..0000000 --- a/docker/dev/podman/podman-sail-alias.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash - -# Podman aliases for Laravel Sail compatibility -# Source this file to use Sail commands with Podman -# Usage: source docker/dev/podman/podman-sail-alias.sh - -# Create docker alias pointing to podman -alias docker='podman' - -# Create docker-compose alias pointing to podman-compose -alias docker-compose='podman-compose' - -# Sail wrapper function that uses podman-compose -sail() { - if [[ -f docker/dev/podman/docker-compose.yml ]]; then - podman-compose -f docker/dev/podman/docker-compose.yml "$@" - else - echo "โŒ Podman compose file not found at docker/dev/podman/docker-compose.yml" - return 1 - fi -} - -# FFR-specific helper functions -ffr-test() { - echo "๐Ÿงช Running FFR tests..." - podman exec ffr-dev-app php artisan test "$@" -} - -ffr-artisan() { - echo "๐Ÿ”ง Running artisan command..." - podman exec ffr-dev-app php artisan "$@" -} - -ffr-logs() { - echo "๐Ÿ“‹ Showing FFR application logs..." - podman-compose -f docker/dev/podman/docker-compose.yml logs -f app -} - -ffr-shell() { - echo "๐Ÿš Opening shell in FFR container..." - podman exec -it ffr-dev-app bash -} - -echo "โœ… FFR Podman aliases set up for Laravel Sail compatibility" -echo "๐Ÿณ 'docker' โ†’ 'podman'" -echo "๐Ÿ”ง 'docker-compose' โ†’ 'podman-compose'" -echo "โ›ต 'sail' โ†’ uses podman-compose with dev configuration" -echo "๐Ÿš€ FFR-specific commands:" -echo " 'ffr-test' โ†’ run tests" -echo " 'ffr-artisan' โ†’ run artisan commands" -echo " 'ffr-logs' โ†’ view application logs" -echo " 'ffr-shell' โ†’ open container shell" \ No newline at end of file diff --git a/docker/dev/podman/start-dev.sh b/docker/dev/podman/start-dev.sh deleted file mode 100755 index 55f455f..0000000 --- a/docker/dev/podman/start-dev.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash - -# Podman development environment startup script for FFR - -set -e - -echo "๐Ÿš€ Starting FFR development environment with Podman..." - -# Check if .env exists -if [ ! -f .env ]; then - echo "๐Ÿ“‹ Creating .env file from .env.example..." - cp .env.example .env -fi - -# Check if podman-compose is available -if ! command -v podman-compose &> /dev/null; then - echo "โŒ podman-compose not found." - exit -fi - -# Start services -echo "๐Ÿ”ง Starting services..." -podman-compose -f docker/dev/podman/docker-compose.yml up -d - -# Wait for database to be ready -echo "โณ Waiting for database to be ready..." -sleep 10 - -# Install/update dependencies if needed -echo "๐Ÿ“ฆ Installing dependencies..." -podman exec ffr-dev-app bash -c "cd /var/www/html/backend && composer install" -podman exec ffr-dev-app bash -c "cd /var/www/html/frontend && npm install" - -# Run migrations and seeders -echo "๐Ÿ—ƒ๏ธ Running database migrations..." -podman exec ffr-dev-app bash -c "cd /var/www/html/backend && php artisan migrate --force" -echo "๐ŸŒฑ Running database seeders..." -podman exec ffr-dev-app bash -c "cd /var/www/html/backend && php artisan db:seed --force" - -# Wait for container services to be fully ready -echo "โณ Waiting for container services to initialize..." -sleep 5 - -# Start React dev server if not already running -echo "๐Ÿš€ Starting React dev server..." -podman exec -d ffr-dev-app bash -c "cd /var/www/html/frontend && npm run dev -- --host 0.0.0.0 --port 5173 > /dev/null 2>&1 &" -sleep 5 - -# Verify Vite is running -if podman exec ffr-dev-app bash -c "curl -s http://localhost:5173 > /dev/null 2>&1"; then - echo "โœ… Vite dev server is running" -else - echo "โš ๏ธ Vite dev server may not have started properly" -fi - -# Check Laravel Horizon status inside the app container -echo "๐Ÿ” Checking Laravel Horizon in app container..." -HSTATUS="$(podman exec ffr-dev-app bash -lc "cd /var/www/html/backend && php artisan horizon:status" 2>/dev/null || echo "Horizon status: unknown")" -echo "$HSTATUS" -if echo "$HSTATUS" | grep -qi 'inactive'; then - echo "โ„น๏ธ Horizon is inactive. Attempting to start..." - podman exec -d ffr-dev-app bash -lc "cd /var/www/html/backend && php artisan horizon > /dev/null 2>&1 &" || true - sleep 2 - podman exec ffr-dev-app bash -lc "cd /var/www/html/backend && php artisan horizon:status" || true -else - echo "โœ… Horizon appears to be running." -fi -# Show supervisors summary (non-fatal if unavailable) -podman exec ffr-dev-app bash -lc "cd /var/www/html/backend && php artisan horizon:supervisors | sed -n '1,80p'" || true - -echo "โœ… Development environment is ready!" -echo "๐ŸŒ Application: http://localhost:8000" -echo "๐Ÿ”ฅ Vite dev server: http://localhost:5173" -echo "๐Ÿ’พ Database: localhost:3307" -echo "๐Ÿ”ด Redis: localhost:6380" -echo "" -echo "๐Ÿ“‹ Useful commands:" -echo " Stop: podman-compose -f docker/dev/podman/docker-compose.yml down" -echo " Logs: podman-compose -f docker/dev/podman/docker-compose.yml logs -f" -echo " Exec: podman exec -it ffr-dev-app bash" -echo " Tests: podman exec ffr-dev-app php artisan test" diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..20e63fc --- /dev/null +++ b/shell.nix @@ -0,0 +1,109 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = with pkgs; [ + # PHP and tools + php84 + php84Packages.composer + + # Node.js and npm + nodejs_22 + + # Container tools + podman + podman-compose + + # Database client (for direct DB access) + mariadb.client + + # Redis client + redis + + # Utilities + git + curl + gnumake + ]; + + shellHook = '' + export USER_ID=$(id -u) + export GROUP_ID=$(id -g) + export PODMAN_USERNS=keep-id + + # Compose file location + COMPOSE_FILE="$PWD/docker/dev/docker-compose.yml" + + # =================== + # ALIASES + # =================== + alias pc='podman-compose -f $COMPOSE_FILE' + + # =================== + # DEV COMMANDS + # =================== + dev-up() { + echo "Starting services..." + PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@" + echo "" + podman-compose -f $COMPOSE_FILE ps + echo "" + echo "App available at: http://localhost:8000" + } + + dev-down() { + if [[ "$1" == "-v" ]]; then + echo "Stopping services and removing volumes..." + podman-compose -f $COMPOSE_FILE down -v + else + echo "Stopping services..." + podman-compose -f $COMPOSE_FILE down + fi + } + + dev-restart() { + echo "Restarting services..." + podman-compose -f $COMPOSE_FILE restart "$@" + } + + dev-logs() { + podman-compose -f $COMPOSE_FILE logs -f app "$@" + } + + dev-logs-db() { + podman-compose -f $COMPOSE_FILE logs -f db "$@" + } + + dev-shell() { + podman-compose -f $COMPOSE_FILE exec app sh + } + + dev-artisan() { + podman-compose -f $COMPOSE_FILE exec app php artisan "$@" + } + + # =================== + # WELCOME MESSAGE + # =================== + echo "" + echo "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—" + echo "โ•‘ FFR Dev Environment โ•‘" + echo "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "" + echo " Podman: $(podman --version | cut -d' ' -f3)" + echo "" + echo "Commands:" + echo " dev-up [services] Start all or specific services" + echo " dev-down [-v] Stop services (-v removes volumes)" + echo " dev-restart Restart services" + echo " dev-logs Tail app logs" + echo " dev-logs-db Tail database logs" + echo " dev-shell Shell into app container" + echo " dev-artisan Run artisan command" + echo "" + echo "Services:" + echo " app Laravel + Vite http://localhost:8000" + echo " db MariaDB localhost:3307" + echo " redis Redis localhost:6380" + echo "" + ''; +} diff --git a/vite.config.js b/vite.config.js index 421b569..9e105b4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,4 +8,13 @@ export default defineConfig({ refresh: true, }), ], + server: { + host: '0.0.0.0', + port: 5173, + strictPort: true, + cors: true, + hmr: { + host: 'localhost', + }, + }, }); -- 2.45.2 From b6290c0f8d8992af848db3011a40acb2e5dd826e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 23 Jan 2026 00:30:05 +0100 Subject: [PATCH 48/51] 73 - Fix prod environment --- Dockerfile | 127 ++++++++++++ README.md | 299 ++++++++++----------------- docker/production/Dockerfile | 87 -------- docker/production/docker-compose.yml | 59 +++--- docker/production/nginx.conf | 82 -------- docker/production/start-app.sh | 51 ----- docker/production/supervisord.conf | 45 ---- shell.nix | 31 ++- 8 files changed, 291 insertions(+), 490 deletions(-) create mode 100644 Dockerfile delete mode 100644 docker/production/Dockerfile delete mode 100644 docker/production/nginx.conf delete mode 100644 docker/production/start-app.sh delete mode 100644 docker/production/supervisord.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..11ce824 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,127 @@ +# Production Dockerfile with FrankenPHP +FROM dunglas/frankenphp:latest-php8.3-alpine + +# Install system dependencies +RUN apk add --no-cache \ + nodejs \ + npm \ + git \ + mysql-client + +# Install PHP extensions +RUN install-php-extensions \ + pdo_mysql \ + opcache \ + zip \ + gd \ + intl \ + bcmath \ + redis \ + pcntl + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Set fixed production environment variables +ENV APP_ENV=production \ + APP_DEBUG=false \ + DB_CONNECTION=mysql \ + DB_HOST=db \ + DB_PORT=3306 \ + SESSION_DRIVER=redis \ + CACHE_STORE=redis \ + QUEUE_CONNECTION=redis \ + LOG_CHANNEL=stack \ + LOG_LEVEL=error + +# Copy application code first +COPY . . + +# Install PHP dependencies (production only) +RUN composer install --no-dev --no-interaction --optimize-autoloader + +# Install ALL Node dependencies (including dev for building) +RUN npm ci + +# Build frontend assets +RUN npm run build + +# Remove node_modules after build to save space +RUN rm -rf node_modules + +# Laravel optimizations +RUN php artisan config:cache \ + && php artisan route:cache \ + && php artisan view:cache \ + && composer dump-autoload --optimize + +# Set permissions +RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache + +# Configure Caddy +RUN cat > /etc/caddy/Caddyfile < /start-prod.sh <<'EOF' +#!/bin/sh +set -e + +# Wait for database to be ready +echo "Waiting for database..." +for i in $(seq 1 30); do + if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then + echo "Database is ready!" + break + fi + echo "Waiting for database... ($i/30)" + sleep 2 +done + +# Run migrations +echo "Running migrations..." +php artisan migrate --force || echo "Migrations failed or already up-to-date" + +# Start Horizon in the background +php artisan horizon & + +# Start FrankenPHP +exec frankenphp run --config /etc/caddy/Caddyfile +EOF + +RUN chmod +x /start-prod.sh + +# Start with our script +CMD ["/start-prod.sh"] diff --git a/README.md b/README.md index 1153f7b..47f8177 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,128 @@ -# Fedi Feed Router (FFR) v1.0.0 +# FFR (Feed to Fediverse Router) -
-FFR Logo +A Laravel-based application for routing RSS/Atom feeds to Fediverse platforms like Lemmy. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment. -**A minimal working version โ€” limited to two hardcoded sources, designed for self-hosters.** -*Future versions will expand configurability and support.* -
+## Features ---- +- **Feed aggregation** - Fetch articles from multiple RSS/Atom feeds +- **Fediverse publishing** - Automatically post to Lemmy communities +- **Route configuration** - Map feeds to specific channels with keywords +- **Approval workflow** - Optional manual approval before publishing +- **Queue processing** - Background job handling with Laravel Horizon +- **Single container deployment** - Simplified hosting with FrankenPHP -## ๐Ÿ”ฐ Project Overview +## Self-hosting -**One-liner:** FFR routes content from RSS/Atom feeds to the fediverse based on keyword matching. +The production image is available at `codeberg.org/lvl0/ffr:latest`. -FFR is a self-hosted tool that monitors RSS/Atom feeds, filters articles based on keywords, and automatically publishes matching content to fediverse platforms like Lemmy. This v1.0.0 release provides a working foundation with two hardcoded news sources (CBC and BBC), designed specifically for self-hosters who want a simple, privacy-first solution without SaaS dependencies. +### docker-compose.yml -## โš™๏ธ Features +```yaml +services: + app: + image: codeberg.org/lvl0/ffr:latest + container_name: ffr_app + restart: always + ports: + - "8000:8000" + environment: + APP_KEY: "${APP_KEY}" + APP_URL: "${APP_URL}" + DB_DATABASE: "${DB_DATABASE}" + DB_USERNAME: "${DB_USERNAME}" + DB_PASSWORD: "${DB_PASSWORD}" + REDIS_HOST: redis + REDIS_PORT: 6379 + volumes: + - app_storage:/app/storage + depends_on: + - db + - redis + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/up"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s -Current v1.0.0 features: -- โœ… Fetches articles from two hardcoded RSS feeds (CBC News, BBC News) -- โœ… Keyword-based content filtering and matching -- โœ… Automatic posting to Lemmy communities -- โœ… Web dashboard for monitoring and management -- โœ… Docker-based deployment for easy self-hosting -- โœ… Privacy-first design with no external dependencies + db: + image: mariadb:11 + container_name: ffr_db + restart: always + environment: + MYSQL_DATABASE: "${DB_DATABASE}" + MYSQL_USER: "${DB_USERNAME}" + MYSQL_PASSWORD: "${DB_PASSWORD}" + MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}" + volumes: + - db_data:/var/lib/mysql -Limitations (to be addressed in future versions): -- Feed sources are currently hardcoded (not user-configurable) -- Only supports Lemmy as target platform -- Basic keyword matching (no regex or complex rules yet) + redis: + image: redis:7-alpine + container_name: ffr_redis + restart: always + volumes: + - redis_data:/data -## ๐Ÿš€ Installation - -### Quick Start with Docker - -1. **Clone the repository:** - ```bash - git clone https://codeberg.org/lvl0/ffr.git - cd ffr - ``` - -2. **Create environment file:** - ```bash - cp docker/production/.env.example .env - ``` - -3. **Configure your environment variables:** - ```env - # Required variables only - APP_URL=http://your-domain.com:8000 - DB_PASSWORD=your-secure-db-password - DB_ROOT_PASSWORD=your-secure-root-password - ``` - -4. **Start the application:** - ```bash - docker-compose -f docker/production/docker-compose.yml up -d - ``` - -The application will be available at `http://localhost:8000` - -### System Requirements - -- Docker and Docker Compose (or Podman) -- 2GB RAM minimum -- 10GB disk space -- Linux/macOS/Windows with WSL2 - -## ๐Ÿ•น๏ธ Usage - -### Web Interface - -Access the dashboard at `http://localhost:8000` to: -- View fetched articles -- Monitor posting queue -- Check system logs -- Manage keywords (coming in v2.0) - -### Manual Commands - -Trigger article refresh manually: -```bash -docker compose exec app php artisan article:refresh +volumes: + db_data: + redis_data: + app_storage: ``` -View application logs: -```bash -docker compose logs -f app -``` +### Environment Variables -### Scheduled Tasks +| Variable | Required | Description | +|----------|----------|-------------| +| `APP_KEY` | Yes | Encryption key. Generate with: `echo "base64:$(openssl rand -base64 32)"` | +| `APP_URL` | Yes | Your domain (e.g., `https://ffr.example.com`) | +| `DB_DATABASE` | Yes | Database name | +| `DB_USERNAME` | Yes | Database user | +| `DB_PASSWORD` | Yes | Database password | +| `DB_ROOT_PASSWORD` | Yes | MariaDB root password | -The application automatically: -- Fetches new articles every hour -- Publishes matching articles every 5 minutes -- Syncs with Lemmy communities every 10 minutes +## Development -## ๐Ÿ“œ Logging & Debugging - -**Log locations:** -- Application logs: Available in web dashboard under "Logs" section -- Docker logs: `docker compose logs -f app` -- Laravel logs: Inside container at `/var/www/html/backend/storage/logs/` - -**Debug mode:** -To enable debug mode for troubleshooting, add to your `.env`: -```env -APP_DEBUG=true -``` -โš ๏ธ Remember to disable debug mode in production! - -## ๐Ÿค Contributing - -We welcome contributions! Here's how you can help: - -1. **Report bugs:** Open an issue describing the problem -2. **Suggest features:** Create an issue with your idea -3. **Submit PRs:** Fork, create a feature branch, and submit a pull request -4. **Improve docs:** Documentation improvements are always appreciated - -For development setup, see the [Development Setup](#development-setup) section below. - -## ๐Ÿ“˜ License - -This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3). -See [LICENSE](LICENSE) file for details. - -## ๐Ÿงญ Roadmap - -### v1.0.0 (Current Release) -- โœ… Basic feed fetching from hardcoded sources -- โœ… Keyword filtering -- โœ… Lemmy posting -- โœ… Web dashboard -- โœ… Docker deployment - -### v2.0.0 (Planned) -- [ ] User-configurable feed sources -- [ ] Advanced filtering rules (regex, boolean logic) -- [ ] Support for Mastodon and other ActivityPub platforms -- [ ] API for external integrations -- [ ] Multi-user support with permissions - -### v3.0.0 (Future) -- [ ] Machine learning-based content categorization -- [ ] Feed discovery and recommendations -- [ ] Scheduled posting with optimal timing -- [ ] Analytics and insights dashboard - ---- - -## Development Setup - -For contributors and developers who want to work on FFR: - -### Prerequisites - -- Podman and podman-compose (or Docker) -- Git -- PHP 8.2+ (for local development) -- Node.js 18+ (for frontend development) - -### Quick Start - -1. **Clone and start the development environment:** - ```bash - git clone https://codeberg.org/lvl0/ffr.git - cd ffr - ./docker/dev/podman/start-dev.sh - ``` - -2. **Access the development environment:** - - Web interface: http://localhost:8000 - - Vite dev server: http://localhost:5173 - - Database: localhost:3307 - - Redis: localhost:6380 - -### Development Commands +### NixOS / Nix ```bash -# Run tests with coverage -podman-compose -f docker/dev/podman/docker-compose.yml exec app bash -c "cd backend && XDEBUG_MODE=coverage php artisan test --coverage-html=coverage-report" - -# Execute artisan commands -podman-compose -f docker/dev/podman/docker-compose.yml exec app php artisan migrate -podman-compose -f docker/dev/podman/docker-compose.yml exec app php artisan tinker - -# View logs -podman-compose -f docker/dev/podman/docker-compose.yml logs -f - -# Access container shell -podman-compose -f docker/dev/podman/docker-compose.yml exec app bash - -# Stop environment -podman-compose -f docker/dev/podman/docker-compose.yml down +git clone https://codeberg.org/lvl0/ffr.git +cd ffr +nix-shell ``` -### Development Features +The shell will display available commands and optionally start the containers for you. -- **Hot reload:** Vite automatically reloads frontend changes -- **Pre-seeded database:** Sample data for immediate testing -- **Laravel Horizon:** Queue monitoring dashboard -- **Xdebug:** Configured for debugging and code coverage -- **Redis:** For caching, sessions, and queues +#### Available Commands ---- +| Command | Description | +|---------|-------------| +| `dev-up` | Start development environment | +| `dev-down` | Stop development environment | +| `dev-restart` | Restart containers | +| `dev-logs` | Follow app logs | +| `dev-logs-db` | Follow database logs | +| `dev-shell` | Enter app container | +| `dev-artisan ` | Run artisan commands | +| `prod-build [tag]` | Build and push prod image (default: latest) | + +#### Services + +| Service | URL | +|---------|-----| +| App | http://localhost:8000 | +| Vite | http://localhost:5173 | +| MariaDB | localhost:3307 | +| Redis | localhost:6380 | + +### Other Platforms + +Contributions welcome for development setup instructions on other platforms. + +## License + +This project is open-source software licensed under the [AGPL-3.0 license](LICENSE). ## Support -For help and support: -- ๐Ÿ’ฌ Open a [Discussion](https://codeberg.org/lvl0/ffr/discussions) -- ๐Ÿ› Report [Issues](https://codeberg.org/lvl0/ffr/issues) - ---- - -
-Built with โค๏ธ for the self-hosting community -
\ No newline at end of file +For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues). diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile deleted file mode 100644 index 8690f35..0000000 --- a/docker/production/Dockerfile +++ /dev/null @@ -1,87 +0,0 @@ -# Multi-stage build for FFR Laravel application -FROM node:22-alpine AS frontend-builder - -WORKDIR /app - -# Copy frontend package files -COPY frontend/package*.json ./ - -# Install Node dependencies -RUN npm ci - -# Copy frontend source -COPY frontend/ ./ - -# Build frontend assets -RUN npm run build - -# PHP runtime stage -FROM php:8.4-fpm-alpine - -# Install system dependencies -RUN apk add --no-cache \ - git \ - curl \ - libpng-dev \ - libxml2-dev \ - zip \ - unzip \ - oniguruma-dev \ - mysql-client \ - nginx \ - supervisor \ - autoconf \ - gcc \ - g++ \ - make - -# Install PHP extensions -RUN docker-php-ext-install \ - pdo_mysql \ - mbstring \ - exif \ - pcntl \ - bcmath \ - gd \ - && pecl install redis \ - && docker-php-ext-enable redis - -# Install Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# Set working directory -WORKDIR /var/www/html - -# Copy application code -COPY . . - -# Install PHP dependencies in backend directory -WORKDIR /var/www/html/backend -RUN composer install --no-dev --optimize-autoloader --no-interaction - -# Copy built frontend assets from builder stage to frontend dist -COPY --from=frontend-builder /app/dist/ /var/www/html/frontend/dist/ - -# Back to main directory -WORKDIR /var/www/html - -# Copy nginx and supervisor configurations -COPY docker/production/nginx.conf /etc/nginx/http.d/default.conf -COPY docker/production/supervisord.conf /etc/supervisord.conf -COPY docker/production/start-app.sh /usr/local/bin/start-app - -# Set proper permissions -RUN chown -R www-data:www-data /var/www/html \ - && chmod -R 755 /var/www/html/backend/storage \ - && chmod -R 755 /var/www/html/backend/bootstrap/cache \ - && chmod +x /usr/local/bin/start-app - -# Expose port 80 for nginx -EXPOSE 80 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD cd /var/www/html/backend && php artisan --version || exit 1 - -# Start the application -CMD ["/usr/local/bin/start-app"] \ No newline at end of file diff --git a/docker/production/docker-compose.yml b/docker/production/docker-compose.yml index 8b9aa31..20ce7c5 100644 --- a/docker/production/docker-compose.yml +++ b/docker/production/docker-compose.yml @@ -1,23 +1,28 @@ +# =================== +# FFR Production Services +# =================== + services: app: - image: codeberg.org/lvl0/ffr:v1.0.0-rc1 - container_name: ffr-app + build: + context: ../.. + dockerfile: Dockerfile + image: codeberg.org/lvl0/ffr:latest + container_name: ffr_app restart: unless-stopped - environment: - - APP_URL=${APP_URL} - - DB_CONNECTION=mysql - - DB_HOST=db - - DB_PORT=3306 - - DB_DATABASE=ffr - - DB_USERNAME=ffr_user - - DB_PASSWORD=${DB_PASSWORD} - - REDIS_HOST=redis - - REDIS_PORT=6379 - - CACHE_DRIVER=redis - - SESSION_DRIVER=redis - - QUEUE_CONNECTION=redis ports: - - "8000:80" + - "8000:8000" + environment: + APP_NAME: "${APP_NAME:-FFR}" + APP_KEY: "${APP_KEY}" + APP_URL: "${APP_URL}" + DB_HOST: db + DB_PORT: 3306 + DB_DATABASE: "${DB_DATABASE:-ffr}" + DB_USERNAME: "${DB_USERNAME:-ffr}" + DB_PASSWORD: "${DB_PASSWORD}" + REDIS_HOST: redis + REDIS_PORT: 6379 depends_on: db: condition: service_healthy @@ -27,28 +32,28 @@ services: - ffr-network db: - image: docker.io/library/mysql:8.4 - container_name: ffr-db + image: mariadb:11 + container_name: ffr_db restart: unless-stopped environment: - - MYSQL_DATABASE=ffr - - MYSQL_USER=ffr_user - - MYSQL_PASSWORD=${DB_PASSWORD} - - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} + MYSQL_DATABASE: "${DB_DATABASE:-ffr}" + MYSQL_USER: "${DB_USERNAME:-ffr}" + MYSQL_PASSWORD: "${DB_PASSWORD}" + MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}" volumes: - db_data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "ffr_user", "-p${DB_PASSWORD}"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s timeout: 5s retries: 5 - interval: 3s start_period: 30s networks: - ffr-network redis: - image: docker.io/library/redis:7-alpine - container_name: ffr-redis + image: redis:7-alpine + container_name: ffr_redis restart: unless-stopped volumes: - redis_data:/data @@ -61,6 +66,4 @@ networks: volumes: db_data: - driver: local redis_data: - driver: local diff --git a/docker/production/nginx.conf b/docker/production/nginx.conf deleted file mode 100644 index 5ff7994..0000000 --- a/docker/production/nginx.conf +++ /dev/null @@ -1,82 +0,0 @@ -server { - listen 80; - server_name localhost; - - # Serve static React build files - root /var/www/html/frontend/dist; - index index.html; - - # API requests to Laravel backend - location /api/ { - root /var/www/html/backend/public; - try_files /index.php =404; - - fastcgi_pass 127.0.0.1:9000; - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME /var/www/html/backend/public/index.php; - include fastcgi_params; - - # Increase timeouts for long-running requests - fastcgi_read_timeout 300; - fastcgi_send_timeout 300; - } - - # Serve Laravel public assets (images, etc.) - location /images/ { - alias /var/www/html/backend/public/images/; - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # React app - catch all routes - location / { - try_files $uri $uri/ /index.html; - } - - # Static assets with far-future expiry - location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - access_log off; - } - - # Security headers - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-Content-Type-Options "nosniff"; - add_header X-XSS-Protection "1; mode=block"; - - # Deny access to hidden files - location ~ /\.ht { - deny all; - } - - # Deny access to sensitive files - location ~ /\.(env|git) { - deny all; - } - - location = /favicon.ico { - access_log off; - log_not_found off; - } - - location = /robots.txt { - access_log off; - log_not_found off; - } - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_proxied any; - gzip_comp_level 6; - gzip_types - text/plain - text/css - text/xml - text/javascript - application/javascript - application/xml+rss - application/json; -} \ No newline at end of file diff --git a/docker/production/start-app.sh b/docker/production/start-app.sh deleted file mode 100644 index 0d0df99..0000000 --- a/docker/production/start-app.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -# Create .env file if it doesn't exist -if [ ! -f /var/www/html/backend/.env ]; then - cp /var/www/html/backend/.env.example /var/www/html/backend/.env 2>/dev/null || touch /var/www/html/backend/.env -fi - -# Wait for database to be ready using PHP -echo "Waiting for database..." -until php -r " -\$host = getenv('DB_HOST') ?: 'db'; -\$user = getenv('DB_USERNAME') ?: 'ffr_user'; -\$pass = getenv('DB_PASSWORD'); -\$db = getenv('DB_DATABASE') ?: 'ffr'; -try { - \$pdo = new PDO(\"mysql:host=\$host;dbname=\$db\", \$user, \$pass, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false - ]); - echo 'Database ready'; - exit(0); -} catch (Exception \$e) { - exit(1); -} -" 2>/dev/null; do - echo "Database not ready, waiting..." - sleep 1 -done -echo "Database connection established!" - -# Generate app key if not set -if ! grep -q "APP_KEY=base64:" /var/www/html/backend/.env; then - cd /var/www/html/backend && php artisan key:generate --force -fi - -# Laravel optimizations for production -cd /var/www/html/backend -php artisan config:cache -php artisan route:cache -php artisan view:cache - -# Run migrations -cd /var/www/html/backend -php artisan migrate --force - -# Run all seeders (same as dev) -cd /var/www/html/backend -php artisan db:seed --force - -# Start supervisor to manage nginx and php-fpm -supervisord -c /etc/supervisord.conf \ No newline at end of file diff --git a/docker/production/supervisord.conf b/docker/production/supervisord.conf deleted file mode 100644 index 5df63d5..0000000 --- a/docker/production/supervisord.conf +++ /dev/null @@ -1,45 +0,0 @@ -[supervisord] -nodaemon=true -user=root -logfile=/var/log/supervisord.log -pidfile=/var/run/supervisord.pid - -[program:nginx] -command=nginx -g "daemon off;" -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=10 - -[program:php-fpm] -command=php-fpm --nodaemonize -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=10 - -[program:horizon] -command=php /var/www/html/backend/artisan horizon -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=20 - -[program:scheduler] -command=sh -c "while true; do php /var/www/html/backend/artisan schedule:run --verbose --no-interaction; sleep 60; done" -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=20 \ No newline at end of file diff --git a/shell.nix b/shell.nix index 20e63fc..e14efc4 100644 --- a/shell.nix +++ b/shell.nix @@ -3,8 +3,8 @@ pkgs.mkShell { buildInputs = with pkgs; [ # PHP and tools - php84 - php84Packages.composer + php83 + php83Packages.composer # Node.js and npm nodejs_22 @@ -81,6 +81,32 @@ pkgs.mkShell { podman-compose -f $COMPOSE_FILE exec app php artisan "$@" } + # =================== + # PROD COMMANDS + # =================== + prod-build() { + local tag="''${1:-latest}" + local image="codeberg.org/lvl0/ffr:$tag" + + echo "Building production image: $image" + if ! podman build -t "$image" -f Dockerfile .; then + echo "Build failed!" + return 1 + fi + + echo "" + echo "Pushing to registry..." + if ! podman push "$image"; then + echo "" + echo "Push failed! You may need to login first:" + echo " podman login codeberg.org" + return 1 + fi + + echo "" + echo "Done! Image pushed: $image" + } + # =================== # WELCOME MESSAGE # =================== @@ -99,6 +125,7 @@ pkgs.mkShell { echo " dev-logs-db Tail database logs" echo " dev-shell Shell into app container" echo " dev-artisan Run artisan command" + echo " prod-build [tag] Build and push prod image (default: latest)" echo "" echo "Services:" echo " app Laravel + Vite http://localhost:8000" -- 2.45.2 From 03fa4b803f297124056353d7af5ffd31a4377864 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 23 Jan 2026 00:56:01 +0100 Subject: [PATCH 49/51] 74 - Fix auth + onboarding layout --- app/Livewire/Onboarding.php | 32 +++++++------- resources/views/auth/login.blade.php | 37 +++++++++++----- resources/views/auth/register.blade.php | 26 ++++++----- .../views/components/primary-button.blade.php | 2 +- .../views/components/text-input.blade.php | 2 +- resources/views/layouts/guest.blade.php | 29 +++++++++++-- resources/views/layouts/onboarding.blade.php | 43 +++++++++++++++++++ resources/views/livewire/onboarding.blade.php | 20 ++++----- 8 files changed, 138 insertions(+), 53 deletions(-) create mode 100644 resources/views/layouts/onboarding.blade.php diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php index defd807..f6ab9de 100644 --- a/app/Livewire/Onboarding.php +++ b/app/Livewire/Onboarding.php @@ -44,7 +44,7 @@ class Onboarding extends Component public int $routePriority = 50; // State - public array $errors = []; + public array $formErrors = []; public bool $isLoading = false; protected LemmyAuthService $lemmyAuthService; @@ -96,20 +96,20 @@ public function mount(): void public function goToStep(int $step): void { $this->step = $step; - $this->errors = []; + $this->formErrors = []; } public function nextStep(): void { $this->step++; - $this->errors = []; + $this->formErrors = []; } public function previousStep(): void { if ($this->step > 1) { $this->step--; - $this->errors = []; + $this->formErrors = []; } } @@ -128,7 +128,7 @@ public function deleteAccount(): void public function createPlatformAccount(): void { - $this->errors = []; + $this->formErrors = []; $this->isLoading = true; $this->validate([ @@ -184,12 +184,12 @@ public function createPlatformAccount(): void $this->nextStep(); } catch (\App\Exceptions\PlatformAuthException $e) { if (str_contains($e->getMessage(), 'Rate limited by')) { - $this->errors['general'] = $e->getMessage(); + $this->formErrors['general'] = $e->getMessage(); } else { - $this->errors['general'] = 'Invalid username or password. Please check your credentials and try again.'; + $this->formErrors['general'] = 'Invalid username or password. Please check your credentials and try again.'; } } catch (\Exception $e) { - $this->errors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; + $this->formErrors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; } finally { $this->isLoading = false; } @@ -197,7 +197,7 @@ public function createPlatformAccount(): void public function createFeed(): void { - $this->errors = []; + $this->formErrors = []; $this->isLoading = true; $this->validate([ @@ -227,7 +227,7 @@ public function createFeed(): void $this->nextStep(); } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create feed. Please try again.'; + $this->formErrors['general'] = 'Failed to create feed. Please try again.'; } finally { $this->isLoading = false; } @@ -235,7 +235,7 @@ public function createFeed(): void public function createChannel(): void { - $this->errors = []; + $this->formErrors = []; $this->isLoading = true; $this->validate([ @@ -254,7 +254,7 @@ public function createChannel(): void ->get(); if ($activeAccounts->isEmpty()) { - $this->errors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.'; + $this->formErrors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.'; $this->isLoading = false; return; } @@ -279,7 +279,7 @@ public function createChannel(): void $this->nextStep(); } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create channel. Please try again.'; + $this->formErrors['general'] = 'Failed to create channel. Please try again.'; } finally { $this->isLoading = false; } @@ -287,7 +287,7 @@ public function createChannel(): void public function createRoute(): void { - $this->errors = []; + $this->formErrors = []; $this->isLoading = true; $this->validate([ @@ -309,7 +309,7 @@ public function createRoute(): void $this->nextStep(); } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create route. Please try again.'; + $this->formErrors['general'] = 'Failed to create route. Please try again.'; } finally { $this->isLoading = false; } @@ -344,6 +344,6 @@ public function render() 'feeds' => $feeds, 'channels' => $channels, 'feedProviders' => $feedProviders, - ])->layout('layouts.guest'); + ])->layout('layouts.onboarding'); } } diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 78b684f..fb71818 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -1,4 +1,9 @@ +
+

Welcome back

+

Sign in to your account

+
+ @@ -14,7 +19,14 @@
- +
+ + @if (Route::has('password.request')) + + {{ __('Forgot password?') }} + + @endif +
-
- @if (Route::has('password.request')) - - {{ __('Forgot your password?') }} - - @endif - - - {{ __('Log in') }} +
+ + {{ __('Sign in') }}
+ + @if (Route::has('register')) +
+ Don't have an account? + + {{ __('Sign up') }} + +
+ @endif diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index a857242..9c56eca 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -1,4 +1,9 @@ +
+

Create an account

+

Get started with FFR

+
+
@csrf @@ -19,34 +24,33 @@
- -
- -
-
- - {{ __('Already registered?') }} - - - - {{ __('Register') }} +
+ + {{ __('Create account') }}
+ +
+ Already have an account? + + {{ __('Sign in') }} + +
diff --git a/resources/views/components/primary-button.blade.php b/resources/views/components/primary-button.blade.php index d71f0b6..6a376dd 100644 --- a/resources/views/components/primary-button.blade.php +++ b/resources/views/components/primary-button.blade.php @@ -1,3 +1,3 @@ - diff --git a/resources/views/components/text-input.blade.php b/resources/views/components/text-input.blade.php index da1b12d..e17e5a0 100644 --- a/resources/views/components/text-input.blade.php +++ b/resources/views/components/text-input.blade.php @@ -1,3 +1,3 @@ @props(['disabled' => false]) -merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) }}> +merge(['class' => 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 rounded-lg shadow-sm px-4 py-3']) }}> diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php index 3628119..469c4c3 100644 --- a/resources/views/layouts/guest.blade.php +++ b/resources/views/layouts/guest.blade.php @@ -5,18 +5,41 @@ - {{ config('app.name', 'Laravel') }} + {{ config('app.name', 'FFR') }} - + @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles - {{ $slot }} +
+ + + + +
+ {{ $slot }} +
+ + +
+

Route your feeds to the Fediverse

+
+
@livewireScripts diff --git a/resources/views/layouts/onboarding.blade.php b/resources/views/layouts/onboarding.blade.php new file mode 100644 index 0000000..7679da7 --- /dev/null +++ b/resources/views/layouts/onboarding.blade.php @@ -0,0 +1,43 @@ + + + + + + + + {{ config('app.name', 'FFR') }} - Setup + + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + +
+ + + + + {{ $slot }} + + +
+

Route your feeds to the Fediverse

+
+
+ @livewireScripts + + diff --git a/resources/views/livewire/onboarding.blade.php b/resources/views/livewire/onboarding.blade.php index bba2156..4bf82ba 100644 --- a/resources/views/livewire/onboarding.blade.php +++ b/resources/views/livewire/onboarding.blade.php @@ -1,5 +1,5 @@ -
-
+
+
{{-- Step 1: Welcome --}} @if ($step === 1) @@ -59,9 +59,9 @@ class="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 tran
4
- @if (!empty($errors['general'])) + @if (!empty($formErrors['general']))
-

{{ $errors['general'] }}

+

{{ $formErrors['general'] }}

@endif @@ -190,9 +190,9 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
- @if (!empty($errors['general'])) + @if (!empty($formErrors['general']))
-

{{ $errors['general'] }}

+

{{ $formErrors['general'] }}

@endif @@ -294,9 +294,9 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
- @if (!empty($errors['general'])) + @if (!empty($formErrors['general']))
-

{{ $errors['general'] }}

+

{{ $formErrors['general'] }}

@endif @@ -400,9 +400,9 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
- @if (!empty($errors['general'])) + @if (!empty($formErrors['general']))
-

{{ $errors['general'] }}

+

{{ $formErrors['general'] }}

@endif -- 2.45.2 From 16ce3b6324b0d5f2057d2a7df958c531e7efab8b Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 23 Jan 2026 00:58:10 +0100 Subject: [PATCH 50/51] 74 - Minor fixes - Add logout button to onboarding menu - remove Forgot Password --- resources/views/auth/login.blade.php | 9 +------- resources/views/layouts/onboarding.blade.php | 24 +++++++++++++++++++- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index fb71818..dba51e5 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -19,14 +19,7 @@
-
- - @if (Route::has('password.request')) - - {{ __('Forgot password?') }} - - @endif -
+ -
+
+ + @auth +
+
+ +
+ + @csrf + + +
+
+
+ @endauth +
-- 2.45.2 From 3e23dad5c5e8320a8ffde621413fa7ca94f219d3 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 25 Feb 2026 20:22:02 +0100 Subject: [PATCH 51/51] Minor bug fixes --- app/Livewire/Onboarding.php | 15 ++++++++++++--- app/Modules/Lemmy/Services/LemmyApiService.php | 5 +++++ .../2024_01_01_000003_create_platforms.php | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php index f6ab9de..2c738c8 100644 --- a/app/Livewire/Onboarding.php +++ b/app/Livewire/Onboarding.php @@ -183,13 +183,22 @@ public function createPlatformAccount(): void $this->nextStep(); } catch (\App\Exceptions\PlatformAuthException $e) { - if (str_contains($e->getMessage(), 'Rate limited by')) { - $this->formErrors['general'] = $e->getMessage(); + $message = $e->getMessage(); + if (str_contains($message, 'Rate limited by')) { + $this->formErrors['general'] = $message; + } elseif (str_contains($message, 'Connection failed')) { + $this->formErrors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; } else { $this->formErrors['general'] = 'Invalid username or password. Please check your credentials and try again.'; } } catch (\Exception $e) { - $this->formErrors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; + logger()->error('Lemmy platform account creation failed', [ + 'instance_url' => $fullInstanceUrl, + 'username' => $this->username, + 'error' => $e->getMessage(), + 'class' => get_class($e), + ]); + $this->formErrors['general'] = 'An error occurred while setting up your account. Please try again.'; } finally { $this->isLoading = false; } diff --git a/app/Modules/Lemmy/Services/LemmyApiService.php b/app/Modules/Lemmy/Services/LemmyApiService.php index 3329703..d0ebb55 100644 --- a/app/Modules/Lemmy/Services/LemmyApiService.php +++ b/app/Modules/Lemmy/Services/LemmyApiService.php @@ -63,6 +63,11 @@ public function login(string $username, string $password): ?string $data = $response->json(); return $data['jwt'] ?? null; } catch (Exception $e) { + // Re-throw rate limit exceptions immediately + if (str_contains($e->getMessage(), 'Rate limited')) { + throw $e; + } + logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]); // If this was the first attempt and HTTPS, try HTTP next if ($idx === 0 && in_array('http', $schemesToTry, true)) { diff --git a/database/migrations/2024_01_01_000003_create_platforms.php b/database/migrations/2024_01_01_000003_create_platforms.php index 9a074a9..9b7f7af 100644 --- a/database/migrations/2024_01_01_000003_create_platforms.php +++ b/database/migrations/2024_01_01_000003_create_platforms.php @@ -27,7 +27,7 @@ public function up(): void $table->enum('platform', ['lemmy']); $table->string('instance_url'); $table->string('username'); - $table->string('password'); + $table->text('password'); $table->json('settings')->nullable(); $table->boolean('is_active')->default(false); $table->timestamp('last_tested_at')->nullable(); -- 2.45.2