feat: Server Content Phase 2 – schema, cache mode setting, install history, manifest, requires_stop"

Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/117ba84d-d347-410b-a634-69df74f31061

Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-18 22:00:22 +00:00 committed by GitHub
parent 211875aaf3
commit 6d9cd28a0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 715 additions and 49 deletions

View file

@ -34,21 +34,68 @@ User clicks button
---
## 2. Existing Database Fields (`OGP_DB_PREFIXaddons`)
## 2. Database Schema (db_version 4 — Phase 2)
### `OGP_DB_PREFIXaddons` (content item catalogue)
| 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 |
|---------------|-----------------|--------------------------------------------------|
| 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 |
|-----------------|------------------|-------------|
| 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 |
\* Expanded from VARCHAR(7) to VARCHAR(32) in db_version 2
(migration runs automatically via the module update system).
### `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`).

View file

@ -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 = [];
}
@ -175,6 +175,22 @@ 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];
$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 "<p><a href=\"?m=addonsmanager&amp;p=addons&amp;addon_type=".urlencode($addon_info['addon_type'] ?? '')."&amp;home_id=$home_id&amp;mod_id=$mod_id&amp;ip=$ip&amp;port=$port\">".get_lang('back')."</a></p>";
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 "<p><a href=\"?m=addonsmanager&amp;p=user_addons&amp;home_id=$home_id".
"&amp;mod_id=$mod_id&amp;ip=$ip&amp;port=$port\">".get_lang('back')."</a></p>";
$view->refresh("?m=addonsmanager&amp;p=user_addons&amp;home_id=$home_id".

View file

@ -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,6 +59,7 @@ 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']) )
{
$valid_install_methods = array_keys($install_methods);
$fields['name'] = $_POST['name'];
$fields['url'] = $_POST['url'];
$fields['path'] = $_POST['path'];
@ -61,6 +67,13 @@ function exec_ogp_module() {
$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']));
@ -77,6 +90,13 @@ function exec_ogp_module() {
$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']))
{
@ -92,6 +112,13 @@ function exec_ogp_module() {
$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'] : "";
}
?>
<form action="" method="post">
@ -214,6 +241,79 @@ function exec_ogp_module() {
</select>
</td>
</tr>
<!-- ── Phase 2 fields ────────────────────────────────────────────────── -->
<tr>
<td align="right">
<b>Install Method</b>
</td>
<td align="left">
<select name="install_method">
<?php
foreach ((array)$install_methods as $method_key => $method_label) {
$sel = ($method_key == $install_method) ? 'selected="selected"' : '';
echo '<option value="'.htmlspecialchars($method_key).'" '.$sel.'>'.htmlspecialchars($method_label).'</option>'."\n";
}
?>
</select>
<small style="color:#666;"> The mechanism used to deliver this content to the server.</small>
</td>
</tr>
<tr>
<td align="right">
<b>Content Version</b>
</td>
<td align="left">
<input type="text" value="<?php echo htmlspecialchars($content_version, ENT_QUOTES, 'UTF-8'); ?>" name="content_version" size="40" placeholder="e.g. 1.21.1 or 2024-05-01" />
<small style="color:#666;"> Optional version tag shown in the installed-content list.</small>
</td>
</tr>
<tr>
<td align="right">
<b>Description</b>
</td>
<td align="left">
<textarea name="description" style="width:99%;height:60px;" placeholder="Short description shown to users."><?php echo htmlspecialchars($description, ENT_QUOTES, 'UTF-8'); ?></textarea>
</td>
</tr>
<tr>
<td align="right">
<b>Behaviour Options</b>
</td>
<td align="left">
<label>
<input type="checkbox" name="requires_stop" value="1" <?php echo $requires_stop ? 'checked' : ''; ?> />
Stop server before installing
</label>
&nbsp;&nbsp;
<label>
<input type="checkbox" name="backup_before_install" value="1" <?php echo $backup_before_install ? 'checked' : ''; ?> />
Backup target path before installing
</label>
&nbsp;&nbsp;
<label>
<input type="checkbox" name="restart_after_install" value="1" <?php echo $restart_after_install ? 'checked' : ''; ?> />
Restart server after successful install
</label>
</td>
</tr>
<tr>
<td align="right">
<b>Content Reuse</b>
</td>
<td align="left">
<label>
<input type="checkbox" name="is_cacheable" value="1" <?php echo $is_cacheable ? 'checked' : ''; ?> />
Mark as cacheable / reusable
</label>
<small style="color:#666;">
Only check this for public, non-sensitive content (maps, mods, jars).
<strong>Never</strong> check for configs, saves, credentials, or user-edited files.
Caching only activates when the <em>Server Content Cache Mode</em> panel
setting (in Panel Settings) is set to something other than <em>Disabled</em>.
</small>
</td>
</tr>
<!-- ── end Phase 2 fields ─────────────────────────────────────────────── -->
<tr>
<td colspan="2" align="center">
<?php

View file

@ -13,13 +13,18 @@
* 1 initial schema (addons table, addon_type VARCHAR(7))
* 2 expand addon_type to VARCHAR(32) to support extended content types
* (workshop=8 chars, and any future type up to 32 chars)
* 3 add server_content_workshop table for per-server Workshop item selections
* 4 Phase 2: add install_method / content_version / requires_stop /
* backup_before_install / restart_after_install / is_cacheable /
* description columns to addons table; add server_content_manifest
* and server_content_install_history tables
*
*/
// Module general information
$module_title = "Server Content Manager";
$module_version = "2.1";
$db_version = 3;
$module_version = "2.2";
$db_version = 4;
$module_required = TRUE;
$module_menus = array(
array( 'subpage' => '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;
},
);
?>

View file

@ -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<string,string> 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<string,string>
*/
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()"
);
}

View file

@ -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,6 +210,22 @@ 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');