diff --git a/app/Actions/Teams/ExportTeamConfiguration.php b/app/Actions/Teams/ExportTeamConfiguration.php new file mode 100644 index 0000000..7255fb2 --- /dev/null +++ b/app/Actions/Teams/ExportTeamConfiguration.php @@ -0,0 +1,106 @@ +PrintServers() + ->with(['Printers.ClientApplications', 'tokens']) + ->get() + ->map(function ($server) { + return [ + 'ulid' => $server->ulid, + 'name' => $server->name, + 'location' => $server->location, + 'api_token' => $this->getApiToken($server), + 'printers' => $server->Printers->map(function ($printer) { + return [ + 'ulid' => $printer->ulid, + 'name' => $printer->name, + 'location' => $printer->location, + 'enabled' => $printer->enabled, + 'uri' => $printer->uri, + 'ppd_support' => $printer->ppd_support, + 'ppd_options' => $printer->ppd_options, + 'ppd_options_layout' => $printer->ppd_options_layout, + 'raw_languages_supported' => $printer->raw_languages_supported, + ]; + })->values()->all(), + ]; + })->values()->all(); + + $clientApplications = $team->ClientApplications() + ->with(['Printers', 'tokens']) + ->get() + ->map(function ($app) { + return [ + 'ulid' => $app->ulid, + 'name' => $app->name, + 'url' => $app->url, + 'api_token' => $this->getApiToken($app), + 'printer_access' => $app->Printers->pluck('ulid')->values()->all(), + ]; + })->values()->all(); + + return [ + 'version' => '1.0.0', + 'schema_version' => 'webprint-server-1.0', + 'export_date' => now()->toIso8601String(), + 'export_type' => 'team_infrastructure', + 'metadata' => [ + 'source_version' => config('app.version', '1.0.0'), + 'exporter_ulid' => auth()->user()?->ulid, + ], + 'print_servers' => $printServers, + 'client_applications' => $clientApplications, + ]; + } + + /** + * Get the hashed API token for a tokenable model. + * + * @param mixed $tokenable + * @return string|null + */ + protected function getApiToken($tokenable): ?string + { + $token = $tokenable->tokens->first(); + + return $token?->token; + } + + /** + * Export team configuration as JSON string. + * + * @param \App\Models\Team $team + * @return string + */ + public function exportAsJson(Team $team): string + { + return json_encode($this->export($team), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * Generate filename for the export. + * + * @param \App\Models\Team $team + * @return string + */ + public function generateFilename(Team $team): string + { + $slug = \Illuminate\Support\Str::slug($team->name); + $date = now()->format('Y-m-d_His'); + + return sprintf('team-config-%s-%s.json', $slug, $date); + } +} diff --git a/app/Console/Commands/ExportTeamConfigurationCommand.php b/app/Console/Commands/ExportTeamConfigurationCommand.php new file mode 100644 index 0000000..4ca955e --- /dev/null +++ b/app/Console/Commands/ExportTeamConfigurationCommand.php @@ -0,0 +1,86 @@ +argument('team_ulid'); + + // Find team by ULID + $team = Team::where('ulid', $teamUlid)->first(); + + if (! $team) { + $this->error("Team with ULID '{$teamUlid}' not found."); + + return self::FAILURE; + } + + if ($team->personal_team) { + $this->error('Cannot export personal team configuration.'); + + return self::FAILURE; + } + + $this->info("Exporting configuration for team: {$team->name}"); + + // Export the configuration + $json = $exporter->exportAsJson($team); + + // Determine output path + $outputPath = $this->option('output'); + + if (! $outputPath) { + $outputPath = storage_path('app/'.$exporter->generateFilename($team)); + } + + // Ensure directory exists + $directory = dirname($outputPath); + if (! File::isDirectory($directory)) { + File::makeDirectory($directory, 0755, true); + } + + // Write to file + File::put($outputPath, $json); + + $this->info("Configuration exported successfully to: {$outputPath}"); + + // Show statistics + $data = json_decode($json, true); + $this->newLine(); + $this->line('Export Statistics:'); + $this->line(' Print Servers: '.count($data['print_servers'] ?? [])); + $this->line(' Printers: '.collect($data['print_servers'] ?? [])->sum(fn ($server) => count($server['printers'] ?? []))); + $this->line(' Client Applications: '.count($data['client_applications'] ?? [])); + + return self::SUCCESS; + } +} diff --git a/app/Http/Livewire/Teams/ExportTeamConfigurationForm.php b/app/Http/Livewire/Teams/ExportTeamConfigurationForm.php new file mode 100644 index 0000000..846c0cf --- /dev/null +++ b/app/Http/Livewire/Teams/ExportTeamConfigurationForm.php @@ -0,0 +1,83 @@ +team = $team; + } + + /** + * Confirm export and check password. + * + * @return void + */ + public function confirmExport() + { + $this->ensurePasswordIsConfirmed(); + + $this->confirmingExport = true; + } + + /** + * Export the team configuration. + * + * @param \App\Actions\Teams\ExportTeamConfiguration $exporter + * @return \Symfony\Component\HttpFoundation\StreamedResponse + */ + public function export(ExportTeamConfiguration $exporter): StreamedResponse + { + Gate::authorize('export', $this->team); + + $json = $exporter->exportAsJson($this->team); + $filename = $exporter->generateFilename($this->team); + + $this->confirmingExport = false; + + return response()->streamDownload(function () use ($json) { + echo $json; + }, $filename, [ + 'Content-Type' => 'application/json', + ]); + } + + /** + * Render the component. + * + * @return \Illuminate\View\View + */ + public function render() + { + return view('teams.export-team-configuration-form'); + } +} diff --git a/app/Policies/TeamPolicy.php b/app/Policies/TeamPolicy.php index ebbcd49..8983036 100755 --- a/app/Policies/TeamPolicy.php +++ b/app/Policies/TeamPolicy.php @@ -108,4 +108,14 @@ public function delete(User $user, Team $team): bool { return $user->ownsTeam($team) && ! $team->personal_team; } + + /** + * Determine whether the user can export team configuration. + * + * @return mixed + */ + public function export(User $user, Team $team): bool + { + return $user->ownsTeam($team) && ! $team->personal_team; + } } diff --git a/resources/views/teams/export-team-configuration-form.blade.php b/resources/views/teams/export-team-configuration-form.blade.php new file mode 100644 index 0000000..247120e --- /dev/null +++ b/resources/views/teams/export-team-configuration-form.blade.php @@ -0,0 +1,70 @@ + + + {{ __('Export Team Configuration') }} + + + + {{ __('Download team print infrastructure configuration.') }} + + + +
+ {{ __('Export the complete print infrastructure configuration for this team, including print servers, printers, client applications, and their API tokens. This file can be used to migrate to a new WebPrint Server instance.') }} +
+ +
+ + + {{ __('Export Configuration') }} + + +
+ + + + + {{ __('Export Team Configuration') }} + + + +
+

{{ __('You are about to export the complete print infrastructure configuration for this team.') }}

+ +
+
+
+ + + +
+
+

{{ __('Security Notice') }}

+
+

{{ __('The export file contains hashed API tokens. Store this file securely as it can be used to restore access to your print infrastructure.') }}

+
+
+
+
+ +

{{ __('The export includes:') }}

+
    +
  • {{ __('Print servers and their configurations') }}
  • +
  • {{ __('Printers with PPD options and settings') }}
  • +
  • {{ __('Client applications and printer access mappings') }}
  • +
  • {{ __('API tokens (hashed)') }}
  • +
+
+
+ + + + {{ __('Cancel') }} + + + + {{ __('Download Export') }} + + +
+
+
diff --git a/resources/views/teams/show.blade.php b/resources/views/teams/show.blade.php index ac8d21f..82247ae 100755 --- a/resources/views/teams/show.blade.php +++ b/resources/views/teams/show.blade.php @@ -11,6 +11,14 @@ @livewire('teams.team-member-manager', ['team' => $team]) + @if (Gate::check('export', $team) && ! $team->personal_team) + + +
+ @livewire('teams.export-team-configuration-form', ['team' => $team]) +
+ @endif + @if (Gate::check('delete', $team) && ! $team->personal_team) diff --git a/team-configuration-export-schema.json b/team-configuration-export-schema.json new file mode 100644 index 0000000..2a373b5 --- /dev/null +++ b/team-configuration-export-schema.json @@ -0,0 +1,278 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://webprint-server.example.com/schemas/team-configuration-export-v1.0.0.json", + "title": "WebPrint Server Team Configuration Export", + "description": "Schema for exporting and importing team print infrastructure configuration", + "type": "object", + "required": ["version", "schema_version", "export_date", "export_type", "print_servers", "client_applications"], + "properties": { + "version": { + "type": "string", + "description": "Export format version", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "examples": ["1.0.0"] + }, + "schema_version": { + "type": "string", + "description": "Schema identifier", + "const": "webprint-server-1.0" + }, + "export_date": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when export was created" + }, + "export_type": { + "type": "string", + "description": "Type of export", + "const": "team_infrastructure" + }, + "metadata": { + "type": "object", + "description": "Optional metadata about the export", + "properties": { + "source_version": { + "type": "string", + "description": "WebPrint Server version that created this export" + }, + "exporter_ulid": { + "type": "string", + "description": "ULID of the user who performed the export", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" + }, + "notes": { + "type": "string", + "description": "Free-form notes about this export" + } + } + }, + "print_servers": { + "type": "array", + "description": "Array of print servers and their printers", + "items": { + "$ref": "#/definitions/printServer" + } + }, + "client_applications": { + "type": "array", + "description": "Array of client applications and their printer access", + "items": { + "$ref": "#/definitions/clientApplication" + } + } + }, + "definitions": { + "printServer": { + "type": "object", + "description": "Print server configuration", + "required": ["ulid", "name", "printers"], + "properties": { + "ulid": { + "type": "string", + "description": "Unique ULID identifier for the print server", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" + }, + "name": { + "type": "string", + "description": "Display name of the print server", + "minLength": 1, + "maxLength": 255 + }, + "location": { + "type": ["string", "null"], + "description": "Physical location of the print server (nullable)", + "maxLength": 255 + }, + "api_token": { + "type": ["string", "null"], + "description": "Hashed API token for print server authentication (nullable). If null, no token will be created on import.", + "pattern": "^[a-f0-9]{64}$|^$" + }, + "printers": { + "type": "array", + "description": "Printers managed by this print server", + "items": { + "$ref": "#/definitions/printer" + } + } + } + }, + "printer": { + "type": "object", + "description": "Printer configuration", + "required": ["ulid", "name", "enabled", "uri", "ppd_support", "raw_languages_supported"], + "properties": { + "ulid": { + "type": "string", + "description": "Unique ULID identifier for the printer", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" + }, + "name": { + "type": "string", + "description": "Display name of the printer", + "minLength": 1, + "maxLength": 255 + }, + "location": { + "type": ["string", "null"], + "description": "Physical location of the printer (nullable)", + "maxLength": 255 + }, + "enabled": { + "type": "boolean", + "description": "Whether the printer is enabled and available for use" + }, + "uri": { + "type": "string", + "description": "CUPS printer URI (e.g., ipp://192.168.1.100:631/printers/hp4050)", + "minLength": 1, + "maxLength": 255 + }, + "ppd_support": { + "type": "boolean", + "description": "Whether PostScript Printer Definition (PPD) support is enabled" + }, + "ppd_options": { + "type": ["array", "null"], + "description": "PPD configuration options (nullable, typically null when ppd_support is false)", + "items": { + "$ref": "#/definitions/ppdOption" + } + }, + "ppd_options_layout": { + "type": ["object", "null"], + "description": "PPD options UI layout configuration (nullable)", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "name", "order"], + "properties": { + "key": { + "type": "string", + "description": "Group identifier" + }, + "name": { + "type": "string", + "description": "Display name of the group" + }, + "order": { + "type": "integer", + "description": "Display order of the group", + "minimum": 0 + } + } + } + } + } + }, + "raw_languages_supported": { + "type": "array", + "description": "Array of supported print languages (e.g., ['PCL', 'PostScript', '*'])", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + } + } + }, + "ppdOption": { + "type": "object", + "description": "PostScript Printer Definition option configuration", + "required": ["key", "name", "values", "default", "enabled", "order"], + "properties": { + "key": { + "type": "string", + "description": "PPD option key (e.g., 'PageSize', 'ColorModel')", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Display name of the option", + "minLength": 1 + }, + "values": { + "type": "array", + "description": "Available values for this option", + "items": { + "type": "object", + "required": ["key", "name"], + "properties": { + "key": { + "type": "string", + "description": "Value key identifier", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Display name of the value", + "minLength": 1 + } + } + }, + "minItems": 1 + }, + "default": { + "type": "string", + "description": "Default value key for this option", + "minLength": 1 + }, + "enabled": { + "type": "boolean", + "description": "Whether this option is enabled" + }, + "order": { + "type": "integer", + "description": "Display order of this option", + "minimum": 0 + }, + "group_key": { + "type": "string", + "description": "Group identifier this option belongs to" + }, + "group_name": { + "type": "string", + "description": "Display name of the group" + } + } + }, + "clientApplication": { + "type": "object", + "description": "Client application configuration", + "required": ["ulid", "name", "printer_access"], + "properties": { + "ulid": { + "type": "string", + "description": "Unique ULID identifier for the client application", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" + }, + "name": { + "type": "string", + "description": "Display name of the client application", + "minLength": 1, + "maxLength": 255 + }, + "url": { + "type": ["string", "null"], + "description": "Application URL (nullable)", + "maxLength": 255 + }, + "api_token": { + "type": ["string", "null"], + "description": "Hashed API token for client application authentication (nullable). If null, no token will be created on import.", + "pattern": "^[a-f0-9]{64}$|^$" + }, + "printer_access": { + "type": "array", + "description": "Array of printer ULIDs this application can access (can be empty)", + "items": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" + } + } + } + } + } +}