diff --git a/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md b/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md index ec596aa4..d6a616f1 100644 --- a/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md +++ b/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md @@ -34,21 +34,68 @@ User clicks button --- -## 2. Existing Database Fields (`OGP_DB_PREFIXaddons`) +## 2. Database Schema (db_version 4 — Phase 2) -| Column | Type | Description | -|---------------|-----------------|--------------------------------------------------| -| addon_id | INT UNSIGNED PK | Auto-increment primary key | -| name | VARCHAR(80) | Display name shown to users | -| url | VARCHAR(200) | Download URL (zip / tar.gz) | -| path | VARCHAR(80) | Relative target path inside server home | -| addon_type | VARCHAR(32)* | Content category key (plugin / mappack / config / …) | -| home_cfg_id | VARCHAR(7) | Linked game configuration ID | -| post_script | LONGTEXT | Bash script run by agent after install | -| group_id | INT(11) NULL | Restrict visibility to a specific user group | +### `OGP_DB_PREFIXaddons` (content item catalogue) -\* Expanded from VARCHAR(7) to VARCHAR(32) in db_version 2 - (migration runs automatically via the module update system). +| Column | Type | Default | Description | +|-----------------------|-----------------|---------------|-------------| +| addon_id | INT UNSIGNED PK | auto | Primary key | +| name | VARCHAR(80) | | Display name | +| url | VARCHAR(200) | | Download URL (zip / tar.gz / API endpoint) | +| path | VARCHAR(80) | | Relative target path inside server home | +| addon_type | VARCHAR(32) | | Category key (plugin / mappack / config / …) | +| home_cfg_id | VARCHAR(7) | | Linked game config ID | +| post_script | LONGTEXT | | Bash script run by agent after install | +| group_id | INT(11) NULL | | Restrict to a specific user group | +| install_method ¹ | VARCHAR(32) | download_zip | How the content is delivered | +| content_version ¹ | VARCHAR(64) NULL| | Version tag shown in manifest | +| requires_stop ¹ | TINYINT(1) | 1 | Must server be stopped before install? | +| backup_before_install¹| TINYINT(1) | 1 | Back up target path before installing? | +| restart_after_install¹| TINYINT(1) | 0 | Restart server after successful install? | +| is_cacheable ¹ | TINYINT(1) | 0 | Safe for cross-server copy / shared cache? | +| description ¹ | TEXT NULL | | Short description shown to users | + +¹ Added in db_version 4 (Phase 2). + +### `OGP_DB_PREFIXserver_content_manifest` (Phase 2 — per-server installed state) + +One row per (home_id, addon_id) pair. Updated on every successful install. + +| Column | Type | Description | +|-----------------|------------------|-------------| +| id | INT UNSIGNED PK | Auto primary key | +| home_id | INT | Server home | +| addon_id | INT | Installed content item | +| install_method | VARCHAR(32) | Method used for the install | +| content_version | VARCHAR(64) NULL | Version tag at time of install | +| install_state | VARCHAR(32) | installed / failed / removed | +| checksum_sha256 | VARCHAR(64) NULL | SHA-256 of the installed archive | +| source_url | VARCHAR(255) NULL| URL used for the install | +| installed_at | DATETIME | Timestamp of last successful install | +| installed_by | INT NULL | User ID who performed the install | +| updated_at | DATETIME NULL | Last update time | +| notes | TEXT NULL | Free-text admin notes | + +### `OGP_DB_PREFIXserver_content_install_history` (Phase 2 — install log) + +One row per install attempt. Never deleted (audit trail). + +| Column | Type | Description | +|-----------------|-------------------|-------------| +| id | INT UNSIGNED PK | Auto primary key | +| home_id | INT | Server home | +| addon_id | INT | Content item installed | +| installed_by | INT NULL | User who triggered the install | +| started_at | DATETIME | When the install was initiated | +| completed_at | DATETIME NULL | When the install completed | +| install_state | VARCHAR(32) | started / installed / failed / cancelled | +| install_method | VARCHAR(32) NULL | Method used | +| content_version | VARCHAR(64) NULL | Version tag | +| source_url | VARCHAR(255) NULL | Source URL used | +| cache_mode_used | VARCHAR(32) NULL | Value of server_content_cache_mode at install time | +| result_code | INT NULL | Script exit code (0 = success) | +| log_output | MEDIUMTEXT NULL | Script / download log excerpt | --- @@ -161,21 +208,38 @@ Apply this as `$install_queries[2]` (db_version 3) in `module.php` when ready. ## 9. Recommended Phased Migration Plan -### Phase 1 (complete — this PR) +### Phase 1 (complete) - [x] UI labels renamed to "Server Content Manager / Server Content". - [x] Central category map created (`server_content_categories.php`). - [x] `addon_type` column expanded to VARCHAR(32) via db_version 2 migration. - [x] `addons_installer.php` and `user_addons.php` use category map for validation. - [x] Full TODO/comment blocks added to installer for next phase work. - [x] Module folder, table names, URL routes, function names unchanged. +- [x] Workshop Content Phase 1: manual Workshop ID entry, per-server manifest, + agent script runner, install/update/remove actions (`db_version 3`). ### Phase 2 — Schema & install_method support -- [ ] Apply the `$install_queries[2]` schema above (db_version 3). -- [ ] Add `install_method` dropdown to admin create/edit form. -- [ ] Implement `requires_stop` check in installer before download. -- [ ] Implement `backup_before_install` using agent tar/zip helper. -- [ ] Implement `restart_after_install` using existing server start logic. -- [ ] Add install history table and log writes. +- [x] Apply Phase 2 schema: `install_method`, `content_version`, `requires_stop`, + `backup_before_install`, `restart_after_install`, `is_cacheable`, `description` + columns added to `addons` table via `$install_queries[3]` (db_version 4). +- [x] `server_content_manifest` table created – tracks one row per installed + content item per server home (version, checksum, state, installed_by). +- [x] `server_content_install_history` table created – one row per install + attempt with result code, log snippet, and cache_mode_used. +- [x] `install_method` dropdown added to admin create/edit form. +- [x] `content_version`, `description`, `is_cacheable` fields added to form. +- [x] `requires_stop` / `backup_before_install` / `restart_after_install` + checkboxes added to admin form. +- [x] `requires_stop` check implemented in installer: blocks install if server + is running (Phase 3 will add automatic stop/start). +- [x] Install history row written on `state=start`; completed on success. +- [x] Manifest row upserted on successful install. +- [x] `server_content_cache_mode` panel setting added (disabled / search_existing_servers / + shared_cache / shared_cache_and_search; default: **disabled**). +- [x] `is_cacheable` flag stored per content item; cache mode and is_cacheable + guard are in place — actual cross-server/cache copy logic is Phase 3. +- [ ] `backup_before_install` agent call (Phase 3 — needs agent tar/zip helper). +- [ ] `restart_after_install` agent call (Phase 3 — needs server start after install). ### Phase 3 — Steam Workshop integration See Part 6 below. @@ -295,3 +359,59 @@ Steam Workshop content is treated as a Server Content type (`addon_type=workshop 6. **Install history must be logged.** - Record who installed what, when, and the script exit code. - This log must be readable by admins but not modifiable by users. + +--- + +## 14. Part 10: Content Reuse / Cache Mode Security Rules + +### Governing setting: `server_content_cache_mode` + +| Value | Agent behaviour | +|--------------------------|-----------------| +| `disabled` (**default**) | Always install from the configured source/script. No cross-server copy, no shared cache. | +| `search_existing_servers` | Agent may scan other local game-server folders for matching **cacheable** content and copy directly if the checksum matches. | +| `shared_cache` | Agent may store **cacheable** content in a shared cache folder and use it for future installs. | +| `shared_cache_and_search` | Both `shared_cache` and `search_existing_servers` are active. | + +### Content eligibility for sharing / caching + +Only content where **`is_cacheable = 1`** on the addon record may ever be +shared or placed in the shared cache. Admins must explicitly opt in per item. + +**Never mark the following as cacheable:** +- Config files, server.cfg, whitelist / banlist, RCON passwords +- User-edited files, saves, player databases +- Log files, crash dumps +- Files containing credentials, tokens, or keys +- Entire server home directories + +**Safe to mark cacheable (public, non-sensitive, reproducible):** +- Publicly distributed map packs (.bsp, .nav, .vmf bundles) +- Workshop content / mod archives sourced from public URLs +- Vanilla server jars (Minecraft, etc.) from official APIs +- Stock config templates (not user-edited copies) + +### Checksum enforcement + +When a cached or copied file is used, the agent **must** verify the SHA-256 +checksum against the value stored in `server_content_manifest.checksum_sha256` +(if present). A checksum mismatch must abort the install and fall back to the +original source download. + +### Path restrictions + +- The agent must never copy files **outside** approved content paths. +- Users **cannot** specify source paths. Only admin-defined content items + define where content comes from and where it is placed. +- Path traversal (`../`) is forbidden in all `path` fields. The panel + validates this; the agent provides a second layer of defence. +- The shared cache folder must be separate from any game server home and not + accessible directly by game server processes. + +### Phase 3 implementation checklist (not yet done) + +- [ ] Agent-side: implement copy-from-existing-server with checksum verification. +- [ ] Agent-side: implement shared cache store and restore. +- [ ] Panel-side: validate `is_cacheable` before passing cache-mode hint to agent. +- [ ] Panel-side: expose cache hit/miss in install history log. +- [ ] Shared cache path configurable in panel settings (`server_content_cache_path`). diff --git a/Panel/modules/addonsmanager/addons_installer.php b/Panel/modules/addonsmanager/addons_installer.php index 7e86b645..240050ef 100644 --- a/Panel/modules/addonsmanager/addons_installer.php +++ b/Panel/modules/addonsmanager/addons_installer.php @@ -161,7 +161,7 @@ function exec_ogp_module() { { $addon_id = (int)$_REQUEST['addon_id']; - $addons_rows = $db->resultQuery("SELECT url, path, post_script FROM OGP_DB_PREFIXaddons WHERE addon_id=".$addon_id.$query_groups); + $addons_rows = $db->resultQuery("SELECT url, path, post_script, addon_type, install_method, content_version, requires_stop, restart_after_install FROM OGP_DB_PREFIXaddons WHERE addon_id=".$addon_id.$query_groups); if (!is_array($addons_rows)) { $addons_rows = []; } @@ -174,7 +174,23 @@ function exec_ogp_module() { $remote = new OGPRemoteLibrary($home_info['agent_ip'],$home_info['agent_port'],$home_info['encryption_key'],$home_info['timeout']); - $addon_info = $addons_rows[0]; + $addon_info = $addons_rows[0]; + $install_method = isset($addon_info['install_method']) ? $addon_info['install_method'] : 'download_zip'; + $content_version = isset($addon_info['content_version']) ? $addon_info['content_version'] : ''; + $requires_stop = !empty($addon_info['requires_stop']) ? 1 : 0; + + // ── requires_stop guard ─────────────────────────────────────────────── + // If the content item requires the server to be stopped first, check + // whether the server is currently running and block the install if so. + // (Phase 2 blocks install; automatic stop/start is Phase 3.) + if ( $state == "start" && $requires_stop ) { + $is_running = $remote->is_screen_running( $home_info['home_name'], $home_info['home_id'] ); + if ( $is_running === 1 ) { + print_failure('This content item requires the server to be stopped before installing. Please stop the server and try again.'); + echo "

