From f1399517edf8dfe07e0cd4a38a61c707883873cd Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 25 Aug 2026 20:24:03 +0100 Subject: [PATCH] updated design --- Modules/Helpdesk/Config/config.php | 21 +- .../Helpdesk/Console/HelpdeskSyncCommand.php | 78 +++ Modules/Helpdesk/Documentation/Remote_Sync.md | 83 +++ .../Controllers/HelpdeskSyncController.php | 59 ++ .../Middleware/VerifyHelpdeskSyncToken.php | 29 + Modules/Helpdesk/Routes/api.php | 11 + .../Helpdesk/Services/HelpdeskSyncService.php | 605 ++++++++++++++++++ 7 files changed, 885 insertions(+), 1 deletion(-) create mode 100644 Modules/Helpdesk/Console/HelpdeskSyncCommand.php create mode 100644 Modules/Helpdesk/Documentation/Remote_Sync.md create mode 100644 Modules/Helpdesk/Http/Controllers/HelpdeskSyncController.php create mode 100644 Modules/Helpdesk/Http/Middleware/VerifyHelpdeskSyncToken.php create mode 100644 Modules/Helpdesk/Services/HelpdeskSyncService.php diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index 99ea6ba..9868548 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -7,6 +7,26 @@ return [ 'middleware' => ['web', 'auth'], 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'), 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), + + 'sync' => [ + // URL of the other Helpdesk host, e.g. https://helpdesk-remote.example.com + 'remote_url' => env('HELPDESK_SYNC_REMOTE_URL'), + + // Bearer token this host sends to the configured remote host. + 'remote_token' => env('HELPDESK_SYNC_REMOTE_TOKEN'), + + // Bearer token this host accepts on /api/helpdesk/sync/*. + // Leave blank to disable inbound remote sync completely. + 'accept_token' => env('HELPDESK_SYNC_ACCEPT_TOKEN'), + + 'include_attachments' => env('HELPDESK_SYNC_INCLUDE_ATTACHMENTS', true), + 'max_attachment_bytes' => (int) env('HELPDESK_SYNC_MAX_ATTACHMENT_BYTES', 10485760), + 'connect_timeout' => (int) env('HELPDESK_SYNC_CONNECT_TIMEOUT', 10), + 'timeout' => (int) env('HELPDESK_SYNC_TIMEOUT', 120), + 'retries' => (int) env('HELPDESK_SYNC_RETRIES', 2), + 'retry_delay_ms' => (int) env('HELPDESK_SYNC_RETRY_DELAY_MS', 500), + ], + 'allowed_extensions' => [ 'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip', 'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less', @@ -24,7 +44,6 @@ return [ 'blocked_extensions' => ['exe', 'dll', 'com', 'scr', 'msi', 'app', 'dmg', 'pkg', 'jar', 'war'], 'virus_scanner' => env('HELPDESK_VIRUS_SCANNER', null), - // Backwards-compatible alias for older module code/tests that still read this key. 'allowed_mimes' => [], 'default_status_slug' => 'new', 'closed_status_slug' => 'closed', diff --git a/Modules/Helpdesk/Console/HelpdeskSyncCommand.php b/Modules/Helpdesk/Console/HelpdeskSyncCommand.php new file mode 100644 index 0000000..8bff141 --- /dev/null +++ b/Modules/Helpdesk/Console/HelpdeskSyncCommand.php @@ -0,0 +1,78 @@ +argument('direction')); + + if (! in_array($direction, ['push', 'pull'], true)) { + $this->error('Direction must be "push" or "pull".'); + + return self::FAILURE; + } + + $url = trim((string) ($this->option('url') ?: config('helpdesk.sync.remote_url'))); + $token = trim((string) ($this->option('token') ?: config('helpdesk.sync.remote_token'))); + + if ($url === '' || $token === '') { + $this->error('Configure HELPDESK_SYNC_REMOTE_URL and HELPDESK_SYNC_REMOTE_TOKEN, or pass --url and --token.'); + + return self::FAILURE; + } + + $settingsOnly = (bool) $this->option('settings-only'); + $includeFiles = ! (bool) $this->option('without-files'); + $mirror = (bool) $this->option('mirror'); + + $this->info(sprintf( + '%s Helpdesk %s %s', + ucfirst($direction), + $settingsOnly ? 'settings' : 'settings + data', + $direction === 'push' ? 'to '.$url : 'from '.$url, + )); + + if ($mirror) { + $this->warn('Mirror mode is enabled. Destination Helpdesk rows absent from the source can be deleted.'); + } + + try { + $result = $direction === 'push' + ? $sync->push($url, $token, $settingsOnly, $includeFiles, $mirror) + : $sync->pull($url, $token, $settingsOnly, $includeFiles, $mirror); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->newLine(); + $this->table( + ['Metric', 'Value'], + collect($result)->map(fn ($value, $key): array => [ + (string) $key, + is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_UNESCAPED_SLASHES), + ])->values()->all(), + ); + + return self::SUCCESS; + } +} diff --git a/Modules/Helpdesk/Documentation/Remote_Sync.md b/Modules/Helpdesk/Documentation/Remote_Sync.md new file mode 100644 index 0000000..5cf1906 --- /dev/null +++ b/Modules/Helpdesk/Documentation/Remote_Sync.md @@ -0,0 +1,83 @@ +# Helpdesk Remote Sync + +The Helpdesk module can synchronise its configuration and operational data between two Helpdesk installations. + +## Configure both hosts + +Use a long random shared token. You can use the same token in both directions or separate inbound/outbound tokens. + +```dotenv +HELPDESK_SYNC_REMOTE_URL=https://other-helpdesk.example.com +HELPDESK_SYNC_REMOTE_TOKEN=replace-with-a-long-random-token +HELPDESK_SYNC_ACCEPT_TOKEN=replace-with-a-long-random-token + +HELPDESK_SYNC_INCLUDE_ATTACHMENTS=true +HELPDESK_SYNC_MAX_ATTACHMENT_BYTES=10485760 +HELPDESK_SYNC_TIMEOUT=120 +``` + +After changing `.env`: + +```bash +php artisan optimize:clear +``` + +## Check the remote endpoint + +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://other-helpdesk.example.com/api/helpdesk/sync/status +``` + +## Pull from the configured remote host + +```bash +php artisan helpdesk:sync pull +``` + +## Push to the configured remote host + +```bash +php artisan helpdesk:sync push +``` + +## Settings only + +```bash +php artisan helpdesk:sync pull --settings-only +php artisan helpdesk:sync push --settings-only +``` + +## Do not transfer attachment file contents + +```bash +php artisan helpdesk:sync pull --without-files +``` + +Attachment database metadata still synchronises when Helpdesk data is included. + +## Mirror mode + +By default, sync is non-destructive: rows are inserted or updated but destination-only rows are retained. + +To make the destination mirror the source: + +```bash +php artisan helpdesk:sync pull --mirror +``` + +Use mirror mode carefully. It can delete Helpdesk records from the destination. + +## User mapping + +Helpdesk users are mapped by email address, not numeric user ID. `helpdesk_admin` and `helpdesk_agent` flags are synchronised. Password hashes are never copied. If a referenced user does not exist on the destination, a local user record is created with a random password. + +## API endpoints + +All sync endpoints require a Bearer token matching `HELPDESK_SYNC_ACCEPT_TOKEN`. + +- `GET /api/helpdesk/sync/status` +- `GET /api/helpdesk/sync/export` +- `POST /api/helpdesk/sync/import` + +The payload is versioned using `schema_version`. diff --git a/Modules/Helpdesk/Http/Controllers/HelpdeskSyncController.php b/Modules/Helpdesk/Http/Controllers/HelpdeskSyncController.php new file mode 100644 index 0000000..03d40c8 --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/HelpdeskSyncController.php @@ -0,0 +1,59 @@ +json($sync->status()); + } + + public function export(Request $request, HelpdeskSyncService $sync): JsonResponse + { + $settingsOnly = $request->boolean('settings_only'); + $includeFiles = ! $request->boolean('without_files'); + + return response()->json( + $sync->snapshot( + settingsOnly: $settingsOnly, + includeFiles: $includeFiles, + ) + ); + } + + public function import(Request $request, HelpdeskSyncService $sync): JsonResponse + { + $validated = $request->validate([ + 'snapshot' => ['required', 'array'], + 'mirror' => ['sometimes', 'boolean'], + ]); + + try { + $result = $sync->importSnapshot( + $validated['snapshot'], + mirror: (bool) ($validated['mirror'] ?? false), + ); + } catch (Throwable $exception) { + report($exception); + + return response()->json([ + 'ok' => false, + 'message' => $exception->getMessage(), + ], 422); + } + + return response()->json([ + 'ok' => true, + 'result' => $result, + ]); + } +} diff --git a/Modules/Helpdesk/Http/Middleware/VerifyHelpdeskSyncToken.php b/Modules/Helpdesk/Http/Middleware/VerifyHelpdeskSyncToken.php new file mode 100644 index 0000000..8df9769 --- /dev/null +++ b/Modules/Helpdesk/Http/Middleware/VerifyHelpdeskSyncToken.php @@ -0,0 +1,29 @@ +bearerToken(); + + if ($provided === '' || ! hash_equals($expected, $provided)) { + abort(401, 'Invalid Helpdesk sync token.'); + } + + return $next($request); + } +} diff --git a/Modules/Helpdesk/Routes/api.php b/Modules/Helpdesk/Routes/api.php index dfe7212..c76087d 100644 --- a/Modules/Helpdesk/Routes/api.php +++ b/Modules/Helpdesk/Routes/api.php @@ -4,12 +4,23 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; use Modules\Helpdesk\Http\Controllers\GitWebhookController; +use Modules\Helpdesk\Http\Controllers\HelpdeskSyncController; +use Modules\Helpdesk\Http\Middleware\VerifyHelpdeskSyncToken; Route::get('/status', static fn (): array => [ 'module' => 'helpdesk', 'status' => 'ok', ])->name('status'); +Route::middleware(VerifyHelpdeskSyncToken::class) + ->prefix('sync') + ->name('sync.') + ->group(static function (): void { + Route::get('/status', [HelpdeskSyncController::class, 'status'])->name('status'); + Route::get('/export', [HelpdeskSyncController::class, 'export'])->name('export'); + Route::post('/import', [HelpdeskSyncController::class, 'import'])->name('import'); + }); + Route::post('/git/webhook/{repository}', GitWebhookController::class) ->whereNumber('repository') ->name('git.webhook'); diff --git a/Modules/Helpdesk/Services/HelpdeskSyncService.php b/Modules/Helpdesk/Services/HelpdeskSyncService.php new file mode 100644 index 0000000..6cdb0e2 --- /dev/null +++ b/Modules/Helpdesk/Services/HelpdeskSyncService.php @@ -0,0 +1,605 @@ + + */ + private const SETTINGS_TABLES = [ + 'helpdesk_statuses', + 'helpdesk_priorities', + 'helpdesk_categories', + 'helpdesk_teams', + 'helpdesk_projects', + 'helpdesk_products', + 'helpdesk_tags', + 'helpdesk_kanban_boards', + 'helpdesk_kanban_board_statuses', + 'helpdesk_workflow_templates', + 'helpdesk_sla_policies', + 'helpdesk_canned_responses', + 'helpdesk_automation_rules', + 'helpdesk_checklist_templates', + 'helpdesk_git_repositories', + 'helpdesk_category_user', + ]; + + /** + * Operational Helpdesk tables, in dependency/import order. + * + * @var list + */ + private const DATA_TABLES = [ + 'helpdesk_tickets', + 'helpdesk_comments', + 'helpdesk_attachments', + 'helpdesk_checklist_items', + 'helpdesk_ticket_tag', + 'helpdesk_ticket_time_logs', + 'helpdesk_time_logs', + 'helpdesk_ticket_sla_events', + 'helpdesk_ticket_ratings', + 'helpdesk_git_commits', + 'helpdesk_git_webhook_logs', + 'helpdesk_workflow_logs', + ]; + + /** + * Columns that point to App\Models\User. These are remapped using email + * because user IDs can differ between the source and destination hosts. + * + * @var list + */ + private const USER_ID_COLUMNS = [ + 'user_id', + 'agent_id', + 'owner_id', + 'completed_by', + 'deleted_by', + 'created_by', + 'updated_by', + 'assigned_by', + ]; + + public function status(): array + { + return [ + 'ok' => true, + 'module' => 'helpdesk', + 'schema_version' => self::SCHEMA_VERSION, + 'host' => config('app.url'), + 'settings_tables' => count($this->existingTables(self::SETTINGS_TABLES)), + 'data_tables' => count($this->existingTables(self::DATA_TABLES)), + 'accepting_sync' => (string) config('helpdesk.sync.accept_token', '') !== '', + 'attachments_enabled' => (bool) config('helpdesk.sync.include_attachments', true), + ]; + } + + public function snapshot(bool $settingsOnly = false, bool $includeFiles = true): array + { + $tables = $this->existingTables(array_merge( + self::SETTINGS_TABLES, + $settingsOnly ? [] : self::DATA_TABLES, + )); + + $rowsByTable = []; + + foreach ($tables as $table) { + $rowsByTable[$table] = $this->rows($table); + } + + $users = $this->exportUsers($rowsByTable); + + $snapshot = [ + 'schema_version' => self::SCHEMA_VERSION, + 'generated_at' => now()->toIso8601String(), + 'source' => [ + 'url' => config('app.url'), + 'name' => config('app.name'), + ], + 'settings_only' => $settingsOnly, + 'users' => $users, + 'tables' => $rowsByTable, + 'files' => [], + ]; + + if ( + ! $settingsOnly + && $includeFiles + && (bool) config('helpdesk.sync.include_attachments', true) + && isset($rowsByTable['helpdesk_attachments']) + ) { + $snapshot['files'] = $this->exportAttachmentFiles($rowsByTable['helpdesk_attachments']); + } + + return $snapshot; + } + + public function importSnapshot(array $snapshot, bool $mirror = false): array + { + $version = (int) ($snapshot['schema_version'] ?? 0); + + if ($version !== self::SCHEMA_VERSION) { + throw new RuntimeException(sprintf( + 'Unsupported Helpdesk sync schema version %d; expected %d.', + $version, + self::SCHEMA_VERSION, + )); + } + + if (! isset($snapshot['tables']) || ! is_array($snapshot['tables'])) { + throw new RuntimeException('Invalid Helpdesk sync payload: tables are missing.'); + } + + $result = [ + 'tables' => 0, + 'rows' => 0, + 'users_mapped' => 0, + 'files' => 0, + 'mirror' => $mirror, + ]; + + $userMap = $this->importUsers( + is_array($snapshot['users'] ?? null) ? $snapshot['users'] : [] + ); + + $result['users_mapped'] = count($userMap); + + Schema::disableForeignKeyConstraints(); + + try { + DB::transaction(function () use ($snapshot, $userMap, $mirror, &$result): void { + $allowed = array_merge(self::SETTINGS_TABLES, self::DATA_TABLES); + + foreach ($allowed as $table) { + if (! Schema::hasTable($table)) { + continue; + } + + $rows = $snapshot['tables'][$table] ?? null; + + if (! is_array($rows)) { + continue; + } + + $this->importTable($table, $rows, $userMap, $mirror); + + $result['tables']++; + $result['rows'] += count($rows); + } + }); + } finally { + Schema::enableForeignKeyConstraints(); + } + + if (is_array($snapshot['files'] ?? null)) { + $result['files'] = $this->importAttachmentFiles($snapshot['files']); + } + + return $result; + } + + public function push( + string $url, + string $token, + bool $settingsOnly = false, + bool $includeFiles = true, + bool $mirror = false, + ): array { + $snapshot = $this->snapshot($settingsOnly, $includeFiles); + + $response = $this->client($token) + ->post($this->endpoint($url, '/api/helpdesk/sync/import'), [ + 'snapshot' => $snapshot, + 'mirror' => $mirror, + ]) + ->throw() + ->json(); + + if (! ($response['ok'] ?? false)) { + throw new RuntimeException((string) ($response['message'] ?? 'Remote Helpdesk import failed.')); + } + + return (array) ($response['result'] ?? []); + } + + public function pull( + string $url, + string $token, + bool $settingsOnly = false, + bool $includeFiles = true, + bool $mirror = false, + ): array { + $snapshot = $this->client($token) + ->get($this->endpoint($url, '/api/helpdesk/sync/export'), [ + 'settings_only' => $settingsOnly ? 1 : 0, + 'without_files' => $includeFiles ? 0 : 1, + ]) + ->throw() + ->json(); + + if (! is_array($snapshot)) { + throw new RuntimeException('Remote Helpdesk returned an invalid snapshot.'); + } + + return $this->importSnapshot($snapshot, $mirror); + } + + private function client(string $token): PendingRequest + { + return Http::acceptJson() + ->asJson() + ->withToken($token) + ->connectTimeout((int) config('helpdesk.sync.connect_timeout', 10)) + ->timeout((int) config('helpdesk.sync.timeout', 120)) + ->retry( + (int) config('helpdesk.sync.retries', 2), + (int) config('helpdesk.sync.retry_delay_ms', 500), + ); + } + + private function endpoint(string $baseUrl, string $path): string + { + return rtrim($baseUrl, '/').'/'.ltrim($path, '/'); + } + + /** + * @param list $tables + * @return list + */ + private function existingTables(array $tables): array + { + return array_values(array_filter( + $tables, + static fn (string $table): bool => Schema::hasTable($table), + )); + } + + /** + * @return list> + */ + private function rows(string $table): array + { + $query = DB::table($table); + + $columns = Schema::getColumnListing($table); + + if (in_array('id', $columns, true)) { + $query->orderBy('id'); + } + + return array_map( + static fn (object $row): array => (array) $row, + $query->get()->all(), + ); + } + + /** + * @param array>> $tables + * @return list> + */ + private function exportUsers(array $tables): array + { + if (! Schema::hasTable('users')) { + return []; + } + + $ids = []; + + foreach ($tables as $rows) { + foreach ($rows as $row) { + foreach (self::USER_ID_COLUMNS as $column) { + if (isset($row[$column]) && is_numeric($row[$column])) { + $ids[(int) $row[$column]] = true; + } + } + } + } + + $userColumns = Schema::getColumnListing('users'); + + $query = DB::table('users'); + + $hasAgent = in_array('helpdesk_agent', $userColumns, true); + $hasAdmin = in_array('helpdesk_admin', $userColumns, true); + + $query->where(function ($query) use ($ids, $hasAgent, $hasAdmin): void { + if ($ids !== []) { + $query->whereIn('id', array_keys($ids)); + } else { + $query->whereRaw('1 = 0'); + } + + if ($hasAgent) { + $query->orWhere('helpdesk_agent', true); + } + + if ($hasAdmin) { + $query->orWhere('helpdesk_admin', true); + } + }); + + $select = array_values(array_intersect([ + 'id', + 'name', + 'email', + 'email_verified_at', + 'helpdesk_agent', + 'helpdesk_admin', + ], $userColumns)); + + if ($select === []) { + return []; + } + + return array_map( + static fn (object $user): array => (array) $user, + $query->select($select)->orderBy('id')->get()->all(), + ); + } + + /** + * @param list> $users + * @return array + */ + private function importUsers(array $users): array + { + if (! Schema::hasTable('users')) { + return []; + } + + $columns = Schema::getColumnListing('users'); + $map = []; + + foreach ($users as $source) { + $sourceId = isset($source['id']) ? (int) $source['id'] : 0; + $email = strtolower(trim((string) ($source['email'] ?? ''))); + + if ($sourceId < 1 || $email === '') { + continue; + } + + $target = DB::table('users')->whereRaw('LOWER(email) = ?', [$email])->first(); + + if ($target === null) { + $insert = [ + 'name' => (string) ($source['name'] ?? $email), + 'email' => $email, + 'password' => password_hash(Str::random(64), PASSWORD_BCRYPT), + 'created_at' => now(), + 'updated_at' => now(), + ]; + + if (in_array('email_verified_at', $columns, true)) { + $insert['email_verified_at'] = $source['email_verified_at'] ?? now(); + } + + if (in_array('helpdesk_agent', $columns, true)) { + $insert['helpdesk_agent'] = (bool) ($source['helpdesk_agent'] ?? false); + } + + if (in_array('helpdesk_admin', $columns, true)) { + $insert['helpdesk_admin'] = (bool) ($source['helpdesk_admin'] ?? false); + } + + $targetId = (int) DB::table('users')->insertGetId( + array_intersect_key($insert, array_flip($columns)) + ); + } else { + $targetId = (int) $target->id; + + $updates = []; + + if (in_array('name', $columns, true) && isset($source['name'])) { + $updates['name'] = $source['name']; + } + + if (in_array('helpdesk_agent', $columns, true)) { + $updates['helpdesk_agent'] = (bool) ($source['helpdesk_agent'] ?? false); + } + + if (in_array('helpdesk_admin', $columns, true)) { + $updates['helpdesk_admin'] = (bool) ($source['helpdesk_admin'] ?? false); + } + + if (in_array('updated_at', $columns, true)) { + $updates['updated_at'] = now(); + } + + if ($updates !== []) { + DB::table('users')->where('id', $targetId)->update($updates); + } + } + + $map[$sourceId] = $targetId; + } + + return $map; + } + + /** + * @param list> $rows + * @param array $userMap + */ + private function importTable(string $table, array $rows, array $userMap, bool $mirror): void + { + $columns = Schema::getColumnListing($table); + $hasId = in_array('id', $columns, true); + $sourceIds = []; + + foreach ($rows as $row) { + if (! is_array($row)) { + continue; + } + + $row = array_intersect_key($row, array_flip($columns)); + $row = $this->remapUserColumns($row, $userMap); + + if ($row === []) { + continue; + } + + if ($hasId && isset($row['id'])) { + $id = (int) $row['id']; + $sourceIds[] = $id; + + DB::table($table)->updateOrInsert(['id' => $id], $row); + + continue; + } + + $key = $this->compositeKey($table, $row); + + DB::table($table)->updateOrInsert($key, $row); + } + + if ($mirror && $hasId) { + if ($sourceIds === []) { + DB::table($table)->delete(); + } else { + DB::table($table)->whereNotIn('id', $sourceIds)->delete(); + } + } + } + + /** + * @param array $row + * @param array $userMap + * @return array + */ + private function remapUserColumns(array $row, array $userMap): array + { + foreach (self::USER_ID_COLUMNS as $column) { + if (! array_key_exists($column, $row) || $row[$column] === null) { + continue; + } + + $sourceId = (int) $row[$column]; + + if (isset($userMap[$sourceId])) { + $row[$column] = $userMap[$sourceId]; + } + } + + return $row; + } + + /** + * @param array $row + * @return array + */ + private function compositeKey(string $table, array $row): array + { + $preferred = match ($table) { + 'helpdesk_ticket_tag' => ['ticket_id', 'tag_id'], + 'helpdesk_category_user' => ['category_id', 'user_id'], + 'helpdesk_kanban_board_statuses' => ['board_id', 'status_id'], + default => [], + }; + + $key = array_intersect_key($row, array_flip($preferred)); + + if ($key !== []) { + return $key; + } + + if (isset($row['uuid'])) { + return ['uuid' => $row['uuid']]; + } + + if (isset($row['slug'])) { + return ['slug' => $row['slug']]; + } + + throw new RuntimeException("Cannot determine an import key for {$table}."); + } + + /** + * @param list> $attachments + * @return list> + */ + private function exportAttachmentFiles(array $attachments): array + { + $files = []; + $maxBytes = (int) config('helpdesk.sync.max_attachment_bytes', 10 * 1024 * 1024); + + foreach ($attachments as $attachment) { + $disk = (string) ($attachment['disk'] ?? config('helpdesk.disk', 'local')); + $path = (string) ($attachment['path'] ?? ''); + + if ($path === '' || ! Storage::disk($disk)->exists($path)) { + continue; + } + + $size = Storage::disk($disk)->size($path); + + if ($maxBytes > 0 && $size > $maxBytes) { + continue; + } + + $files[] = [ + 'attachment_id' => (int) ($attachment['id'] ?? 0), + 'disk' => $disk, + 'path' => $path, + 'size' => $size, + 'sha256' => hash('sha256', Storage::disk($disk)->get($path)), + 'contents_base64' => base64_encode(Storage::disk($disk)->get($path)), + ]; + } + + return $files; + } + + /** + * @param list> $files + */ + private function importAttachmentFiles(array $files): int + { + $written = 0; + + foreach ($files as $file) { + $disk = (string) ($file['disk'] ?? config('helpdesk.disk', 'local')); + $path = (string) ($file['path'] ?? ''); + $encoded = (string) ($file['contents_base64'] ?? ''); + + if ($path === '' || $encoded === '') { + continue; + } + + $contents = base64_decode($encoded, true); + + if ($contents === false) { + throw new RuntimeException("Invalid base64 attachment payload for {$path}."); + } + + $expected = (string) ($file['sha256'] ?? ''); + + if ($expected !== '' && ! hash_equals($expected, hash('sha256', $contents))) { + throw new RuntimeException("Attachment checksum mismatch for {$path}."); + } + + Storage::disk($disk)->put($path, $contents); + $written++; + } + + return $written; + } +}