diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c85c7f --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Blockland Config + +**Version 1.7.0** · Author: RallyBlock + +A Pelican Panel plugin for managing Blockland dedicated server configuration directly from the panel UI. + +**Compatibility:** Pelican Panel (Filament 4.x, Laravel 12.x) + +## Features + +### Add-Ons +- Toggle server add-ons on/off, grouped by category (Bricks, Vehicles, Weapons, etc.) +- Categories are automatically detected from add-on names +- Orphaned entries (add-ons removed from the server) are automatically cleaned up +- Save changes or **Save & Restart** to apply immediately + +### ColorSet +- Browse installed ColorSet add-ons and activate them with one click +- Read-only preview of the currently active color palette with color swatches +- Server restarts automatically after activating a new ColorSet +- Supports up to 64 colors (Blockland's maximum) + +### Preferences +- Edit server preferences (`prefs.cs`) with typed form fields (toggles, text, numeric, password) +- Preferences are grouped by category (General, Bricks, Network, Quotas, etc.) +- **Save** syncs prefs to server vars and runs `webcom_postserver()` for live effect; **Save & Restart** for immediate full reload + +### Admins +- Manage AutoAdminList and AutoSuperAdminList in `prefs.cs` +- **Add by BL_ID** with role (Admin or Super Admin) and mode: **Auto** (saved to prefs) or **Manual** (session only, no rejoin persistence) +- **Demote by BL_ID** for manually granted admins (player must be online) +- Apply changes to connected players immediately when adding Auto admins—no rejoin required (vanilla TorqueScript: `findClientByBL_ID`, `commandToClient setAdminLevel`, `messageAll`) +- **Remove** button to remove BL_IDs from Super Admins or Admins lists; demotes connected players instantly if online +- **Silent mode** toggle—when enabled, no `messageAll` announcements are sent for promotions, demotions, or preference updates + +### Add-On Browser +- Browse and install community add-ons directly from [bl.kenko.dev](https://bl.kenko.dev/Add-ons/Retail/) +- Browse by category with a searchable dropdown +- Search and filter add-ons by name +- Paginated results with a configurable page size (10, 25, 50, or 100 per page) +- One-click install downloads add-ons directly into the server's `Add-Ons/` folder +- **Note:** These add-ons are for Retail Blockland (v21+), are community-created, and have not been tested for safety + +### Save Loader +- List .bls save files from the server's `saves/` folder with optional preview thumbnails +- Copy any save to `base/server/temp/temp.bls` — then run /ReloadBricks in-game to load it +- Upload new .bls files into the saves folder; optionally add a preview image (.jpg, .jpeg, or .png with the same filename) + +## Requirements + +- Pelican Panel with a Blockland server egg (must have `blockland` in egg tags or features) + +The plugin is **standalone** and works with vanilla Blockland—no Blockland Glass or Support Preferences (BLPrefs) required. + +## Enabling Pages Per Server + +Pages are controlled by **egg features** so you can choose which pages each server gets. Add these feature keys to your Blockland egg's `features` array: + +| Feature | Page | Description | +|---------|------|-------------| +| `blockland-all` | All pages | Enable all Blockland pages at once | +| `blockland-addons` | Add-Ons | Toggle add-ons on/off, save or restart | +| `blockland-colorset` | ColorSet | Activate ColorSet add-ons, preview palette | +| `blockland-prefs` | Preferences | Edit prefs.cs with typed form fields | +| `blockland-admins` | Admins | Manage admin lists, add/remove by BL_ID, demote, silent mode | +| `blockland-addon-browser` | Add-On Browser | Browse and install add-ons from bl.kenko.dev | +| `blockland-save-loader` | Save Loader | List saves with previews, copy to temp.bls, upload .bls and preview images | + +Only pages whose features are present on the server's egg will appear in the server panel. For example, adding only `blockland-addons` and `blockland-prefs` will show just those two pages for servers using that egg. + +## Server File Paths + +The plugin reads and writes the following files on the game server: + +- `config/server/ADD_ON_LIST.cs` — Add-on enable/disable list +- `config/server/colorSet.txt` — Active color palette +- `config/server/prefs.cs` — Server preferences +- `config/server/.blockland-panel` — Panel config (e.g. silent mode) +- `Add-Ons/` — Installed add-ons (folders or .zip files) +- `Add-Ons/Colorset_*/` — Installed ColorSet add-ons +- `saves/` — Save files (.bls) and optional preview images (.jpg, .jpeg, .png); Save Loader lists, uploads, and displays thumbnails +- `base/server/temp/temp.bls` — Target for Save Loader "Copy to temp.bls" (run /ReloadBricks in-game to load) diff --git a/config/blockland-config.php b/config/blockland-config.php new file mode 100644 index 0000000..ebf75f5 --- /dev/null +++ b/config/blockland-config.php @@ -0,0 +1,14 @@ + 'Loading...', + 'loading_addons' => 'Loading add-ons...', + 'loading_admins' => 'Loading admins...', + 'loading_colorset' => 'Loading ColorSet...', + 'loading_prefs' => 'Loading preferences...', + 'loading_categories' => 'Loading categories...', + 'loading_saves' => 'Loading saves...', + + // Page titles + 'pages' => [ + 'addons' => 'Add-Ons', + 'colorset' => 'ColorSet', + 'preferences' => 'Preferences', + 'admins' => 'Admins', + 'addon_browser' => 'Add-On Browser', + 'save_loader' => 'Save Loader', + ], + + // Actions + 'actions' => [ + 'save' => 'Save', + 'save_and_restart' => 'Save & Restart', + 'activate_colorset' => 'Activate ColorSet Add-On', + ], + + // Modals + 'modals' => [ + 'restart_heading' => 'Save & Restart Server', + 'restart_description' => 'This will save your add-on changes and restart the server. Players will be disconnected.', + 'prefs_restart_description' => 'This will save your preference changes and restart the server. Players will be disconnected.', + ], + + // Add-On categories (known Blockland categories; unknown ones fall back to raw name) + 'categories' => [ + 'ai' => 'AI', + 'bot' => 'Bots', + 'brick' => 'Bricks', + 'chatemotes' => 'Chat Emotes', + 'client' => 'Client', + 'colorset' => 'ColorSets', + 'content' => 'Content', + 'daycycle' => 'Day Cycles', + 'decal' => 'Decals', + 'emote' => 'Emotes', + 'event' => 'Events', + 'face' => 'Faces', + 'gamemode' => 'Game Modes', + 'ground' => 'Grounds', + 'hat' => 'Hats', + 'hatmod' => 'Hat Mods', + 'instrument' => 'Instruments', + 'item' => 'Items', + 'jvs' => 'JVS', + 'langscript' => 'Language Scripts', + 'light' => 'Lights', + 'map' => 'Maps', + 'particle' => 'Particles', + 'player' => 'Player', + 'print' => 'Prints', + 'projectile' => 'Projectiles', + 'prop' => 'Props', + 'rp' => 'Roleplay', + 'script' => 'Scripts', + 'server' => 'Server', + 'sky' => 'Skies', + 'sound' => 'Sounds', + 'staticmap' => 'Static Maps', + 'sunflare' => 'Sun Flares', + 'support' => 'Support', + 'system' => 'System', + 'theme' => 'Themes', + 'tool' => 'Tools', + 'vehicle' => 'Vehicles', + 'water' => 'Water', + 'weapon' => 'Weapons', + ], + + // ColorSet + 'colorset' => [ + 'select_addon' => 'Choose a ColorSet add-on to replace the active palette', + 'active_palette' => 'Active Palette', + 'color_count' => ':count / :max colors', + 'no_colorset' => 'No active colorset found.', + ], + + // Prefs + 'prefs' => [ + 'restart_hint' => 'Restart required for this preference to take effect.', + ], + + // Validation + 'validation' => [ + 'max_length' => 'Must be at most :max characters.', + 'must_be_numeric' => 'Must be a number.', + 'min_value' => 'Must be at least :min.', + 'max_value' => 'Must be at most :max.', + ], + + // Pref groups + 'pref_groups' => [ + 'general' => 'General', + 'bricks' => 'Bricks', + 'newduplicator' => 'New Duplicator', + 'newbricktool' => 'New Brick Tool', + 'newpaintcan' => 'New Paint Can', + 'quotas' => 'Quotas', + 'lan_quotas' => 'LAN Quotas', + 'network' => 'Network', + 'other' => 'Other', + ], + + // Notifications + 'notifications' => [ + 'addons_saved' => 'Add-on list saved successfully.', + 'addons_saved_restarting' => 'Add-on list saved. Server is restarting...', + 'addons_cleaned' => 'Add-on list cleaned up.', + 'addons_cleaned_detail' => 'Removed :count add-on(s) that no longer exist in the Add-Ons folder.', + 'colorset_activated' => 'ColorSet activated successfully.', + 'colorset_activated_restarting' => 'ColorSet activated. Server is restarting...', + 'colorset_activate_failed' => 'Failed to activate colorset.', + 'colorset_too_many_colors' => 'The selected colorset exceeds the maximum of :max colors.', + 'colorset_not_found' => 'Could not find a colorSet.txt in the selected add-on. It may be corrupted or missing.', + 'prefs_saved' => 'Preferences saved successfully.', + 'prefs_saved_restarting' => 'Preferences saved. Server is restarting...', + 'prefs_applied_live' => 'Changes applied to the running server.', + 'prefs_restart_to_apply' => 'Restart the server for changes to take effect.', + 'save_failed' => 'Failed to save changes.', + 'addon_install_started' => 'Download started.', + 'addon_install_started_detail' => ':name is being downloaded to your Add-Ons folder.', + 'addon_install_failed' => 'Failed to install add-on.', + ], + + // Egg feature keys (for documentation; add these to egg features to enable pages per server) + 'features' => [ + 'all' => 'blockland-all', + 'addons' => 'blockland-addons', + 'colorset' => 'blockland-colorset', + 'prefs' => 'blockland-prefs', + 'admins' => 'blockland-admins', + 'addon_browser' => 'blockland-addon-browser', + 'save_loader' => 'blockland-save-loader', + ], + + // Save Loader + 'save_loader' => [ + 'upload' => 'Upload to Saves', + 'upload_hint' => 'Choose a .bls file from your computer, then click Upload to add it to your server\'s saves folder.', + 'upload_drop_or_click' => 'Drop .bls here or click to choose', + 'upload_file_types' => '.bls files only', + 'upload_change' => 'Change', + 'upload_button' => 'Upload to saves folder', + 'upload_preview_optional' => 'Optional: Add a preview image (same filename as the save, but .jpg, .jpeg, or .png)', + 'upload_preview_add' => 'Add preview image', + 'upload_preview_change' => 'Change', + 'upload_success' => 'File uploaded to saves folder.', + 'upload_failed' => 'Upload failed.', + 'upload_no_file' => 'Please select a file first.', + 'upload_invalid_type' => 'File must have a .bls extension.', + 'saves_folder' => 'Saves Folder', + 'copy_hint' => 'Copy a save to base/server/temp/temp.bls. Then run /ReloadBricks in-game.', + 'copy_to_temp' => 'Copy to temp.bls', + 'copy_success' => 'Save copied to temp.bls. Run /ReloadBricks in-game to load it.', + 'copy_failed' => 'Failed to copy save.', + 'no_saves' => 'No .bls save files found in the saves folder.', + ], + + // Add-On Browser + 'browser' => [ + 'warning' => 'These add-ons are for Retail Blockland (v21+) and may not work on older versions. They are sourced from bl.kenko.dev, community-created, and have not been tested for safety. Use at your own risk.', + 'category' => 'Category', + 'select_category' => 'Choose a category...', + 'select_category_prompt' => 'Select a category above to browse available add-ons.', + 'select_category_or_search_prompt' => 'Select a category or search above to find add-ons.', + 'search' => 'Search', + 'search_placeholder' => 'Filter add-ons...', + 'addon_name' => 'Name', + 'addon_size' => 'Size', + 'install' => 'Install', + 'install_heading' => 'Install :name', + 'install_description' => 'This will download the add-on to your server\'s Add-Ons folder. You may need to restart the server for it to take effect.', + 'showing_count' => ':count add-on(s) available', + 'showing_range' => 'Showing :from to :to of :total', + 'per_page' => 'Per page', + 'no_addons' => 'No add-ons found.', + 'searching_addons' => 'Searching add-ons...', + ], + + // Admins + 'admins' => [ + 'online_players' => 'Online Players', + 'refresh' => 'Refresh', + 'refresh_players' => 'Refresh player list', + 'online_loading' => 'Loading players...', + 'online_empty' => 'No players online.', + 'players_online' => 'Players online', + 'make_admin' => 'Make Admin', + 'make_admin_manual' => 'Make Admin (Manual)', + 'make_super_admin' => 'Make Super Admin', + 'make_super_admin_manual' => 'Make Super Admin (Manual)', + 'promote_to_super_admin' => 'Promote to Super Admin', + 'promote_to_super_admin_manual' => 'Promote to Super Admin (Manual)', + 'role_super_admin' => 'Super Admin', + 'role_admin' => 'Admin', + 'role_player' => 'Player', + 'super_admins' => 'Super Admins', + 'admins' => 'Admins', + 'announcements' => 'Announcements', + 'announcement_message' => 'Message', + 'announcement_placeholder' => 'Message to send to all players...', + 'announcement_empty' => 'Please enter a message.', + 'announcement_sent' => 'Announcement sent.', + 'send_announcement' => 'Send', + 'add_by_blid' => 'Add by BL_ID', + 'bl_id' => 'BL_ID', + 'mode' => 'Mode', + 'mode_auto' => 'Auto (saved)', + 'mode_auto_checkbox' => 'Auto (saved)', + 'mode_manual' => 'Manual (session only)', + 'session_only' => '(session only)', + 'role' => 'Role', + 'add' => 'Add', + 'remove' => 'Remove', + 'demote' => 'Demote', + 'revoke' => 'Revoke', + 'cancel' => 'Cancel', + 'confirm_demote' => 'Demote BL_ID :bl_id? They will lose admin for this session.', + 'confirm_revoke' => 'Revoke admin for BL_ID :bl_id? They will lose admin for this session.', + 'confirm_remove' => 'Remove BL_ID :bl_id from :role?', + 'demote_by_blid' => 'Demote by BL_ID', + 'revoke_by_blid' => 'Revoke by BL_ID', + 'demote_by_blid_hint' => 'For manually granted admins. Applies only if player is online.', + 'revoke_by_blid_hint' => 'For manually granted admins. Applies only if player is online.', + 'demote_from' => 'Demote from', + 'revoke_from' => 'Revoke from', + 'no_super_admins' => 'No super admins.', + 'no_admins' => 'No admins.', + 'added' => ':name added as :role.', + 'removed' => 'BL_ID :id removed from :role.', + 'server_offline' => 'Server must be running to apply changes to connected players.', + 'manual_requires_online' => 'Player must be online for Manual promotion.', + 'demoted' => 'Player demoted.', + 'revoked' => 'Admin privileges have been revoked.', + 'banned' => 'Player banned.', + 'unbanned' => 'Player unbanned.', + 'ban_list' => 'Ban List', + 'ban' => 'Ban', + 'unban' => 'Unban', + 'no_bans' => 'No players banned.', + 'ban_time' => 'Time (minutes)', + 'ban_reason' => 'Reason', + 'silent_mode' => 'Silent mode', + 'silent_mode_tooltip' => 'When on, no messageAll announcements are sent for promotes, revokes, or preference updates', + 'silent_enabled' => 'Silent mode enabled. No in-game announcements will be sent.', + 'silent_disabled' => 'Silent mode disabled. In-game announcements will be sent.', + ], +]; diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..215eae8 --- /dev/null +++ b/plugin.json @@ -0,0 +1,22 @@ +{ + "id": "blockland-config", + "name": "Blockland Config", + "author": "RallyBlock", + "version": "1.7.0", + "description": "Manage Blockland server add-ons, color sets, and preferences", + "category": "plugin", + "url": null, + "update_url": null, + "namespace": "Pelican\\BlocklandConfig", + "class": "BlocklandConfigPlugin", + "panels": [ + "admin", + "server" + ], + "panel_version": null, + "composer_packages": null, + "meta": { + "status": "enabled", + "status_message": null + } +} \ No newline at end of file diff --git a/resources/views/pages/addon-browser.blade.php b/resources/views/pages/addon-browser.blade.php new file mode 100644 index 0000000..7151e2a --- /dev/null +++ b/resources/views/pages/addon-browser.blade.php @@ -0,0 +1,141 @@ + + @if(!$categoriesLoaded) + +
+ + {{ trans('blockland-config::blockland-config.loading_categories') }} +
+
+ @else +
+
+ + {{-- Warning banner --}} +
+ +