".get_lang('back')."

"; + return; + } + } $url = $addon_info['url']; $filename = basename($url); #### Replace template variables in the post-install script with @@ -231,8 +247,22 @@ function exec_ogp_module() { } #### end of replacements - if ( $state == "start" AND $addon_id != "" ) + if ( $state == "start" AND $addon_id != "" ) { + // Record install attempt in history before triggering download. + $cache_mode = scm_get_cache_mode($db); + $history_id = scm_record_install_start( + $db, + $home_id, + $addon_id, + $user_id, + $addon_info['url'], + $content_version, + $install_method, + $cache_mode + ); + $_SESSION['scm_history_id_' . $home_id . '_' . $addon_id] = $history_id; $pid = $remote->start_file_download( $addon_info['url'], $home_info['home_path']."/".$addon_info['path'], $filename, "uncompress", $post_script); + } $headers = get_headers($url, 1); @@ -301,6 +331,19 @@ function exec_ogp_module() { elseif( $remote->is_file_download_in_progress($pid) === 0 AND $remote->is_screen_running("post_script",$pid) === 0 ) { print_success(get_lang('addon_installed_successfully')); + // Update install history and manifest on successful completion. + $history_key = 'scm_history_id_' . $home_id . '_' . $addon_id; + if (!empty($_SESSION[$history_key])) { + scm_record_install_done($db, (int)$_SESSION[$history_key], 'installed', 0); + unset($_SESSION[$history_key]); + } + scm_upsert_manifest($db, $home_id, $addon_id, array( + 'install_method' => $install_method, + 'content_version' => $content_version, + 'install_state' => 'installed', + 'source_url' => $addon_info['url'], + 'installed_by' => $user_id, + )); echo "

