diff --git a/.env.example b/.env.example index 99426fc..e17322d 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,10 @@ AWS_USE_PATH_STYLE_ENDPOINT=false OLLAMA_API_KEY= GEMINI_API_KEY= +OPENAI_API_KEY= +CONTENT_MODERATION_ENABLED=false +CONTENT_MODERATION_MODEL=omni-moderation-latest +CONTENT_MODERATION_TIMEOUT=12 AI_MEAL_USER_NAME="Daily Meal AI" AI_MEAL_USER_EMAIL=ai@daily-meal.local diff --git a/.env.prod.example b/.env.prod.example index 0a4b971..4088467 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -52,6 +52,10 @@ MEILISEARCH_KEY= MEILISEARCH_VERSION=1.49.0 GEMINI_API_KEY= +OPENAI_API_KEY= +CONTENT_MODERATION_ENABLED=true +CONTENT_MODERATION_MODEL=omni-moderation-latest +CONTENT_MODERATION_TIMEOUT=12 AI_MEAL_USER_NAME="Daily Meal AI" AI_MEAL_USER_EMAIL=ai@daily-meal.local diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php new file mode 100644 index 0000000..5a5567e --- /dev/null +++ b/app/Http/Controllers/SearchController.php @@ -0,0 +1,68 @@ +validated('q'); + $perPage = (int) ($request->validated('per_page') ?? 20); + + if ($request->validated('type') === 'users') { + $users = User::query() + ->whereKeyNot($request->user()->getKey()) + ->whereNull('suspended_at') + ->withoutBlockingRelationshipWith($request->user()) + ->where(function (Builder $builder) use ($query): void { + $builder + ->whereLike('name', "%{$query}%", caseSensitive: false) + ->orWhereLike('bio', "%{$query}%", caseSensitive: false); + }) + ->orderByRaw('CASE WHEN LOWER(name) = LOWER(?) THEN 0 WHEN LOWER(name) LIKE LOWER(?) THEN 1 ELSE 2 END', [ + $query, + "{$query}%", + ]) + ->orderBy('name') + ->paginate($perPage) + ->withQueryString(); + + return SearchUserResource::collection($users); + } + + $meals = MealPosts::query() + ->where('visibility', MealPostVisibility::Public) + ->whereNull('hidden_at') + ->whereHas( + 'user', + fn (Builder $builder): Builder => $builder + ->whereNull('suspended_at') + ->withoutBlockingRelationshipWith($request->user()) + ) + ->where(function (Builder $builder) use ($query): void { + $builder + ->whereLike('title', "%{$query}%", caseSensitive: false) + ->orWhereLike('caption', "%{$query}%", caseSensitive: false) + ->orWhereHas( + 'ingredients', + fn (Builder $ingredientQuery): Builder => $ingredientQuery + ->whereLike('ingredient', "%{$query}%", caseSensitive: false) + ); + }) + ->with('user:id,name,avatar_url,account_verified_at') + ->latest('eaten_at') + ->paginate($perPage) + ->withQueryString(); + + return MealPostsResource::collection($meals); + } +} diff --git a/app/Http/Requests/Concerns/ModeratesUserContent.php b/app/Http/Requests/Concerns/ModeratesUserContent.php new file mode 100644 index 0000000..5597f34 --- /dev/null +++ b/app/Http/Requests/Concerns/ModeratesUserContent.php @@ -0,0 +1,25 @@ + $texts + * @param array $images + * @return array + */ + protected function moderationChecks(array $texts, array $images = []): array + { + return [function (Validator $validator) use ($texts, $images): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + app(ContentModerationService::class)->ensureAcceptable($texts, $images); + }]; + } +} diff --git a/app/Http/Requests/MealPostsRequest.php b/app/Http/Requests/MealPostsRequest.php index 3ebb629..8730155 100644 --- a/app/Http/Requests/MealPostsRequest.php +++ b/app/Http/Requests/MealPostsRequest.php @@ -6,18 +6,24 @@ use App\Enums\DietType; use App\Enums\IngredientUnit; use App\Enums\MealPostType; use App\Enums\MealPostVisibility; +use App\Http\Requests\Concerns\ModeratesUserContent; +use App\Models\MealPosts; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; class MealPostsRequest extends FormRequest { + use ModeratesUserContent; + public function rules(): array { $isCreating = $this->isMethod('post'); return [ 'image' => [$isCreating ? 'required_without:image_url' : 'sometimes', 'nullable', 'image', 'max:4096'], - 'image_url' => [$isCreating ? 'required_without:image' : 'sometimes', 'nullable', 'string', 'max:2048'], + 'image_url' => [$isCreating ? 'required_without:image' : 'sometimes', 'nullable', 'url:http,https', 'max:2048'], 'caption' => ['nullable', 'string', 'max:1000'], 'calories' => ['nullable', 'integer', 'min:0'], 'proteins' => ['nullable', 'numeric', 'min:0'], @@ -42,6 +48,39 @@ class MealPostsRequest extends FormRequest return true; } + public function after(): array + { + $mealPost = $this->route('mealPost'); + $currentVisibility = $mealPost instanceof MealPosts + ? $mealPost->visibility + : MealPostVisibility::Private; + $visibility = MealPostVisibility::tryFrom((string) $this->input('visibility')) ?? $currentVisibility; + + if ($visibility !== MealPostVisibility::Public) { + return []; + } + + $isBecomingPublic = $mealPost instanceof MealPosts + && $mealPost->visibility !== MealPostVisibility::Public; + $ingredients = $this->has('ingredients') + ? collect($this->input('ingredients', []))->pluck('ingredient')->all() + : ($isBecomingPublic ? $mealPost->ingredients()->pluck('ingredient')->all() : []); + $existingImageUrl = $isBecomingPublic && filled($mealPost->image_url) + ? (Str::startsWith($mealPost->image_url, ['http://', 'https://']) + ? $mealPost->image_url + : asset(Storage::url($mealPost->image_url))) + : null; + + return $this->moderationChecks( + [ + $this->has('title') ? $this->input('title') : ($isBecomingPublic ? $mealPost->title : null), + $this->has('caption') ? $this->input('caption') : ($isBecomingPublic ? $mealPost->caption : null), + $ingredients, + ], + [$this->file('image'), $this->input('image_url'), $existingImageUrl], + ); + } + public function messages(): array { return [ diff --git a/app/Http/Requests/PostReviewsRequest.php b/app/Http/Requests/PostReviewsRequest.php index 723a505..cfb2246 100644 --- a/app/Http/Requests/PostReviewsRequest.php +++ b/app/Http/Requests/PostReviewsRequest.php @@ -2,10 +2,13 @@ namespace App\Http\Requests; +use App\Http\Requests\Concerns\ModeratesUserContent; use Illuminate\Foundation\Http\FormRequest; class PostReviewsRequest extends FormRequest { + use ModeratesUserContent; + public function rules(): array { $isCreating = $this->isMethod('post'); @@ -20,4 +23,9 @@ class PostReviewsRequest extends FormRequest { return true; } + + public function after(): array + { + return $this->moderationChecks([$this->input('comment')]); + } } diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php index a11ba20..351ad31 100644 --- a/app/Http/Requests/RegisterRequest.php +++ b/app/Http/Requests/RegisterRequest.php @@ -6,12 +6,15 @@ use App\Enums\PacePreference; use App\Enums\PhysicalActivityLevel; use App\Enums\UserSex; use App\Enums\WeightGoal; +use App\Http\Requests\Concerns\ModeratesUserContent; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; class RegisterRequest extends FormRequest { + use ModeratesUserContent; + /** * Determine if the user is authorized to make this request. */ @@ -68,6 +71,14 @@ class RegisterRequest extends FormRequest ]; } + public function after(): array + { + return $this->moderationChecks( + [$this->input('name'), $this->input('bio')], + [$this->file('avatar')], + ); + } + protected function prepareForValidation(): void { if ($this->has('locale')) { diff --git a/app/Http/Requests/SearchRequest.php b/app/Http/Requests/SearchRequest.php new file mode 100644 index 0000000..4b28e4d --- /dev/null +++ b/app/Http/Requests/SearchRequest.php @@ -0,0 +1,33 @@ + ['required', 'string', 'min:2', 'max:100'], + 'type' => ['required', Rule::in(['meals', 'users'])], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'], + ]; + } + + protected function prepareForValidation(): void + { + if ($this->has('q')) { + $this->merge([ + 'q' => trim((string) $this->input('q')), + ]); + } + } +} diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php index 35591fd..1f973e0 100644 --- a/app/Http/Requests/UpdateUserRequest.php +++ b/app/Http/Requests/UpdateUserRequest.php @@ -6,11 +6,14 @@ use App\Enums\PacePreference; use App\Enums\PhysicalActivityLevel; use App\Enums\UserSex; use App\Enums\WeightGoal; +use App\Http\Requests\Concerns\ModeratesUserContent; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class UpdateUserRequest extends FormRequest { + use ModeratesUserContent; + public function authorize(): bool { return true; @@ -36,6 +39,14 @@ class UpdateUserRequest extends FormRequest ]; } + public function after(): array + { + return $this->moderationChecks( + [$this->input('bio')], + [$this->file('avatar')], + ); + } + public function messages(): array { return [ diff --git a/app/Http/Resources/SearchUserResource.php b/app/Http/Resources/SearchUserResource.php new file mode 100644 index 0000000..466be4f --- /dev/null +++ b/app/Http/Resources/SearchUserResource.php @@ -0,0 +1,23 @@ + $this->id, + 'name' => $this->name, + 'bio' => $this->bio, + 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, + 'accountVerified' => $this->account_verified_at !== null, + ]; + } +} diff --git a/app/Services/ContentModerationService.php b/app/Services/ContentModerationService.php new file mode 100644 index 0000000..1a4d48c --- /dev/null +++ b/app/Services/ContentModerationService.php @@ -0,0 +1,132 @@ + $texts + * @param array $images + */ + public function ensureAcceptable(array $texts = [], array $images = []): void + { + $normalizedTexts = collect($texts) + ->flatten() + ->filter(fn (mixed $text): bool => is_string($text) && filled(trim($text))) + ->map(fn (string $text): string => trim($text)) + ->values() + ->all(); + $normalizedImages = collect($images) + ->filter(fn (mixed $image): bool => $image instanceof UploadedFile || $this->isRemoteImageUrl($image)) + ->values() + ->all(); + + if ($normalizedTexts === [] && $normalizedImages === []) { + return; + } + + if ($this->containsExplicitlyBlockedText($normalizedTexts)) { + $this->rejectContent(); + } + + if (! config('moderation.content.enabled')) { + return; + } + + $apiKey = trim((string) config('moderation.content.openai_key')); + + if ($apiKey === '') { + throw new HttpException(503, __('api.moderation.unavailable')); + } + + $input = []; + + if ($normalizedTexts !== []) { + $input[] = [ + 'type' => 'text', + 'text' => implode("\n", $normalizedTexts), + ]; + } + + foreach ($normalizedImages as $image) { + $input[] = [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $image instanceof UploadedFile + ? $this->uploadedFileDataUrl($image) + : $image, + ], + ]; + } + + try { + $response = Http::baseUrl(rtrim((string) config('moderation.content.openai_url'), '/')) + ->withToken($apiKey) + ->acceptJson() + ->timeout((int) config('moderation.content.timeout', 12)) + ->retry(2, 250) + ->post('/moderations', [ + 'model' => config('moderation.content.model'), + 'input' => $input, + ]) + ->throw(); + } catch (Throwable $exception) { + report($exception); + + throw new HttpException(503, __('api.moderation.unavailable'), $exception); + } + + if (collect($response->json('results', []))->contains( + fn (mixed $result): bool => is_array($result) && ($result['flagged'] ?? false) === true + )) { + $this->rejectContent(); + } + } + + /** + * @param array $texts + */ + private function containsExplicitlyBlockedText(array $texts): bool + { + $content = Str::lower(Str::ascii(implode("\n", $texts))); + + return collect(config('moderation.content.blocked_phrases', [])) + ->filter(fn (mixed $phrase): bool => is_string($phrase) && filled($phrase)) + ->contains(fn (string $phrase): bool => Str::contains($content, Str::lower(Str::ascii($phrase)))); + } + + private function uploadedFileDataUrl(UploadedFile $image): string + { + $contents = file_get_contents($image->getRealPath()); + + if ($contents === false) { + throw new HttpException(503, __('api.moderation.unavailable')); + } + + return sprintf( + 'data:%s;base64,%s', + $image->getMimeType() ?: 'application/octet-stream', + base64_encode($contents), + ); + } + + private function isRemoteImageUrl(mixed $image): bool + { + return is_string($image) + && Str::startsWith($image, ['https://', 'http://']); + } + + private function rejectContent(): never + { + throw ValidationException::withMessages([ + 'content' => [__('api.moderation.rejected')], + ]); + } +} diff --git a/config/moderation.php b/config/moderation.php index 3fa5ab3..706114b 100644 --- a/config/moderation.php +++ b/config/moderation.php @@ -5,6 +5,22 @@ use App\Models\PostReviews; use App\Models\User; return [ + 'content' => [ + 'enabled' => env('CONTENT_MODERATION_ENABLED', false), + 'model' => env('CONTENT_MODERATION_MODEL', 'omni-moderation-latest'), + 'openai_key' => env('OPENAI_API_KEY'), + 'openai_url' => env('OPENAI_URL', 'https://api.openai.com/v1'), + 'timeout' => (int) env('CONTENT_MODERATION_TIMEOUT', 12), + 'blocked_phrases' => [ + 'child pornography', + 'pornographie infantile', + 'kill yourself', + 'suicide-toi', + 'je vais te tuer', + 'i am going to kill you', + ], + ], + 'default_report_threshold' => 3, 'report_thresholds' => [ diff --git a/lang/en/api.php b/lang/en/api.php index bf27602..58ef55d 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -28,6 +28,10 @@ return [ 'failed' => 'Unable to analyze the image right now.', 'subscription_required' => 'Analysis is available during the trial or with a subscription.', ], + 'moderation' => [ + 'rejected' => 'This content cannot be published because it violates the community guidelines.', + 'unavailable' => 'Content verification is temporarily unavailable. Please try again shortly.', + ], 'legal_documents' => [ 'not_found' => 'Legal document not found.', ], diff --git a/lang/fr/api.php b/lang/fr/api.php index ef7e72c..780b115 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -28,6 +28,10 @@ return [ 'failed' => "Impossible d'analyser l'image pour le moment.", 'subscription_required' => "L'analyse est disponible pendant l'essai ou avec l'abonnement.", ], + 'moderation' => [ + 'rejected' => 'Ce contenu ne peut pas être publié car il enfreint les règles de la communauté.', + 'unavailable' => 'La vérification du contenu est momentanément indisponible. Réessaie dans quelques instants.', + ], 'legal_documents' => [ 'not_found' => 'Document légal introuvable.', ], diff --git a/routes/api.php b/routes/api.php index f1ea71e..df19a4a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\NotificationPreferenceController; use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PublicUserProfileController; use App\Http\Controllers\ReportController; +use App\Http\Controllers\SearchController; use App\Http\Controllers\StravaController; use App\Http\Controllers\UserBlockController; use App\Http\Controllers\WorkoutSessionController; @@ -98,6 +99,7 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function // Meals Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { + Route::get('search', SearchController::class)->name('search'); Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show'); Route::get('me/blocked-users', [UserBlockController::class, 'index'])->name('users.blocks.index'); Route::post('users/{user}/block', [UserBlockController::class, 'store'])->middleware('throttle:engagement')->name('users.blocks.store'); diff --git a/tests/Feature/ContentModerationTest.php b/tests/Feature/ContentModerationTest.php new file mode 100644 index 0000000..510e336 --- /dev/null +++ b/tests/Feature/ContentModerationTest.php @@ -0,0 +1,137 @@ +set('moderation.content.enabled', true); + config()->set('moderation.content.openai_key', 'test-key'); + config()->set('moderation.content.openai_url', 'https://api.openai.com/v1'); +}); + +it('rejects a public meal when text moderation flags it', function () { + Storage::fake(); + Http::fake([ + 'https://api.openai.com/v1/moderations' => Http::response([ + 'results' => [['flagged' => true]], + ]), + ]); + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $this->postJson('/api/meal-posts', [ + 'title' => 'Contenu interdit', + 'eaten_at' => now()->toISOString(), + 'image' => UploadedFile::fake()->image('meal.jpg'), + 'type' => 'lunch', + 'visibility' => MealPostVisibility::Public->value, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('content'); + + $this->assertDatabaseCount('meal_posts', 0); + expect(Storage::allFiles())->toBeEmpty(); +}); + +it('sends public meal text and image to multimodal moderation before storing it', function () { + Storage::fake(); + Http::fake([ + 'https://api.openai.com/v1/moderations' => Http::response([ + 'results' => [['flagged' => false]], + ]), + ]); + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $this->postJson('/api/meal-posts', [ + 'title' => 'Bowl printanier', + 'caption' => 'Une assiette fraîche', + 'eaten_at' => now()->toISOString(), + 'image' => UploadedFile::fake()->image('meal.jpg'), + 'type' => 'lunch', + 'visibility' => MealPostVisibility::Public->value, + ])->assertCreated(); + + Http::assertSent(function (Request $request): bool { + $input = $request->data()['input'] ?? []; + + return $request->url() === 'https://api.openai.com/v1/moderations' + && ($input[0]['type'] ?? null) === 'text' + && str_contains((string) ($input[0]['text'] ?? ''), 'Bowl printanier') + && ($input[1]['type'] ?? null) === 'image_url' + && str_starts_with((string) ($input[1]['image_url']['url'] ?? ''), 'data:image/jpeg;base64,'); + }); +}); + +it('rejects an explicitly forbidden review even when remote moderation is disabled', function () { + config()->set('moderation.content.enabled', false); + $user = User::factory()->create(); + $meal = MealPosts::factory()->create(); + Sanctum::actingAs($user); + + $this->postJson("/api/meal-posts/{$meal->id}/reviews", [ + 'rating' => 1, + 'comment' => 'Kill yourself', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('content'); + + $this->assertDatabaseCount('post_reviews', 0); +}); + +it('moderates existing content when a private meal becomes public', function () { + Http::fake([ + 'https://api.openai.com/v1/moderations' => Http::response([ + 'results' => [['flagged' => true]], + ]), + ]); + $user = User::factory()->create(); + $meal = MealPosts::factory()->for($user, 'user')->create([ + 'title' => 'Ancien contenu privé', + 'image_url' => 'https://example.com/private-meal.jpg', + 'visibility' => MealPostVisibility::Private, + ]); + Sanctum::actingAs($user); + + $this->patchJson("/api/meal-posts/{$meal->id}", [ + 'visibility' => MealPostVisibility::Public->value, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('content'); + + expect($meal->fresh()->visibility)->toBe(MealPostVisibility::Private); + + Http::assertSent(function (Request $request): bool { + $input = $request->data()['input'] ?? []; + + return str_contains((string) ($input[0]['text'] ?? ''), 'Ancien contenu privé') + && ($input[1]['image_url']['url'] ?? null) === 'https://example.com/private-meal.jpg'; + }); +}); + +it('fails closed when production moderation cannot be reached', function () { + Http::fake([ + 'https://api.openai.com/v1/moderations' => Http::response([], 500), + ]); + $user = User::factory()->create(); + $meal = MealPosts::factory()->create(); + Sanctum::actingAs($user); + + $this->postJson("/api/meal-posts/{$meal->id}/reviews", [ + 'rating' => 5, + 'comment' => 'Très bon repas.', + ]) + ->assertServiceUnavailable() + ->assertJsonPath('message', __('api.moderation.unavailable')); + + $this->assertDatabaseCount('post_reviews', 0); +}); diff --git a/tests/Feature/SearchApiTest.php b/tests/Feature/SearchApiTest.php new file mode 100644 index 0000000..1d5f341 --- /dev/null +++ b/tests/Feature/SearchApiTest.php @@ -0,0 +1,71 @@ +create(); + $owner = User::factory()->create(); + + $titleMatch = MealPosts::factory()->for($owner, 'user')->create([ + 'title' => 'Bowl méditerranéen', + 'visibility' => MealPostVisibility::Public, + ]); + $ingredientMatch = MealPosts::factory()->for($owner, 'user')->create([ + 'title' => 'Déjeuner coloré', + 'caption' => 'Simple et frais', + 'visibility' => MealPostVisibility::Public, + ]); + $ingredientMatch->ingredients()->create([ + 'ingredient' => 'Tomates cerises', + 'position' => 0, + 'quantity' => 100, + 'unit' => 'g', + ]); + MealPosts::factory()->for($owner, 'user')->create([ + 'title' => 'Bowl privé', + 'visibility' => MealPostVisibility::Private, + ]); + + Sanctum::actingAs($viewer); + + $this->getJson('/api/search?q=bowl&type=meals&per_page=1') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $titleMatch->id) + ->assertJsonPath('meta.total', 1); + + $this->getJson('/api/search?q=tomates&type=meals') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ingredientMatch->id); +}); + +it('searches users while excluding suspended and blocked accounts', function () { + $viewer = User::factory()->create(['name' => 'viewer']); + $visible = User::factory()->create(['name' => 'alex_bowli', 'bio' => 'Cuisine végétale']); + $blocked = User::factory()->create(['name' => 'alex_blocked']); + User::factory()->create(['name' => 'alex_suspended', 'suspended_at' => now()]); + $viewer->blockedUsers()->attach($blocked); + + Sanctum::actingAs($viewer); + + $this->getJson('/api/search?q=alex&type=users') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $visible->id) + ->assertJsonMissing(['id' => $blocked->id]); +}); + +it('validates the search query and result type', function () { + Sanctum::actingAs(User::factory()->create()); + + $this->getJson('/api/search?q=a&type=unknown') + ->assertUnprocessable() + ->assertJsonValidationErrors(['q', 'type']); +});