+ {{ trans('blockland-config::blockland-config.browser.warning') }} +

+
+ + {{-- Category & search filters --}} +
+ {{ $this->form }} +
+ +
+
+ +
+ + {{ trans('blockland-config::blockland-config.browser.searching_addons') }} +
+
+
+
+ @if($this->selectedCategory || $this->search !== '') + @php $paginated = $this->getPaginatedData(); @endphp + + + +
+

{{ trans('blockland-config::blockland-config.browser.showing_count', ['count' => $paginated['total']]) }}

+
+ + + + + + + + + +
+
+
+ + @if(count($paginated['items']) > 0) +
+ @foreach($paginated['items'] as $addOn) +
+
+

{{ $addOn['name'] }}

+ @if($addOn['size']) +

{{ $addOn['size'] }}

+ @endif +
+ + {{ trans('blockland-config::blockland-config.browser.install') }} + +
+ @endforeach +
+ + @if($paginated['lastPage'] > 1) +
+

+ {{ trans('blockland-config::blockland-config.browser.showing_range', [ + 'from' => $paginated['from'], + 'to' => $paginated['to'], + 'total' => $paginated['total'], + ]) }} +

+
+ + @for($p = 1; $p <= $paginated['lastPage']; $p++) + @if($paginated['lastPage'] <= 7 || $p <= 2 || $p >= $paginated['lastPage'] - 1 || abs($p - $paginated['page']) <= 1) + + {{ $p }} + + @elseif($p === 3 || $p === $paginated['lastPage'] - 2) + + @endif + @endfor + +
+
+ @endif + @else + + @endif +
+ @else + + @endif +
+
+ +
+
+ @endif +
diff --git a/resources/views/pages/addons-page.blade.php b/resources/views/pages/addons-page.blade.php new file mode 100644 index 0000000..7869741 --- /dev/null +++ b/resources/views/pages/addons-page.blade.php @@ -0,0 +1,16 @@ + + @if(!$addOnsLoaded) + +
+ + {{ trans('blockland-config::blockland-config.loading_addons') }} +
+
+ @else +
+
+ {{ $this->form }} +
+
+ @endif +
diff --git a/resources/views/pages/admins-page.blade.php b/resources/views/pages/admins-page.blade.php new file mode 100644 index 0000000..1139c1d --- /dev/null +++ b/resources/views/pages/admins-page.blade.php @@ -0,0 +1,121 @@ + + @if(!$adminsLoaded) + +
+ + {{ trans('blockland-config::blockland-config.loading_admins') }} +
+
+ @else +
+ + {{-- Confirmation modal popup --}} + @if($this->confirmingDemote || $this->confirmingRemove) + + @endif + + {{-- Add by BL_ID --}} + +
+
+ {{ $this->addForm }} +
+
+ + {{ trans('blockland-config::blockland-config.admins.add') }} + +
+
+
+ + {{-- Demote by BL_ID --}} + +
+
+ {{ $this->demoteForm }} +
+
+ + {{ trans('blockland-config::blockland-config.admins.demote') }} + +
+
+
+ + {{-- Super Admins --}} + + @if(count($this->superAdmins) > 0) +
+ @foreach($this->superAdmins as $blId) +
+ BL_ID {{ $blId }} + + {{ trans('blockland-config::blockland-config.admins.remove') }} + +
+ @endforeach +
+ @else +

{{ trans('blockland-config::blockland-config.admins.no_super_admins') }}

+ @endif +
+ + {{-- Admins --}} + + @if(count($this->admins) > 0) +
+ @foreach($this->admins as $blId) +
+ BL_ID: {{ $blId }} + + {{ trans('blockland-config::blockland-config.admins.remove') }} + +
+ @endforeach +
+ @else +

{{ trans('blockland-config::blockland-config.admins.no_admins') }}

+ @endif +
+ +
+ @endif +
diff --git a/resources/views/pages/colorset-page.blade.php b/resources/views/pages/colorset-page.blade.php new file mode 100644 index 0000000..989a540 --- /dev/null +++ b/resources/views/pages/colorset-page.blade.php @@ -0,0 +1,46 @@ + + @if(!$colorSetLoaded) + +
+ + {{ trans('blockland-config::blockland-config.loading_colorset') }} +
+
+ @else +
+ {{-- Color count summary --}} + +
+
+ + {{ trans('blockland-config::blockland-config.colorset.active_palette') }} +
+ {{ trans('blockland-config::blockland-config.colorset.color_count', ['count' => $this->totalColors, 'max' => 64]) }} +
+
+ + {{-- Swatch preview per section --}} + @forelse($this->colorSetPreview as $section) + +
+ @foreach($section['colors'] as $color) +
+ @endforeach +
+
+ @empty + + @endforelse +
+ @endif +
diff --git a/resources/views/pages/prefs-page.blade.php b/resources/views/pages/prefs-page.blade.php new file mode 100644 index 0000000..0012b33 --- /dev/null +++ b/resources/views/pages/prefs-page.blade.php @@ -0,0 +1,16 @@ + + @if(!$prefsLoaded) + +
+ + {{ trans('blockland-config::blockland-config.loading_prefs') }} +
+
+ @else +
+
+ {{ $this->form }} +
+
+ @endif +
diff --git a/resources/views/pages/save-loader-page.blade.php b/resources/views/pages/save-loader-page.blade.php new file mode 100644 index 0000000..e22528f --- /dev/null +++ b/resources/views/pages/save-loader-page.blade.php @@ -0,0 +1,100 @@ + + @if(!$savesLoaded) + +
+ + {{ trans('blockland-config::blockland-config.loading_saves') }} +
+
+ @else +
+ {{-- Upload to Saves --}} + +
+ @if($uploadedFile) +
+
+ + + {{ trans('blockland-config::blockland-config.save_loader.upload_button') }} + +
+
+

{{ trans('blockland-config::blockland-config.save_loader.upload_preview_optional') }}