".get_lang('back')."

"; $view->refresh("?m=addonsmanager&p=user_addons&home_id=$home_id". diff --git a/Panel/modules/addonsmanager/addons_manager.php b/Panel/modules/addonsmanager/addons_manager.php index 0527f987..ef9ecde3 100644 --- a/Panel/modules/addonsmanager/addons_manager.php +++ b/Panel/modules/addonsmanager/addons_manager.php @@ -25,16 +25,21 @@ // Central category map — defines all valid addon_type values and their labels. require_once(dirname(__FILE__) . '/server_content_categories.php'); +require_once(dirname(__FILE__) . '/server_content_helpers.php'); function exec_ogp_module() { global $db; + // Ensure Phase 2 schema is present (idempotent). + scm_ensure_phase2_schema($db); + // Build the complete list of allowed content types from the category map. // Admins can create items of any registered type; the original three types // (plugin, mappack, config) are always included. $addon_types = get_server_content_type_keys(); // all keys $addon_type_labels = get_server_content_categories(); // key => label + $install_methods = scm_get_install_methods(); // install_method keys => labels if (isset($_POST['create_addon']) AND isset($_POST['name']) AND $_POST['url']=="") { @@ -54,13 +59,21 @@ function exec_ogp_module() { } elseif (isset($_POST['create_addon']) AND isset($_POST['name']) AND isset($_POST['url']) AND isset($_POST['addon_type']) and isset($_POST['home_cfg_id']) ) { - $fields['name'] = $_POST['name']; - $fields['url'] = $_POST['url']; - $fields['path'] = $_POST['path']; - $fields['addon_type'] = $_POST['addon_type']; - $fields['home_cfg_id'] = $_POST['home_cfg_id']; - $fields['post_script'] = $_POST['post_script']; - $fields['group_id'] = $_POST['group_id']; + $valid_install_methods = array_keys($install_methods); + $fields['name'] = $_POST['name']; + $fields['url'] = $_POST['url']; + $fields['path'] = $_POST['path']; + $fields['addon_type'] = $_POST['addon_type']; + $fields['home_cfg_id'] = $_POST['home_cfg_id']; + $fields['post_script'] = $_POST['post_script']; + $fields['group_id'] = $_POST['group_id']; + $fields['install_method'] = in_array($_POST['install_method'], $valid_install_methods) ? $_POST['install_method'] : 'download_zip'; + $fields['content_version'] = isset($_POST['content_version']) ? $_POST['content_version'] : ''; + $fields['requires_stop'] = !empty($_POST['requires_stop']) ? 1 : 0; + $fields['backup_before_install'] = !empty($_POST['backup_before_install']) ? 1 : 0; + $fields['restart_after_install'] = !empty($_POST['restart_after_install']) ? 1 : 0; + $fields['is_cacheable'] = !empty($_POST['is_cacheable']) ? 1 : 0; + $fields['description'] = isset($_POST['description']) ? $_POST['description'] : ''; if( is_numeric($db->resultInsertId( 'addons', $fields )) ) { print_success(get_lang_f("addon_has_been_created",$_POST['name'])); @@ -70,13 +83,20 @@ function exec_ogp_module() { } echo "

