From d06367136c334f658624cc7a141849edbc22296c Mon Sep 17 00:00:00 2001 From: ildyria Date: Sat, 21 Mar 2026 17:22:08 +0100 Subject: [PATCH 1/4] Add backend --- app/Console/Commands/RescanFailedFaces.php | 83 +++++ app/Console/Commands/ScanFaces.php | 67 ++++ app/Enum/FacePermissionMode.php | 17 + app/Enum/FaceScanStatus.php | 16 + .../Admin/Maintenance/ResetStuckFaces.php | 59 ++++ .../Controllers/AiVision/FaceController.php | 106 ++++++ .../AiVision/FaceDetectionController.php | 317 ++++++++++++++++++ .../Controllers/AiVision/PeopleController.php | 125 +++++++ .../AiVision/PersonClaimController.php | 105 ++++++ .../AiVision/PersonPhotosController.php | 70 ++++ .../AiVision/SelfieClaimController.php | 114 +++++++ app/Http/Middleware/ConfigIntegrity.php | 7 + app/Http/Requests/Face/AssignFaceRequest.php | 49 +++ app/Http/Requests/Face/BulkScanRequest.php | 55 +++ .../Face/DestroyDismissedFacesRequest.php | 22 ++ app/Http/Requests/Face/ScanPhotosRequest.php | 75 +++++ .../Requests/Face/ToggleDismissedRequest.php | 22 ++ .../Maintenance/ResetStuckFacesRequest.php | 54 +++ .../Requests/Person/ClaimPersonRequest.php | 22 ++ .../Requests/Person/DestroyPersonRequest.php | 41 +++ .../Requests/Person/ListPersonsRequest.php | 22 ++ .../Requests/Person/MergePersonRequest.php | 41 +++ .../Requests/Person/SelfieClaimRequest.php | 44 +++ .../Requests/Person/StorePersonRequest.php | 49 +++ .../Requests/Person/UpdatePersonRequest.php | 57 ++++ app/Http/Resources/Models/FaceResource.php | 54 +++ .../Models/FaceSuggestionResource.php | 30 ++ app/Http/Resources/Models/PersonResource.php | 47 +++ app/Jobs/DispatchFaceScanJob.php | 94 ++++++ app/Listeners/AutoScanFacesOnUpload.php | 44 +++ app/Models/Face.php | 140 ++++++++ app/Models/FaceSuggestion.php | 72 ++++ app/Models/Person.php | 103 ++++++ app/Models/Photo.php | 12 + app/Models/User.php | 10 + app/Policies/AiVisionPolicy.php | 147 ++++++++ app/Providers/AuthServiceProvider.php | 6 + app/Providers/EventServiceProvider.php | 3 + config/features.php | 15 + ...2026_03_21_000001_create_persons_table.php | 40 +++ .../2026_03_21_000002_create_faces_table.php | 67 ++++ .../2026_03_21_000003_ai_vision_config.php | 103 ++++++ ...03_21_000004_create_ai_vision_category.php | 37 ++ routes/api_v2.php | 36 ++ tests/Unit/Actions/Db/OptimizeTablesTest.php | 2 +- 45 files changed, 2700 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/RescanFailedFaces.php create mode 100644 app/Console/Commands/ScanFaces.php create mode 100644 app/Enum/FacePermissionMode.php create mode 100644 app/Enum/FaceScanStatus.php create mode 100644 app/Http/Controllers/Admin/Maintenance/ResetStuckFaces.php create mode 100644 app/Http/Controllers/AiVision/FaceController.php create mode 100644 app/Http/Controllers/AiVision/FaceDetectionController.php create mode 100644 app/Http/Controllers/AiVision/PeopleController.php create mode 100644 app/Http/Controllers/AiVision/PersonClaimController.php create mode 100644 app/Http/Controllers/AiVision/PersonPhotosController.php create mode 100644 app/Http/Controllers/AiVision/SelfieClaimController.php create mode 100644 app/Http/Requests/Face/AssignFaceRequest.php create mode 100644 app/Http/Requests/Face/BulkScanRequest.php create mode 100644 app/Http/Requests/Face/DestroyDismissedFacesRequest.php create mode 100644 app/Http/Requests/Face/ScanPhotosRequest.php create mode 100644 app/Http/Requests/Face/ToggleDismissedRequest.php create mode 100644 app/Http/Requests/Maintenance/ResetStuckFacesRequest.php create mode 100644 app/Http/Requests/Person/ClaimPersonRequest.php create mode 100644 app/Http/Requests/Person/DestroyPersonRequest.php create mode 100644 app/Http/Requests/Person/ListPersonsRequest.php create mode 100644 app/Http/Requests/Person/MergePersonRequest.php create mode 100644 app/Http/Requests/Person/SelfieClaimRequest.php create mode 100644 app/Http/Requests/Person/StorePersonRequest.php create mode 100644 app/Http/Requests/Person/UpdatePersonRequest.php create mode 100644 app/Http/Resources/Models/FaceResource.php create mode 100644 app/Http/Resources/Models/FaceSuggestionResource.php create mode 100644 app/Http/Resources/Models/PersonResource.php create mode 100644 app/Jobs/DispatchFaceScanJob.php create mode 100644 app/Listeners/AutoScanFacesOnUpload.php create mode 100644 app/Models/Face.php create mode 100644 app/Models/FaceSuggestion.php create mode 100644 app/Models/Person.php create mode 100644 app/Policies/AiVisionPolicy.php create mode 100644 database/migrations/2026_03_21_000001_create_persons_table.php create mode 100644 database/migrations/2026_03_21_000002_create_faces_table.php create mode 100644 database/migrations/2026_03_21_000003_ai_vision_config.php create mode 100644 database/migrations/2026_03_21_000004_create_ai_vision_category.php diff --git a/app/Console/Commands/RescanFailedFaces.php b/app/Console/Commands/RescanFailedFaces.php new file mode 100644 index 00000000000..53179134435 --- /dev/null +++ b/app/Console/Commands/RescanFailedFaces.php @@ -0,0 +1,83 @@ + N minutes back to null, + * making them eligible for a fresh scan + */ +class RescanFailedFaces extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'lychee:rescan-failed-faces + {--stuck-pending : Also reset photos stuck in pending state} + {--older-than=60 : Minutes threshold for stuck-pending reset}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Re-enqueue failed face scan photos; optionally reset stuck pending records.'; + + /** + * Execute the console command. + */ + public function handle(): int + { + $reset_count = 0; + $dispatched = 0; + + // Reset stuck-pending records if requested + if ($this->option('stuck-pending') === true) { + $older_than = (int) $this->option('older-than'); + $cutoff = Carbon::now()->subMinutes($older_than); + + $reset_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->update(['face_scan_status' => null]); + + $this->info("Reset {$reset_count} stuck-pending photo(s) older than {$older_than} minutes."); + Log::info("lychee:rescan-failed-faces reset {$reset_count} stuck-pending records."); + } + + // Re-enqueue failed photos + Photo::query() + ->select('id') + ->where('face_scan_status', '=', FaceScanStatus::FAILED->value) + ->lazyById(200, 'id') + ->each(function (Photo $photo) use (&$dispatched): void { + Photo::where('id', '=', $photo->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + DispatchFaceScanJob::dispatch($photo->id); + $dispatched++; + }); + + $this->info("Dispatched {$dispatched} re-scan job(s) for failed photos."); + Log::info("lychee:rescan-failed-faces dispatched {$dispatched} re-scan jobs."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/ScanFaces.php b/app/Console/Commands/ScanFaces.php new file mode 100644 index 00000000000..52fbc122412 --- /dev/null +++ b/app/Console/Commands/ScanFaces.php @@ -0,0 +1,67 @@ +option('album'); + + $query = Photo::query()->select('id')->whereNull('face_scan_status'); + + if ($album_id !== null) { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + $dispatched = 0; + + $query->lazyById(200, 'id')->each(function (Photo $photo) use (&$dispatched): void { + Photo::where('id', '=', $photo->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + DispatchFaceScanJob::dispatch($photo->id); + $dispatched++; + }); + + $this->info("Dispatched {$dispatched} face scan job(s)."); + Log::info("lychee:scan-faces dispatched {$dispatched} jobs."); + + return Command::SUCCESS; + } +} diff --git a/app/Enum/FacePermissionMode.php b/app/Enum/FacePermissionMode.php new file mode 100644 index 00000000000..811ab1299d8 --- /dev/null +++ b/app/Enum/FacePermissionMode.php @@ -0,0 +1,17 @@ +subMinutes(self::DEFAULT_OLDER_THAN); + + return Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->count(); + } + + /** + * Do: reset stuck pending photos back to null and return the count reset. + * + * @return array{reset_count: int} + */ + public function do(ResetStuckFacesRequest $request): array + { + $cutoff = Carbon::now()->subMinutes($request->olderThanMinutes()); + + $reset_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->update(['face_scan_status' => null]); + + return ['reset_count' => $reset_count]; + } +} diff --git a/app/Http/Controllers/AiVision/FaceController.php b/app/Http/Controllers/AiVision/FaceController.php new file mode 100644 index 00000000000..cc2ded3252f --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceController.php @@ -0,0 +1,106 @@ +personId() !== null) { + $face->person_id = $request->personId(); + } elseif ($request->newPersonName() !== null) { + $is_searchable_default = app(ConfigManager::class)->getValueAsString('ai_vision_face_person_is_searchable_default') === '1'; + $person = new Person(); + $person->name = $request->newPersonName(); + $person->is_searchable = $is_searchable_default; + $person->save(); + $face->person_id = $person->id; + } else { + abort(422, 'Either person_id or new_person_name must be provided.'); + } + + $face->save(); + + return FaceResource::fromModel($face->load(['suggestions.suggestedFace.person', 'person'])); + } + + /** + * Toggle the is_dismissed flag on a face. + * Only the photo owner or admin can dismiss/undismiss. + * + * PATCH /Face/{id} + * + * @return FaceResource + */ + public function toggleDismissed(ToggleDismissedRequest $_request, string $id): FaceResource + { + $face = Face::with('photo')->findOrFail($id); + $user = Auth::user(); + + // Check ownership: photo owner or admin + if (!($user?->may_administrate === true) && $face->photo->owner_id !== $user?->id) { + abort(403, 'Only the photo owner or an admin can dismiss faces.'); + } + + $face->is_dismissed = !$face->is_dismissed; + $face->save(); + + return FaceResource::fromModel($face->load(['suggestions.suggestedFace.person', 'person'])); + } + + /** + * Hard-delete all dismissed faces and remove their crop files. + * Admin-only. + * + * DELETE /Face/dismissed + * + * @return array{deleted_count: int} + */ + public function destroyDismissed(DestroyDismissedFacesRequest $_request): array + { + $dismissed_faces = Face::where('is_dismissed', '=', true)->get(); + $count = 0; + + foreach ($dismissed_faces as $face) { + // Delete crop file + if ($face->crop_token !== null) { + $tok = $face->crop_token; + $path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + Storage::disk('images')->delete($path); + } + $face->delete(); + $count++; + } + + return ['deleted_count' => $count]; + } +} diff --git a/app/Http/Controllers/AiVision/FaceDetectionController.php b/app/Http/Controllers/AiVision/FaceDetectionController.php new file mode 100644 index 00000000000..24fd9834fa8 --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceDetectionController.php @@ -0,0 +1,317 @@ +photoIds(); + $album_id = $request->albumId(); + $force = $request->force(); + + if ($photo_ids === null && $album_id === null) { + abort(422, 'Either photo_ids or album_id must be provided.'); + } + + // Collect target photos + $query = Photo::query()->select('id'); + + if ($photo_ids !== null) { + $query->whereIn('id', $photo_ids); + } else { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + if (!$force) { + // Skip photos that have at least one face with a person assigned + $query->whereDoesntHave('faces', fn ($q) => $q->whereNotNull('person_id')); + } + + $batch_size = (int) app(ConfigManager::class)->getValueAsString('ai_vision_face_scan_batch_size'); + + $dispatched = 0; + $query->lazyById($batch_size, 'id')->chunk($batch_size)->each(function ($chunk) use (&$dispatched): void { + $ids = $chunk->pluck('id')->all(); + + // Set status to pending in bulk + Photo::whereIn('id', $ids)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + + // Dispatch a job for each photo + foreach ($ids as $photo_id) { + DispatchFaceScanJob::dispatch($photo_id); + $dispatched++; + } + }); + + Log::info("FaceDetectionController::scan — dispatched {$dispatched} scans."); + + return response()->noContent(202); + } + + /** + * Receive face detection results callback from the Python service. + * Authentication is exclusively via X-API-Key header; no user session required. + * + * POST /FaceDetection/results + * + * @return array> + */ + public function results(Request $request): array + { + // Validate API key — exclusive auth for this endpoint + $expected_key = config('features.ai-vision.face-api-key', ''); + $provided_key = $request->header('X-API-Key', ''); + + if ($expected_key === '' || $provided_key !== $expected_key) { + abort(401, 'Invalid or missing X-API-Key.'); + } + + $data = $request->validate([ + 'photo_id' => 'required|string|exists:photos,id', + 'status' => 'required|string|in:success,error', + 'faces' => 'sometimes|array', + 'faces.*.x' => 'required_with:faces|numeric', + 'faces.*.y' => 'required_with:faces|numeric', + 'faces.*.width' => 'required_with:faces|numeric', + 'faces.*.height' => 'required_with:faces|numeric', + 'faces.*.confidence' => 'required_with:faces|numeric', + 'faces.*.embedding_id' => 'required_with:faces|string', + 'faces.*.crop' => 'sometimes|string', + 'faces.*.suggestions' => 'sometimes|array', + 'faces.*.suggestions.*.lychee_face_id' => 'required|string', + 'faces.*.suggestions.*.confidence' => 'required|numeric', + 'error_code' => 'sometimes|string', + 'message' => 'sometimes|string', + ]); + + $photo_id = $data['photo_id']; + + if ($data['status'] === 'error') { + Photo::where('id', '=', $photo_id)->update(['face_scan_status' => FaceScanStatus::FAILED->value]); + Log::info("FaceDetectionController::results — photo {$photo_id} face scan failed: " . ($data['message'] ?? 'unknown error')); + + return ['faces' => []]; + } + + // Process success payload + $incoming_faces = $data['faces'] ?? []; + $mapping = $this->processFaceResults($photo_id, $incoming_faces); + + Photo::where('id', '=', $photo_id)->update(['face_scan_status' => FaceScanStatus::COMPLETED->value]); + + return ['faces' => $mapping]; + } + + /** + * Admin: enqueue all photos with face_scan_status IS NULL for face detection. + * + * POST /FaceDetection/bulk-scan + * + * @return \Illuminate\Http\Response + */ + public function bulkScan(BulkScanRequest $request): \Illuminate\Http\Response + { + $album_id = $request->albumId(); + $batch_size = (int) app(ConfigManager::class)->getValueAsString('ai_vision_face_scan_batch_size'); + + $query = Photo::query()->select('id')->whereNull('face_scan_status'); + + if ($album_id !== null) { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + $dispatched = 0; + $query->lazyById($batch_size, 'id')->chunk($batch_size)->each(function ($chunk) use (&$dispatched): void { + $ids = $chunk->pluck('id')->all(); + Photo::whereIn('id', $ids)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + + foreach ($ids as $photo_id) { + DispatchFaceScanJob::dispatch($photo_id); + $dispatched++; + } + }); + + Log::info("FaceDetectionController::bulkScan — dispatched {$dispatched} scans."); + + return response()->noContent(202); + } + + /** + * Process incoming face detection results for a photo. + * IoU-matches new faces against old faces to preserve person assignments. + * + * @param string $photo_id + * @param array}> $incoming_faces + * + * @return array + */ + private function processFaceResults(string $photo_id, array $incoming_faces): array + { + $iou_threshold = (float) config('features.ai-vision.face-rescan-iou-threshold', self::DEFAULT_IOU_THRESHOLD); + + // Load existing faces for this photo + $old_faces = Face::where('photo_id', '=', $photo_id)->get(); + + // For each incoming face, find best IoU match among old faces + // Track matched old face indices to avoid double-matching + $matched_old_indices = []; + $person_ids_for_new = array_fill(0, count($incoming_faces), null); + + foreach ($incoming_faces as $new_idx => $new_face) { + $best_iou = $iou_threshold; + $best_old_idx = null; + + foreach ($old_faces as $old_idx => $old_face) { + if (in_array($old_idx, $matched_old_indices, true)) { + continue; + } + + $iou = $this->computeIoU( + $new_face['x'], $new_face['y'], $new_face['width'], $new_face['height'], + $old_face->x, $old_face->y, $old_face->width, $old_face->height + ); + + if ($iou > $best_iou) { + $best_iou = $iou; + $best_old_idx = $old_idx; + } + } + + if ($best_old_idx !== null) { + $matched_old_indices[] = $best_old_idx; + $person_ids_for_new[$new_idx] = $old_faces[$best_old_idx]->person_id; + } + } + + // Delete ALL old face records (and their crops) — they will be replaced + foreach ($old_faces as $old_face) { + $this->deleteCropFile($old_face->crop_token); + } + Face::where('photo_id', '=', $photo_id)->delete(); + FaceSuggestion::whereNotExists(function ($query): void { + $query->select(DB::raw(1))->from('faces')->whereColumn('face_suggestions.face_id', 'faces.id'); + })->delete(); + + // Create new face records and return embedding_id → lychee_face_id mapping + $mapping = []; + + foreach ($incoming_faces as $idx => $face_data) { + $tok = Str::random(24); + $crop_path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + + // Store crop file if provided + if (isset($face_data['crop']) && $face_data['crop'] !== '') { + try { + $decoded = base64_decode($face_data['crop']); + Storage::disk('images')->put($crop_path, $decoded); + } catch (\Safe\Exceptions\MiscException) { + $tok = null; + } + } else { + $tok = null; + } + + $face = new Face(); + $face->photo_id = $photo_id; + $face->person_id = $person_ids_for_new[$idx]; + $face->x = $face_data['x']; + $face->y = $face_data['y']; + $face->width = $face_data['width']; + $face->height = $face_data['height']; + $face->confidence = $face_data['confidence']; + $face->crop_token = $tok; + $face->is_dismissed = false; + $face->save(); + + // Create suggestion records + foreach ($face_data['suggestions'] ?? [] as $suggestion) { + $sug = new FaceSuggestion(); + $sug->face_id = $face->id; + $sug->suggested_face_id = $suggestion['lychee_face_id']; + $sug->confidence = $suggestion['confidence']; + $sug->save(); + } + + $mapping[] = [ + 'embedding_id' => $face_data['embedding_id'], + 'lychee_face_id' => $face->id, + ]; + } + + return $mapping; + } + + /** + * Compute Intersection over Union (IoU) for two bounding boxes. + * All coordinates in normalized [0,1] space. + */ + private function computeIoU( + float $x1, float $y1, float $w1, float $h1, + float $x2, float $y2, float $w2, float $h2, + ): float { + $ix = max(0.0, min($x1 + $w1, $x2 + $w2) - max($x1, $x2)); + $iy = max(0.0, min($y1 + $h1, $y2 + $h2) - max($y1, $y2)); + $inter = $ix * $iy; + + if ($inter === 0.0) { + return 0.0; + } + + $union = $w1 * $h1 + $w2 * $h2 - $inter; + + return $union > 0.0 ? $inter / $union : 0.0; + } + + /** + * Delete a face crop file from storage if the token is set. + */ + private function deleteCropFile(?string $crop_token): void + { + if ($crop_token === null) { + return; + } + + $path = 'faces/' . substr($crop_token, 0, 2) . '/' . substr($crop_token, 2, 2) . '/' . $crop_token . '.jpg'; + Storage::disk('images')->delete($path); + } +} diff --git a/app/Http/Controllers/AiVision/PeopleController.php b/app/Http/Controllers/AiVision/PeopleController.php new file mode 100644 index 00000000000..83056aec626 --- /dev/null +++ b/app/Http/Controllers/AiVision/PeopleController.php @@ -0,0 +1,125 @@ + + */ + public function index(ListPersonsRequest $_request): PaginatedDataCollection + { + $user = Auth::user(); + $query = Person::query()->orderBy('name'); + + if ($user === null || !$user->may_administrate) { + // Non-admin: only show searchable persons, plus the person linked to the current user + $user_id = $user?->id; + $query->where(function ($q) use ($user_id): void { + $q->where('is_searchable', '=', true); + if ($user_id !== null) { + $q->orWhere('user_id', '=', $user_id); + } + }); + } + + $persons = $query->paginate(50); + + return PersonResource::collect($persons, PaginatedDataCollection::class); + } + + /** + * Show a single person. + * + * @return PersonResource + */ + public function show(ListPersonsRequest $_request, string $id): PersonResource + { + $user = Auth::user(); + $person = Person::findOrFail($id); + + // Non-admin cannot see non-searchable persons unless they are linked to them + if (!($user?->may_administrate === true) && !$person->is_searchable && $person->user_id !== $user?->id) { + abort(403); + } + + return PersonResource::fromModel($person); + } + + /** + * Create a new Person. + * + * @return PersonResource + */ + public function store(StorePersonRequest $request): PersonResource + { + $is_searchable_default = app(ConfigManager::class)->getValueAsString('ai_vision_face_person_is_searchable_default') === '1'; + + $person = new Person(); + $person->name = $request->name(); + $person->user_id = $request->userId(); + $person->is_searchable = $is_searchable_default; + $person->save(); + + return PersonResource::fromModel($person); + } + + /** + * Update a Person (name and/or searchability). + * + * @return PersonResource + */ + public function update(UpdatePersonRequest $request): PersonResource + { + $person = Person::findOrFail($request->personId()); + $user = Auth::user(); + + // For searchability toggle, only the linked user or admin can change + if ($request->isSearchable() !== null) { + if (!($user?->may_administrate === true) && $person->user_id !== $user?->id) { + abort(403); + } + $person->is_searchable = $request->isSearchable(); + } + + if ($request->name() !== null) { + $person->name = $request->name(); + } + + $person->save(); + + return PersonResource::fromModel($person); + } + + /** + * Delete a Person. All associated faces will have their person_id set to null. + */ + public function destroy(DestroyPersonRequest $request): void + { + $person = Person::findOrFail($request->personId()); + $person->delete(); + } +} diff --git a/app/Http/Controllers/AiVision/PersonClaimController.php b/app/Http/Controllers/AiVision/PersonClaimController.php new file mode 100644 index 00000000000..244ca88003f --- /dev/null +++ b/app/Http/Controllers/AiVision/PersonClaimController.php @@ -0,0 +1,105 @@ +may_administrate) { + // Check if user claims are allowed + if (app(ConfigManager::class)->getValueAsString('ai_vision_face_allow_user_claim') !== '1') { + abort(403, 'User claims are not permitted by the administrator.'); + } + + // Non-admin: conflict if already claimed by another user + if ($person->user_id !== null && $person->user_id !== $user->id) { + abort(409, 'This person is already claimed by another user.'); + } + + // Non-admin: ensure user doesn't already have a different person + $existing = Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->first(); + if ($existing !== null) { + abort(409, 'You have already claimed a different person.'); + } + } else { + // Admin force-claim: clear any existing link for this user + Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->update(['user_id' => null]); + } + + $person->user_id = $user->id; + $person->save(); + + return PersonResource::fromModel($person->fresh()); + } + + /** + * Unclaim a Person — remove the user_id link. + * Only the linked user or admin can unclaim. + */ + public function unclaim(ClaimPersonRequest $_request, string $id): void + { + /** @var \App\Models\User $user */ + $user = Auth::user(); + $person = Person::findOrFail($id); + + if (!$user->may_administrate && $person->user_id !== $user->id) { + abort(403, 'Only the linked user or an admin can unclaim this person.'); + } + + $person->user_id = null; + $person->save(); + } + + /** + * Merge source Person into target Person. + * All Face records from source are reassigned to target; source is deleted. + * + * URL: POST /Person/{id}/merge where {id} is the TARGET person (kept). + * Body: source_person_id = the person to be destroyed. + * + * @return PersonResource + */ + public function merge(MergePersonRequest $request, string $id): PersonResource + { + $target = Person::findOrFail($id); + $source = Person::findOrFail($request->sourcePersonId()); + + // Reassign all faces from source to target + Face::where('person_id', '=', $source->id)->update(['person_id' => $target->id]); + + // Delete source person + $source->delete(); + + return PersonResource::fromModel($target->fresh()); + } +} diff --git a/app/Http/Controllers/AiVision/PersonPhotosController.php b/app/Http/Controllers/AiVision/PersonPhotosController.php new file mode 100644 index 00000000000..686b8a76c97 --- /dev/null +++ b/app/Http/Controllers/AiVision/PersonPhotosController.php @@ -0,0 +1,70 @@ + + */ + public function index(ListPersonsRequest $_request, string $id) + { + $user = Auth::user(); + $person = Person::findOrFail($id); + + // Non-admin cannot see non-searchable persons unless linked to them + if (!($user?->may_administrate === true) && !$person->is_searchable && $person->user_id !== $user?->id) { + abort(403); + } + + $query = Photo::query() + ->select(['photos.id', 'photos.title']) + ->join('faces', 'faces.photo_id', '=', 'photos.id') + ->where('faces.person_id', '=', $id) + ->orderBy('photos.taken_at', 'desc'); + + // Access control: restrict to photos accessible to the current user + if (!($user?->may_administrate === true)) { + $query->where(function ($q) use ($user): void { + // Photos in at least one public album (base_albums.is_public = true) + $q->whereExists(function ($sub): void { + $sub->select(\Illuminate\Support\Facades\DB::raw(1)) + ->from('photo_album') + ->join('base_albums', 'base_albums.id', '=', 'photo_album.album_id') + ->whereColumn('photo_album.photo_id', 'photos.id') + ->where('base_albums.is_public', '=', true); + }); + + // OR photos owned by the authenticated user + if ($user !== null) { + $q->orWhere('photos.owner_id', '=', $user->id); + } + }); + } + + return $query->distinct()->paginate(50); + } +} diff --git a/app/Http/Controllers/AiVision/SelfieClaimController.php b/app/Http/Controllers/AiVision/SelfieClaimController.php new file mode 100644 index 00000000000..effd9229ace --- /dev/null +++ b/app/Http/Controllers/AiVision/SelfieClaimController.php @@ -0,0 +1,114 @@ +selfie(); + + try { + $response = Http::withHeaders(['X-API-Key' => $api_key]) + ->attach('image', file_get_contents($selfie->getRealPath()), $selfie->getClientOriginalName()) + ->post($service_url . '/match'); + } catch (\Exception $e) { + Log::warning('AI Vision selfie match request failed: ' . $e->getMessage()); + abort(503, 'AI Vision service is unavailable.'); + } finally { + // Discard selfie immediately + unlink($selfie->getRealPath()); + } + + if (!$response->successful()) { + if ($response->status() === 422) { + abort(422, 'No face detected in the selfie image.'); + } + abort(503, 'AI Vision service returned an error.'); + } + + /** @var array{matches: array} $data */ + $data = $response->json(); + $matches = $data['matches'] ?? []; + + if ($matches === []) { + abort(404, 'No matching person found for the selfie.'); + } + + $threshold = (float) app(ConfigManager::class)->getValueAsString('ai_vision_face_selfie_confidence_threshold'); + $best_match = $matches[0]; + + if ($best_match['confidence'] < $threshold) { + abort(404, 'No matching person found with sufficient confidence.'); + } + + $face = Face::findOrFail($best_match['lychee_face_id']); + + if ($face->person_id === null) { + abort(404, 'No person associated with the matched face.'); + } + + $person = Person::findOrFail($face->person_id); + + // Check if already claimed by another user + if ($person->user_id !== null && $person->user_id !== $user->id) { + abort(409, 'This person is already claimed by another user.'); + } + + // Ensure user doesn't already have a different person linked + $existing = Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->first(); + if ($existing !== null) { + abort(409, 'You have already claimed a different person.'); + } + + // Check if user claims are allowed + if (!$user->may_administrate && app(ConfigManager::class)->getValueAsString('ai_vision_face_allow_user_claim') !== '1') { + abort(403, 'User claims are not permitted by the administrator.'); + } + + $person->user_id = $user->id; + $person->save(); + + return PersonResource::fromModel($person->fresh()); + } +} diff --git a/app/Http/Middleware/ConfigIntegrity.php b/app/Http/Middleware/ConfigIntegrity.php index d9c043baf81..12a0c416324 100644 --- a/app/Http/Middleware/ConfigIntegrity.php +++ b/app/Http/Middleware/ConfigIntegrity.php @@ -105,6 +105,13 @@ class ConfigIntegrity 'contact_form_security_question', 'contact_form_security_answer', 'contact_form_custom_consent_required', + 'ai_vision_enabled', + 'ai_vision_face_enabled', + 'ai_vision_face_permission_mode', + 'ai_vision_face_selfie_confidence_threshold', + 'ai_vision_face_person_is_searchable_default', + 'ai_vision_face_allow_user_claim', + 'ai_vision_face_scan_batch_size', ]; public const PRO_FIELDS = [ diff --git a/app/Http/Requests/Face/AssignFaceRequest.php b/app/Http/Requests/Face/AssignFaceRequest.php new file mode 100644 index 00000000000..45d9e75fdc7 --- /dev/null +++ b/app/Http/Requests/Face/AssignFaceRequest.php @@ -0,0 +1,49 @@ + ['nullable', 'string', 'exists:persons,id'], + 'new_person_name' => ['nullable', 'string', 'max:255'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person_id = $values['person_id'] ?? null; + $this->new_person_name = $values['new_person_name'] ?? null; + } + + public function personId(): ?string + { + return $this->person_id; + } + + public function newPersonName(): ?string + { + return $this->new_person_name; + } +} diff --git a/app/Http/Requests/Face/BulkScanRequest.php b/app/Http/Requests/Face/BulkScanRequest.php new file mode 100644 index 00000000000..0a62d0fb955 --- /dev/null +++ b/app/Http/Requests/Face/BulkScanRequest.php @@ -0,0 +1,55 @@ + 'nullable|string|exists:albums,id', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->album_id = $values['album_id'] ?? null; + } + + public function albumId(): ?string + { + return $this->album_id; + } +} diff --git a/app/Http/Requests/Face/DestroyDismissedFacesRequest.php b/app/Http/Requests/Face/DestroyDismissedFacesRequest.php new file mode 100644 index 00000000000..d357f02f808 --- /dev/null +++ b/app/Http/Requests/Face/DestroyDismissedFacesRequest.php @@ -0,0 +1,22 @@ + 'nullable|array', + 'photo_ids.*' => 'string|exists:photos,id', + 'album_id' => 'nullable|string|exists:albums,id', + 'force' => 'sometimes|boolean', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->photo_ids = $values['photo_ids'] ?? null; + $this->album_id = $values['album_id'] ?? null; + $this->force = isset($values['force']) && static::toBoolean($values['force']); + } + + /** + * @return string[]|null + */ + public function photoIds(): ?array + { + return $this->photo_ids; + } + + public function albumId(): ?string + { + return $this->album_id; + } + + public function force(): bool + { + return $this->force; + } +} diff --git a/app/Http/Requests/Face/ToggleDismissedRequest.php b/app/Http/Requests/Face/ToggleDismissedRequest.php new file mode 100644 index 00000000000..8ec4639b69f --- /dev/null +++ b/app/Http/Requests/Face/ToggleDismissedRequest.php @@ -0,0 +1,22 @@ + 'sometimes|integer|min:1', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->older_than_minutes = isset($values['older_than_minutes']) ? (int) $values['older_than_minutes'] : 60; + } + + public function olderThanMinutes(): int + { + return $this->older_than_minutes; + } +} diff --git a/app/Http/Requests/Person/ClaimPersonRequest.php b/app/Http/Requests/Person/ClaimPersonRequest.php new file mode 100644 index 00000000000..b09501f29e8 --- /dev/null +++ b/app/Http/Requests/Person/ClaimPersonRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'exists:persons,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person_id = $values['person_id']; + } + + public function personId(): string + { + return $this->person_id; + } +} diff --git a/app/Http/Requests/Person/ListPersonsRequest.php b/app/Http/Requests/Person/ListPersonsRequest.php new file mode 100644 index 00000000000..5bb5b605844 --- /dev/null +++ b/app/Http/Requests/Person/ListPersonsRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'exists:persons,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->source_person_id = $values['source_person_id']; + } + + public function sourcePersonId(): string + { + return $this->source_person_id; + } +} diff --git a/app/Http/Requests/Person/SelfieClaimRequest.php b/app/Http/Requests/Person/SelfieClaimRequest.php new file mode 100644 index 00000000000..fb87105fd70 --- /dev/null +++ b/app/Http/Requests/Person/SelfieClaimRequest.php @@ -0,0 +1,44 @@ + ['required', 'file', 'image', 'max:10240'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + /** @var UploadedFile $selfie */ + $selfie = $files['selfie']; + $this->selfie = $selfie; + } + + public function selfie(): UploadedFile + { + return $this->selfie; + } +} diff --git a/app/Http/Requests/Person/StorePersonRequest.php b/app/Http/Requests/Person/StorePersonRequest.php new file mode 100644 index 00000000000..1ebf1e5cb50 --- /dev/null +++ b/app/Http/Requests/Person/StorePersonRequest.php @@ -0,0 +1,49 @@ + ['required', 'string', 'max:255'], + 'user_id' => ['nullable', 'integer', 'exists:users,id', 'unique:persons,user_id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->name = $values['name']; + $this->user_id = isset($values['user_id']) ? (int) $values['user_id'] : null; + } + + public function name(): string + { + return $this->name; + } + + public function userId(): ?int + { + return $this->user_id; + } +} diff --git a/app/Http/Requests/Person/UpdatePersonRequest.php b/app/Http/Requests/Person/UpdatePersonRequest.php new file mode 100644 index 00000000000..20799b71afd --- /dev/null +++ b/app/Http/Requests/Person/UpdatePersonRequest.php @@ -0,0 +1,57 @@ + ['required', 'string', 'exists:persons,id'], + 'name' => ['sometimes', 'string', 'max:255'], + 'is_searchable' => ['sometimes', 'boolean'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person_id = $values['person_id']; + $this->name = $values['name'] ?? null; + $this->is_searchable = isset($values['is_searchable']) ? static::toBoolean($values['is_searchable']) : null; + } + + public function personId(): string + { + return $this->person_id; + } + + public function name(): ?string + { + return $this->name; + } + + public function isSearchable(): ?bool + { + return $this->is_searchable; + } +} diff --git a/app/Http/Resources/Models/FaceResource.php b/app/Http/Resources/Models/FaceResource.php new file mode 100644 index 00000000000..63c8f7c8542 --- /dev/null +++ b/app/Http/Resources/Models/FaceResource.php @@ -0,0 +1,54 @@ +id = $face->id; + $this->photo_id = $face->photo_id; + $this->person_id = $face->person_id; + $this->x = $face->x; + $this->y = $face->y; + $this->width = $face->width; + $this->height = $face->height; + $this->confidence = $face->confidence; + $this->is_dismissed = $face->is_dismissed; + $this->crop_url = $face->crop_url; + $this->suggestions = $face->suggestions + ->map(fn (FaceSuggestion $s) => new FaceSuggestionResource($s)) + ->values() + ->all(); + } + + public static function fromModel(Face $face): self + { + return new self($face); + } +} diff --git a/app/Http/Resources/Models/FaceSuggestionResource.php b/app/Http/Resources/Models/FaceSuggestionResource.php new file mode 100644 index 00000000000..44e32375dd6 --- /dev/null +++ b/app/Http/Resources/Models/FaceSuggestionResource.php @@ -0,0 +1,30 @@ +suggested_face_id = $suggestion->suggested_face_id; + $this->crop_url = $suggestion->suggestedFace->crop_url ?? null; + $this->person_name = $suggestion->suggestedFace->person?->name ?? null; + $this->confidence = $suggestion->confidence; + } +} diff --git a/app/Http/Resources/Models/PersonResource.php b/app/Http/Resources/Models/PersonResource.php new file mode 100644 index 00000000000..c194ac1e0ce --- /dev/null +++ b/app/Http/Resources/Models/PersonResource.php @@ -0,0 +1,47 @@ +id = $person->id; + $this->name = $person->name; + $this->user_id = $person->user_id; + $this->is_searchable = $person->is_searchable; + $this->face_count = $person->faces()->count(); + $this->photo_count = $person->faces()->distinct('photo_id')->count('photo_id'); + + $crop_token = $person->faces()->whereNotNull('crop_token')->value('crop_token'); + if ($crop_token !== null) { + $this->representative_crop_url = 'uploads/faces/' . substr($crop_token, 0, 2) . '/' . substr($crop_token, 2, 2) . '/' . $crop_token . '.jpg'; + } else { + $this->representative_crop_url = null; + } + } + + public static function fromModel(Person $person): self + { + return new self($person); + } +} diff --git a/app/Jobs/DispatchFaceScanJob.php b/app/Jobs/DispatchFaceScanJob.php new file mode 100644 index 00000000000..ef3af34c7ea --- /dev/null +++ b/app/Jobs/DispatchFaceScanJob.php @@ -0,0 +1,94 @@ +photo_id = $photo_id; + } + + /** + * Execute the job: send the photo to the AI Vision service. + */ + public function handle(): void + { + $service_url = config('features.ai-vision.face-url', ''); + $api_key = config('features.ai-vision.face-api-key', ''); + + $photo = Photo::with('size_variants')->find($this->photo_id); + + if ($photo === null) { + Log::warning("DispatchFaceScanJob: photo {$this->photo_id} not found, skipping."); + + return; + } + + if ($service_url === '') { + Log::warning("DispatchFaceScanJob: AI Vision service not configured, marking photo {$this->photo_id} as failed."); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + + return; + } + + $original = $photo->size_variants->getOriginal(); + + if ($original === null) { + Log::warning("DispatchFaceScanJob: no original size variant for photo {$this->photo_id}, marking as failed."); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + + return; + } + + $photo_path = Storage::disk($original->storage_disk->value)->path($original->short_path); + + try { + $response = Http::withHeaders(['X-API-Key' => $api_key]) + ->post($service_url . '/detect', [ + 'photo_id' => $this->photo_id, + 'photo_path' => $photo_path, + ]); + + if (!$response->successful()) { + Log::warning("DispatchFaceScanJob: /detect returned HTTP {$response->status()} for photo {$this->photo_id}."); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + } + } catch (\Exception $e) { + Log::warning("DispatchFaceScanJob: /detect request failed for photo {$this->photo_id}: " . $e->getMessage()); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + } + } +} diff --git a/app/Listeners/AutoScanFacesOnUpload.php b/app/Listeners/AutoScanFacesOnUpload.php new file mode 100644 index 00000000000..0e724eb9f74 --- /dev/null +++ b/app/Listeners/AutoScanFacesOnUpload.php @@ -0,0 +1,44 @@ +getValueAsString('ai_vision_enabled') !== '1') { + return; + } + + if ($config_manager->getValueAsString('ai_vision_face_enabled') !== '1') { + return; + } + + Log::info("AutoScanFacesOnUpload: dispatching face scan for photo {$event->photo_id}."); + DispatchFaceScanJob::dispatch($event->photo_id); + } +} diff --git a/app/Models/Face.php b/app/Models/Face.php new file mode 100644 index 00000000000..92b56df178b --- /dev/null +++ b/app/Models/Face.php @@ -0,0 +1,140 @@ + $suggestions + * @property string|null $crop_url + */ +class Face extends Model +{ + use HasFactory; + /** @phpstan-use HasRandomIDAndLegacyTimeBasedID */ + use HasRandomIDAndLegacyTimeBasedID; + use ThrowsConsistentExceptions; + use ToArrayThrowsNotImplemented; + + /** + * @var string The type of the primary key + */ + protected $keyType = 'string'; + + /** + * Indicates if the model's primary key is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'photo_id', + 'person_id', + 'x', + 'y', + 'width', + 'height', + 'confidence', + 'crop_token', + 'is_dismissed', + ]; + + /** + * @var array + */ + protected $casts = [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'x' => 'float', + 'y' => 'float', + 'width' => 'float', + 'height' => 'float', + 'confidence' => 'float', + 'is_dismissed' => 'boolean', + ]; + + /** + * @var list + */ + protected $appends = ['crop_url']; + + /** + * Get the crop URL derived from the crop_token. + * Path format: uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg. + * + * @return string|null + */ + public function getCropUrlAttribute(): ?string + { + if ($this->crop_token === null) { + return null; + } + $tok = $this->crop_token; + + return 'uploads/faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + } + + /** + * Return the photo this face belongs to. + * + * @return BelongsTo + */ + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class, 'photo_id', 'id'); + } + + /** + * Return the person associated with this face. + * + * @return BelongsTo + */ + public function person(): BelongsTo + { + return $this->belongsTo(Person::class, 'person_id', 'id'); + } + + /** + * Return the suggestions for this face (pre-computed similar faces). + * + * @return HasMany + */ + public function suggestions(): HasMany + { + return $this->hasMany(FaceSuggestion::class, 'face_id', 'id'); + } +} diff --git a/app/Models/FaceSuggestion.php b/app/Models/FaceSuggestion.php new file mode 100644 index 00000000000..b276162f3c7 --- /dev/null +++ b/app/Models/FaceSuggestion.php @@ -0,0 +1,72 @@ + + */ + protected $fillable = [ + 'face_id', + 'suggested_face_id', + 'confidence', + ]; + + /** + * @var array + */ + protected $casts = [ + 'confidence' => 'float', + ]; + + /** + * Return the face this suggestion belongs to. + * + * @return BelongsTo + */ + public function face(): BelongsTo + { + return $this->belongsTo(Face::class, 'face_id', 'id'); + } + + /** + * Return the suggested face. + * + * @return BelongsTo + */ + public function suggestedFace(): BelongsTo + { + return $this->belongsTo(Face::class, 'suggested_face_id', 'id'); + } +} diff --git a/app/Models/Person.php b/app/Models/Person.php new file mode 100644 index 00000000000..51246b38534 --- /dev/null +++ b/app/Models/Person.php @@ -0,0 +1,103 @@ + $faces + */ +class Person extends Model +{ + use HasFactory; + /** @phpstan-use HasRandomIDAndLegacyTimeBasedID */ + use HasRandomIDAndLegacyTimeBasedID; + use ThrowsConsistentExceptions; + use ToArrayThrowsNotImplemented; + + /** + * @var string The type of the primary key + */ + protected $keyType = 'string'; + + /** + * Indicates if the model's primary key is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'name', + 'user_id', + 'is_searchable', + ]; + + /** + * @var array + */ + protected $casts = [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'is_searchable' => 'boolean', + 'user_id' => 'integer', + ]; + + /** + * Return the user linked to this person. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } + + /** + * Return the faces associated with this person. + * + * @return HasMany + */ + public function faces(): HasMany + { + return $this->hasMany(Face::class, 'person_id', 'id'); + } + + /** + * Scope to only return searchable persons. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeSearchable($query) + { + return $query->where('is_searchable', '=', true); + } +} diff --git a/app/Models/Photo.php b/app/Models/Photo.php index ab31e9b0e78..8384445a19a 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -13,6 +13,7 @@ use App\Casts\MustNotSetCast; use App\Constants\PhotoAlbum as PA; use App\Contracts\Models\HasUTCBasedTimes; +use App\Enum\FaceScanStatus; use App\Enum\LicenseType; use App\Enum\SmartAlbumType; use App\Enum\StorageDiskType; @@ -170,6 +171,7 @@ class Photo extends Model implements HasUTCBasedTimes 'altitude' => 'float', 'img_direction' => 'float', 'rating_avg' => 'decimal:4', + 'face_scan_status' => FaceScanStatus::class, ]; /** @@ -272,6 +274,16 @@ public function palette(): HasOne return $this->hasOne(Palette::class, 'photo_id', 'id'); } + /** + * Return the faces detected in this photo. + * + * @return HasMany + */ + public function faces(): HasMany + { + return $this->hasMany(Face::class, 'photo_id', 'id'); + } + /** * Returns the relationship between a tag and all photos with whom * this tag is attached. diff --git a/app/Models/User.php b/app/Models/User.php index fd85d664cf5..1091621e418 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -181,6 +181,16 @@ public function oauthCredentials(): HasMany return $this->hasMany(OauthCredential::class, 'user_id', 'id'); } + /** + * Return the Person linked to this user (1-1). + * + * @return \Illuminate\Database\Eloquent\Relations\HasOne + */ + public function person(): \Illuminate\Database\Eloquent\Relations\HasOne + { + return $this->hasOne(Person::class, 'user_id', 'id'); + } + /** * Used by Larapass. * diff --git a/app/Policies/AiVisionPolicy.php b/app/Policies/AiVisionPolicy.php new file mode 100644 index 00000000000..5f5dd3444d7 --- /dev/null +++ b/app/Policies/AiVisionPolicy.php @@ -0,0 +1,147 @@ +getValueAsEnum('ai_vision_face_permission_mode', FacePermissionMode::class) ?? FacePermissionMode::RESTRICTED; + } + + /** + * View People page / list persons. + * public: guest; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canViewPeople(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => true, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * Create/edit Person. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canEditPerson(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * Assign face to person. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canAssignFace(?User $user): bool + { + // Same rules as edit + return $this->canEditPerson($user); + } + + /** + * Trigger face scan on photos. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: owner+admin. + */ + public function canTriggerScan(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * Claim a person (link to own user account). + * All modes: logged users (admins always pass via before()). + */ + public function canClaimPerson(?User $user): bool + { + return $user !== null; + } + + /** + * Merge two persons. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canMergePersons(?User $user): bool + { + // Same rules as edit + return $this->canEditPerson($user); + } + + /** + * Dismiss / undismiss a face. + * photo owner or admin (handled at controller level; this gate is for admin check only). + */ + public function canDismissFace(?User $user): bool + { + return $user !== null; + } + + /** + * Check if user can manage a specific person (delete, update searchability). + * Only the linked user or admin can manage. + */ + public function canManagePerson(?User $user, Person $person): bool + { + if ($user === null) { + return false; + } + + return $person->user_id === $user->id; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 8ff4fff84ba..cd6e4b50dd2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -13,11 +13,14 @@ use App\Models\Album; use App\Models\Configs; use App\Models\Extensions\BaseAlbum; +use App\Models\Face; use App\Models\LiveMetrics; +use App\Models\Person; use App\Models\Photo; use App\Models\Tag; use App\Models\User; use App\Models\UserGroup; +use App\Policies\AiVisionPolicy; use App\Policies\AlbumPolicy; use App\Policies\MetricsPolicy; use App\Policies\PhotoPolicy; @@ -57,6 +60,9 @@ class AuthServiceProvider extends ServiceProvider UserGroup::class => UserGroupPolicy::class, Tag::class => TagPolicy::class, + + Person::class => AiVisionPolicy::class, + Face::class => AiVisionPolicy::class, ]; /** diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 4e6dab0a795..56f82d3ef47 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -23,6 +23,7 @@ use App\Events\PhotoSaved; use App\Events\TaggedRouteCacheUpdated; use App\Listeners\AlbumCacheCleaner; +use App\Listeners\AutoScanFacesOnUpload; use App\Listeners\CacheListener; use App\Listeners\LogQueryTimeout; use App\Listeners\MetricsListener; @@ -119,5 +120,7 @@ public function boot(): void Event::listen(PhotoDeleted::class, RecomputeAlbumSizeOnPhotoMutation::class . '@handlePhotoDeleted'); Event::listen(AlbumSaved::class, RecomputeAlbumSizeOnAlbumChange::class . '@handleAlbumSaved'); Event::listen(AlbumDeleted::class, RecomputeAlbumSizeOnAlbumChange::class . '@handleAlbumDeleted'); + + Event::listen(PhotoSaved::class, AutoScanFacesOnUpload::class . '@handle'); } } diff --git a/config/features.php b/config/features.php index c10d4734683..077c1e25e87 100644 --- a/config/features.php +++ b/config/features.php @@ -151,4 +151,19 @@ | missing macros and test failures. */ 'populate-request-macros' => (bool) env('POPULATE_REQUEST_MACROS', false), + + /* + |-------------------------------------------------------------------------- + | AI Vision service integration. + |-------------------------------------------------------------------------- + | + | Infrastructure keys for the external AI Vision (facial recognition) service. + | These are NOT stored in the configs table to avoid exposing the service URL + | or shared API key through the admin settings UI. + */ + 'ai-vision' => [ + 'face-url' => env('AI_VISION_FACE_URL', ''), + 'face-api-key' => env('AI_VISION_FACE_API_KEY', ''), + 'face-rescan-iou-threshold' => (float) env('VISION_FACE_RESCAN_IOU_THRESHOLD', 0.3), + ], ]; \ No newline at end of file diff --git a/database/migrations/2026_03_21_000001_create_persons_table.php b/database/migrations/2026_03_21_000001_create_persons_table.php new file mode 100644 index 00000000000..48ba2e08c1a --- /dev/null +++ b/database/migrations/2026_03_21_000001_create_persons_table.php @@ -0,0 +1,40 @@ +char('id', self::RANDOM_ID_LENGTH)->primary(); + $table->string('name', 255)->nullable(false); + $table->unsignedInteger('user_id')->nullable(true)->unique(); + $table->boolean('is_searchable')->default(true); + $table->timestamps(); + + $table->index('user_id'); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('persons'); + } +}; diff --git a/database/migrations/2026_03_21_000002_create_faces_table.php b/database/migrations/2026_03_21_000002_create_faces_table.php new file mode 100644 index 00000000000..a598d9cf987 --- /dev/null +++ b/database/migrations/2026_03_21_000002_create_faces_table.php @@ -0,0 +1,67 @@ +char('id', self::RANDOM_ID_LENGTH)->primary(); + $table->char('photo_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->char('person_id', self::RANDOM_ID_LENGTH)->nullable(true); + $table->float('x')->nullable(false); + $table->float('y')->nullable(false); + $table->float('width')->nullable(false); + $table->float('height')->nullable(false); + $table->float('confidence')->nullable(false); + $table->string('crop_token')->nullable(true); + $table->boolean('is_dismissed')->default(false); + $table->timestamps(); + + $table->index('photo_id'); + $table->index('person_id'); + $table->foreign('photo_id')->references('id')->on('photos')->cascadeOnDelete(); + $table->foreign('person_id')->references('id')->on('persons')->nullOnDelete(); + }); + + Schema::create('face_suggestions', function (Blueprint $table) { + $table->char('face_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->char('suggested_face_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->float('confidence')->nullable(false); + + $table->unique(['face_id', 'suggested_face_id']); + $table->foreign('face_id')->references('id')->on('faces')->cascadeOnDelete(); + $table->foreign('suggested_face_id')->references('id')->on('faces')->cascadeOnDelete(); + }); + + Schema::table('photos', function (Blueprint $table) { + $table->string('face_scan_status', 16)->nullable(true)->after('is_highlighted'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + $table->dropColumn('face_scan_status'); + }); + + Schema::dropIfExists('face_suggestions'); + Schema::dropIfExists('faces'); + } +}; diff --git a/database/migrations/2026_03_21_000003_ai_vision_config.php b/database/migrations/2026_03_21_000003_ai_vision_config.php new file mode 100644 index 00000000000..7e56dccd657 --- /dev/null +++ b/database/migrations/2026_03_21_000003_ai_vision_config.php @@ -0,0 +1,103 @@ + 'ai_vision_enabled', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Enable AI Vision features', + 'details' => 'Master toggle for the AI Vision subsystem. When disabled, all AI Vision endpoints and UI elements are inactive.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 10, + ], + [ + 'key' => 'ai_vision_face_enabled', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Enable facial recognition', + 'details' => 'Enable the facial recognition subsystem. Requires ai_vision_enabled = 1. When disabled, face detection endpoints, People pages, and auto-scan on upload are inactive.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 11, + ], + [ + 'key' => 'ai_vision_face_permission_mode', + 'value' => 'restricted', + 'cat' => self::CAT, + 'type_range' => 'public|private|privacy-preserving|restricted', + 'description' => 'Permission mode for facial recognition features', + 'details' => 'Controls who can view people, face overlays, and manage faces. Options: public, private, privacy-preserving, restricted.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 12, + ], + [ + 'key' => 'ai_vision_face_selfie_confidence_threshold', + 'value' => '0.8', + 'cat' => self::CAT, + 'type_range' => self::STRING, + 'description' => 'Minimum confidence threshold for selfie-based person claim', + 'details' => 'Minimum match confidence score (0.0-1.0) required to automatically link a person via selfie upload.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 13, + ], + [ + 'key' => 'ai_vision_face_person_is_searchable_default', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Default searchability for new persons', + 'details' => 'Default value of the is_searchable flag when a new Person record is created.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 14, + ], + [ + 'key' => 'ai_vision_face_allow_user_claim', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Allow users to claim their person', + 'details' => 'When enabled, regular (non-admin) users may claim a Person record to link it to their account. Admins can always claim/unclaim regardless of this setting.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 15, + ], + [ + 'key' => 'ai_vision_face_scan_batch_size', + 'value' => '200', + 'cat' => self::CAT, + 'type_range' => self::POSITIVE, + 'description' => 'Batch size for bulk face scanning', + 'details' => 'Number of photo IDs dispatched per job chunk when bulk-scanning. Lower values reduce burst queue load.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 16, + ], + ]; + } +}; diff --git a/database/migrations/2026_03_21_000004_create_ai_vision_category.php b/database/migrations/2026_03_21_000004_create_ai_vision_category.php new file mode 100644 index 00000000000..3240775329f --- /dev/null +++ b/database/migrations/2026_03_21_000004_create_ai_vision_category.php @@ -0,0 +1,37 @@ +insert([ + [ + 'cat' => self::CAT, + 'name' => 'AI Vision', + 'description' => 'This module integrates with an external AI service to provide facial recognition, person management, and automatic face scanning capabilities.', + 'order' => 27, + ], + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('config_categories')->where('cat', self::CAT)->delete(); + } +}; diff --git a/routes/api_v2.php b/routes/api_v2.php index 67b1857dfa5..ff7bd801a4f 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -345,3 +345,39 @@ Route::delete('/Renamer', [RenamerController::class, 'destroy'])->middleware(['support:se']); Route::post('/Renamer::test', [RenamerController::class, 'test'])->middleware(['support:se']); Route::post('/Renamer::preview', [RenamerController::class, 'preview'])->middleware(['support:se']); + +/** + * AI VISION — PEOPLE. + */ +Route::get('/People', [AiVision\PeopleController::class, 'index'])->middleware(['support:se']); +Route::get('/Person/{id}', [AiVision\PeopleController::class, 'show'])->middleware(['support:se']); +Route::post('/Person', [AiVision\PeopleController::class, 'store'])->middleware(['support:se']); +Route::patch('/Person/{id}', [AiVision\PeopleController::class, 'update'])->middleware(['support:se']); +Route::delete('/Person/{id}', [AiVision\PeopleController::class, 'destroy'])->middleware(['support:se']); +Route::post('/Person/{id}/claim', [AiVision\PersonClaimController::class, 'claim'])->middleware(['support:se']); +Route::delete('/Person/{id}/claim', [AiVision\PersonClaimController::class, 'unclaim'])->middleware(['support:se']); +Route::post('/Person/{id}/merge', [AiVision\PersonClaimController::class, 'merge'])->middleware(['support:se']); +Route::post('/Person/claim-by-selfie', [AiVision\SelfieClaimController::class, 'claimBySelfie']) + ->middleware(['support:se', 'throttle:5,1']) + ->withoutMiddleware(['content_type:json']); +Route::get('/Person/{id}/photos', [AiVision\PersonPhotosController::class, 'index'])->middleware(['support:se']); + +/** + * AI VISION — FACES. + */ +Route::post('/Face/{id}/assign', [AiVision\FaceController::class, 'assign'])->middleware(['support:se']); +Route::patch('/Face/{id}', [AiVision\FaceController::class, 'toggleDismissed'])->middleware(['support:se']); +Route::delete('/Face/dismissed', [AiVision\FaceController::class, 'destroyDismissed'])->middleware(['support:se']); + +/** + * AI VISION — FACE DETECTION. + */ +Route::post('/FaceDetection/scan', [AiVision\FaceDetectionController::class, 'scan'])->middleware(['support:se']); +Route::post('/FaceDetection/results', [AiVision\FaceDetectionController::class, 'results'])->withoutMiddleware(['auth']); +Route::post('/FaceDetection/bulk-scan', [AiVision\FaceDetectionController::class, 'bulkScan'])->middleware(['support:se']); + +/** + * AI VISION — MAINTENANCE. + */ +Route::get('/Maintenance::resetStuckFaces', [Admin\Maintenance\ResetStuckFaces::class, 'check']); +Route::post('/Maintenance::resetStuckFaces', [Admin\Maintenance\ResetStuckFaces::class, 'do']); diff --git a/tests/Unit/Actions/Db/OptimizeTablesTest.php b/tests/Unit/Actions/Db/OptimizeTablesTest.php index dfe79d944c6..4a1f23b457a 100644 --- a/tests/Unit/Actions/Db/OptimizeTablesTest.php +++ b/tests/Unit/Actions/Db/OptimizeTablesTest.php @@ -32,6 +32,6 @@ public function testOptimizeTables(): void { $optimize = new OptimizeTables(); $output = count($optimize->do()); - self::assertTrue(in_array($output, [3, 37, 38], true), 'OptimizeTables should return either 3 or 37 or 38: ' . $output); + self::assertTrue(in_array($output, [3, 37, 38, 40, 41], true), 'OptimizeTables should return either 3 or 37 or 38 or 40 or 41: ' . $output); } } From a7de26fbf4ed84edb41c56f46827a5b6e44bb2c1 Mon Sep 17 00:00:00 2001 From: ildyria Date: Sat, 21 Mar 2026 17:53:10 +0100 Subject: [PATCH 2/4] tasks --- .../features/030-ai-vision-service/tasks.md | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md b/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md index 4e3bb522903..9d9f3fc5759 100644 --- a/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md +++ b/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md @@ -76,19 +76,19 @@ _Last updated: 2026-03-21_ ### I4 – Database Migrations -- [ ] T-030-10 – Create `persons` table migration (FR-030-01, S-030-01). +- [x] T-030-10 – Create `persons` table migration (FR-030-01, S-030-01). _Intent:_ Migration with columns: id (string PK), name (varchar 255), user_id (nullable unsigned int, unique, FK→users ON DELETE SET NULL), is_searchable (boolean default true), timestamps. Index on user_id. _Verification commands:_ - `php artisan test` _Notes:_ Use string PK consistent with Photo/Album models. -- [ ] T-030-11 – Create `faces` table migration and `face_suggestions` table migration (FR-030-02, DO-030-02, DO-030-05, Q-030-33/34/38). +- [x] T-030-11 – Create `faces` table migration and `face_suggestions` table migration (FR-030-02, DO-030-02, DO-030-05, Q-030-33/34/38). _Intent:_ `faces` migration: `id` (string PK), `photo_id` (FK→photos CASCADE), `person_id` (nullable FK→persons SET NULL), `x`/`y`/`width`/`height` (float, 0.0–1.0), `confidence` (float), `crop_token` (nullable string — random high-entropy token; file served nginx-direct from `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`, Q-030-34), `is_dismissed` (boolean default false), timestamps. Indexes on `photo_id`, `person_id`. `face_suggestions` migration: `face_id` (FK→faces CASCADE), `suggested_face_id` (FK→faces CASCADE), `confidence` (float 0.0–1.0); unique on `(face_id, suggested_face_id)`. Separate migration: add nullable `face_scan_status VARCHAR(16)` column to `photos` table (Q-030-38). _Verification commands:_ - `php artisan test` _Notes:_ Bounding box values are relative (0.0–1.0) per NFR-030-06. `crop_path` is NOT a column — `crop_url` is a computed accessor derived from `crop_token`. -- [ ] T-030-12 – Add AI Vision config entries migration (FR-030-07, FR-030-08, NFR-030-09). +- [x] T-030-12 – Add AI Vision config entries migration (FR-030-07, FR-030-08, NFR-030-09). _Intent:_ Config entries in `configs` table (`cat = 'AI Vision'`, `level = 1` / Supporter Edition): `ai_vision_enabled` (0|1, default 0), `ai_vision_face_enabled` (0|1, default 0), `ai_vision_face_permission_mode` (string, default `restricted`), `ai_vision_face_selfie_confidence_threshold` (float, default 0.8), `ai_vision_face_person_is_searchable_default` (0|1, default 1), `ai_vision_face_allow_user_claim` (0|1, default 1), `ai_vision_face_scan_batch_size` (integer, default 200). Infrastructure secrets (`AI_VISION_FACE_URL`, `AI_VISION_FACE_API_KEY`) added to `config/features.php` — NOT in the `configs` table. _Verification commands:_ - `php artisan test` @@ -107,19 +107,19 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=FaceModelTest` - `make phpstan` -- [ ] T-030-15 – Implement Person model (FR-030-01, FR-030-03). +- [x] T-030-15 – Implement Person model (FR-030-01, FR-030-03). _Intent:_ Eloquent model with: `user()` belongsTo, `faces()` hasMany, `photos()` custom relation via Face→Photo, `scopeSearchable()` query scope for is_searchable filtering. Fillable: name, user_id, is_searchable. _Verification commands:_ - `php artisan test --filter=PersonModelTest` - `make phpstan` -- [ ] T-030-16 – Implement Face model (FR-030-02). +- [x] T-030-16 – Implement Face model (FR-030-02). _Intent:_ Eloquent model with: `photo()` belongsTo, `person()` belongsTo (nullable). `crop_url` computed accessor (from `crop_token`). Fillable: photo_id, person_id, x, y, width, height, confidence, crop_token, is_dismissed. Casts for float fields. _Verification commands:_ - `php artisan test --filter=FaceModelTest` - `make phpstan` -- [ ] T-030-17 – Add `faces()`, `faceSuggestions()` relationships to Photo model; `person()` to User model; `ScanStatus` Enum cast (FR-030-04, FR-030-05, Q-030-38). +- [x] T-030-17 – Add `faces()`, `faceSuggestions()` relationships to Photo model; `person()` to User model; `ScanStatus` Enum cast (FR-030-04, FR-030-05, Q-030-38). _Intent:_ Photo hasMany Face; User hasOne Person. Add `ScanStatus` PHP Backed Enum (values: `pending`, `completed`, `failed`) and cast `face_scan_status` via it on the Photo model. FaceSuggestion Eloquent model: `face()` / `suggestedFace()` belongsTo Face; `confidence` float; fillable `[face_id, suggested_face_id, confidence]`. *(DO-030-05, DO-030-06)* _Verification commands:_ - `php artisan test --filter=PersonModelTest` @@ -128,7 +128,7 @@ _Last updated: 2026-03-21_ ### I6 – Spatie Data Resources -- [ ] T-030-18 – Create PersonResource and FaceResource (DO-030-03, DO-030-04, Q-030-46). +- [x] T-030-18 – Create PersonResource and FaceResource (DO-030-03, DO-030-04, Q-030-46). _Intent:_ PersonResource: `id`, `name`, `user_id`, `is_searchable`, `face_count` (int), `photo_count` (int), `representative_crop_url`. FaceResource per DO-030-04: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`/`y`/`width`/`height` (float 0.0–1.0), `confidence`, `is_dismissed`, `crop_url` (computed from `crop_token`: `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`; null if no crop). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's own crop or null), `person_name` (nullable, LEFT JOIN), `confidence`. Suggestions always included (pre-computed from `face_suggestions` table, no N+1 risk). Include FaceResource array in PhotoResource with `hidden_face_count` (int, count of suppressed non-searchable faces — Q-030-10). _Verification commands:_ - `make phpstan` @@ -142,7 +142,7 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=PeopleControllerTest` - `make phpstan` -- [ ] T-030-20 – Implement PeopleController with CRUD actions (API-030-01 through API-030-05). +- [x] T-030-20 – Implement PeopleController with CRUD actions (API-030-01 through API-030-05). _Intent:_ index (paginated, searchable scope), show, store, update, destroy. Form requests: StorePersonRequest (name required, user_id optional unique), UpdatePersonRequest (name, is_searchable). Permission mode middleware/gate: check `ai_vision_face_permission_mode` config. Routes in api_v2.php. _Verification commands:_ - `php artisan test --filter=PeopleControllerTest` @@ -157,7 +157,7 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=PersonMergeTest` - `make phpstan` -- [ ] T-030-22 – Implement claim (user + admin override) and merge actions (API-030-06, API-030-07). +- [x] T-030-22 – Implement claim (user + admin override) and merge actions (API-030-06, API-030-07). _Intent:_ ClaimPerson action: set person.user_id to Auth::id(), enforce uniqueness for non-admin. Admin claim: override existing link (clear previous user's claim, set new). MergePerson action: reassign Face records, delete source Person. Register routes. _Verification commands:_ - `php artisan test --filter=PersonClaimTest` @@ -170,7 +170,7 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=SelfieClaimTest` - `make phpstan` -- [ ] T-030-24 – Implement SelfieClaimController (API-030-13). +- [x] T-030-24 – Implement SelfieClaimController (API-030-13). _Intent:_ POST /Person/claim-by-selfie: accepts multipart image upload, sends to Python service `POST /match` (Q-030-12 resolved: dedicated endpoint), receives matching person_id + confidence, validates confidence ≥ `ai_vision_face_selfie_confidence_threshold`, links Person to User (same 1-1 rules), deletes temp selfie. Register route. _Verification commands:_ - `php artisan test --filter=SelfieClaimTest` @@ -190,7 +190,7 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=FaceDismissTest` - `make phpstan` -- [ ] T-030-26b – Implement FaceController: `assign`, `toggleDismissed`, and `destroyDismissed` actions (API-030-09, API-030-14, API-030-16). +- [x] T-030-26b – Implement FaceController: `assign`, `toggleDismissed`, and `destroyDismissed` actions (API-030-09, API-030-14, API-030-16). _Intent:_ `POST /Face/{id}/assign`: accepts `person_id` OR `new_person_name`; creates Person if needed; updates `face.person_id`. `PATCH /Face/{id}`: flips `is_dismissed`; auth: photo owner or admin; emits `face.dismissed` or `face.undismissed`. `DELETE /Face/dismissed`: admin-only; loops `is_dismissed = true` faces, deletes crop files from `uploads/faces/`, deletes Face records, emits `face.bulk_deleted` with count. Create form requests: `AssignFaceRequest`, `ToggleDismissedRequest`. Register routes. _Verification commands:_ - `php artisan test --filter=FaceAssignmentTest` @@ -211,7 +211,7 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=FaceDetectionServiceUnavailableTest` - `make phpstan` -- [ ] T-030-29 – Implement FaceDetectionController, DispatchFaceScanJob, ProcessFaceDetectionResults, and auto-on-upload hook (API-030-10, API-030-11, API-030-12, S-030-23, Q-030-28/33/34/35/45). +- [x] T-030-29 – Implement FaceDetectionController, DispatchFaceScanJob, ProcessFaceDetectionResults, and auto-on-upload hook (API-030-10, API-030-11, API-030-12, S-030-23, Q-030-28/33/34/35/45). _Intent:_ `scan` action: validate target (`photo_ids[]` or `album_id`), set `face_scan_status = pending`, dispatch DispatchFaceScanJob in chunks of `ai_vision_face_scan_batch_size` (default 200, Q-030-45), return 202. Job sends HTTP `POST /detect` with `photo_path` (filesystem via shared volume) — **no `callback_url` in body** (Python reads callback URL from `VISION_FACE_LYCHEE_API_URL` env, Q-030-28). `results` action: validate X-API-Key; on success — decode base64 crops, store at `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`, create Face records with `crop_token`, create/replace FaceSuggestion rows from `suggestions[]` (Q-030-33), IoU-match old faces on re-scan to preserve `person_id` (Q-030-14; threshold from `VISION_FACE_RESCAN_IOU_THRESHOLD`, Q-030-35), set `face_scan_status = completed`; on error — set `face_scan_status = failed` (Q-030-17). `bulk-scan` action: enqueue photos where `face_scan_status IS NULL` (Q-030-40/41). Auto-on-upload: listener on PhotoSaved event dispatches job when `ai_vision_face_enabled = 1`. _Verification commands:_ - `php artisan test --filter=FaceDetection` @@ -225,13 +225,13 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=ScanFacesCommandTest` - `make phpstan` -- [ ] T-030-31 – Implement `lychee:scan-faces` and `lychee:scan-faces --album={id}` commands (CLI-030-01, CLI-030-02). +- [x] T-030-31 – Implement `lychee:scan-faces` and `lychee:scan-faces --album={id}` commands (CLI-030-01, CLI-030-02). _Intent:_ Query photos where `face_scan_status IS NULL`, dispatch DispatchFaceScanJob. `--album={id}` limits to direct photos in that album (non-recursive). Progress output per batch. _Verification commands:_ - `php artisan test --filter=ScanFacesCommandTest` - `make phpstan` -- [ ] T-030-31b – Implement `lychee:rescan-failed-faces [--stuck-pending] [--older-than=N]` command (CLI-030-03, Q-030-40/48). +- [x] T-030-31b – Implement `lychee:rescan-failed-faces [--stuck-pending] [--older-than=N]` command (CLI-030-03, Q-030-40/48). _Intent:_ Default: re-enqueue all photos where `face_scan_status = 'failed'`. With `--stuck-pending`: additionally reset photos with `face_scan_status = 'pending'` and `updated_at < now() - N minutes` (default `--older-than=60`) back to `null`, making them eligible for a fresh scan. *(Q-030-48)* _Verification commands:_ - `php artisan test --filter=RescanFailedFacesCommandTest` @@ -251,7 +251,7 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=PersonPhotosTest` - `make phpstan` -- [ ] T-030-33 – Implement PersonPhotosController (API-030-08). +- [x] T-030-33 – Implement PersonPhotosController (API-030-08). _Intent:_ GET /Person/{id}/photos: paginated photos through Face join, apply PhotoQueryPolicy for access control. Register route. _Verification commands:_ - `php artisan test --filter=PersonPhotosTest` From 993b8709d6246d32ae0c4a1fdf5f0b18878565c6 Mon Sep 17 00:00:00 2001 From: ildyria Date: Sun, 22 Mar 2026 21:25:47 +0100 Subject: [PATCH 3/4] Update --- .gitignore | 2 + ai-vision-service/README.md | 4 +- ai-vision-service/app/api/routes.py | 16 ++- ai-vision-service/app/config.py | 7 +- ai-vision-service/app/main.py | 10 ++ ai-vision-service/tests/conftest.py | 2 - ai-vision-service/tests/test_smoke.py | 3 +- app/Contracts/Http/Requests/HasFace.php | 16 +++ app/Contracts/Http/Requests/HasPerson.php | 16 +++ .../Controllers/AiVision/FaceController.php | 24 ++-- .../AiVision/FaceDetectionController.php | 56 +++------- .../Controllers/AiVision/PeopleController.php | 20 +--- .../AiVision/PersonClaimController.php | 21 +--- .../AiVision/PersonPhotosController.php | 3 +- .../AiVision/SelfieClaimController.php | 6 +- app/Http/Middleware/VerifyCsrfToken.php | 1 + app/Http/Requests/Face/AssignFaceRequest.php | 38 ++++++- app/Http/Requests/Face/BulkScanRequest.php | 13 ++- .../Face/FaceDetectionResultsRequest.php | 104 ++++++++++++++++++ app/Http/Requests/Face/ScanPhotosRequest.php | 27 ++++- .../Requests/Face/ToggleDismissedRequest.php | 36 +++++- .../Requests/Person/ClaimPersonRequest.php | 55 ++++++++- .../Requests/Person/ShowPersonRequest.php | 45 ++++++++ .../Requests/Person/UnclaimPersonRequest.php | 65 +++++++++++ .../Requests/Person/UpdatePersonRequest.php | 34 ++++-- app/Http/Requests/Traits/HasFaceTrait.php | 21 ++++ app/Http/Requests/Traits/HasPersonTrait.php | 21 ++++ app/Jobs/DispatchFaceScanJob.php | 14 +-- app/Models/Person.php | 2 + app/Policies/AiVisionPolicy.php | 25 +++++ 30 files changed, 556 insertions(+), 151 deletions(-) create mode 100644 app/Contracts/Http/Requests/HasFace.php create mode 100644 app/Contracts/Http/Requests/HasPerson.php create mode 100644 app/Http/Requests/Face/FaceDetectionResultsRequest.php create mode 100644 app/Http/Requests/Person/ShowPersonRequest.php create mode 100644 app/Http/Requests/Person/UnclaimPersonRequest.php create mode 100644 app/Http/Requests/Traits/HasFaceTrait.php create mode 100644 app/Http/Requests/Traits/HasPersonTrait.php diff --git a/.gitignore b/.gitignore index 0089dc9ee36..b0ec9675541 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ clover.xml .NO_AUTO_COMPOSER_MIGRATE storage/bootstrap/cache/* storage/image-jobs/* +**/__pycache__/** +.coverage # used by Vite public/hot diff --git a/ai-vision-service/README.md b/ai-vision-service/README.md index 49fd8899989..b3564531404 100644 --- a/ai-vision-service/README.md +++ b/ai-vision-service/README.md @@ -59,8 +59,7 @@ All variables are prefixed `VISION_FACE_`. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `VISION_FACE_LYCHEE_API_URL` | Yes | — | Lychee base URL for callbacks | -| `VISION_FACE_API_KEY` | Yes | — | API key expected on inbound requests from Lychee | -| `VISION_FACE_LYCHEE_API_KEY` | Yes | — | API key sent on outbound callbacks to Lychee | +| `VISION_FACE_API_KEY` | Yes | — | Shared API key: validated on inbound requests from Lychee, and sent on outbound callbacks to Lychee | | `VISION_FACE_MODEL_NAME` | No | `buffalo_l` | InsightFace model pack | | `VISION_FACE_DETECTION_THRESHOLD` | No | `0.5` | Confidence filter for detected faces | | `VISION_FACE_MATCH_THRESHOLD` | No | `0.5` | Cosine-similarity cutoff for selfie matching | @@ -108,7 +107,6 @@ docker build -t lychee-ai-vision . docker run --rm \ -e VISION_FACE_LYCHEE_API_URL=http://lychee \ -e VISION_FACE_API_KEY=changeme \ - -e VISION_FACE_LYCHEE_API_KEY=changeme \ -v /path/to/photos:/data/photos:ro \ -v ai-vision-embeddings:/data/embeddings \ -p 8000:8000 \ diff --git a/ai-vision-service/app/api/routes.py b/ai-vision-service/app/api/routes.py index 01fa6c43bee..eeb528bc88b 100644 --- a/ai-vision-service/app/api/routes.py +++ b/ai-vision-service/app/api/routes.py @@ -63,11 +63,11 @@ async def detect( Results are POSTed back to Lychee's results endpoint once detection completes. """ - resolved = Path(body.photo_path).resolve() + resolved = Path(settings.photos_path.removesuffix("/") + "/" + body.photo_path.removeprefix("/")).resolve() photos_root = Path(settings.photos_path).resolve() if not str(resolved).startswith(str(photos_root) + "/") and resolved != photos_root: - raise HTTPException(status_code=400, detail="photo_path is outside the allowed directory") + raise HTTPException(status_code=400, detail=f"photo_path {resolved} is outside the allowed directory") if not resolved.is_file(): raise HTTPException(status_code=400, detail="photo_path does not exist or is not a file") @@ -213,7 +213,11 @@ async def _run_detection_job( response = await client.post( callback_url, json=payload.model_dump(), - headers={"X-API-Key": settings.lychee_api_key}, + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, timeout=30.0, ) response.raise_for_status() @@ -240,7 +244,11 @@ async def _send_error_callback(photo_id: str, error_code: str, message: str, set await client.post( callback_url, json=payload.model_dump(), - headers={"X-API-Key": settings.lychee_api_key}, + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, timeout=10.0, ) except Exception: diff --git a/ai-vision-service/app/config.py b/ai-vision-service/app/config.py index 620754f90b5..e32d55d48ec 100644 --- a/ai-vision-service/app/config.py +++ b/ai-vision-service/app/config.py @@ -20,10 +20,9 @@ class AppSettings(BaseSettings): """Lychee instance base URL for callbacks (e.g. ``http://lychee``). No trailing slash.""" api_key: str - """API key this service validates on *inbound* requests from Lychee (``X-API-Key`` header).""" - - lychee_api_key: str - """API key sent as ``X-API-Key`` on *outbound* callbacks to Lychee.""" + """Shared API key used in both directions: validated on *inbound* requests from Lychee + (``X-API-Key`` header) and sent as ``X-API-Key`` on *outbound* callbacks to Lychee. + Must match ``AI_VISION_FACE_API_KEY`` in the Lychee ``.env``.""" # --- Model --- model_name: str = "buffalo_l" diff --git a/ai-vision-service/app/main.py b/ai-vision-service/app/main.py index a7fe018322e..826b553ddb7 100644 --- a/ai-vision-service/app/main.py +++ b/ai-vision-service/app/main.py @@ -9,7 +9,17 @@ from __future__ import annotations import logging +import warnings from concurrent.futures import ThreadPoolExecutor + +# insightface uses the deprecated SimilarityTransform.estimate() API from scikit-image >=0.26. +# Suppress until insightface ships a fix upstream. +warnings.filterwarnings( + "ignore", + message=r"`estimate` is deprecated", + category=FutureWarning, + module=r"skimage", +) from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any diff --git a/ai-vision-service/tests/conftest.py b/ai-vision-service/tests/conftest.py index e4faeb49ab7..179a06f774b 100644 --- a/ai-vision-service/tests/conftest.py +++ b/ai-vision-service/tests/conftest.py @@ -34,7 +34,6 @@ def mock_settings() -> AppSettings: m = MagicMock(spec=AppSettings) m.api_key = "test-api-key" m.lychee_api_url = "http://lychee-test" - m.lychee_api_key = "test-lychee-key" m.photos_path = "/tmp" # overridden where needed m.match_threshold = 0.5 m.max_faces_per_photo = 10 @@ -110,7 +109,6 @@ def _override_settings() -> AppSettings: m = MagicMock(spec=AppSettings) m.api_key = "test-api-key" m.lychee_api_url = "http://lychee-test" - m.lychee_api_key = "test-lychee-key" m.photos_path = str(tmp_path) m.match_threshold = 0.5 m.max_faces_per_photo = 10 diff --git a/ai-vision-service/tests/test_smoke.py b/ai-vision-service/tests/test_smoke.py index 8743e10aa9c..92672cef83c 100644 --- a/ai-vision-service/tests/test_smoke.py +++ b/ai-vision-service/tests/test_smoke.py @@ -11,8 +11,7 @@ Prerequisites: - Docker and docker-compose available in PATH - ``docker-compose.minimal.yaml`` with the ``ai-vision`` service block - - ``VISION_FACE_API_KEY`` and ``VISION_FACE_LYCHEE_API_KEY`` env vars set - (or defaults are used for local testing) + - ``VISION_FACE_API_KEY`` env var set (or the default is used for local testing) """ from __future__ import annotations diff --git a/app/Contracts/Http/Requests/HasFace.php b/app/Contracts/Http/Requests/HasFace.php new file mode 100644 index 00000000000..982cfb79f2e --- /dev/null +++ b/app/Contracts/Http/Requests/HasFace.php @@ -0,0 +1,16 @@ +face(); - if ($request->personId() !== null) { - $face->person_id = $request->personId(); - } elseif ($request->newPersonName() !== null) { - $is_searchable_default = app(ConfigManager::class)->getValueAsString('ai_vision_face_person_is_searchable_default') === '1'; + if ($request->person() !== null) { + $face->person_id = $request->person()->id; + } else { + $is_searchable_default = app(ConfigManager::class)->getValueAsBool('ai_vision_face_person_is_searchable_default'); $person = new Person(); $person->name = $request->newPersonName(); $person->is_searchable = $is_searchable_default; $person->save(); $face->person_id = $person->id; - } else { - abort(422, 'Either person_id or new_person_name must be provided.'); } $face->save(); @@ -61,16 +58,9 @@ public function assign(AssignFaceRequest $request, string $id): FaceResource * * @return FaceResource */ - public function toggleDismissed(ToggleDismissedRequest $_request, string $id): FaceResource + public function toggleDismissed(ToggleDismissedRequest $request, string $id): FaceResource { - $face = Face::with('photo')->findOrFail($id); - $user = Auth::user(); - - // Check ownership: photo owner or admin - if (!($user?->may_administrate === true) && $face->photo->owner_id !== $user?->id) { - abort(403, 'Only the photo owner or an admin can dismiss faces.'); - } - + $face = $request->face(); $face->is_dismissed = !$face->is_dismissed; $face->save(); diff --git a/app/Http/Controllers/AiVision/FaceDetectionController.php b/app/Http/Controllers/AiVision/FaceDetectionController.php index 24fd9834fa8..519e8606d24 100644 --- a/app/Http/Controllers/AiVision/FaceDetectionController.php +++ b/app/Http/Controllers/AiVision/FaceDetectionController.php @@ -10,19 +10,20 @@ use App\Enum\FaceScanStatus; use App\Http\Requests\Face\BulkScanRequest; +use App\Http\Requests\Face\FaceDetectionResultsRequest; use App\Http\Requests\Face\ScanPhotosRequest; use App\Jobs\DispatchFaceScanJob; use App\Models\Face; use App\Models\FaceSuggestion; use App\Models\Photo; use App\Repositories\ConfigManager; -use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use function Safe\base64_decode; +use Safe\Exceptions\MiscException; /** * Controller for AI Vision face detection scan management. @@ -46,13 +47,9 @@ class FaceDetectionController extends Controller public function scan(ScanPhotosRequest $request): \Illuminate\Http\Response { $photo_ids = $request->photoIds(); - $album_id = $request->albumId(); + $album_id = $request->album()?->id; $force = $request->force(); - if ($photo_ids === null && $album_id === null) { - abort(422, 'Either photo_ids or album_id must be provided.'); - } - // Collect target photos $query = Photo::query()->select('id'); @@ -67,7 +64,7 @@ public function scan(ScanPhotosRequest $request): \Illuminate\Http\Response $query->whereDoesntHave('faces', fn ($q) => $q->whereNotNull('person_id')); } - $batch_size = (int) app(ConfigManager::class)->getValueAsString('ai_vision_face_scan_batch_size'); + $batch_size = app(ConfigManager::class)->getValueAsInt('ai_vision_face_scan_batch_size'); $dispatched = 0; $query->lazyById($batch_size, 'id')->chunk($batch_size)->each(function ($chunk) use (&$dispatched): void { @@ -96,46 +93,19 @@ public function scan(ScanPhotosRequest $request): \Illuminate\Http\Response * * @return array> */ - public function results(Request $request): array + public function results(FaceDetectionResultsRequest $request): array { - // Validate API key — exclusive auth for this endpoint - $expected_key = config('features.ai-vision.face-api-key', ''); - $provided_key = $request->header('X-API-Key', ''); - - if ($expected_key === '' || $provided_key !== $expected_key) { - abort(401, 'Invalid or missing X-API-Key.'); - } + $photo_id = $request->photoId(); - $data = $request->validate([ - 'photo_id' => 'required|string|exists:photos,id', - 'status' => 'required|string|in:success,error', - 'faces' => 'sometimes|array', - 'faces.*.x' => 'required_with:faces|numeric', - 'faces.*.y' => 'required_with:faces|numeric', - 'faces.*.width' => 'required_with:faces|numeric', - 'faces.*.height' => 'required_with:faces|numeric', - 'faces.*.confidence' => 'required_with:faces|numeric', - 'faces.*.embedding_id' => 'required_with:faces|string', - 'faces.*.crop' => 'sometimes|string', - 'faces.*.suggestions' => 'sometimes|array', - 'faces.*.suggestions.*.lychee_face_id' => 'required|string', - 'faces.*.suggestions.*.confidence' => 'required|numeric', - 'error_code' => 'sometimes|string', - 'message' => 'sometimes|string', - ]); - - $photo_id = $data['photo_id']; - - if ($data['status'] === 'error') { + if ($request->status() === 'error') { Photo::where('id', '=', $photo_id)->update(['face_scan_status' => FaceScanStatus::FAILED->value]); - Log::info("FaceDetectionController::results — photo {$photo_id} face scan failed: " . ($data['message'] ?? 'unknown error')); + Log::info("FaceDetectionController::results — photo {$photo_id} face scan failed: " . ($request->message() ?? 'unknown error')); return ['faces' => []]; } // Process success payload - $incoming_faces = $data['faces'] ?? []; - $mapping = $this->processFaceResults($photo_id, $incoming_faces); + $mapping = $this->processFaceResults($photo_id, $request->faces()); Photo::where('id', '=', $photo_id)->update(['face_scan_status' => FaceScanStatus::COMPLETED->value]); @@ -151,10 +121,10 @@ public function results(Request $request): array */ public function bulkScan(BulkScanRequest $request): \Illuminate\Http\Response { - $album_id = $request->albumId(); - $batch_size = (int) app(ConfigManager::class)->getValueAsString('ai_vision_face_scan_batch_size'); + $album_id = $request->album()?->id; + $batch_size = $request->configs()->getValueAsInt('ai_vision_face_scan_batch_size'); - $query = Photo::query()->select('id')->whereNull('face_scan_status'); + $query = Photo::query()->select('id')->whereNull('face_scan_status')->orWhere('face_scan_status', '=', FaceScanStatus::FAILED->value); if ($album_id !== null) { $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); @@ -244,7 +214,7 @@ private function processFaceResults(string $photo_id, array $incoming_faces): ar try { $decoded = base64_decode($face_data['crop']); Storage::disk('images')->put($crop_path, $decoded); - } catch (\Safe\Exceptions\MiscException) { + } catch (MiscException) { $tok = null; } } else { diff --git a/app/Http/Controllers/AiVision/PeopleController.php b/app/Http/Controllers/AiVision/PeopleController.php index 83056aec626..a146fd079f5 100644 --- a/app/Http/Controllers/AiVision/PeopleController.php +++ b/app/Http/Controllers/AiVision/PeopleController.php @@ -10,6 +10,7 @@ use App\Http\Requests\Person\DestroyPersonRequest; use App\Http\Requests\Person\ListPersonsRequest; +use App\Http\Requests\Person\ShowPersonRequest; use App\Http\Requests\Person\StorePersonRequest; use App\Http\Requests\Person\UpdatePersonRequest; use App\Http\Resources\Models\PersonResource; @@ -56,17 +57,9 @@ public function index(ListPersonsRequest $_request): PaginatedDataCollection * * @return PersonResource */ - public function show(ListPersonsRequest $_request, string $id): PersonResource + public function show(ShowPersonRequest $request): PersonResource { - $user = Auth::user(); - $person = Person::findOrFail($id); - - // Non-admin cannot see non-searchable persons unless they are linked to them - if (!($user?->may_administrate === true) && !$person->is_searchable && $person->user_id !== $user?->id) { - abort(403); - } - - return PersonResource::fromModel($person); + return PersonResource::fromModel($request->person()); } /** @@ -94,14 +87,9 @@ public function store(StorePersonRequest $request): PersonResource */ public function update(UpdatePersonRequest $request): PersonResource { - $person = Person::findOrFail($request->personId()); - $user = Auth::user(); + $person = $request->person(); - // For searchability toggle, only the linked user or admin can change if ($request->isSearchable() !== null) { - if (!($user?->may_administrate === true) && $person->user_id !== $user?->id) { - abort(403); - } $person->is_searchable = $request->isSearchable(); } diff --git a/app/Http/Controllers/AiVision/PersonClaimController.php b/app/Http/Controllers/AiVision/PersonClaimController.php index 244ca88003f..61574a7d7eb 100644 --- a/app/Http/Controllers/AiVision/PersonClaimController.php +++ b/app/Http/Controllers/AiVision/PersonClaimController.php @@ -10,10 +10,10 @@ use App\Http\Requests\Person\ClaimPersonRequest; use App\Http\Requests\Person\MergePersonRequest; +use App\Http\Requests\Person\UnclaimPersonRequest; use App\Http\Resources\Models\PersonResource; use App\Models\Face; use App\Models\Person; -use App\Repositories\ConfigManager; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Auth; @@ -29,18 +29,13 @@ class PersonClaimController extends Controller * * @return PersonResource */ - public function claim(ClaimPersonRequest $_request, string $id): PersonResource + public function claim(ClaimPersonRequest $request, string $id): PersonResource { /** @var \App\Models\User $user */ $user = Auth::user(); - $person = Person::findOrFail($id); + $person = $request->person(); if (!$user->may_administrate) { - // Check if user claims are allowed - if (app(ConfigManager::class)->getValueAsString('ai_vision_face_allow_user_claim') !== '1') { - abort(403, 'User claims are not permitted by the administrator.'); - } - // Non-admin: conflict if already claimed by another user if ($person->user_id !== null && $person->user_id !== $user->id) { abort(409, 'This person is already claimed by another user.'); @@ -66,15 +61,9 @@ public function claim(ClaimPersonRequest $_request, string $id): PersonResource * Unclaim a Person — remove the user_id link. * Only the linked user or admin can unclaim. */ - public function unclaim(ClaimPersonRequest $_request, string $id): void + public function unclaim(UnclaimPersonRequest $request, string $id): void { - /** @var \App\Models\User $user */ - $user = Auth::user(); - $person = Person::findOrFail($id); - - if (!$user->may_administrate && $person->user_id !== $user->id) { - abort(403, 'Only the linked user or an admin can unclaim this person.'); - } + $person = $request->person(); $person->user_id = null; $person->save(); diff --git a/app/Http/Controllers/AiVision/PersonPhotosController.php b/app/Http/Controllers/AiVision/PersonPhotosController.php index 686b8a76c97..bc85a13c1b0 100644 --- a/app/Http/Controllers/AiVision/PersonPhotosController.php +++ b/app/Http/Controllers/AiVision/PersonPhotosController.php @@ -13,6 +13,7 @@ use App\Models\Photo; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; /** * Controller for listing photos containing faces of a given Person. @@ -51,7 +52,7 @@ public function index(ListPersonsRequest $_request, string $id) $query->where(function ($q) use ($user): void { // Photos in at least one public album (base_albums.is_public = true) $q->whereExists(function ($sub): void { - $sub->select(\Illuminate\Support\Facades\DB::raw(1)) + $sub->select(DB::raw(1)) ->from('photo_album') ->join('base_albums', 'base_albums.id', '=', 'photo_album.album_id') ->whereColumn('photo_album.photo_id', 'photos.id') diff --git a/app/Http/Controllers/AiVision/SelfieClaimController.php b/app/Http/Controllers/AiVision/SelfieClaimController.php index effd9229ace..761d5846dd7 100644 --- a/app/Http/Controllers/AiVision/SelfieClaimController.php +++ b/app/Http/Controllers/AiVision/SelfieClaimController.php @@ -34,7 +34,7 @@ class SelfieClaimController extends Controller * * @return PersonResource */ - public function claimBySelfie(SelfieClaimRequest $request): PersonResource + public function claimBySelfie(SelfieClaimRequest $request, ConfigManager $config_manager): PersonResource { /** @var \App\Models\User $user */ $user = Auth::user(); @@ -75,7 +75,7 @@ public function claimBySelfie(SelfieClaimRequest $request): PersonResource abort(404, 'No matching person found for the selfie.'); } - $threshold = (float) app(ConfigManager::class)->getValueAsString('ai_vision_face_selfie_confidence_threshold'); + $threshold = (float) $config_manager->getValueAsString('ai_vision_face_selfie_confidence_threshold'); $best_match = $matches[0]; if ($best_match['confidence'] < $threshold) { @@ -102,7 +102,7 @@ public function claimBySelfie(SelfieClaimRequest $request): PersonResource } // Check if user claims are allowed - if (!$user->may_administrate && app(ConfigManager::class)->getValueAsString('ai_vision_face_allow_user_claim') !== '1') { + if (!$user->may_administrate && !$config_manager->getValueAsBool('ai_vision_face_allow_user_claim')) { abort(403, 'User claims are not permitted by the administrator.'); } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index e19c39cae3c..4a2ff857b8c 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -27,6 +27,7 @@ class VerifyCsrfToken extends Middleware '/api/v2/Zip', '/api/v2/Shop/Checkout/Finalize/*', '/api/v2/Photo::random', + '/api/v2/FaceDetection/results', // This is only exposed internally and auth with a token. ]; /** diff --git a/app/Http/Requests/Face/AssignFaceRequest.php b/app/Http/Requests/Face/AssignFaceRequest.php index 45d9e75fdc7..6745c30c334 100644 --- a/app/Http/Requests/Face/AssignFaceRequest.php +++ b/app/Http/Requests/Face/AssignFaceRequest.php @@ -8,14 +8,20 @@ namespace App\Http\Requests\Face; +use App\Contracts\Http\Requests\HasFace; use App\Http\Requests\BaseApiRequest; +use App\Http\Requests\Traits\HasFaceTrait; use App\Models\Face; +use App\Models\Person; use App\Policies\AiVisionPolicy; +use App\Rules\RandomIDRule; use Illuminate\Support\Facades\Gate; -class AssignFaceRequest extends BaseApiRequest +class AssignFaceRequest extends BaseApiRequest implements HasFace { - private ?string $person_id; + use HasFaceTrait; + + private ?Person $person; private ?string $new_person_name; public function authorize(): bool @@ -26,20 +32,42 @@ public function authorize(): bool public function rules(): array { return [ + 'id' => ['required', new RandomIDRule(false)], 'person_id' => ['nullable', 'string', 'exists:persons,id'], 'new_person_name' => ['nullable', 'string', 'max:255'], ]; } + public function withValidator(\Illuminate\Validation\Validator $validator): void + { + $validator->after(function (\Illuminate\Validation\Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $values = $validator->validated(); + if (($values['person_id'] ?? null) === null && ($values['new_person_name'] ?? null) === null) { + $validator->errors()->add('person_id', 'Either person_id or new_person_name must be provided.'); + } + }); + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + protected function processValidatedValues(array $values, array $files): void { - $this->person_id = $values['person_id'] ?? null; + $this->face = Face::findOrFail($values['id']); + $this->person = isset($values['person_id']) ? Person::find($values['person_id']) : null; $this->new_person_name = $values['new_person_name'] ?? null; } - public function personId(): ?string + public function person(): ?Person { - return $this->person_id; + return $this->person; } public function newPersonName(): ?string diff --git a/app/Http/Requests/Face/BulkScanRequest.php b/app/Http/Requests/Face/BulkScanRequest.php index 0a62d0fb955..180eb82d81d 100644 --- a/app/Http/Requests/Face/BulkScanRequest.php +++ b/app/Http/Requests/Face/BulkScanRequest.php @@ -9,8 +9,10 @@ namespace App\Http\Requests\Face; use App\Http\Requests\BaseApiRequest; +use App\Models\Album; use App\Models\Configs; use App\Policies\SettingsPolicy; +use App\Rules\RandomIDRule; use Illuminate\Support\Facades\Gate; /** @@ -20,7 +22,7 @@ */ class BulkScanRequest extends BaseApiRequest { - private ?string $album_id = null; + private ?Album $album = null; /** * {@inheritDoc} @@ -36,7 +38,7 @@ public function authorize(): bool public function rules(): array { return [ - 'album_id' => 'nullable|string|exists:albums,id', + 'album_id' => ['nullable', new RandomIDRule(true)], ]; } @@ -45,11 +47,12 @@ public function rules(): array */ protected function processValidatedValues(array $values, array $files): void { - $this->album_id = $values['album_id'] ?? null; + $album_id = $values['album_id'] ?? null; + $this->album = $album_id !== null ? Album::findOrFail($album_id) : null; } - public function albumId(): ?string + public function album(): ?Album { - return $this->album_id; + return $this->album; } } diff --git a/app/Http/Requests/Face/FaceDetectionResultsRequest.php b/app/Http/Requests/Face/FaceDetectionResultsRequest.php new file mode 100644 index 00000000000..cb901cb6948 --- /dev/null +++ b/app/Http/Requests/Face/FaceDetectionResultsRequest.php @@ -0,0 +1,104 @@ +}> */ + private array $faces = []; + private ?string $error_code = null; + private ?string $message = null; + + /** + * {@inheritDoc} + * + * Validates the X-API-Key header against the configured secret. + */ + public function authorize(): bool + { + $expected_key = config('features.ai-vision.face-api-key', ''); + $provided_key = $this->header('X-API-Key', ''); + + return $expected_key !== '' && $provided_key === $expected_key; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'photo_id' => 'required|string|exists:photos,id', + 'status' => 'required|string|in:success,error', + 'faces' => 'sometimes|array', + 'faces.*.x' => 'required_with:faces|numeric', + 'faces.*.y' => 'required_with:faces|numeric', + 'faces.*.width' => 'required_with:faces|numeric', + 'faces.*.height' => 'required_with:faces|numeric', + 'faces.*.confidence' => 'required_with:faces|numeric', + 'faces.*.embedding_id' => 'required_with:faces|string', + 'faces.*.crop' => 'sometimes|string', + 'faces.*.suggestions' => 'sometimes|array', + 'faces.*.suggestions.*.lychee_face_id' => 'required|string', + 'faces.*.suggestions.*.confidence' => 'required|numeric', + 'error_code' => 'sometimes|string', + 'message' => 'sometimes|string', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->photo_id = $values['photo_id']; + $this->status = $values['status']; + $this->faces = $values['faces'] ?? []; + $this->error_code = $values['error_code'] ?? null; + $this->message = $values['message'] ?? null; + } + + public function photoId(): string + { + return $this->photo_id; + } + + public function status(): string + { + return $this->status; + } + + /** + * @return array}> + */ + public function faces(): array + { + return $this->faces; + } + + public function errorCode(): ?string + { + return $this->error_code; + } + + public function message(): ?string + { + return $this->message; + } +} diff --git a/app/Http/Requests/Face/ScanPhotosRequest.php b/app/Http/Requests/Face/ScanPhotosRequest.php index afda811d80f..872afab33ea 100644 --- a/app/Http/Requests/Face/ScanPhotosRequest.php +++ b/app/Http/Requests/Face/ScanPhotosRequest.php @@ -11,6 +11,7 @@ use App\Http\Requests\BaseApiRequest; use App\Models\Album; use App\Policies\AiVisionPolicy; +use App\Rules\RandomIDRule; use Illuminate\Support\Facades\Gate; /** @@ -21,7 +22,7 @@ class ScanPhotosRequest extends BaseApiRequest { private ?array $photo_ids = null; - private ?string $album_id = null; + private ?Album $album = null; private bool $force = false; /** @@ -39,19 +40,33 @@ public function rules(): array { return [ 'photo_ids' => 'nullable|array', - 'photo_ids.*' => 'string|exists:photos,id', - 'album_id' => 'nullable|string|exists:albums,id', + 'photo_ids.*' => ['string', new RandomIDRule(false)], + 'album_id' => ['nullable', new RandomIDRule(true)], 'force' => 'sometimes|boolean', ]; } + /** + * {@inheritDoc} + */ + public function withValidator(\Illuminate\Validation\Validator $validator): void + { + $validator->after(function (\Illuminate\Validation\Validator $validator): void { + $values = $validator->validated(); + if (($values['photo_ids'] ?? null) === null && ($values['album_id'] ?? null) === null) { + $validator->errors()->add('photo_ids', 'Either photo_ids or album_id must be provided.'); + } + }); + } + /** * {@inheritDoc} */ protected function processValidatedValues(array $values, array $files): void { $this->photo_ids = $values['photo_ids'] ?? null; - $this->album_id = $values['album_id'] ?? null; + $album_id = $values['album_id'] ?? null; + $this->album = $album_id !== null ? Album::findOrFail($album_id) : null; $this->force = isset($values['force']) && static::toBoolean($values['force']); } @@ -63,9 +78,9 @@ public function photoIds(): ?array return $this->photo_ids; } - public function albumId(): ?string + public function album(): ?Album { - return $this->album_id; + return $this->album; } public function force(): bool diff --git a/app/Http/Requests/Face/ToggleDismissedRequest.php b/app/Http/Requests/Face/ToggleDismissedRequest.php index 8ec4639b69f..632366e6f07 100644 --- a/app/Http/Requests/Face/ToggleDismissedRequest.php +++ b/app/Http/Requests/Face/ToggleDismissedRequest.php @@ -8,15 +8,45 @@ namespace App\Http\Requests\Face; -use App\Http\Requests\AbstractEmptyRequest; +use App\Contracts\Http\Requests\HasFace; +use App\Http\Requests\BaseApiRequest; +use App\Http\Requests\Traits\HasFaceTrait; use App\Models\Face; use App\Policies\AiVisionPolicy; +use App\Rules\RandomIDRule; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; -class ToggleDismissedRequest extends AbstractEmptyRequest +class ToggleDismissedRequest extends BaseApiRequest implements HasFace { + use HasFaceTrait; + public function authorize(): bool { - return Gate::check(AiVisionPolicy::CAN_DISMISS_FACE, Face::class); + if (!Gate::check(AiVisionPolicy::CAN_DISMISS_FACE, Face::class)) { + return false; + } + + $user = Auth::user(); + + return ($user?->may_administrate === true) || $this->face->photo->owner_id === $user?->id; + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face = Face::with('photo')->findOrFail($values['id']); } } diff --git a/app/Http/Requests/Person/ClaimPersonRequest.php b/app/Http/Requests/Person/ClaimPersonRequest.php index b09501f29e8..1b6eab3b088 100644 --- a/app/Http/Requests/Person/ClaimPersonRequest.php +++ b/app/Http/Requests/Person/ClaimPersonRequest.php @@ -8,15 +8,64 @@ namespace App\Http\Requests\Person; -use App\Http\Requests\AbstractEmptyRequest; +use App\Contracts\Http\Requests\HasPerson; +use App\Http\Requests\BaseApiRequest; +use App\Http\Requests\Traits\HasPersonTrait; use App\Models\Person; use App\Policies\AiVisionPolicy; +use App\Repositories\ConfigManager; +use App\Rules\RandomIDRule; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; -class ClaimPersonRequest extends AbstractEmptyRequest +class ClaimPersonRequest extends BaseApiRequest implements HasPerson { + use HasPersonTrait; + + /** + * {@inheritDoc} + */ public function authorize(): bool { - return Gate::check(AiVisionPolicy::CAN_CLAIM_PERSON, Person::class); + if (!Gate::check(AiVisionPolicy::CAN_CLAIM_PERSON, Person::class)) { + return false; + } + + /** @var \App\Models\User $user */ + $user = Auth::user(); + if ($user->may_administrate) { + // Admin can always claim (force-claim) + return true; + } + + // Non-admin: check if user claims are allowed by the administrator + return app(ConfigManager::class)->getValueAsBool('ai_vision_face_allow_user_claim'); + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); } } diff --git a/app/Http/Requests/Person/ShowPersonRequest.php b/app/Http/Requests/Person/ShowPersonRequest.php new file mode 100644 index 00000000000..e52c7b42527 --- /dev/null +++ b/app/Http/Requests/Person/ShowPersonRequest.php @@ -0,0 +1,45 @@ +person]); + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Person/UnclaimPersonRequest.php b/app/Http/Requests/Person/UnclaimPersonRequest.php new file mode 100644 index 00000000000..18b61843810 --- /dev/null +++ b/app/Http/Requests/Person/UnclaimPersonRequest.php @@ -0,0 +1,65 @@ +may_administrate || $this->person->user_id === $user->id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Person/UpdatePersonRequest.php b/app/Http/Requests/Person/UpdatePersonRequest.php index 20799b71afd..4b9741e139b 100644 --- a/app/Http/Requests/Person/UpdatePersonRequest.php +++ b/app/Http/Requests/Person/UpdatePersonRequest.php @@ -8,41 +8,55 @@ namespace App\Http\Requests\Person; +use App\Contracts\Http\Requests\HasPerson; use App\Http\Requests\BaseApiRequest; +use App\Http\Requests\Traits\HasPersonTrait; use App\Models\Person; use App\Policies\AiVisionPolicy; +use App\Rules\RandomIDRule; use Illuminate\Support\Facades\Gate; -class UpdatePersonRequest extends BaseApiRequest +class UpdatePersonRequest extends BaseApiRequest implements HasPerson { - private string $person_id; + use HasPersonTrait; + private ?string $name; private ?bool $is_searchable; public function authorize(): bool { - return Gate::check(AiVisionPolicy::CAN_EDIT_PERSON, Person::class); + if (!Gate::check(AiVisionPolicy::CAN_EDIT_PERSON, Person::class)) { + return false; + } + + if ($this->is_searchable !== null && + !Gate::check(AiVisionPolicy::CAN_CHANGE_PERSON_SEARCHABILITY, [Person::class, $this->person])) { + return false; + } + + return true; } public function rules(): array { return [ - 'person_id' => ['required', 'string', 'exists:persons,id'], + 'id' => ['required', new RandomIDRule(false)], 'name' => ['sometimes', 'string', 'max:255'], 'is_searchable' => ['sometimes', 'boolean'], ]; } - protected function processValidatedValues(array $values, array $files): void + protected function prepareForValidation(): void { - $this->person_id = $values['person_id']; - $this->name = $values['name'] ?? null; - $this->is_searchable = isset($values['is_searchable']) ? static::toBoolean($values['is_searchable']) : null; + /** @disregard */ + $this->merge(['id' => $this->route('id')]); } - public function personId(): string + protected function processValidatedValues(array $values, array $files): void { - return $this->person_id; + $this->person = Person::findOrFail($values['id']); + $this->name = $values['name'] ?? null; + $this->is_searchable = isset($values['is_searchable']) ? static::toBoolean($values['is_searchable']) : null; } public function name(): ?string diff --git a/app/Http/Requests/Traits/HasFaceTrait.php b/app/Http/Requests/Traits/HasFaceTrait.php new file mode 100644 index 00000000000..76000a122f6 --- /dev/null +++ b/app/Http/Requests/Traits/HasFaceTrait.php @@ -0,0 +1,21 @@ +face; + } +} diff --git a/app/Http/Requests/Traits/HasPersonTrait.php b/app/Http/Requests/Traits/HasPersonTrait.php new file mode 100644 index 00000000000..93822fd8800 --- /dev/null +++ b/app/Http/Requests/Traits/HasPersonTrait.php @@ -0,0 +1,21 @@ +person; + } +} diff --git a/app/Jobs/DispatchFaceScanJob.php b/app/Jobs/DispatchFaceScanJob.php index ef3af34c7ea..54f57224818 100644 --- a/app/Jobs/DispatchFaceScanJob.php +++ b/app/Jobs/DispatchFaceScanJob.php @@ -17,7 +17,6 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; /** * Dispatches a single photo to the AI Vision Python service for face detection. @@ -71,17 +70,16 @@ public function handle(): void return; } - $photo_path = Storage::disk($original->storage_disk->value)->path($original->short_path); - try { + $data = [ + 'photo_id' => $this->photo_id, + 'photo_path' => $original->short_path, // We just send short path. Not that this means that we do not support s3 for facial recognition. + ]; $response = Http::withHeaders(['X-API-Key' => $api_key]) - ->post($service_url . '/detect', [ - 'photo_id' => $this->photo_id, - 'photo_path' => $photo_path, - ]); + ->post($service_url . '/detect', $data); if (!$response->successful()) { - Log::warning("DispatchFaceScanJob: /detect returned HTTP {$response->status()} for photo {$this->photo_id}."); + Log::warning("DispatchFaceScanJob: /detect returned HTTP {$response->status()} for photo {$this->photo_id}.", ['data' => $data, 'response' => $response->json()]); $photo->face_scan_status = FaceScanStatus::FAILED; $photo->save(); } diff --git a/app/Models/Person.php b/app/Models/Person.php index 51246b38534..4a250515a7e 100644 --- a/app/Models/Person.php +++ b/app/Models/Person.php @@ -38,6 +38,8 @@ class Person extends Model use ThrowsConsistentExceptions; use ToArrayThrowsNotImplemented; + protected $table = 'persons'; + /** * @var string The type of the primary key */ diff --git a/app/Policies/AiVisionPolicy.php b/app/Policies/AiVisionPolicy.php index 5f5dd3444d7..b97a38fcd7e 100644 --- a/app/Policies/AiVisionPolicy.php +++ b/app/Policies/AiVisionPolicy.php @@ -31,12 +31,14 @@ class AiVisionPolicy extends BasePolicy { public const CAN_VIEW_PEOPLE = 'canViewPeople'; + public const CAN_SHOW_PERSON = 'canShowPerson'; public const CAN_EDIT_PERSON = 'canEditPerson'; public const CAN_ASSIGN_FACE = 'canAssignFace'; public const CAN_TRIGGER_SCAN = 'canTriggerScan'; public const CAN_CLAIM_PERSON = 'canClaimPerson'; public const CAN_MERGE_PERSONS = 'canMergePersons'; public const CAN_DISMISS_FACE = 'canDismissFace'; + public const CAN_CHANGE_PERSON_SEARCHABILITY = 'canChangePersonSearchability'; /** * Get the current permission mode from configuration. @@ -62,6 +64,20 @@ public function canViewPeople(?User $user): bool }; } + /** + * View a specific Person record. + * Requires canViewPeople access, plus the person must be searchable or linked to the user. + * Admins always pass via before(). + */ + public function canShowPerson(?User $user, Person $person): bool + { + if (!$this->canViewPeople($user)) { + return false; + } + + return $person->is_searchable || $person->user_id === $user?->id; + } + /** * Create/edit Person. * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. @@ -144,4 +160,13 @@ public function canManagePerson(?User $user, Person $person): bool return $person->user_id === $user->id; } + + /** + * Change the is_searchable flag of a Person. + * Only the person's linked user or admin (via before()) may toggle it. + */ + public function canChangePersonSearchability(?User $user, Person $person): bool + { + return $person->user_id === $user?->id; + } } From 5cd1bcc872495568b6befde15d78635c75f0942c Mon Sep 17 00:00:00 2001 From: ildyria Date: Sun, 22 Mar 2026 21:31:01 +0100 Subject: [PATCH 4/4] Pull more changes from front-end branch --- config/features.php | 2 +- routes/web_v2.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/features.php b/config/features.php index 077c1e25e87..5e8fa6f9a43 100644 --- a/config/features.php +++ b/config/features.php @@ -164,6 +164,6 @@ 'ai-vision' => [ 'face-url' => env('AI_VISION_FACE_URL', ''), 'face-api-key' => env('AI_VISION_FACE_API_KEY', ''), - 'face-rescan-iou-threshold' => (float) env('VISION_FACE_RESCAN_IOU_THRESHOLD', 0.3), + 'face-rescan-iou-threshold' => (float) env('AI_VISION_FACE_RESCAN_IOU_THRESHOLD', 0.3), ], ]; \ No newline at end of file diff --git a/routes/web_v2.php b/routes/web_v2.php index 98a8ee23991..46308a4efc4 100644 --- a/routes/web_v2.php +++ b/routes/web_v2.php @@ -79,6 +79,7 @@ Route::get('/login', VueController::class)->middleware(['migration:complete']); Route::get('/register', VueController::class)->name('register')->middleware(['migration:complete']); +Route::get('/people', VueController::class)->middleware(['migration:complete']); Route::get('/settings', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/permissions', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/fixTree', VueController::class)->middleware(['migration:complete', 'login_required:always']);