+ @if($uploadedPreviewFile) +
+ + +
+ @else + + @endif +
+
+ @else + + @endif +
+
+ + {{-- Saves Folder --}} + + @if(count($saves) > 0) +
+ @foreach($saves as $save) +
+
+
+ @if(isset($save['preview_url'])) + + @else + + @endif +
+ {{ $save['name'] }} +
+ + {{ trans('blockland-config::blockland-config.save_loader.copy_to_temp') }} + +
+ @endforeach +
+ @else + + @endif +
+
+ @endif +
diff --git a/routes/api-client.php b/routes/api-client.php new file mode 100644 index 0000000..2ebd156 --- /dev/null +++ b/routes/api-client.php @@ -0,0 +1,11 @@ +middleware([ServerSubject::class, AuthenticateServerAccess::class, ResourceBelongsToServer::class])->group(function () { + Route::get('/blockland-save-preview', SavePreviewController::class)->name('api:client:server.blockland-save-preview'); +}); diff --git a/src/BlocklandConfigPlugin.php b/src/BlocklandConfigPlugin.php new file mode 100644 index 0000000..d7bde48 --- /dev/null +++ b/src/BlocklandConfigPlugin.php @@ -0,0 +1,36 @@ +getId() === 'server') { + $panel->pages([ + BlocklandAddOnsPage::class, + BlocklandColorSetPage::class, + BlocklandAdminsPage::class, + BlocklandPrefsPage::class, + BlocklandAddOnBrowserPage::class, + BlocklandSaveLoaderPage::class, + ]); + } + } + + public function boot(Panel $panel): void {} +} diff --git a/src/Facades/BlocklandConfig.php b/src/Facades/BlocklandConfig.php new file mode 100644 index 0000000..c83f88b --- /dev/null +++ b/src/Facades/BlocklandConfig.php @@ -0,0 +1,56 @@ +page = 1; + $this->search = ''; + } + + public function updatedSearch(): void + { + $this->page = 1; + } + + public function updatedPerPage(): void + { + $this->page = 1; + } + + public function loadCategories(): void + { + $this->categories = BlocklandConfig::fetchRetailCategories(); + $this->categoriesLoaded = true; + } + + public function form(Schema $form): Schema + { + $options = []; + foreach ($this->categories as $cat) { + $options[$cat['name']] = $cat['name']; + } + + return $form + ->schema([ + Select::make('selectedCategory') + ->label(trans('blockland-config::blockland-config.browser.category')) + ->options($options) + ->searchable() + ->placeholder(trans('blockland-config::blockland-config.browser.select_category')) + ->live(), + TextInput::make('search') + ->label(trans('blockland-config::blockland-config.browser.search')) + ->placeholder(trans('blockland-config::blockland-config.browser.search_placeholder')) + ->live(debounce: 300), + ]) + ->statePath(null); + } + + public function getAllAddOns(): array + { + if ($this->selectedCategory) { + $addOns = BlocklandConfig::fetchRetailAddOns($this->selectedCategory); + } elseif ($this->search !== '') { + $addOns = $this->fetchAddOnsFromAllCategories(); + } else { + return []; + } + + if ($this->search !== '') { + $needle = strtolower($this->search); + $addOns = array_filter($addOns, fn ($a) => str_contains(strtolower($a['name']), $needle)); + $addOns = array_values($addOns); + } + + return $addOns; + } + + protected function fetchAddOnsFromAllCategories(): array + { + $all = []; + $seen = []; + foreach ($this->categories as $cat) { + $addOns = BlocklandConfig::fetchRetailAddOns($cat['name']); + foreach ($addOns as $addOn) { + $key = strtolower($addOn['filename']); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $all[] = $addOn; + } + } + usort($all, fn ($a, $b) => strcasecmp($a['name'], $b['name'])); + + return array_values($all); + } + + public function getPaginatedData(): array + { + $all = $this->getAllAddOns(); + $total = count($all); + $lastPage = max(1, (int) ceil($total / $this->perPage)); + + if ($this->page > $lastPage) { + $this->page = $lastPage; + } + + $offset = ($this->page - 1) * $this->perPage; + $items = array_slice($all, $offset, $this->perPage); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $this->page, + 'lastPage' => $lastPage, + 'perPage' => $this->perPage, + 'from' => $total > 0 ? $offset + 1 : 0, + 'to' => min($offset + $this->perPage, $total), + ]; + } + + public function previousPage(): void + { + $this->page = max(1, $this->page - 1); + } + + public function nextPage(): void + { + $data = $this->getPaginatedData(); + $this->page = min($data['lastPage'], $this->page + 1); + } + + public function goToPage(int $page): void + { + $this->page = max(1, $page); + } + + public function installAddOn(string $url, string $filename, string $name): void + { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + BlocklandConfig::installRetailAddOn($server, $fileRepository, $url, $filename); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.addon_install_started')) + ->body(trans('blockland-config::blockland-config.notifications.addon_install_started_detail', [ + 'name' => $name, + ])) + ->success() + ->send(); + } catch (Exception $e) { + report($e); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.addon_install_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } +} diff --git a/src/Filament/Server/Pages/BlocklandAddOnsPage.php b/src/Filament/Server/Pages/BlocklandAddOnsPage.php new file mode 100644 index 0000000..0ce2cd7 --- /dev/null +++ b/src/Filament/Server/Pages/BlocklandAddOnsPage.php @@ -0,0 +1,209 @@ +title(trans('blockland-config::blockland-config.notifications.addons_cleaned')) + ->body(trans('blockland-config::blockland-config.notifications.addons_cleaned_detail', [ + 'count' => count($result['removed']), + ])) + ->info() + ->send(); + } + + $addOns = $result['cleaned']; + $this->groupedAddOns = BlocklandConfig::groupAddOnsByCategory($addOns); + $this->addOnsLoaded = true; + + $formData = []; + foreach ($addOns as $name => $value) { + $formData['addon_' . $name] = $value === 1; + } + + $this->form->fill($formData); + } + + public function form(Schema $form): Schema + { + if (!$this->addOnsLoaded || empty($this->groupedAddOns)) { + return $form->schema([])->statePath('data'); + } + + $sections = []; + foreach ($this->groupedAddOns as $category => $items) { + $toggles = []; + foreach ($items as $name => $value) { + $label = str_replace('_', ' ', preg_replace('/^' . preg_quote($category, '/') . '_/', '', $name)); + $toggles[] = Toggle::make('addon_' . $name) + ->label($label) + ->inline(false); + } + + $transKey = 'blockland-config::blockland-config.categories.' . strtolower($category); + $translated = trans($transKey); + $sectionLabel = $translated === $transKey ? $category : $translated; + + $sections[] = Section::make($sectionLabel) + ->schema($toggles) + ->collapsible() + ->collapsed() + ->columns(2); + } + + return $form + ->schema($sections) + ->statePath('data'); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('save') + ->label(trans('blockland-config::blockland-config.actions.save')) + ->icon('tabler-device-floppy') + ->disabled(fn () => !$this->addOnsLoaded) + ->action(function () { + $this->saveAddOns(); + }), + Action::make('save_and_restart') + ->label(trans('blockland-config::blockland-config.actions.save_and_restart')) + ->icon('tabler-refresh') + ->color('warning') + ->disabled(fn () => !$this->addOnsLoaded) + ->requiresConfirmation() + ->modalHeading(trans('blockland-config::blockland-config.modals.restart_heading')) + ->modalDescription(trans('blockland-config::blockland-config.modals.restart_description')) + ->action(function () { + $this->saveAddOns(restart: true); + }), + ]; + } + + public function saveAddOns(bool $restart = false): void + { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $currentAddOns = BlocklandConfig::getAddOns($server, $fileRepository); + $installedNames = BlocklandConfig::listInstalledAddOnNames($server, $fileRepository); + $result = BlocklandConfig::cleanupOrphanedAddOns($currentAddOns, $installedNames); + $currentAddOns = $result['cleaned']; + + $formData = $this->form->getState(); + $updatedAddOns = []; + + foreach ($currentAddOns as $name => $oldValue) { + $formKey = 'addon_' . $name; + $updatedAddOns[$name] = !empty($formData[$formKey]) ? 1 : -1; + } + + BlocklandConfig::saveAddOns($server, $fileRepository, $updatedAddOns); + + if ($restart) { + /** @var DaemonServerRepository $serverRepository */ + $serverRepository = app(DaemonServerRepository::class); + $serverRepository->setServer($server)->power('restart'); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.addons_saved_restarting')) + ->success() + ->send(); + } else { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.addons_saved')) + ->success() + ->send(); + } + } catch (Exception $e) { + report($e); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.save_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } +} diff --git a/src/Filament/Server/Pages/BlocklandAdminsPage.php b/src/Filament/Server/Pages/BlocklandAdminsPage.php new file mode 100644 index 0000000..579dd71 --- /dev/null +++ b/src/Filament/Server/Pages/BlocklandAdminsPage.php @@ -0,0 +1,411 @@ +schema([ + Grid::make(2)->schema([ + TextInput::make('newBlId') + ->label(trans('blockland-config::blockland-config.admins.bl_id')) + ->placeholder('BL_ID') + ->live(), + Select::make('newRole') + ->label(trans('blockland-config::blockland-config.admins.role')) + ->options([ + 'admin' => trans('blockland-config::blockland-config.admins.admins'), + 'super' => trans('blockland-config::blockland-config.admins.super_admins'), + ]) + ->placeholder(trans('blockland-config::blockland-config.admins.role')) + ->position('bottom') + ->live(), + ]), + Checkbox::make('newModeAuto') + ->label(trans('blockland-config::blockland-config.admins.mode_auto_checkbox')), + ]) + ->statePath(null); + } + + public function demoteForm(Schema $form): Schema + { + return $form + ->schema([ + TextInput::make('demoteBlId') + ->label(trans('blockland-config::blockland-config.admins.bl_id')) + ->placeholder('BL_ID') + ->live(), + Select::make('demoteFromRole') + ->label(trans('blockland-config::blockland-config.admins.demote_from')) + ->options([ + 'admin' => trans('blockland-config::blockland-config.admins.admins'), + 'super' => trans('blockland-config::blockland-config.admins.super_admins'), + ]) + ->placeholder(trans('blockland-config::blockland-config.admins.demote_from')) + ->position('bottom') + ->live(), + ]) + ->statePath(null) + ->columns(2); + } + + public function loadAdmins(): void + { + $this->loadAdminLists(); + $this->loadSilentMode(); + $this->adminsLoaded = true; + } + + public function loadSilentMode(): void + { + /** @var Server $server */ + $server = Filament::getTenant(); + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + $this->silentMode = BlocklandConfig::isSilentMode($server, $fileRepository); + } + + public function toggleSilentMode(): void + { + /** @var Server $server */ + $server = Filament::getTenant(); + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + $this->silentMode = ! $this->silentMode; + BlocklandConfig::setSilentMode($server, $fileRepository, $this->silentMode); + + Notification::make() + ->title($this->silentMode + ? trans('blockland-config::blockland-config.admins.silent_enabled') + : trans('blockland-config::blockland-config.admins.silent_disabled')) + ->success() + ->send(); + } + + public function loadAdminLists(): void + { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $lists = BlocklandConfig::getAdminLists($server, $fileRepository); + $this->superAdmins = $lists['superAdmins']; + $this->admins = $lists['admins']; + } + + public function askConfirmDemote(): void + { + $blId = trim($this->demoteBlId); + if ($blId === '' || ! ctype_digit($blId)) { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.save_failed')) + ->body(trans('blockland-config::blockland-config.admins.bl_id') . ' must be a numeric BL_ID.') + ->danger() + ->send(); + + return; + } + $this->confirmingDemote = true; + } + + public function askRemoveFromList(string $blId, string $list): void + { + $this->confirmingRemove = ['blId' => $blId, 'list' => $list]; + } + + public function cancelConfirm(): void + { + $this->confirmingDemote = false; + $this->confirmingRemove = null; + } + + public function confirmRemoveFromList(): void + { + if ($this->confirmingRemove === null) { + return; + } + $blId = $this->confirmingRemove['blId']; + $list = $this->confirmingRemove['list']; + $this->confirmingRemove = null; + $this->removeFromList($blId, $list); + } + + public function removeFromList(string $blId, string $list): void + { + $superAdmins = $this->superAdmins; + $admins = $this->admins; + + $fromLevel = $list === 'super' ? 2 : 1; + + if ($list === 'super') { + $superAdmins = array_values(array_filter($superAdmins, fn ($id) => $id !== $blId)); + } else { + $admins = array_values(array_filter($admins, fn ($id) => $id !== $blId)); + } + + $this->saveAndApply($superAdmins, $admins); + + $server = Filament::getTenant(); + $fileRepository = app(DaemonFileRepository::class); + $commands = BlocklandConfig::buildDemoteAdminCommands( + [$blId], + [$blId => $fromLevel], + BlocklandConfig::isSilentMode($server, $fileRepository) + ); + try { + BlocklandConfig::sendConsoleCommands($server, $fileRepository, $commands); + } catch (Exception) { + } + + $roleLabel = $list === 'super' ? trans('blockland-config::blockland-config.admins.super_admins') : trans('blockland-config::blockland-config.admins.admins'); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.prefs_saved')) + ->body(trans('blockland-config::blockland-config.admins.removed', ['id' => $blId, 'role' => $roleLabel])) + ->success() + ->send(); + } + + public function addByBlId(): void + { + $blId = trim($this->newBlId); + if ($blId === '' || ! ctype_digit($blId)) { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.save_failed')) + ->body(trans('blockland-config::blockland-config.admins.bl_id') . ' must be a numeric BL_ID.') + ->danger() + ->send(); + + return; + } + + $manual = ! $this->newModeAuto; + + if ($manual) { + $level = $this->newRole === 'super' ? 2 : 1; + $commands = BlocklandConfig::buildApplyAdminCommands( + [$blId], + $level, + [$blId => 0], + manual: true, + silent: BlocklandConfig::isSilentMode(Filament::getTenant(), app(DaemonFileRepository::class)) + ); + try { + BlocklandConfig::sendConsoleCommands( + Filament::getTenant(), + app(DaemonFileRepository::class), + $commands + ); + } catch (Exception) { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.save_failed')) + ->body(trans('blockland-config::blockland-config.admins.server_offline')) + ->danger() + ->send(); + + return; + } + } else { + $superAdmins = $this->superAdmins; + $admins = $this->admins; + + if ($this->newRole === 'super') { + $admins = array_values(array_filter($admins, fn ($id) => $id !== $blId)); + if (! in_array($blId, $superAdmins, true)) { + $superAdmins[] = $blId; + } + } else { + $superAdmins = array_values(array_filter($superAdmins, fn ($id) => $id !== $blId)); + if (! in_array($blId, $admins, true)) { + $admins[] = $blId; + } + } + + $this->saveAndApply($superAdmins, $admins); + + $level = $this->newRole === 'super' ? 2 : 1; + $server = Filament::getTenant(); + $fileRepository = app(DaemonFileRepository::class); + $commands = BlocklandConfig::buildApplyAdminCommands( + [$blId], + $level, + [$blId => 0], + manual: false, + silent: BlocklandConfig::isSilentMode($server, $fileRepository) + ); + try { + BlocklandConfig::sendConsoleCommands($server, $fileRepository, $commands); + } catch (Exception) { + } + } + + $this->newBlId = ''; + + $role = $this->newRole === 'super' ? 'Super Admin' : 'Admin'; + $suffix = $manual ? ' ' . trans('blockland-config::blockland-config.admins.session_only') : ''; + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.prefs_saved')) + ->body(trans('blockland-config::blockland-config.admins.added', ['name' => "BL_ID {$blId}", 'role' => $role . $suffix])) + ->success() + ->send(); + } + + public function demoteByBlId(): void + { + $blId = trim($this->demoteBlId); + if ($blId === '' || ! ctype_digit($blId)) { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.save_failed')) + ->body(trans('blockland-config::blockland-config.admins.bl_id') . ' must be a numeric BL_ID.') + ->danger() + ->send(); + + return; + } + + $fromLevel = $this->demoteFromRole === 'super' ? 2 : 1; + $server = Filament::getTenant(); + $fileRepository = app(DaemonFileRepository::class); + + $commands = BlocklandConfig::buildDemoteAdminCommands( + [$blId], + [$blId => $fromLevel], + BlocklandConfig::isSilentMode($server, $fileRepository) + ); + + try { + BlocklandConfig::sendConsoleCommands($server, $fileRepository, $commands); + } catch (Exception) { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.save_failed')) + ->body(trans('blockland-config::blockland-config.admins.server_offline')) + ->danger() + ->send(); + + return; + } + + $this->demoteBlId = ''; + $this->confirmingDemote = false; + + Notification::make() + ->title(trans('blockland-config::blockland-config.admins.demoted')) + ->success() + ->send(); + } + + protected function saveAndApply(array $superAdmins, array $admins): void + { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + BlocklandConfig::saveAdminLists($server, $fileRepository, $superAdmins, $admins); + $this->superAdmins = $superAdmins; + $this->admins = $admins; + + try { + BlocklandConfig::sendConsoleCommands($server, $fileRepository, ['exec("config/server/prefs.cs");']); + } catch (Exception) { + } + } + + protected function getHeaderActions(): array + { + return [ + Action::make('silent_mode') + ->label(trans('blockland-config::blockland-config.admins.silent_mode')) + ->icon(fn () => $this->silentMode ? 'tabler-volume-off' : 'tabler-volume') + ->color(fn () => $this->silentMode ? 'success' : 'gray') + ->tooltip(trans('blockland-config::blockland-config.admins.silent_mode_tooltip')) + ->action(fn () => $this->toggleSilentMode()), + ]; + } +} diff --git a/src/Filament/Server/Pages/BlocklandColorSetPage.php b/src/Filament/Server/Pages/BlocklandColorSetPage.php new file mode 100644 index 0000000..b49e107 --- /dev/null +++ b/src/Filament/Server/Pages/BlocklandColorSetPage.php @@ -0,0 +1,168 @@ +colorSetPreview = $colorSet['sections']; + $this->totalColors = $colorSet['totalColors']; + $this->colorSetLoaded = true; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('activate_colorset') + ->label(trans('blockland-config::blockland-config.actions.activate_colorset')) + ->icon('tabler-palette') + ->color('success') + ->disabled(fn () => !$this->colorSetLoaded) + ->form([ + Select::make('colorset') + ->label(trans('blockland-config::blockland-config.colorset.select_addon')) + ->options(fn () => $this->getColorsetOptions()) + ->searchable() + ->required(), + ]) + ->modalCancelAction(fn (Action $action) => $action + ->extraAttributes(['class' => '!bg-white dark:!bg-gray-800 dark:!ring-gray-600 dark:hover:!bg-gray-900 !text-gray-950 dark:!text-white'], merge: true)) + ->action(function (array $data) { + $this->activateColorsetAddOn($data['colorset']); + }), + ]; + } + + protected function getColorsetOptions(): array + { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $addOns = BlocklandConfig::listColorsetAddOns($server, $fileRepository); + + $options = []; + foreach ($addOns as $addOn) { + $key = $addOn['path'] . '|' . ($addOn['isZip'] ? '1' : '0'); + $options[$key] = $addOn['name'] . ($addOn['isZip'] ? ' (.zip)' : ''); + } + + return $options; + } + + public function activateColorsetAddOn(string $selection): void + { + try { + [$path, $isZipStr] = explode('|', $selection, 2); + $isZip = $isZipStr === '1'; + + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $result = BlocklandConfig::activateColorset($server, $fileRepository, $path, $isZip); + + if ($result === 'ok') { + /** @var DaemonServerRepository $serverRepository */ + $serverRepository = app(DaemonServerRepository::class); + $serverRepository->setServer($server)->power('restart'); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.colorset_activated_restarting')) + ->success() + ->send(); + + $this->redirect(static::getUrl()); + } elseif ($result === 'too_many_colors') { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.colorset_activate_failed')) + ->body(trans('blockland-config::blockland-config.notifications.colorset_too_many_colors', ['max' => BlocklandConfigService::MAX_COLORS])) + ->danger() + ->send(); + } else { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.colorset_activate_failed')) + ->body(trans('blockland-config::blockland-config.notifications.colorset_not_found')) + ->danger() + ->send(); + } + } catch (Exception $e) { + report($e); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.colorset_activate_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } +} diff --git a/src/Filament/Server/Pages/BlocklandPrefsPage.php b/src/Filament/Server/Pages/BlocklandPrefsPage.php new file mode 100644 index 0000000..88fa99b --- /dev/null +++ b/src/Filament/Server/Pages/BlocklandPrefsPage.php @@ -0,0 +1,403 @@ + + */ + public array $prefsMeta = []; + + public bool $prefsLoaded = false; + + public static function canAccess(): bool + { + /** @var Server|null $server */ + $server = Filament::getTenant(); + + if (!$server) { + return false; + } + + return parent::canAccess() + && BlocklandConfig::isBlocklandServer($server) + && BlocklandConfig::hasBlocklandFeature($server, \Pelican\BlocklandConfig\Services\BlocklandConfigService::FEATURE_PREFS); + } + + public static function getNavigationLabel(): string + { + return trans('blockland-config::blockland-config.pages.preferences'); + } + + public function getTitle(): string + { + return static::getNavigationLabel(); + } + + public function mount(): void + { + } + + public function loadPrefs(): void + { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $prefs = BlocklandConfig::getPrefs($server, $fileRepository); + $this->prefsMeta = $prefs; + $this->prefsLoaded = true; + + $formData = []; + foreach ($prefs as $i => $pref) { + $fieldType = $this->detectFieldType($pref); + $formKey = 'pref_' . $i; + + if ($fieldType === 'boolean') { + $formData[$formKey] = in_array($pref['value'], ['1', 'true'], true); + } else { + $formData[$formKey] = $pref['value']; + } + } + + $this->form->fill($formData); + } + + protected function detectFieldType(array $pref): string + { + $key = $pref['key']; + $value = $pref['value']; + + $booleanKeys = [ + 'AllowMultiClient', 'AllowColorLoading', 'ETardFilter', 'FallingDamage', + 'RandomBrickColor', 'ResetOnEmpty', 'WrenchEventsAdminOnly', + 'ClearEventsOnClientExit', 'UseRTB', + 'AdminOnly', 'AdminTrustBypass1', 'AdminTrustBypass2', + 'FillBricksAdminOnly', 'FloatAdminOnly', 'LoadAdminOnly', + 'PaintAdminOnly', 'PaintFxAdminOnly', 'PlayMenuSounds', + 'SaveAdminOnly', 'ScatterGhostBricks', 'SelectPublicBricks', + 'SymTableOnStart', 'WrenchAdminOnly', + 'AdminOrb', 'AllowRepeat', 'IgnoreRayCasting', 'IgnoreRaycasting', + 'AdminOrbPainting', 'ColorPick', 'DefaultToPaint', 'ShowBricks', + 'SuperAdmin', + ]; + + $shortKey = preg_replace('/^.*::/', '', $key); + + foreach ($booleanKeys as $bk) { + if ($shortKey === $bk) { + return 'boolean'; + } + } + + if (!$pref['quoted'] && is_numeric($value)) { + return 'numeric'; + } + + if (preg_match('/(Password|WelcomeMessage|Info|Name|AutoAdminList|AutoSuperAdminList|ETardList|ConnectionError)$/', $shortKey)) { + return 'text'; + } + + return 'text'; + } + + protected function getHumanLabel(string $key): string + { + $short = preg_replace('/^[Pp]ref::(Server|Net)::/', '', $key); + + $short = str_replace('::', ' > ', $short); + + return $short; + } + + public function form(Schema $form): Schema + { + if (!$this->prefsLoaded || empty($this->prefsMeta)) { + return $form->schema([])->statePath('data'); + } + + $prefs = $this->prefsMeta; + $grouped = BlocklandConfig::groupPrefs($prefs); + + $globalIndexMap = []; + foreach ($prefs as $i => $pref) { + $globalIndexMap[$pref['key']] = $i; + } + + $sections = []; + + foreach ($grouped as $groupName => $groupPrefs) { + $fields = []; + + foreach ($groupPrefs as $pref) { + $i = $globalIndexMap[$pref['key']] ?? null; + if ($i === null) { + continue; + } + + $shortKey = preg_replace('/^.*::/', '', $pref['key']); + if (in_array($shortKey, BlocklandConfigService::PREF_ADMIN_KEYS, true)) { + continue; + } + + $formKey = 'pref_' . $i; + $label = $this->getHumanLabel($pref['key']); + $fieldType = $this->detectFieldType($pref); + $validationRules = BlocklandConfigService::PREF_VALIDATION[$shortKey] ?? null; + $restartHint = BlocklandConfig::requiresRestart($pref['key']); + + if ($fieldType === 'boolean') { + $fields[] = Toggle::make($formKey) + ->label($label) + ->inline(false) + ->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null); + } elseif ($fieldType === 'numeric') { + $input = TextInput::make($formKey) + ->label($label) + ->numeric(); + if ($validationRules) { + if (isset($validationRules['min'])) { + $input->minValue($validationRules['min']); + } + if (isset($validationRules['max'])) { + $input->maxValue($validationRules['max']); + } + } + $fields[] = $input->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null); + } elseif (preg_match('/(WelcomeMessage|Info)$/', $pref['key'])) { + $input = Textarea::make($formKey) + ->label($label) + ->rows(2) + ->default(''); + if ($validationRules && isset($validationRules['maxLength'])) { + $input->maxLength($validationRules['maxLength']); + } + $fields[] = $input->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null); + } elseif (preg_match('/(Password|SuperAdminPassword|AdminPassword)$/', $pref['key'])) { + $input = TextInput::make($formKey) + ->label($label) + ->password() + ->revealable() + ->default(''); + if ($validationRules && isset($validationRules['maxLength'])) { + $input->maxLength($validationRules['maxLength']); + } + $fields[] = $input->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null); + } else { + $input = TextInput::make($formKey) + ->label($label) + ->default(''); + if ($validationRules && isset($validationRules['maxLength'])) { + $input->maxLength($validationRules['maxLength']); + } + $fields[] = $input->helperText($restartHint ? trans('blockland-config::blockland-config.prefs.restart_hint') : null); + } + } + + if (count($fields) === 0) { + continue; + } + + $translationKey = 'blockland-config::blockland-config.pref_groups.' . strtolower(str_replace(' ', '_', $groupName)); + $label = trans($translationKey); + if ($label === $translationKey) { + $label = Str::title(str_replace('_', ' ', Str::snake($groupName))); + } + + $sections[] = Section::make($label) + ->schema($fields) + ->collapsible() + ->collapsed() + ->columns(2); + } + + return $form + ->schema($sections) + ->statePath('data'); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('save') + ->label(trans('blockland-config::blockland-config.actions.save')) + ->icon('tabler-device-floppy') + ->disabled(fn () => !$this->prefsLoaded) + ->action(function () { + $this->savePrefs(); + }), + Action::make('save_and_restart') + ->label(trans('blockland-config::blockland-config.actions.save_and_restart')) + ->icon('tabler-refresh') + ->color('warning') + ->disabled(fn () => !$this->prefsLoaded) + ->requiresConfirmation() + ->modalHeading(trans('blockland-config::blockland-config.modals.restart_heading')) + ->modalDescription(trans('blockland-config::blockland-config.modals.prefs_restart_description')) + ->action(function () { + $this->savePrefs(restart: true); + }), + ]; + } + + public function savePrefs(bool $restart = false): void + { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $formData = $this->form->getState(); + $updatedPrefs = []; + + foreach ($this->prefsMeta as $i => $meta) { + $shortKey = preg_replace('/^.*::/', '', $meta['key']); + if (in_array($shortKey, BlocklandConfigService::PREF_ADMIN_KEYS, true)) { + $updatedPrefs[] = [ + 'key' => $meta['key'], + 'rawKey' => $meta['rawKey'], + 'value' => $meta['value'], + 'quoted' => $meta['quoted'], + ]; + continue; + } + + $formKey = 'pref_' . $i; + $fieldType = $this->detectFieldType($meta); + + if ($fieldType === 'boolean') { + $newValue = !empty($formData[$formKey]) ? '1' : '0'; + } else { + $newValue = array_key_exists($formKey, $formData) + ? (string) ($formData[$formKey] ?? '') + : $meta['value']; + } + + $error = BlocklandConfig::validatePrefValue($meta['key'], $newValue); + if ($error !== null) { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.save_failed')) + ->body($error) + ->danger() + ->send(); + + return; + } + + $updatedPrefs[] = [ + 'key' => $meta['key'], + 'rawKey' => $meta['rawKey'], + 'value' => $newValue, + 'quoted' => $meta['quoted'], + ]; + } + + BlocklandConfig::savePrefs($server, $fileRepository, $updatedPrefs); + + $oldMeta = $this->prefsMeta; + $this->prefsMeta = $updatedPrefs; + + $changedPrefs = []; + foreach ($updatedPrefs as $i => $pref) { + $shortKey = preg_replace('/^.*::/', '', $pref['key'] ?? ''); + if (in_array($shortKey, BlocklandConfigService::PREF_ADMIN_KEYS, true)) { + continue; + } + $orig = $oldMeta[$i] ?? null; + if ($orig === null || (string) ($orig['value'] ?? '') !== (string) ($pref['value'] ?? '')) { + $changedPrefs[] = $pref; + } + } + + if ($restart) { + /** @var DaemonServerRepository $serverRepository */ + $serverRepository = app(DaemonServerRepository::class); + $serverRepository->setServer($server)->power('restart'); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.prefs_saved_restarting')) + ->success() + ->send(); + + return; + } + + if (! empty($changedPrefs)) { + try { + $commands = array_merge( + BlocklandConfig::buildPrefConsoleCommands($changedPrefs), + ['webcom_postserver();'] + ); + if (!BlocklandConfig::isSilentMode($server, $fileRepository)) { + $commands[] = 'messageAll(\'MsgAdminForce\', "\\c2Server settings have been updated");'; + } + BlocklandConfig::sendConsoleCommands($server, $fileRepository, $commands); + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.prefs_saved')) + ->body(trans('blockland-config::blockland-config.notifications.prefs_applied_live')) + ->success() + ->send(); + } catch (Exception) { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.prefs_saved')) + ->body(trans('blockland-config::blockland-config.notifications.prefs_restart_to_apply')) + ->success() + ->send(); + } + } else { + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.prefs_saved')) + ->success() + ->send(); + } + } catch (Exception $e) { + report($e); + + Notification::make() + ->title(trans('blockland-config::blockland-config.notifications.save_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } +} diff --git a/src/Filament/Server/Pages/BlocklandSaveLoaderPage.php b/src/Filament/Server/Pages/BlocklandSaveLoaderPage.php new file mode 100644 index 0000000..6b12323 --- /dev/null +++ b/src/Filament/Server/Pages/BlocklandSaveLoaderPage.php @@ -0,0 +1,199 @@ + */ + public array $saves = []; + + public ?TemporaryUploadedFile $uploadedFile = null; + + public ?TemporaryUploadedFile $uploadedPreviewFile = null; + + public function mount(): void + { + } + + public static function canAccess(): bool + { + /** @var Server|null $server */ + $server = Filament::getTenant(); + + if (!$server) { + return false; + } + + return parent::canAccess() + && BlocklandConfig::isBlocklandServer($server) + && BlocklandConfig::hasBlocklandFeature($server, BlocklandConfigService::FEATURE_SAVE_LOADER); + } + + public static function getNavigationLabel(): string + { + return trans('blockland-config::blockland-config.pages.save_loader'); + } + + public function getTitle(): string + { + return static::getNavigationLabel(); + } + + public function loadSaves(): void + { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $saves = BlocklandConfig::listSaveFiles($server, $fileRepository); + foreach ($saves as &$save) { + if (isset($save['preview_path'])) { + $save['preview_url'] = route('api:client:server.blockland-save-preview', ['server' => $server->uuid]) . '?file=' . rawurlencode($save['preview_path']); + } + } + $this->saves = $saves; + $this->savesLoaded = true; + } + + public function copyToTemp(string $path): void + { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + BlocklandConfig::copySaveToTemp($server, $fileRepository, $path); + + Notification::make() + ->title(trans('blockland-config::blockland-config.save_loader.copy_success')) + ->success() + ->send(); + } catch (Exception $e) { + report($e); + + Notification::make() + ->title(trans('blockland-config::blockland-config.save_loader.copy_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function uploadSave(): void + { + $file = $this->uploadedFile ?? null; + if (!$file instanceof TemporaryUploadedFile || !$file->isValid()) { + if ($file && !$file->isValid()) { + Notification::make() + ->title(trans('blockland-config::blockland-config.save_loader.upload_failed')) + ->body($file->getErrorMessage()) + ->danger() + ->send(); + } else { + Notification::make() + ->title(trans('blockland-config::blockland-config.save_loader.upload_failed')) + ->body(trans('blockland-config::blockland-config.save_loader.upload_no_file')) + ->danger() + ->send(); + } + + return; + } + + $filename = $file->getClientOriginalName(); + if (!str_ends_with(strtolower($filename), '.bls')) { + Notification::make() + ->title(trans('blockland-config::blockland-config.save_loader.upload_failed')) + ->body(trans('blockland-config::blockland-config.save_loader.upload_invalid_type')) + ->danger() + ->send(); + + return; + } + + try { + $content = file_get_contents($file->getRealPath()); + + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + BlocklandConfig::uploadSaveFile($server, $fileRepository, $content, $filename); + + $base = substr($filename, 0, -4); + $previewFile = $this->uploadedPreviewFile; + if ($previewFile instanceof TemporaryUploadedFile && $previewFile->isValid()) { + $previewName = $previewFile->getClientOriginalName(); + $previewLower = strtolower($previewName); + if (preg_match('/\.(jpg|jpeg|png)$/', $previewLower)) { + $expectedBase = $base; + $actualBase = preg_replace('/\.(jpg|jpeg|png)$/i', '', $previewName); + if (strcasecmp($actualBase, $expectedBase) === 0) { + try { + $previewContent = file_get_contents($previewFile->getRealPath()); + BlocklandConfig::uploadPreviewFile($server, $fileRepository, $previewContent, $previewName); + } catch (Exception $e) { + report($e); + } + } + } + } + + $this->uploadedFile = null; + $this->uploadedPreviewFile = null; + $this->loadSaves(); + + Notification::make() + ->title(trans('blockland-config::blockland-config.save_loader.upload_success')) + ->success() + ->send(); + } catch (Exception $e) { + report($e); + + Notification::make() + ->title(trans('blockland-config::blockland-config.save_loader.upload_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function removeUploadedFile(): void + { + $this->uploadedFile = null; + } + + public function removeUploadedPreviewFile(): void + { + $this->uploadedPreviewFile = null; + } +} diff --git a/src/Http/Controllers/Api/Client/Servers/SavePreviewController.php b/src/Http/Controllers/Api/Client/Servers/SavePreviewController.php new file mode 100644 index 0000000..b4116fc --- /dev/null +++ b/src/Http/Controllers/Api/Client/Servers/SavePreviewController.php @@ -0,0 +1,61 @@ +can(SubuserPermission::FileReadContent, $server), 403); + + $file = $request->query('file'); + if (!is_string($file) || $file === '') { + throw new NotFoundHttpException(); + } + + $path = rawurldecode($file); + if (!str_starts_with($path, BlocklandConfigService::SAVES_DIR . '/')) { + throw new NotFoundHttpException(); + } + $lower = strtolower($path); + if (!preg_match('/\.(jpg|jpeg|png)$/', $lower)) { + throw new NotFoundHttpException(); + } + + try { + $content = $this->fileRepository->setServer($server)->getContent( + $path, + BlocklandConfigService::MAX_PREVIEW_SIZE_BYTES + ); + } catch (\Exception) { + throw new NotFoundHttpException(); + } + + $mime = match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + default => 'application/octet-stream', + }; + + return response($content, 200, [ + 'Content-Type' => $mime, + 'Cache-Control' => 'private, max-age=300', + ]); + } +} diff --git a/src/Providers/BlocklandConfigProvider.php b/src/Providers/BlocklandConfigProvider.php new file mode 100644 index 0000000..b016495 --- /dev/null +++ b/src/Providers/BlocklandConfigProvider.php @@ -0,0 +1,21 @@ +mergeConfigFrom(__DIR__ . '/../../config/blockland-config.php', 'blockland-config'); + $this->app->singleton(BlocklandConfigService::class); + } + + public function boot(): void + { + $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'blockland-config'); + $this->loadTranslationsFrom(__DIR__ . '/../../lang', 'blockland-config'); + } +} diff --git a/src/Providers/BlocklandConfigRoutesProvider.php b/src/Providers/BlocklandConfigRoutesProvider.php new file mode 100644 index 0000000..5c0d335 --- /dev/null +++ b/src/Providers/BlocklandConfigRoutesProvider.php @@ -0,0 +1,19 @@ +routes(function () { + Route::middleware(['api', 'client-api', 'throttle:api.client']) + ->prefix('/api/client') + ->scopeBindings() + ->group(plugin_path('blockland-config', 'routes/api-client.php')); + }); + } +} diff --git a/src/Services/BlocklandConfigService.php b/src/Services/BlocklandConfigService.php new file mode 100644 index 0000000..8f785d1 --- /dev/null +++ b/src/Services/BlocklandConfigService.php @@ -0,0 +1,1472 @@ + "Bot", "GameMode_Rampage" => "GameMode" + */ + public static function extractAddonCategory(string $name): string + { + $pos = strpos($name, '_'); + if ($pos === false) { + return $name; + } + + return substr($name, 0, $pos); + } + + /** Pref keys excluded from the Preferences form (managed by Admins page). */ + public const PREF_ADMIN_KEYS = ['AutoAdminList', 'AutoSuperAdminList']; + + /** Validation rules: shortKey => ['type' => 'string'|'number', 'min' => n, 'max' => n, 'maxLength' => n] */ + public const PREF_VALIDATION = [ + 'Name' => ['type' => 'string', 'maxLength' => 64], + 'WelcomeMessage' => ['type' => 'string', 'maxLength' => 512], + 'Info' => ['type' => 'string', 'maxLength' => 512], + 'MaxPlayers' => ['type' => 'number', 'min' => 1, 'max' => 99], + 'Password' => ['type' => 'string', 'maxLength' => 512], + 'AdminPassword' => ['type' => 'string', 'maxLength' => 512], + 'SuperAdminPassword' => ['type' => 'string', 'maxLength' => 512], + 'MaxBricksPerSecond' => ['type' => 'number', 'min' => 1, 'max' => 9999], + 'TooFarDistance' => ['type' => 'number', 'min' => 1, 'max' => 999999], + 'BrickPublicDomainTimeout' => ['type' => 'number', 'min' => -1, 'max' => 99999], + 'Port' => ['type' => 'number', 'min' => 1, 'max' => 65535], + ]; + + /** Pref keys that require server restart to take effect. */ + public const PREF_REQUIRES_RESTART = ['Port']; + + public const PREFS_BACKUP_PATH = 'config/server/prefs.cs.backup'; + + public const ONLINE_PLAYERS_FILE = 'config/server/.blpanel_players.txt'; + + public const BANLIST_PATH = 'config/server/BANLIST.txt'; + + public const PANEL_CONFIG_PATH = 'config/server/.blockland-panel'; + + + /** Core pref keys (no namespace) grouped by category. */ + public const PREF_GROUPS = [ + 'General' => [ + 'Name', 'Info', 'Port', 'Password', 'SuperAdminPassword', 'AdminPassword', + 'MaxPlayers', 'WelcomeMessage', 'BanTime', 'KickBanTime', 'AllowMultiClient', + 'AllowColorLoading', 'AutoAdminList', 'AutoSuperAdminList', 'ETardFilter', + 'ETardList', 'ConnectionError', 'UseRTB', 'ResetOnEmpty', 'RandomBrickColor', + 'WrenchEventsAdminOnly', 'ClearEventsOnClientExit', 'TooFarDistance', + 'FallingDamage', + ], + 'Bricks' => [ + 'BrickLimit', 'BrickRespawnTime', 'BrickPublicDomainTimeout', + 'MaxBricksPerSecond', 'GhostLimit', + ], + ]; + + /** Map pref namespace (from key) to display name. Unknown namespaces use the raw value. */ + public const PREF_NAMESPACE_DISPLAY = [ + 'ND' => 'NewDuplicator', + 'Quota' => 'Quotas', + 'QuotaLAN' => 'LAN Quotas', + ]; + + /** Section display order: General, Bricks, Network, then add-on sections alphabetically, then Other. */ + public const PREF_GROUP_ORDER = ['General', 'Bricks', 'Network']; + + /** Egg feature keys for per-page enable/disable (add to egg features per server). */ + public const FEATURE_ALL = 'blockland-all'; + public const FEATURE_ADDONS = 'blockland-addons'; + public const FEATURE_COLORSET = 'blockland-colorset'; + public const FEATURE_PREFS = 'blockland-prefs'; + public const FEATURE_ADMINS = 'blockland-admins'; + public const FEATURE_ADDON_BROWSER = 'blockland-addon-browser'; + public const FEATURE_SAVE_LOADER = 'blockland-save-loader'; + + public const SAVES_DIR = 'saves'; + public const TEMP_BLS_PATH = 'base/server/temp/temp.bls'; + public const MAX_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024; + public const MAX_PREVIEW_SIZE_BYTES = 2 * 1024 * 1024; + + // ─── Server Detection ──────────────────────────────────────────────── + + public function isBlocklandServer(Server $server): bool + { + $features = $server->egg->features ?? []; + $tags = $server->egg->tags ?? []; + + return in_array('blockland', $features) + || in_array('blockland', $tags); + } + + /** + * Check if the server's egg has a given Blockland feature. + * Add features to the egg (e.g. blockland-addons) to enable pages per server. + * Add blockland-all to enable all Blockland pages at once. + */ + public function hasBlocklandFeature(Server $server, string $feature): bool + { + $server->loadMissing('egg'); + $features = $server->egg->features ?? []; + + return in_array(self::FEATURE_ALL, $features) || in_array($feature, $features); + } + + // ─── Console Commands ───────────────────────────────────────────────── + + /** + * Send commands to the server console via the Wings daemon API. + * + * @param array $commands + * + * @throws Exception if the server is not running + */ + public function sendConsoleCommands(Server $server, DaemonFileRepository $fileRepository, array $commands): void + { + if (empty($commands)) { + return; + } + + $server->refresh(); + if ($server->status === 'suspended' || $server->status === 'installing') { + throw new Exception('Cannot send console commands: server is ' . $server->status . '.'); + } + + $serverRepo = app(\App\Repositories\Daemon\DaemonServerRepository::class); + $serverRepo->setServer($server); + + foreach ($commands as $command) { + try { + $serverRepo->getHttpClient() + ->post("/api/servers/{$server->uuid}/commands", [ + 'commands' => [$command], + ]); + } catch (\Illuminate\Http\Client\RequestException $e) { + if ($e->getCode() === 502) { + throw new Exception('Server is not running. Preferences were saved but could not be applied live.'); + } + + throw $e; + } + } + } + + // ─── Save Loader ───────────────────────────────────────────────────── + + /** Preview image extensions (same basename as .bls). */ + private const PREVIEW_EXTENSIONS = ['.jpg', '.jpeg', '.png']; + + /** + * List .bls save files in the saves folder. + * Includes preview_path when a matching image exists (same basename, .jpg/.jpeg/.png). + * + * @return array + */ + public function listSaveFiles(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $listing = $fileRepository->setServer($server)->getDirectory(self::SAVES_DIR); + } catch (Exception $e) { + report($e); + + return []; + } + + $filesInDir = []; + foreach ($listing as $entry) { + $name = $entry['name'] ?? ''; + $isFile = $entry['file'] ?? $entry['is_file'] ?? true; + if ($isFile) { + $filesInDir[strtolower($name)] = self::SAVES_DIR . '/' . $name; + } + } + + $saves = []; + foreach ($listing as $entry) { + $name = $entry['name'] ?? ''; + $isFile = $entry['file'] ?? $entry['is_file'] ?? true; + if (!str_ends_with(strtolower($name), '.bls') || !$isFile) { + continue; + } + + $base = substr($name, 0, -4); + $previewPath = null; + foreach (self::PREVIEW_EXTENSIONS as $ext) { + $candidate = $base . $ext; + if (isset($filesInDir[strtolower($candidate)])) { + $previewPath = $filesInDir[strtolower($candidate)]; + break; + } + } + + $save = [ + 'name' => $name, + 'path' => self::SAVES_DIR . '/' . $name, + ]; + if ($previewPath !== null) { + $save['preview_path'] = $previewPath; + } + $saves[] = $save; + } + + return $saves; + } + + /** + * Copy a save file to base/server/temp/temp.bls. The player runs ServerCmdReloadBricks themselves. + */ + public function copySaveToTemp(Server $server, DaemonFileRepository $fileRepository, string $path): void + { + $repo = $fileRepository->setServer($server); + + try { + $repo->createDirectory('temp', 'base/server'); + } catch (FileExistsException) { + } + + $content = $repo->getContent($path); + $repo->putContent(self::TEMP_BLS_PATH, $content); + } + + /** + * Upload a save file to the saves folder. + */ + public function uploadSaveFile(Server $server, DaemonFileRepository $fileRepository, string $content, string $filename): void + { + $filename = basename($filename); + if (!str_ends_with(strtolower($filename), '.bls')) { + throw new Exception('Filename must end with .bls'); + } + if (str_contains($filename, '..') || preg_match('#[/\\\\]#', $filename)) { + throw new Exception('Invalid filename'); + } + if (strlen($content) > self::MAX_UPLOAD_SIZE_BYTES) { + throw new Exception('File too large (max ' . (self::MAX_UPLOAD_SIZE_BYTES / 1024 / 1024) . 'MB)'); + } + + $repo = $fileRepository->setServer($server); + $repo->putContent(self::SAVES_DIR . '/' . $filename, $content); + } + + /** + * Upload a preview image to the saves folder. + * Filename must match the save basename with .jpg, .jpeg, or .png. + */ + public function uploadPreviewFile(Server $server, DaemonFileRepository $fileRepository, string $content, string $filename): void + { + $filename = basename($filename); + $lower = strtolower($filename); + $validExt = preg_match('/\.(jpg|jpeg|png)$/', $lower); + if (!$validExt) { + throw new Exception('Preview must be .jpg, .jpeg, or .png'); + } + if (str_contains($filename, '..') || preg_match('#[/\\\\]#', $filename)) { + throw new Exception('Invalid filename'); + } + if (strlen($content) > self::MAX_PREVIEW_SIZE_BYTES) { + throw new Exception('Preview too large (max ' . (self::MAX_PREVIEW_SIZE_BYTES / 1024 / 1024) . 'MB)'); + } + + $fileRepository->setServer($server)->putContent(self::SAVES_DIR . '/' . $filename, $content); + } + + // ─── ADD_ON_LIST.cs ────────────────────────────────────────────────── + + /** + * @return array name => 1 (enabled) or -1 (disabled) + */ + public function getAddOns(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $content = $fileRepository->setServer($server)->getContent(self::ADDON_LIST_PATH); + + return $this->parseAddOnList($content); + } catch (Exception $e) { + report($e); + + return []; + } + } + + /** + * @param array $addOns name => 1 or -1 + */ + public function saveAddOns(Server $server, DaemonFileRepository $fileRepository, array $addOns): void + { + $content = $this->serializeAddOnList($addOns); + $fileRepository->setServer($server)->putContent(self::ADDON_LIST_PATH, $content); + } + + /** + * @return array + */ + public function parseAddOnList(string $content): array + { + $addOns = []; + + foreach (explode("\n", $content) as $line) { + $line = $this->stripInlineComment(trim($line)); + if (preg_match('/^\$AddOn__(\S+)\s*=\s*(-?1);$/', $line, $m)) { + $addOns[$m[1]] = (int) $m[2]; + } + } + + return $addOns; + } + + /** + * @param array $addOns + */ + public function serializeAddOnList(array $addOns): string + { + $lines = []; + foreach ($addOns as $name => $value) { + $lines[] = '$AddOn__' . $name . ' = ' . $value . ';'; + } + + return implode("\n", $lines) . "\n"; + } + + /** + * List the names of all add-ons actually installed in the Add-Ons directory. + * Matches folder names and .zip filenames (without extension). + * + * @return array + */ + public function listInstalledAddOnNames(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $listing = $fileRepository->setServer($server)->getDirectory(self::ADDONS_DIR); + } catch (Exception $e) { + report($e); + + return []; + } + + $names = []; + + foreach ($listing as $entry) { + $entryName = $entry['name'] ?? ''; + $isFile = $entry['file'] ?? ($entry['is_file'] ?? false); + + if (!$isFile) { + $names[] = $entryName; + } elseif (preg_match('/^(.+)\.zip$/i', $entryName, $m)) { + $names[] = $m[1]; + } + } + + return $names; + } + + /** + * Remove entries from the add-on list that no longer exist on the filesystem. + * + * @param array $addOns The parsed add-on list + * @param array $installedNames Names from listInstalledAddOnNames() + * @return array{cleaned: array, removed: array} + */ + public function cleanupOrphanedAddOns(array $addOns, array $installedNames): array + { + $installed = array_flip($installedNames); + $cleaned = []; + $removed = []; + + foreach ($addOns as $name => $value) { + if (isset($installed[$name])) { + $cleaned[$name] = $value; + } else { + $removed[$name] = $value; + } + } + + return ['cleaned' => $cleaned, 'removed' => $removed]; + } + + /** + * Group add-ons by their category prefix. + * + * @param array $addOns + * @return array> category => [name => value] + */ + public function groupAddOnsByCategory(array $addOns): array + { + $grouped = []; + + foreach ($addOns as $name => $value) { + $category = self::extractAddonCategory($name); + $grouped[$category][$name] = $value; + } + + return $grouped; + } + + /** + * Strip TorqueScript inline comments (// ...) from a line, + * but not from within quoted strings. + */ + protected function stripInlineComment(string $line): string + { + $pos = strpos($line, '//'); + if ($pos === false) { + return $line; + } + + $inQuote = false; + for ($i = 0; $i < strlen($line) - 1; $i++) { + if ($line[$i] === '"') { + $inQuote = !$inQuote; + } + if (!$inQuote && $line[$i] === '/' && $line[$i + 1] === '/') { + return trim(substr($line, 0, $i)); + } + } + + return $line; + } + + // ─── colorSet.txt ──────────────────────────────────────────────────── + + /** + * Parse a colorSet.txt into structured sections. + * + * @return array{sections: array}>, totalColors: int} + */ + public function parseColorSet(string $content): array + { + $sections = []; + $currentColors = []; + $totalColors = 0; + + foreach (explode("\n", $content) as $line) { + $trimmed = trim($line); + + if ($trimmed === '' || str_starts_with($trimmed, '//')) { + continue; + } + + if (preg_match('/^DIV:\s*(.*)$/', $trimmed, $m)) { + $sections[] = [ + 'label' => trim($m[1]), + 'colors' => $currentColors, + ]; + $currentColors = []; + + continue; + } + + $color = $this->parseColorLine($trimmed); + if ($color !== null) { + $currentColors[] = $color; + $totalColors++; + } + } + + if (!empty($currentColors)) { + $sections[] = [ + 'label' => 'Unlabeled', + 'colors' => $currentColors, + ]; + } + + return [ + 'sections' => $sections, + 'totalColors' => $totalColors, + ]; + } + + /** + * @return array{r: int, g: int, b: int, a: int, raw: string}|null + */ + protected function parseColorLine(string $line): ?array + { + $parts = preg_split('/\s+/', $this->stripInlineComment($line)); + if ($parts === false || count($parts) !== 4) { + return null; + } + + $hasDot = str_contains($parts[0], '.') || str_contains($parts[1], '.') || str_contains($parts[2], '.') || str_contains($parts[3], '.'); + $isFloat = $hasDot && max(array_map('floatval', $parts)) <= 1.0; + + if ($isFloat) { + $r = (int) round((float) $parts[0] * 255); + $g = (int) round((float) $parts[1] * 255); + $b = (int) round((float) $parts[2] * 255); + $a = (int) round((float) $parts[3] * 255); + } else { + $r = (int) $parts[0]; + $g = (int) $parts[1]; + $b = (int) $parts[2]; + $a = (int) $parts[3]; + } + + return [ + 'r' => max(0, min(255, $r)), + 'g' => max(0, min(255, $g)), + 'b' => max(0, min(255, $b)), + 'a' => max(0, min(255, $a)), + 'raw' => $line, + ]; + } + + /** + * Serialize sections back to colorSet.txt format. + * Uses integer RGBA (0-255) for all colors. + * + * @param array}> $sections + */ + public function serializeColorSet(array $sections): string + { + $lines = []; + foreach ($sections as $section) { + foreach ($section['colors'] as $color) { + $lines[] = $color['r'] . ' ' . $color['g'] . ' ' . $color['b'] . ' ' . $color['a']; + } + $lines[] = 'DIV: ' . $section['label']; + $lines[] = ''; + } + + return implode("\n", $lines); + } + + // ─── prefs.cs ──────────────────────────────────────────────────────── + + /** + * @return array + */ + public function parsePrefs(string $content): array + { + $prefs = []; + + foreach (explode("\n", $content) as $line) { + $trimmed = $this->stripInlineComment(trim($line)); + if ($trimmed === '') { + continue; + } + + if (preg_match('/^\$((?:[Pp]ref)::(?:Server|Net)::\S+)\s*=\s*(.+);$/', $trimmed, $m)) { + $rawKey = $m[1]; + $rawValue = trim($m[2]); + $quoted = false; + $value = $rawValue; + + if (preg_match('/^"(.*)"$/', $rawValue, $vm)) { + $quoted = true; + $value = $vm[1]; + } + + $prefs[] = [ + 'key' => $rawKey, + 'rawKey' => '$' . $rawKey, + 'value' => $value, + 'quoted' => $quoted, + 'line' => $trimmed, + ]; + } + } + + return $prefs; + } + + /** + * @param array $prefs + */ + public function serializePrefs(array $prefs): string + { + $lines = []; + foreach ($prefs as $pref) { + $value = $pref['value']; + if ($pref['quoted']) { + $value = str_replace('"', '', $value); + $val = '"' . $value . '"'; + } else { + $val = $value; + } + $lines[] = $pref['rawKey'] . ' = ' . $val . ';'; + } + + return implode("\n", $lines) . "\n"; + } + + public function getPrefs(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $content = $fileRepository->setServer($server)->getContent(self::PREFS_PATH); + + return $this->parsePrefs($content); + } catch (Exception $e) { + report($e); + + return []; + } + } + + public function savePrefs(Server $server, DaemonFileRepository $fileRepository, array $prefs): void + { + $this->backupPrefs($server, $fileRepository); + $content = $this->serializePrefs($prefs); + $fileRepository->setServer($server)->putContent(self::PREFS_PATH, $content); + } + + public function backupPrefs(Server $server, DaemonFileRepository $fileRepository): void + { + try { + $content = $fileRepository->setServer($server)->getContent(self::PREFS_PATH); + $fileRepository->setServer($server)->putContent(self::PREFS_BACKUP_PATH, $content); + } catch (Exception $e) { + report($e); + } + } + + public function validatePrefValue(string $key, string $value): ?string + { + $shortKey = preg_replace('/^.*::/', '', $key); + $rules = self::PREF_VALIDATION[$shortKey] ?? null; + if (!$rules) { + return null; + } + if ($rules['type'] === 'string' && isset($rules['maxLength'])) { + if (strlen($value) > $rules['maxLength']) { + return trans('blockland-config::blockland-config.validation.max_length', [ + 'max' => $rules['maxLength'], + ]); + } + } + if ($rules['type'] === 'number' && (isset($rules['min']) || isset($rules['max']))) { + if (!is_numeric($value)) { + return trans('blockland-config::blockland-config.validation.must_be_numeric'); + } + $num = (float) $value; + if (isset($rules['min']) && $num < $rules['min']) { + return trans('blockland-config::blockland-config.validation.min_value', ['min' => $rules['min']]); + } + if (isset($rules['max']) && $num > $rules['max']) { + return trans('blockland-config::blockland-config.validation.max_value', ['max' => $rules['max']]); + } + } + return null; + } + + public function requiresRestart(string $key): bool + { + $shortKey = preg_replace('/^.*::/', '', $key); + + return in_array($shortKey, self::PREF_REQUIRES_RESTART, true); + } + + /** Prefs that sync to $Server:: for live effect (RTB and vanilla). */ + private const PREF_TO_SERVER_SYNC = [ + 'Name' => '$Server::Name', + 'WelcomeMessage' => '$Server::WelcomeMessage', + 'MaxBricksPerSecond' => '$Server::MaxBricksPerSecond', + 'WrenchEventsAdminOnly' => '$Server::WrenchEventsAdminOnly', + ]; + + /** + * Build console commands to set each pref variable directly (more reliable than exec). + * + * @param array $prefs + * @return array + */ + public function buildPrefConsoleCommands(array $prefs): array + { + $commands = []; + foreach ($prefs as $pref) { + $rawKey = $pref['rawKey']; + $value = $pref['value']; + $shortKey = preg_replace('/^.*::/', '', $pref['key'] ?? $rawKey); + if ($pref['quoted']) { + $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + $cmd = $rawKey . ' = "' . $escaped . '";'; + } else { + $cmd = $rawKey . ' = ' . $value . ';'; + } + $commands[] = $cmd; + if (isset(self::PREF_TO_SERVER_SYNC[$shortKey])) { + $serverVar = self::PREF_TO_SERVER_SYNC[$shortKey]; + $commands[] = $serverVar . ' = ' . $rawKey . ';'; + } + } + + return $commands; + } + + /** + * Group prefs by logical category. + * + * @param array $prefs + * @return array> + */ + /** + * Group prefs by add-on namespace (dynamic) or core category. + * Namespaced keys (e.g. Pref::Server::ND::MaxUndo) are grouped by the first namespace segment. + * + * @param array $prefs + * @return array> + */ + public function groupPrefs(array $prefs): array + { + $grouped = []; + + foreach ($prefs as $pref) { + $key = $pref['key']; + + if (preg_match('/^[Pp]ref::Net::/', $key)) { + $grouped['Network'][] = $pref; + continue; + } + + $serverKey = preg_replace('/^[Pp]ref::Server::/', '', $key); + + if (str_contains($serverKey, '::')) { + $namespace = substr($serverKey, 0, strpos($serverKey, '::')); + $group = self::PREF_NAMESPACE_DISPLAY[$namespace] ?? $namespace; + $grouped[$group][] = $pref; + continue; + } + + $placed = false; + foreach (self::PREF_GROUPS as $group => $keys) { + if (in_array($serverKey, $keys, true)) { + $grouped[$group][] = $pref; + $placed = true; + break; + } + } + + if (!$placed) { + $grouped['Other'][] = $pref; + } + } + + return $this->orderPrefGroups($grouped); + } + + /** + * Sort pref groups: General, Bricks, Network first, then add-on sections alphabetically, then Other. + * + * @param array> $grouped + * @return array> + */ + protected function orderPrefGroups(array $grouped): array + { + $ordered = []; + $addonGroups = []; + + foreach (self::PREF_GROUP_ORDER as $name) { + if (isset($grouped[$name])) { + $ordered[$name] = $grouped[$name]; + } + } + + foreach ($grouped as $name => $prefs) { + if ($name === 'Other' || in_array($name, self::PREF_GROUP_ORDER, true)) { + continue; + } + $addonGroups[$name] = $prefs; + } + + ksort($addonGroups, SORT_NATURAL | SORT_FLAG_CASE); + foreach ($addonGroups as $name => $prefs) { + $ordered[$name] = $prefs; + } + + if (! empty($grouped['Other'] ?? [])) { + $ordered['Other'] = $grouped['Other']; + } + + return $ordered; + } + + // ─── Admin Lists ───────────────────────────────────────────────────── + + /** + * @return array{superAdmins: array, admins: array} + */ + public function getAdminLists(Server $server, DaemonFileRepository $fileRepository): array + { + $prefs = $this->getPrefs($server, $fileRepository); + $superAdmins = []; + $admins = []; + + foreach ($prefs as $pref) { + if (preg_match('/AutoSuperAdminList$/i', $pref['key'])) { + $superAdmins = array_filter(preg_split('/\s+/', trim($pref['value'] ?? ''))); + $superAdmins = array_values($superAdmins); + } + if (preg_match('/AutoAdminList$/i', $pref['key'])) { + $admins = array_filter(preg_split('/\s+/', trim($pref['value'] ?? ''))); + $admins = array_values($admins); + } + } + + return ['superAdmins' => $superAdmins, 'admins' => $admins]; + } + + /** + * Build TorqueScript commands to apply Admin/Super Admin to online players. + * + * @param array $blIds + * @param int $level 1=Admin, 2=SuperAdmin + * @param array $fromLevelByBlId BL_ID => from_level (0=Player, 1=Admin, 2=SuperAdmin) + * @return array + */ + public function buildApplyAdminCommands(array $blIds, int $level, array $fromLevelByBlId, bool $manual = false, bool $silent = false): array + { + $suffix = $manual ? ' (Manual)' : ' (Auto)'; + $commands = []; + + $baseCmd = '$blpanel_c=findClientByBL_ID(%s);if(isObject($blpanel_c)){$blpanel_c.isAdmin=%d;$blpanel_c.isSuperAdmin=%d;$blpanel_c.sendPlayerListUpdate();commandToClient($blpanel_c,\'setAdminLevel\',%d)'; + $withMessage = $baseCmd . ';messageAll(\'MsgAdminForce\',"\\c2"@$blpanel_c.netname@"%s");}'; + $withoutMessage = $baseCmd . ';}'; + + foreach ($blIds as $blId) { + $fromLevel = $fromLevelByBlId[$blId] ?? 0; + + if ($level === 1) { + $msg = " has become Admin{$suffix}"; + } elseif ($level === 2 && $fromLevel === 1) { + $msg = " has been promoted to Super Admin{$suffix}"; + } else { + $msg = " has become Super Admin{$suffix}"; + } + + $cmd = $silent + ? sprintf($withoutMessage, $blId, $level >= 1 ? 1 : 0, $level >= 2 ? 1 : 0, $level) + : sprintf($withMessage, $blId, $level >= 1 ? 1 : 0, $level >= 2 ? 1 : 0, $level, $msg); + + $commands[] = $cmd; + } + + return $commands; + } + + /** + * Build TorqueScript commands to demote online players (remove Admin/Super Admin). + * + * @param array $blIds + * @param array $fromLevelByBlId BL_ID => level (1=Admin, 2=Super Admin) for demotion message + * @return array + */ + public function buildDemoteAdminCommands(array $blIds, array $fromLevelByBlId = [], bool $silent = false): array + { + $commands = []; + + $withMessage = '$blpanel_c=findClientByBL_ID(%s);if(isObject($blpanel_c)){$blpanel_c.isAdmin=0;$blpanel_c.isSuperAdmin=0;$blpanel_c.sendPlayerListUpdate();commandToClient($blpanel_c,\'setAdminLevel\',0);messageAll(\'MsgAdminForce\',"\\c2"@$blpanel_c.netname@"%s");}'; + $withoutMessage = '$blpanel_c=findClientByBL_ID(%s);if(isObject($blpanel_c)){$blpanel_c.isAdmin=0;$blpanel_c.isSuperAdmin=0;$blpanel_c.sendPlayerListUpdate();commandToClient($blpanel_c,\'setAdminLevel\',0);}'; + + foreach ($blIds as $blId) { + $fromLevel = $fromLevelByBlId[$blId] ?? 0; + $msg = $fromLevel === 2 + ? ' has been demoted from Super Admin' + : ' has been demoted from Admin'; + $cmd = $silent + ? sprintf($withoutMessage, $blId) + : sprintf($withMessage, $blId, $msg); + $commands[] = $cmd; + } + + return $commands; + } + + public function saveAdminLists(Server $server, DaemonFileRepository $fileRepository, array $superAdmins, array $admins): void + { + $prefs = $this->getPrefs($server, $fileRepository); + $superAdminKey = null; + $adminKey = null; + + foreach ($prefs as $i => $pref) { + if (preg_match('/AutoSuperAdminList$/i', $pref['key'])) { + $superAdminKey = $i; + } + if (preg_match('/AutoAdminList$/i', $pref['key'])) { + $adminKey = $i; + } + } + + $superAdminStr = implode(' ', array_map('trim', $superAdmins)); + $adminStr = implode(' ', array_map('trim', $admins)); + + foreach ($prefs as $i => $pref) { + if ($i === $superAdminKey) { + $prefs[$i]['value'] = $superAdminStr; + } + if ($i === $adminKey) { + $prefs[$i]['value'] = $adminStr; + } + } + + $this->savePrefs($server, $fileRepository, $prefs); + } + + /** Minimum seconds between online player fetches to avoid console spam. */ + public const ONLINE_PLAYERS_THROTTLE_SECONDS = 30; + + /** + * @return array + */ + public function getOnlinePlayers(Server $server, DaemonFileRepository $fileRepository, bool $forceRefresh = false): array + { + $server->refresh(); + if ($server->status === 'suspended' || $server->status === 'installing') { + return []; + } + + $cacheKey = 'blconfig:online_players:' . $server->uuid; + if (! $forceRefresh) { + $cached = Cache::get($cacheKey); + if (is_array($cached) && isset($cached['ts'], $cached['players']) && (time() - $cached['ts']) < self::ONLINE_PLAYERS_THROTTLE_SECONDS) { + return $cached['players']; + } + } + + $writeCmd = '$blpanel_f=new FileObject(); $blpanel_f.openForWrite("' . self::ONLINE_PLAYERS_FILE . '"); for($blpanel_i=0;$blpanel_isendConsoleCommands($server, $fileRepository, [$writeCmd]); + } catch (Exception $e) { + return []; + } + + usleep(800000); + + try { + $content = $fileRepository->setServer($server)->getContent(self::ONLINE_PLAYERS_FILE); + } catch (Exception $e) { + return []; + } + + $players = []; + foreach (explode("\n", $content) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = explode("\t", $line, 4); + $blId = trim($parts[0] ?? ''); + $name = trim($parts[1] ?? ''); + $isAdmin = isset($parts[2]) && (trim($parts[2]) === '1' || trim($parts[2]) === 'true'); + $isSuperAdmin = isset($parts[3]) && (trim($parts[3]) === '1' || trim($parts[3]) === 'true'); + if ($blId !== '') { + $players[] = [ + 'bl_id' => $blId, + 'name' => $name, + 'server_is_admin' => $isAdmin, + 'server_is_super_admin' => $isSuperAdmin, + ]; + } + } + + Cache::put($cacheKey, ['ts' => time(), 'players' => $players], self::ONLINE_PLAYERS_THROTTLE_SECONDS + 10); + + return $players; + } + + /** + * Get the ban list. Returns [] if file does not exist (no bans yet). + * + * @return array + */ + public function getBanList(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $content = $fileRepository->setServer($server)->getContent(self::BANLIST_PATH); + } catch (Exception) { + return []; + } + + $bans = []; + foreach (explode("\n", $content) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = explode("\t", $line); + $bans[] = [ + 'source' => trim($parts[0] ?? ''), + 'time' => trim($parts[1] ?? ''), + 'username' => trim($parts[2] ?? ''), + 'bl_id' => trim($parts[3] ?? ''), + 'reason' => trim($parts[5] ?? ''), + 'raw' => $line, + ]; + } + + return $bans; + } + + /** + * Remove a ban by BL_ID from BANLIST.txt. + */ + public function removeFromBanList(Server $server, DaemonFileRepository $fileRepository, string $blId): void + { + try { + $content = $fileRepository->setServer($server)->getContent(self::BANLIST_PATH); + } catch (Exception) { + return; + } + + $lines = array_filter(explode("\n", $content), function (string $line) use ($blId) { + $parts = explode("\t", trim($line)); + $lineBlId = trim($parts[3] ?? ''); + + return $lineBlId !== $blId; + }); + + $fileRepository->setServer($server)->putContent(self::BANLIST_PATH, implode("\n", $lines) . "\n"); + } + + /** Cache key prefix for silent mode (message announcements toggle). */ + private static function silentModeCacheKey(Server $server): string + { + return 'blockland:silent_mode:' . $server->id; + } + + /** + * Check if silent mode is enabled (no messageAll announcements). + * Uses cache so the choice is remembered across sessions. + */ + public function isSilentMode(Server $server, DaemonFileRepository $fileRepository): bool + { + $key = self::silentModeCacheKey($server); + + return Cache::remember($key, 86400 * 30, function () use ($server, $fileRepository) { + try { + $content = trim($fileRepository->setServer($server)->getContent(self::PANEL_CONFIG_PATH)); + + return str_contains($content, 'silent=1'); + } catch (Exception) { + return false; + } + }); + } + + /** + * Enable or disable silent mode (no messageAll announcements). + * Persists to file and updates cache so the choice is remembered. + */ + public function setSilentMode(Server $server, DaemonFileRepository $fileRepository, bool $silent): void + { + $content = $silent ? "silent=1\n" : "silent=0\n"; + $fileRepository->setServer($server)->putContent(self::PANEL_CONFIG_PATH, $content); + + Cache::put(self::silentModeCacheKey($server), $silent, 86400 * 30); + } + + // ─── Colorset Add-On Discovery ─────────────────────────────────────── + + /** + * List available colorset add-ons from the server's Add-Ons directory. + * + * @return array + */ + public function listColorsetAddOns(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $listing = $fileRepository->setServer($server)->getDirectory(self::ADDONS_DIR); + } catch (Exception $e) { + report($e); + + return []; + } + + $folders = []; + $zips = []; + + foreach ($listing as $entry) { + $entryName = $entry['name'] ?? ''; + $isFile = $entry['file'] ?? ($entry['is_file'] ?? false); + + if (!$isFile && preg_match('/^[Cc]olorset_(.+)$/', $entryName, $m)) { + $folders[$m[1]] = $entryName; + } elseif ($isFile && preg_match('/^[Cc]olorset_(.+)\.zip$/i', $entryName, $m)) { + $zips[$m[1]] = $entryName; + } + } + + $addOns = []; + + foreach ($folders as $name => $folderName) { + $addOns[] = [ + 'name' => $name, + 'path' => self::ADDONS_DIR . '/' . $folderName, + 'isZip' => false, + ]; + } + + foreach ($zips as $name => $zipName) { + if (!isset($folders[$name])) { + $addOns[] = [ + 'name' => $name, + 'path' => self::ADDONS_DIR . '/' . $zipName, + 'isZip' => true, + ]; + } + } + + usort($addOns, fn ($a, $b) => strcasecmp($a['name'], $b['name'])); + + return $addOns; + } + + /** + * Read a colorSet.txt from a colorset add-on (folder or zip). + */ + public function readColorsetAddOn(Server $server, DaemonFileRepository $fileRepository, string $path, bool $isZip): ?string + { + try { + if (!$isZip) { + return $this->readColorsetFromFolder($server, $fileRepository, $path); + } + + return $this->readColorsetFromZip($server, $fileRepository, $path); + } catch (Exception $e) { + report($e); + + return null; + } + } + + protected function readColorsetFromFolder(Server $server, DaemonFileRepository $fileRepository, string $folderPath): ?string + { + $repo = $fileRepository->setServer($server); + + $candidates = [ + $folderPath . '/colorSet.txt', + $folderPath . '/ColorSet.txt', + ]; + + foreach ($candidates as $candidate) { + try { + return $repo->getContent($candidate); + } catch (Exception) { + } + } + + try { + $subEntries = $repo->getDirectory($folderPath); + foreach ($subEntries as $entry) { + $name = $entry['name'] ?? ''; + $isFile = $entry['file'] ?? ($entry['is_file'] ?? false); + if (!$isFile && preg_match('/^[Cc]olorset_/i', $name)) { + $nestedCandidates = [ + $folderPath . '/' . $name . '/colorSet.txt', + $folderPath . '/' . $name . '/ColorSet.txt', + ]; + foreach ($nestedCandidates as $nested) { + try { + return $repo->getContent($nested); + } catch (Exception) { + } + } + } + } + } catch (Exception) { + } + + return null; + } + + protected function readColorsetFromZip(Server $server, DaemonFileRepository $fileRepository, string $zipPath): ?string + { + $repo = $fileRepository->setServer($server); + $zipContent = $repo->getContent($zipPath); + + $tmpFile = tempnam(sys_get_temp_dir(), 'blcs_'); + if ($tmpFile === false) { + return null; + } + file_put_contents($tmpFile, $zipContent); + + try { + $zip = new ZipArchive(); + if ($zip->open($tmpFile) !== true) { + return null; + } + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entryName = $zip->getNameIndex($i); + if (preg_match('/[Cc]olor[Ss]et\.txt$/', $entryName)) { + $content = $zip->getFromIndex($i); + $zip->close(); + + return $content !== false ? $content : null; + } + } + + $zip->close(); + + return null; + } finally { + @unlink($tmpFile); + } + } + + /** + * Activate a colorset add-on by copying its colorSet.txt to the active path. + * + * @return string 'ok', 'not_found', or 'too_many_colors' + */ + public function activateColorset(Server $server, DaemonFileRepository $fileRepository, string $path, bool $isZip): string + { + $content = $this->readColorsetAddOn($server, $fileRepository, $path, $isZip); + if ($content === null) { + return 'not_found'; + } + + $parsed = $this->parseColorSet($content); + if ($parsed['totalColors'] > self::MAX_COLORS) { + return 'too_many_colors'; + } + + $fileRepository->setServer($server)->putContent(self::COLORSET_PATH, $content); + + return 'ok'; + } + + /** + * Get the active colorset from the server. + */ + public function getActiveColorSet(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $content = $fileRepository->setServer($server)->getContent(self::COLORSET_PATH); + + return $this->parseColorSet($content); + } catch (Exception $e) { + report($e); + + return ['sections' => [], 'totalColors' => 0]; + } + } + + /** + * Save the active colorset to the server. + * + * @param array}> $sections + */ + public function saveActiveColorSet(Server $server, DaemonFileRepository $fileRepository, array $sections): bool + { + $totalColors = 0; + foreach ($sections as $section) { + $totalColors += count($section['colors']); + } + + if ($totalColors > self::MAX_COLORS) { + return false; + } + + $content = $this->serializeColorSet($sections); + $fileRepository->setServer($server)->putContent(self::COLORSET_PATH, $content); + + return true; + } + + // ─── Retail Add-On Archive (bl.kenko.dev) ───────────────────────────── + + /** + * Fetch the list of categories from the bl.kenko.dev retail archive. + * + * @return array + */ + public function fetchRetailCategories(): array + { + $cached = Cache::get('blconfig:retail:categories'); + if ($cached !== null) { + return $cached; + } + + try { + $response = Http::timeout(10)->get(self::RETAIL_BASE_URL); + } catch (Exception $e) { + report($e); + + return []; + } + + if (!$response->successful()) { + return []; + } + + $html = $response->body(); + $categories = []; + + preg_match_all('#]+href="([^"]*)"[^>]*>\s*([^<]+?)\s*#i', $html, $matches, PREG_SET_ORDER); + + $skipNames = ['parent directory', 'add-ons', '..', '.']; + + foreach ($matches as $m) { + $href = $m[1]; + $linkText = trim($m[2]); + + if (!str_ends_with($href, '/')) { + continue; + } + + if (in_array(strtolower($linkText), $skipNames)) { + continue; + } + $hrefTrimmed = rtrim($href, '/'); + $dirName = basename(urldecode($hrefTrimmed)); + + if ($dirName === '' || $dirName === '.' || $dirName === '..') { + continue; + } + + $categories[] = [ + 'name' => $dirName, + 'url' => self::RETAIL_BASE_URL . rawurlencode($dirName) . '/', + ]; + } + + $seen = []; + $categories = array_filter($categories, function ($cat) use (&$seen) { + $key = strtolower($cat['name']); + if (isset($seen[$key])) { + return false; + } + $seen[$key] = true; + + return true; + }); + + usort($categories, fn ($a, $b) => strcasecmp($a['name'], $b['name'])); + $categories = array_values($categories); + + if (!empty($categories)) { + Cache::put('blconfig:retail:categories', $categories, self::RETAIL_CACHE_TTL); + } + + return $categories; + } + + /** + * Fetch the list of add-on zips in a category from bl.kenko.dev. + * + * @return array + */ + public function fetchRetailAddOns(string $category): array + { + $cacheKey = 'blconfig:retail:addons:' . md5($category); + + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + $categoryUrl = self::RETAIL_BASE_URL . rawurlencode($category) . '/'; + + try { + $response = Http::timeout(15)->get($categoryUrl); + } catch (Exception $e) { + report($e); + + return []; + } + + if (!$response->successful()) { + return []; + } + + $html = $response->body(); + $addOns = []; + + preg_match_all('#]+href="([^"]*\.zip(?:%[0-9a-f]{2})*)"[^>]*>\s*([^<]+?)\s*#i', $html, $matches, PREG_SET_ORDER); + + foreach ($matches as $m) { + $href = $m[1]; + $linkText = urldecode(trim($m[2])); + $filename = preg_replace('/\.zip$/i', '', $linkText) . '.zip'; + if (!preg_match('/\.zip$/i', $linkText)) { + $filename = $linkText . '.zip'; + } else { + $filename = $linkText; + } + $name = preg_replace('/\.zip$/i', '', $filename); + + $downloadUrl = $this->resolveRetailUrl($href, $category); + + $addOns[] = [ + 'filename' => $filename, + 'name' => $name, + 'size' => '', + 'url' => $downloadUrl, + ]; + } + + preg_match_all('#]+href="[^"]*\.zip[^"]*"[^>]*>[^<]+.*?]*>\s*([0-9][^<]*?)\s*#is', $html, $sizeMatches, PREG_SET_ORDER); + foreach ($sizeMatches as $i => $sm) { + if (isset($addOns[$i])) { + $addOns[$i]['size'] = trim($sm[1]); + } + } + + $seen = []; + $addOns = array_filter($addOns, function ($a) use (&$seen) { + $key = strtolower($a['filename']); + if (isset($seen[$key])) { + return false; + } + $seen[$key] = true; + + return true; + }); + + usort($addOns, fn ($a, $b) => strcasecmp($a['name'], $b['name'])); + $addOns = array_values($addOns); + + if (!empty($addOns)) { + Cache::put($cacheKey, $addOns, self::RETAIL_CACHE_TTL); + } + + return $addOns; + } + + protected function resolveRetailUrl(string $href, string $category): string + { + $decoded = urldecode($href); + + if (str_starts_with($decoded, 'http://') || str_starts_with($decoded, 'https://')) { + return $decoded; + } + + if (str_starts_with($href, '/')) { + return 'https://bl.kenko.dev' . $href; + } + + return self::RETAIL_BASE_URL . rawurlencode($category) . '/' . $href; + } + + /** + * Install a retail add-on by having the Wings daemon pull the zip directly. + */ + public function installRetailAddOn(Server $server, DaemonFileRepository $fileRepository, string $url, ?string $filename = null): void + { + $params = []; + if ($filename) { + $params['filename'] = $filename; + } + + $fileRepository->setServer($server)->pull($url, self::ADDONS_DIR, $params); + } +}