".get_lang('addons_manager')."

"; - $name = isset($_POST['name']) ? $_POST['name'] : ""; - $url = isset($_POST['url']) ? $_POST['url'] : ""; - $path = isset($_POST['path']) ? $_POST['path'] : ""; - $post_script = isset($_POST['post_script']) ? $_POST['post_script'] : ""; - $home_cfg_id = isset($_POST['home_cfg_id']) ? $_POST['home_cfg_id'] : ""; - $addon_type = isset($_POST['addon_type']) ? $_POST['addon_type'] : ""; - $group_id = isset($_POST['group_id']) ? $_POST['group_id'] : ""; + $name = isset($_POST['name']) ? $_POST['name'] : ""; + $url = isset($_POST['url']) ? $_POST['url'] : ""; + $path = isset($_POST['path']) ? $_POST['path'] : ""; + $post_script = isset($_POST['post_script']) ? $_POST['post_script'] : ""; + $home_cfg_id = isset($_POST['home_cfg_id']) ? $_POST['home_cfg_id'] : ""; + $addon_type = isset($_POST['addon_type']) ? $_POST['addon_type'] : ""; + $group_id = isset($_POST['group_id']) ? $_POST['group_id'] : ""; + $install_method = isset($_POST['install_method']) ? $_POST['install_method'] : "download_zip"; + $content_version = isset($_POST['content_version']) ? $_POST['content_version'] : ""; + $requires_stop = isset($_POST['requires_stop']) ? (int)$_POST['requires_stop'] : 1; + $backup_before_install = isset($_POST['backup_before_install']) ? (int)$_POST['backup_before_install'] : 1; + $restart_after_install = isset($_POST['restart_after_install']) ? (int)$_POST['restart_after_install'] : 0; + $is_cacheable = isset($_POST['is_cacheable']) ? (int)$_POST['is_cacheable'] : 0; + $description = isset($_POST['description']) ? $_POST['description'] : ""; if (isset($_POST['addon_id']) && (int)$_POST['addon_id'] > 0 && isset($_POST['edit'])) { @@ -84,14 +104,21 @@ function exec_ogp_module() { if (!is_array($addons_rows)) { $addons_rows = []; } - $addon_info = $addons_rows[0]; - $name = isset($addon_info['name']) ? $addon_info['name'] : ""; - $url = isset($addon_info['url']) ? $addon_info['url'] : ""; - $path = isset($addon_info['path']) ? $addon_info['path'] : ""; - $post_script = isset($addon_info['post_script']) ? $addon_info['post_script'] : ""; - $home_cfg_id = isset($addon_info['home_cfg_id']) ? $addon_info['home_cfg_id'] : ""; - $addon_type = isset($addon_info['addon_type']) ? $addon_info['addon_type'] : ""; - $group_id = isset($addon_info['group_id']) ? $addon_info['group_id'] : ""; + $addon_info = $addons_rows[0]; + $name = isset($addon_info['name']) ? $addon_info['name'] : ""; + $url = isset($addon_info['url']) ? $addon_info['url'] : ""; + $path = isset($addon_info['path']) ? $addon_info['path'] : ""; + $post_script = isset($addon_info['post_script']) ? $addon_info['post_script'] : ""; + $home_cfg_id = isset($addon_info['home_cfg_id']) ? $addon_info['home_cfg_id'] : ""; + $addon_type = isset($addon_info['addon_type']) ? $addon_info['addon_type'] : ""; + $group_id = isset($addon_info['group_id']) ? $addon_info['group_id'] : ""; + $install_method = isset($addon_info['install_method']) ? $addon_info['install_method'] : "download_zip"; + $content_version = isset($addon_info['content_version']) ? $addon_info['content_version'] : ""; + $requires_stop = isset($addon_info['requires_stop']) ? (int)$addon_info['requires_stop'] : 1; + $backup_before_install = isset($addon_info['backup_before_install']) ? (int)$addon_info['backup_before_install'] : 1; + $restart_after_install = isset($addon_info['restart_after_install']) ? (int)$addon_info['restart_after_install'] : 0; + $is_cacheable = isset($addon_info['is_cacheable']) ? (int)$addon_info['is_cacheable'] : 0; + $description = isset($addon_info['description']) ? $addon_info['description'] : ""; } ?>
@@ -214,6 +241,79 @@ function exec_ogp_module() { + + + + Install Method + + + + The mechanism used to deliver this content to the server. + + + + + Content Version + + + + Optional version tag shown in the installed-content list. + + + + + Description + + + + + + + + Behaviour Options + + + +    + +    + + + + + + Content Reuse + + + + + Only check this for public, non-sensitive content (maps, mods, jars). + Never check for configs, saves, credentials, or user-edited files. + Caching only activates when the Server Content Cache Mode panel + setting (in Panel Settings) is set to something other than Disabled. + + + + 'addons_manager', 'name' => 'Server Content Manager', 'group' => 'admin' ) @@ -72,4 +77,92 @@ $install_queries[2] = array( KEY `idx_install_state` (`install_state`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" ); + +// ── db_version 4 : Phase 2 – install_method, per-server manifest, install history ── +// +// Uses a PHP callable so each ALTER is applied only when the column does not +// already exist (safe for repeated runs, compatible with all MySQL versions). +// +$install_queries[3] = array( + function ($db) { + $prefix = OGP_DB_PREFIX; + + // ── Extend the addons table with Phase 2 columns ────────────────────── + $new_columns = array( + 'install_method' => "VARCHAR(32) NOT NULL DEFAULT 'download_zip' AFTER `group_id`", + 'content_version' => "VARCHAR(64) NULL AFTER `install_method`", + 'requires_stop' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `content_version`", + 'backup_before_install' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `requires_stop`", + 'restart_after_install' => "TINYINT(1) NOT NULL DEFAULT 0 AFTER `backup_before_install`", + 'is_cacheable' => "TINYINT(1) NOT NULL DEFAULT 0 AFTER `restart_after_install`", + 'description' => "TEXT NULL AFTER `is_cacheable`", + ); + + foreach ($new_columns as $col => $definition) { + $escaped_col = $db->realEscapeSingle($col); + $escaped_table = $db->realEscapeSingle($prefix . 'addons'); + $check = $db->resultQuery( + "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$escaped_table}' + AND COLUMN_NAME = '{$escaped_col}'" + ); + if (empty($check)) { + if (!$db->query("ALTER TABLE `{$prefix}addons` ADD COLUMN `{$col}` {$definition}")) { + return false; + } + } + } + + // ── Per-server installed-content manifest ───────────────────────────── + if (!$db->query( + "CREATE TABLE IF NOT EXISTS `{$prefix}server_content_manifest` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `install_method` VARCHAR(32) NOT NULL DEFAULT 'download_zip', + `content_version` VARCHAR(64) NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'installed', + `checksum_sha256` VARCHAR(64) NULL, + `source_url` VARCHAR(255) NULL, + `installed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `installed_by` INT NULL, + `updated_at` DATETIME NULL, + `notes` TEXT NULL, + UNIQUE KEY `uniq_home_addon` (`home_id`, `addon_id`), + KEY `idx_home_id` (`home_id`), + KEY `idx_addon_id` (`addon_id`), + KEY `idx_install_state` (`install_state`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + )) { + return false; + } + + // ── Install history (one row per install attempt) ───────────────────── + if (!$db->query( + "CREATE TABLE IF NOT EXISTS `{$prefix}server_content_install_history` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `installed_by` INT NULL, + `started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `completed_at` DATETIME NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'started', + `install_method` VARCHAR(32) NULL, + `content_version` VARCHAR(64) NULL, + `source_url` VARCHAR(255) NULL, + `cache_mode_used` VARCHAR(32) NULL, + `result_code` INT NULL, + `log_output` MEDIUMTEXT NULL, + KEY `idx_home_id` (`home_id`), + KEY `idx_addon_id` (`addon_id`), + KEY `idx_started_at` (`started_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + )) { + return false; + } + + return true; + }, +); ?> diff --git a/Panel/modules/addonsmanager/server_content_helpers.php b/Panel/modules/addonsmanager/server_content_helpers.php index cb5c1d38..efebbb0c 100644 --- a/Panel/modules/addonsmanager/server_content_helpers.php +++ b/Panel/modules/addonsmanager/server_content_helpers.php @@ -198,3 +198,295 @@ function scm_validate_csrf_token($token) return hash_equals((string)$_SESSION['addonsmanager_workshop_csrf'], (string)$token); } +// ───────────────────────────────────────────────────────────────────────────── +// Phase 2 helpers – schema guard, cache mode, manifest, install history +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Returns the allowed values for the server_content_cache_mode panel setting. + * + * disabled – Always install from the configured source. No scanning, + * no shared cache. (DEFAULT – safest choice) + * search_existing_servers – Agent may scan other local game-server folders for + * matching cacheable content and copy directly if safe. + * shared_cache – Agent may store cacheable content in a shared cache + * folder and reuse it on future installs. + * shared_cache_and_search – Both shared_cache and search_existing_servers are + * active simultaneously. + * + * Security note: only content explicitly marked is_cacheable=1 on the addon + * record may ever be shared or cached. Private configs, user-edited files, + * saves, databases, logs, and credentials must never be included. + * + * @return array key => human-readable label + */ +function scm_get_valid_cache_modes() +{ + return array( + 'disabled' => 'Disabled (always install from source)', + 'search_existing_servers' => 'Search existing servers (copy from local installs)', + 'shared_cache' => 'Shared cache (store and reuse cached copies)', + 'shared_cache_and_search' => 'Shared cache + search existing servers', + ); +} + +/** + * Reads the current server_content_cache_mode panel setting. + * Returns 'disabled' if not set. + * + * @param object $db Panel DB handle + * @return string One of the scm_get_valid_cache_modes() keys + */ +function scm_get_cache_mode($db) +{ + $valid = scm_get_valid_cache_modes(); + $value = ''; + if (method_exists($db, 'getSetting')) { + $value = (string)$db->getSetting('server_content_cache_mode'); + } + return array_key_exists($value, $valid) ? $value : 'disabled'; +} + +/** + * Returns allowed install_method values and their display labels. + * + * @return array + */ +function scm_get_install_methods() +{ + return array( + 'download_zip' => 'Download & extract archive (.zip / .tar.gz)', + 'download_file' => 'Download single file (no extraction)', + 'post_script' => 'Run post-script only (no download)', + 'steam_workshop' => 'Steam Workshop (via agent SteamCMD)', + 'minecraft_jar' => 'Minecraft server jar / version switcher', + 'profile_copy' => 'Copy stored profile directory', + ); +} + +/** + * Idempotently ensures the Phase 2 schema is present. + * Called from pages that use manifest / history data so that existing + * installs that have not yet run the module updater are covered. + * + * @param object $db Panel DB handle + * @return bool + */ +function scm_ensure_phase2_schema($db) +{ + static $phase2_checked = false; + if ($phase2_checked) { + return true; + } + $phase2_checked = true; + $prefix = OGP_DB_PREFIX; + + // ── Extend addons table ─────────────────────────────────────────────────── + $new_columns = array( + 'install_method' => "VARCHAR(32) NOT NULL DEFAULT 'download_zip'", + 'content_version' => "VARCHAR(64) NULL", + 'requires_stop' => "TINYINT(1) NOT NULL DEFAULT 1", + 'backup_before_install' => "TINYINT(1) NOT NULL DEFAULT 1", + 'restart_after_install' => "TINYINT(1) NOT NULL DEFAULT 0", + 'is_cacheable' => "TINYINT(1) NOT NULL DEFAULT 0", + 'description' => "TEXT NULL", + ); + foreach ($new_columns as $col => $definition) { + $escaped_col = $db->realEscapeSingle($col); + $escaped_table = $db->realEscapeSingle($prefix . 'addons'); + $check = $db->resultQuery( + "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$escaped_table}' + AND COLUMN_NAME = '{$escaped_col}'" + ); + if (empty($check)) { + $db->query("ALTER TABLE `{$prefix}addons` ADD COLUMN `{$col}` {$definition}"); + } + } + + // ── Per-server manifest ─────────────────────────────────────────────────── + $db->query( + "CREATE TABLE IF NOT EXISTS `{$prefix}server_content_manifest` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `install_method` VARCHAR(32) NOT NULL DEFAULT 'download_zip', + `content_version` VARCHAR(64) NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'installed', + `checksum_sha256` VARCHAR(64) NULL, + `source_url` VARCHAR(255) NULL, + `installed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `installed_by` INT NULL, + `updated_at` DATETIME NULL, + `notes` TEXT NULL, + UNIQUE KEY `uniq_home_addon` (`home_id`, `addon_id`), + KEY `idx_home_id` (`home_id`), + KEY `idx_addon_id` (`addon_id`), + KEY `idx_install_state` (`install_state`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + // ── Install history ─────────────────────────────────────────────────────── + $db->query( + "CREATE TABLE IF NOT EXISTS `{$prefix}server_content_install_history` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `installed_by` INT NULL, + `started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `completed_at` DATETIME NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'started', + `install_method` VARCHAR(32) NULL, + `content_version` VARCHAR(64) NULL, + `source_url` VARCHAR(255) NULL, + `cache_mode_used` VARCHAR(32) NULL, + `result_code` INT NULL, + `log_output` MEDIUMTEXT NULL, + KEY `idx_home_id` (`home_id`), + KEY `idx_addon_id` (`addon_id`), + KEY `idx_started_at` (`started_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + return true; +} + +/** + * Returns all manifest rows for a given server home. + * + * @param object $db + * @param int $home_id + * @return array + */ +function scm_get_manifest_rows($db, $home_id) +{ + $home_id = (int)$home_id; + if ($home_id <= 0 || !scm_ensure_phase2_schema($db)) { + return array(); + } + $rows = $db->resultQuery( + "SELECT m.*, a.name AS addon_name, a.addon_type, a.install_method AS addon_install_method + FROM `".OGP_DB_PREFIX."server_content_manifest` m + LEFT JOIN `".OGP_DB_PREFIX."addons` a ON a.addon_id = m.addon_id + WHERE m.home_id = {$home_id} + ORDER BY m.installed_at DESC" + ); + return is_array($rows) ? $rows : array(); +} + +/** + * Creates a new install history row and returns its insert ID. + * Returns 0 on failure. + * + * @param object $db + * @param int $home_id + * @param int $addon_id + * @param int $user_id + * @param string $source_url + * @param string $content_version + * @param string $install_method + * @param string $cache_mode_used + * @return int history row ID, or 0 on failure + */ +function scm_record_install_start($db, $home_id, $addon_id, $user_id, $source_url = '', $content_version = '', $install_method = 'download_zip', $cache_mode_used = 'disabled') +{ + $home_id = (int)$home_id; + $addon_id = (int)$addon_id; + $user_id = (int)$user_id; + $source_url = $db->realEscapeSingle((string)$source_url); + $content_version = $db->realEscapeSingle((string)$content_version); + $install_method = $db->realEscapeSingle((string)$install_method); + $cache_mode_used = $db->realEscapeSingle((string)$cache_mode_used); + + if (!scm_ensure_phase2_schema($db)) { + return 0; + } + $id = $db->resultInsertId( + 'server_content_install_history', + array( + 'home_id' => $home_id, + 'addon_id' => $addon_id, + 'installed_by' => $user_id, + 'install_state' => 'started', + 'install_method' => $install_method, + 'content_version' => $content_version, + 'source_url' => $source_url, + 'cache_mode_used' => $cache_mode_used, + ) + ); + return is_numeric($id) ? (int)$id : 0; +} + +/** + * Updates an existing install history row with the final result. + * + * @param object $db + * @param int $history_id + * @param string $state 'installed' | 'failed' | 'cancelled' + * @param int $result_code Exit code (0 = success) + * @param string $log_output Script/download log snippet + * @return bool + */ +function scm_record_install_done($db, $history_id, $state = 'installed', $result_code = 0, $log_output = '') +{ + $history_id = (int)$history_id; + $state = $db->realEscapeSingle((string)$state); + $result_code = (int)$result_code; + $log_output = $db->realEscapeSingle((string)$log_output); + if ($history_id <= 0) { + return false; + } + return (bool)$db->query( + "UPDATE `".OGP_DB_PREFIX."server_content_install_history` + SET install_state = '{$state}', + result_code = {$result_code}, + log_output = '{$log_output}', + completed_at = NOW() + WHERE id = {$history_id}" + ); +} + +/** + * Inserts or updates a server_content_manifest row for a successful install. + * + * @param object $db + * @param int $home_id + * @param int $addon_id + * @param array $fields Optional overrides: install_method, content_version, + * install_state, source_url, checksum_sha256, installed_by + * @return bool + */ +function scm_upsert_manifest($db, $home_id, $addon_id, array $fields = array()) +{ + $home_id = (int)$home_id; + $addon_id = (int)$addon_id; + if ($home_id <= 0 || $addon_id <= 0 || !scm_ensure_phase2_schema($db)) { + return false; + } + $install_method = $db->realEscapeSingle((string)(isset($fields['install_method']) ? $fields['install_method'] : 'download_zip')); + $content_version = $db->realEscapeSingle((string)(isset($fields['content_version']) ? $fields['content_version'] : '')); + $install_state = $db->realEscapeSingle((string)(isset($fields['install_state']) ? $fields['install_state'] : 'installed')); + $source_url = $db->realEscapeSingle((string)(isset($fields['source_url']) ? $fields['source_url'] : '')); + $checksum = $db->realEscapeSingle((string)(isset($fields['checksum_sha256']) ? $fields['checksum_sha256'] : '')); + $installed_by = isset($fields['installed_by']) ? (int)$fields['installed_by'] : 'NULL'; + if ($installed_by !== 'NULL' && $installed_by <= 0) { + $installed_by = 'NULL'; + } + + return (bool)$db->query( + "INSERT INTO `".OGP_DB_PREFIX."server_content_manifest` + (`home_id`,`addon_id`,`install_method`,`content_version`,`install_state`,`source_url`,`checksum_sha256`,`installed_by`,`installed_at`,`updated_at`) + VALUES + ({$home_id},{$addon_id},'{$install_method}','{$content_version}','{$install_state}','{$source_url}','{$checksum}',{$installed_by},NOW(),NOW()) + ON DUPLICATE KEY UPDATE + install_method = VALUES(install_method), + content_version = VALUES(content_version), + install_state = VALUES(install_state), + source_url = VALUES(source_url), + checksum_sha256 = VALUES(checksum_sha256), + installed_at = NOW(), + updated_at = NOW()" + ); +} + diff --git a/Panel/modules/settings/settings.php b/Panel/modules/settings/settings.php index e69798b0..ce988c9e 100644 --- a/Panel/modules/settings/settings.php +++ b/Panel/modules/settings/settings.php @@ -77,7 +77,9 @@ function exec_ogp_module() "discord_webhook_main" => $_REQUEST['discord_webhook_main'], "discord_webhook_admin" => $_REQUEST['discord_webhook_admin'], // Debug - "debug_level" => $_REQUEST['debug_level'] ?? '1' + "debug_level" => $_REQUEST['debug_level'] ?? '1', + // Server Content + "server_content_cache_mode" => $_REQUEST['server_content_cache_mode'] ?? 'disabled', ); $db->setSettings($settings); @@ -208,8 +210,24 @@ function exec_ogp_module() $ft->add_custom_field('debug_level', create_drop_box_from_array($debug_level_options, 'debug_level', @$row['debug_level'] ?? '1', false)); + // Server Content cache mode + // Controls whether the agent may copy cacheable content from other servers + // or a shared cache instead of always re-downloading from the source. + // Default: disabled (safest – no copying of any content between servers). + // See addonsmanager/SERVER_CONTENT_ROADMAP.md for full description. + $cache_mode_options = array( + 'disabled' => 'Disabled (always install from source — default)', + 'search_existing_servers' => 'Search existing servers (copy cacheable content from local game-server folders)', + 'shared_cache' => 'Shared cache (store and reuse cacheable content in a shared cache folder)', + 'shared_cache_and_search' => 'Shared cache + search existing servers', + ); + $current_cache_mode = isset($row['server_content_cache_mode']) && array_key_exists($row['server_content_cache_mode'], $cache_mode_options) + ? $row['server_content_cache_mode'] : 'disabled'; + $ft->add_custom_field('server_content_cache_mode', + create_drop_box_from_array($cache_mode_options, 'server_content_cache_mode', $current_cache_mode, false)); + // Add option to reset game server order to default - $ft->add_field('checkbox','reset_game_server_order','0'); + $ft->add_field('checkbox','reset_game_server_order','0'); $ft->end_table(); $ft->add_button("submit","update_settings",get_lang('update_settings'));