'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'], ]; } }