Spaces:
Running
Running
| namespace App\Domain\Files; | |
| use App\Domain\Audit\AuditService; | |
| use App\Infrastructure\Database\TransactionManager; | |
| use App\Infrastructure\Ids\UuidGenerator; | |
| use App\Shared\Exceptions\ApiException; | |
| use Illuminate\Support\Carbon; | |
| use Illuminate\Support\Facades\DB; | |
| use Illuminate\Support\Str; | |
| class FileService | |
| { | |
| private const CLASSIFICATIONS = [ | |
| 'APPLICATION_DOCUMENT', | |
| 'ISP_EVIDENCE', | |
| 'MEMBERSHIP_CONTRACT', | |
| 'PRODUCT_CONTRACT', | |
| 'CARE_BENEFICIARY_DOCUMENT', | |
| 'REAL_ESTATE_DUE_DILIGENCE', | |
| 'CONNECT_ATTACHMENT', | |
| 'CONCIERGE_ATTACHMENT', | |
| 'MARKETPLACE_VENDOR_DOCUMENT', | |
| 'SUPPORT_ATTACHMENT', | |
| ]; | |
| private const MIME_TYPES = [ | |
| 'application/pdf', | |
| 'image/jpeg', | |
| 'image/png', | |
| 'text/plain', | |
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | |
| ]; | |
| private const MAX_BYTES = 26214400; | |
| private const TARGET_TABLES = [ | |
| 'application_id' => 'applications', | |
| 'membership_id' => 'memberships', | |
| 'product_request_id' => 'product_requests', | |
| 'contract_id' => 'contracts', | |
| 'support_case_id' => 'support_cases', | |
| 'care_enrollment_id' => 'care_enrollments', | |
| 'care_person_id' => 'care_people', | |
| 'care_claim_id' => 'care_claims', | |
| 'care_beneficiary_id' => 'care_beneficiaries', | |
| 'real_estate_partner_id' => 'real_estate_partners', | |
| 'marketplace_vendor_id' => 'marketplace_vendors', | |
| ]; | |
| public function __construct( | |
| private readonly FileRepository $files, | |
| private readonly FileLinkRepository $links, | |
| private readonly FileAccessPolicy $policy, | |
| private readonly UuidGenerator $ids, | |
| private readonly TransactionManager $transactions, | |
| private readonly AuditService $audit, | |
| private readonly SignedFileUrlFactory $fileUrls, | |
| ) { | |
| } | |
| public function register(array $input, string $actorUserId): array | |
| { | |
| $classification = strtoupper($input['classification']); | |
| $mimeType = strtolower($input['mime_type']); | |
| $size = (int) $input['file_size_bytes']; | |
| if (! in_array($classification, self::CLASSIFICATIONS, true)) { | |
| throw new ApiException('UNSUPPORTED_FILE_CLASSIFICATION', 'File classification is not supported.', [], 422); | |
| } | |
| if (! in_array($mimeType, self::MIME_TYPES, true)) { | |
| throw new ApiException('UNSUPPORTED_FILE_TYPE', 'File type is not supported.', [], 422); | |
| } | |
| if ($size < 1 || $size > self::MAX_BYTES) { | |
| throw new ApiException('FILE_SIZE_NOT_ALLOWED', 'File size is outside allowed limits.', [], 422); | |
| } | |
| $id = $this->ids->generate(); | |
| $storageKey = $input['storage_key'] ?? strtolower($classification).'/'.$id.'/'.Str::slug(pathinfo($input['original_filename'], PATHINFO_FILENAME)).'.'.pathinfo($input['original_filename'], PATHINFO_EXTENSION); | |
| $this->files->create([ | |
| 'id' => $id, | |
| 'classification' => $classification, | |
| 'owner_user_id' => $actorUserId, | |
| 'storage_provider' => 'LOCAL', | |
| 'storage_bucket' => 'mdn-files', | |
| 'storage_key' => $storageKey, | |
| 'original_filename' => $input['original_filename'], | |
| 'mime_type' => $mimeType, | |
| 'file_size_bytes' => $size, | |
| 'checksum_sha256' => $input['checksum_sha256'] ?? null, | |
| 'virus_scan_status' => $input['virus_scan_status'] ?? 'PENDING', | |
| 'retention_until' => $input['retention_until'] ?? null, | |
| ]); | |
| $this->audit->record('FILE_REGISTERED', $actorUserId, null, 'stored_files', $id, 'SUCCESS', [ | |
| 'classification' => $classification, | |
| 'mime_type' => $mimeType, | |
| ]); | |
| return ['file' => $this->payload($this->files->find($id))]; | |
| } | |
| public function link(string $fileId, array $input, string $actorUserId): array | |
| { | |
| $file = $this->requireFile($fileId); | |
| $this->authorizeManage($file, $actorUserId); | |
| $targets = array_filter( | |
| array_intersect_key($input, array_flip(FileLinkRepository::TARGET_COLUMNS)), | |
| fn ($value) => $value !== null && $value !== '' | |
| ); | |
| if (count($targets) !== 1) { | |
| throw new ApiException('INVALID_FILE_LINK_TARGET', 'Exactly one file link target is required.', [], 422); | |
| } | |
| $targetColumn = array_key_first($targets); | |
| $targetId = $targets[$targetColumn]; | |
| $this->ensureTargetExists($targetColumn, $targetId, $actorUserId); | |
| $linkId = $this->links->create(array_merge( | |
| [ | |
| 'id' => $this->ids->generate(), | |
| 'file_id' => $fileId, | |
| 'link_purpose' => $input['link_purpose'] ?? 'GENERAL', | |
| 'linked_by_user_id' => $actorUserId, | |
| ], | |
| array_fill_keys(FileLinkRepository::TARGET_COLUMNS, null), | |
| [$targetColumn => $targetId], | |
| )); | |
| $this->audit->record('FILE_LINKED', $actorUserId, null, 'file_links', $linkId, 'SUCCESS', [ | |
| 'file_id' => $fileId, | |
| 'target_column' => $targetColumn, | |
| ]); | |
| return [ | |
| 'file_link' => [ | |
| 'id' => $linkId, | |
| 'file_id' => $fileId, | |
| 'target_column' => $targetColumn, | |
| 'target_id' => $targetId, | |
| ], | |
| ]; | |
| } | |
| public function get(string $fileId, string $actorUserId): array | |
| { | |
| $file = $this->requireFile($fileId); | |
| $this->authorizeAccess($file, $actorUserId); | |
| $this->audit->record('SENSITIVE_RECORD_ACCESSED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS', [ | |
| 'access_type' => 'metadata_read', | |
| ]); | |
| return ['file' => $this->payload($file)]; | |
| } | |
| public function downloadUrl(string $fileId, string $actorUserId): array | |
| { | |
| $file = $this->requireFile($fileId); | |
| $this->authorizeAccess($file, $actorUserId); | |
| $this->audit->record('SENSITIVE_RECORD_ACCESSED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS', [ | |
| 'access_type' => 'download_url', | |
| ]); | |
| return [ | |
| 'download' => [ | |
| 'file_id' => $fileId, | |
| 'url' => $this->fileUrls->temporaryFileDownloadUrl('applicant.files.download', $fileId, $expiresAt = Carbon::now()->addMinutes(10))['url'], | |
| 'expires_at' => $expiresAt->toIso8601String(), | |
| ], | |
| ]; | |
| } | |
| public function download(string $fileId, string $actorUserId): array | |
| { | |
| $file = $this->requireFile($fileId); | |
| $this->authorizeAccess($file, $actorUserId); | |
| $this->audit->record('SENSITIVE_RECORD_ACCESSED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS', [ | |
| 'access_type' => 'signed_download', | |
| ]); | |
| return ['file' => $this->payload($file), 'storage_provider' => $file->storage_provider]; | |
| } | |
| public function archive(string $fileId, string $actorUserId): array | |
| { | |
| $file = $this->requireFile($fileId); | |
| $this->authorizeManage($file, $actorUserId); | |
| $this->files->archive($fileId); | |
| $this->audit->record('FILE_ARCHIVED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS'); | |
| return ['file' => $this->payload($this->files->find($fileId))]; | |
| } | |
| public function delete(string $fileId, string $actorUserId): array | |
| { | |
| $file = $this->requireFile($fileId); | |
| $this->authorizeManage($file, $actorUserId); | |
| $this->files->softDelete($fileId); | |
| $this->audit->record('FILE_DELETED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS'); | |
| return ['deleted' => true]; | |
| } | |
| private function requireFile(string $fileId): object | |
| { | |
| $file = $this->files->find($fileId); | |
| if ($file === null) { | |
| throw new ApiException('FILE_NOT_FOUND', 'File was not found.', [], 404); | |
| } | |
| return $file; | |
| } | |
| private function authorizeAccess(object $file, string $actorUserId): void | |
| { | |
| if (! $this->policy->canAccess($file, $actorUserId)) { | |
| throw new ApiException('FORBIDDEN', 'You are not authorized to access this file.', [], 403); | |
| } | |
| } | |
| private function authorizeManage(object $file, string $actorUserId): void | |
| { | |
| if (! $this->policy->canManage($file, $actorUserId)) { | |
| throw new ApiException('FORBIDDEN', 'You are not authorized to manage this file.', [], 403); | |
| } | |
| } | |
| private function ensureTargetExists(string $targetColumn, string $targetId, string $actorUserId): void | |
| { | |
| $table = self::TARGET_TABLES[$targetColumn] ?? null; | |
| if ($table === null || ! DB::table($table)->where('id', $targetId)->whereNull('deleted_at')->exists()) { | |
| throw new ApiException('FILE_LINK_TARGET_NOT_FOUND', 'File link target was not found.', [], 404); | |
| } | |
| if ($targetColumn === 'application_id') { | |
| $application = DB::table('applications')->where('id', $targetId)->first(); | |
| if ($application?->user_id !== null && $application->user_id !== $actorUserId && ! $this->policy->canAccess((object) ['owner_user_id' => null], $actorUserId)) { | |
| throw new ApiException('FORBIDDEN', 'You are not authorized to link to this application.', [], 403); | |
| } | |
| } | |
| } | |
| private function payload(object $file): array | |
| { | |
| return [ | |
| 'id' => $file->id, | |
| 'classification' => $file->classification, | |
| 'owner_user_id' => $file->owner_user_id, | |
| 'storage_provider' => $file->storage_provider, | |
| 'storage_bucket' => $file->storage_bucket, | |
| 'storage_key_reference' => hash('sha256', $file->storage_key), | |
| 'original_filename' => $file->original_filename, | |
| 'mime_type' => $file->mime_type, | |
| 'file_size_bytes' => (int) $file->file_size_bytes, | |
| 'checksum_sha256' => $file->checksum_sha256, | |
| 'virus_scan_status' => $file->virus_scan_status, | |
| 'retention_until' => $file->retention_until, | |
| 'archived_at' => $file->archived_at, | |
| 'created_at' => $file->created_at, | |
| 'updated_at' => $file->updated_at, | |
| 'download_url' => $this->fileUrls->temporaryFileDownloadUrl('applicant.files.download', $file->id, Carbon::now()->addMinutes(10))['url'], | |
| 'stream_url' => $this->fileUrls->temporaryFileStreamUrl('applicant.files.stream', $file->id, Carbon::now()->addMinutes(10))['url'], | |
| 'secure_link' => $this->fileUrls->temporaryFileDownloadUrl('applicant.files.download', $file->id, Carbon::now()->addMinutes(10))['url'], | |
| ]; | |
| } | |
| } | |