-
Notifications
You must be signed in to change notification settings - Fork 1
Add Stationeers Launch Pad (SLP) mod management features #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…rs and UI. SLP defaults to false, but if SLP is enabled SLP auto updates default to true
update .gitignore to include .agent-context and .github/copilot-instructions
Implemented comprehensive mod package upload functionality (&UI) - Add functions to handle modpkg upload, extraction, and deletion - Add route POST /api/v2/slp/upload for mod package uploads - Add logger subsystem logger.Modding - Used universal popup system from Dashboard for UI feedback
…nt to get installed mod details
can be called to update the installed mods or to download new mods
…moved "slp" command
…onse with an array of "log" that is returned
…on, and improve popup functionality
…saves to local storage
- remove setDummyBuildID() - fix SLP-related auto update logic config file writer - fix typo in comment - fix remove redundant os.MkdirlAll call in modpackages.go - remove debug statement UpdateWorkshopItems as its not needed
UIMod/onboard_bundled/assets/js/popup.js
Dismissed
| popup.className = 'popup'; | ||
| popupTitle.textContent = ''; | ||
| popupMessage.textContent = message; | ||
| popupMessage.innerHTML = message.replace(/\n/g, '<br>'); |
Check failure
Code scanning / CodeQL
DOM text reinterpreted as HTML High
DOM text
DOM text
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 3 hours ago
In general, the problem is that showPopup passes potentially untrusted message text directly to innerHTML, causing any HTML meta-characters in message (coming from user-controlled sources like selectedModFile.name) to be interpreted as markup. To fix this without changing existing behavior too much, we should stop using innerHTML for raw strings and instead safely encode the message and only then add line breaks. A common pattern is: escape the message to plain text, then replace \n with <br> on the escaped text and inject the result as HTML, or split on \n and append text and <br> nodes manually.
The single best minimal-change fix is to introduce a small helper escapeHtml(text) in popup.js that replaces &, <, >, ", and ' with their HTML entities, and use it in showPopup so that message is escaped before \n is converted to <br>. Concretely, add the helper at the top of popup.js, and change line 8 from popupMessage.innerHTML = message.replace(/\n/g, '<br>'); to something like:
const safeHtml = escapeHtml(String(message)).replace(/\n/g, '<br>');
popupMessage.innerHTML = safeHtml;This preserves the existing line-break behavior while ensuring any user-controlled content (like file names) cannot inject HTML or scripts. No other files need changes; slp.js and config.html can remain as they are because all tainted flow is mediated by showPopup.
-
Copy modified lines R1-R12 -
Copy modified lines R20-R21
| @@ -1,3 +1,15 @@ | ||
| function escapeHtml(text) { | ||
| if (text === null || text === undefined) { | ||
| return ''; | ||
| } | ||
| return String(text) | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
|
|
||
| function showPopup(status, message) { | ||
| const popup = document.getElementById('universalPopup'); | ||
| const popupTitle = document.getElementById('popupTitle'); | ||
| @@ -5,7 +17,8 @@ | ||
|
|
||
| popup.className = 'popup'; | ||
| popupTitle.textContent = ''; | ||
| popupMessage.innerHTML = message.replace(/\n/g, '<br>'); | ||
| const safeMessageHtml = escapeHtml(message).replace(/\n/g, '<br>'); | ||
| popupMessage.innerHTML = safeMessageHtml; | ||
|
|
||
| switch(status.toLowerCase()) { | ||
| case 'error': |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should eventually fix this, but not now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request adds comprehensive mod management features for Stationeers Launch Pad (SLP), enabling server administrators to install SLP, upload mod packages, manage workshop mods, and view installed mods through a web interface.
Changes:
- Added SLP installation/uninstallation functionality with automatic GitHub release fetching
- Implemented mod package upload system with zip extraction and validation
- Integrated Steam Workshop mod download and update capabilities via SteamCMD
- Enhanced web UI with new SLP configuration tab, mod listing, and file upload interface
- Added localization support for English, German, and Swedish languages
- Refactored CLI commands into separate files for better organization
- Introduced new configuration variables for SLP features and auto-update settings
Reviewed changes
Copilot reviewed 31 out of 32 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| src/web/slp-launchpad.go | New HTTP handlers for SLP install, uninstall, upload, and mod listing |
| src/web/routes.go | Registered new API endpoints for SLP and modding features |
| src/web/configpage.go | Added SLP template variables to configuration page |
| src/web/templatevars.go | Defined new UI text variables for SLP interface |
| src/modding/launchpad.go | Core SLP installation/uninstallation logic with GitHub API integration |
| src/modding/launchpad-config.go | SLP auto-update configuration management |
| src/modding/modpackages.go | Mod package upload, extraction, and processing |
| src/modding/modlist.go | Mod discovery, metadata parsing, and workshop handle extraction |
| src/steamcmd/workshop.go | Steam Workshop item download and copy operations |
| src/config/*.go | Added SLP-related configuration variables, getters, and setters |
| src/logger/logger.go | Added dedicated modding subsystem logger |
| src/cli/*.go | Refactored CLI into separate files with new mod-related commands |
| src/core/loader/loader.go | Integrated SLP auto-update check on startup |
| src/setup/update/*.go | Exported WriteCounter for reuse in download progress tracking |
| UIMod/onboard_bundled/ui/config.html | Added SLP tab with installation, upload, and management UI |
| UIMod/onboard_bundled/assets/js/slp.js | JavaScript for SLP operations, file upload, and mod display |
| UIMod/onboard_bundled/assets/js/popup.js | Enhanced popup to support multi-line messages |
| UIMod/onboard_bundled/assets/css/*.css | Added extensive styling for SLP interface components |
| UIMod/onboard_bundled/localization/*.json | Added SLP-related translations for three languages |
| src/web/TwoBoxForm.go | Added "multiplayersafe" branch option |
| .gitignore | Added exclusions for agent context, mods directory, and instructions |
| .devcontainer/devcontainer.json | Removed supermaven extension reference |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| imageHtml = ` | ||
| <div class="mod-image-container" data-mod-index="${index}"> | ||
| <img id="mod-image-${index}" src="data:image/png;base64,${firstImageData}" alt="Mod image"> | ||
| </div> |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Base64 image data from user-uploaded mods is inserted directly into img src without validation. While base64 data URIs are generally safe, there's no verification that the data is actually a valid image or that it doesn't exceed reasonable size limits. Consider validating the image format and size on the backend before encoding to base64, or add client-side validation of the base64 data structure.
| popup.className = 'popup'; | ||
| popupTitle.textContent = ''; | ||
| popupMessage.textContent = message; | ||
| popupMessage.innerHTML = message.replace(/\n/g, '<br>'); |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential XSS vulnerability. The message parameter is directly inserted into innerHTML after only replacing newlines with <br> tags. This allows any HTML/JavaScript in the message to be executed. Since error messages can include user-controlled data (like filenames or error text from uploads), this creates an XSS risk. Use textContent instead of innerHTML, or properly sanitize the message content before insertion.
| popupMessage.innerHTML = message.replace(/\n/g, '<br>'); | |
| // Safely render message as text while preserving newlines as <br> elements | |
| popupMessage.textContent = ''; | |
| if (typeof message === 'string' && message.length > 0) { | |
| const parts = message.split('\n'); | |
| parts.forEach(function(part, index) { | |
| popupMessage.appendChild(document.createTextNode(part)); | |
| if (index < parts.length - 1) { | |
| popupMessage.appendChild(document.createElement('br')); | |
| } | |
| }); | |
| } |
| cmd := exec.Command( | ||
| filepath.Join(steamcmddir, executable), | ||
| "+force_install_dir", "../", | ||
| "+login", "anonymous", | ||
| "+workshop_download_item", "544550", appID, | ||
| "validate", | ||
| "+quit", |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Workshop handles from user-uploaded mod packages are not validated before being passed to SteamCMD. While the command uses exec.Command with separate arguments (preventing shell injection), workshop handles should still be validated to ensure they're numeric Steam Workshop IDs. Add validation to ensure workshop handles match the expected format (e.g., only digits) to prevent potential issues with SteamCMD.
| if err := os.Remove(modconfigPath); err != nil && !os.IsNotExist(err) { | ||
| logger.Modding.Warnf("Failed to remove existing modconfig.xml: %v", err) | ||
| } | ||
| if !os.IsNotExist(err) { |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Logic error in error checking. Line 63 checks !os.IsNotExist(err) but err is from line 60's os.Remove call. If the file exists and was successfully removed, err will be nil, making !os.IsNotExist(err) true (since nil is not IsNotExist), so line 64 will log "Removed existing modconfig.xml" even though it might not have been removed. The condition should check if err == nil instead to indicate successful removal.
| if err := os.Remove(modconfigPath); err != nil && !os.IsNotExist(err) { | |
| logger.Modding.Warnf("Failed to remove existing modconfig.xml: %v", err) | |
| } | |
| if !os.IsNotExist(err) { | |
| if err := os.Remove(modconfigPath); err != nil { | |
| if !os.IsNotExist(err) { | |
| logger.Modding.Warnf("Failed to remove existing modconfig.xml: %v", err) | |
| } | |
| } else { |
| const maxZipSize = 500 * 1024 * 1024 // 500 MB | ||
| sizeLimitedReader := io.LimitReader(r, maxZipSize+1) | ||
|
|
||
| // Read the entire zip file into memory | ||
| zipBytes, err := io.ReadAll(sizeLimitedReader) | ||
| if err != nil { | ||
| logger.Modding.Errorf("Failed to read mod package zip file, filesize might exceed 500mb: %v", err) | ||
| return fmt.Errorf("failed to read mod package zip file, filesize might exceed 500mb: %w", err) | ||
| } | ||
|
|
||
| if len(zipBytes) == 0 { | ||
| logger.Modding.Error("Received empty mod package") | ||
| return fmt.Errorf("mod package is empty") | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The size limit check is flawed. Using io.LimitReader(r, maxZipSize+1) and then checking if len(zipBytes) == 0 doesn't properly validate if the file exceeds 500MB. If a file is exactly maxZipSize+1 bytes, it will be read successfully but not rejected. Add an explicit check: if len(zipBytes) > maxZipSize to properly enforce the limit and provide a clear error message.
| uploadZone.innerHTML = '<span class="upload-icon">✓</span>' + | ||
| '<div class="upload-text">File Selected</div>' + | ||
| '<div class="upload-subtext">' + selectedModFile.name + '</div>' + | ||
| '<div class="upload-subtext" style="margin-top: 5px; color: #00d4ff;">' + (selectedModFile.size / 1024 / 1024).toFixed(2) + ' MB</div>'; |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
User-provided filename is inserted directly into HTML without proper escaping. This creates a potential XSS vulnerability. The filename could contain malicious HTML/JavaScript that would be executed. Use proper HTML escaping (e.g., textContent instead of innerHTML, or escape the filename before inserting it).
| uploadZone.innerHTML = '<span class="upload-icon">✓</span>' + | |
| '<div class="upload-text">File Selected</div>' + | |
| '<div class="upload-subtext">' + selectedModFile.name + '</div>' + | |
| '<div class="upload-subtext" style="margin-top: 5px; color: #00d4ff;">' + (selectedModFile.size / 1024 / 1024).toFixed(2) + ' MB</div>'; | |
| // Set static structure via innerHTML and inject dynamic values safely via DOM APIs | |
| uploadZone.innerHTML = '<span class="upload-icon">✓</span>' + | |
| '<div class="upload-text">File Selected</div>'; | |
| const nameDiv = document.createElement('div'); | |
| nameDiv.className = 'upload-subtext'; | |
| nameDiv.textContent = selectedModFile.name; | |
| uploadZone.appendChild(nameDiv); | |
| const sizeDiv = document.createElement('div'); | |
| sizeDiv.className = 'upload-subtext'; | |
| sizeDiv.style.marginTop = '5px'; | |
| sizeDiv.style.color = '#00d4ff'; | |
| sizeDiv.textContent = (selectedModFile.size / 1024 / 1024).toFixed(2) + ' MB'; | |
| uploadZone.appendChild(sizeDiv); |
| rc.Close() | ||
| return fmt.Errorf("failed to create output file: %w", err) | ||
| } | ||
|
|
||
| if _, err := io.Copy(outFile, rc); err != nil { | ||
| outFile.Close() | ||
| rc.Close() | ||
| return fmt.Errorf("failed to write file: %w", err) | ||
| } | ||
|
|
||
| outFile.Close() | ||
| rc.Close() | ||
|
|
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
File handle may be writable as a result of data flow from a call to OpenFile and closing it may result in data loss upon failure, which is not handled explicitly.
| rc.Close() | |
| return fmt.Errorf("failed to create output file: %w", err) | |
| } | |
| if _, err := io.Copy(outFile, rc); err != nil { | |
| outFile.Close() | |
| rc.Close() | |
| return fmt.Errorf("failed to write file: %w", err) | |
| } | |
| outFile.Close() | |
| rc.Close() | |
| rcCloseErr := rc.Close() | |
| if rcCloseErr != nil { | |
| return fmt.Errorf("failed to create output file: %w (and failed to close archive file: %v)", err, rcCloseErr) | |
| } | |
| return fmt.Errorf("failed to create output file: %w", err) | |
| } | |
| if _, err := io.Copy(outFile, rc); err != nil { | |
| closeErr := outFile.Close() | |
| rcCloseErr := rc.Close() | |
| if closeErr != nil { | |
| return fmt.Errorf("failed to write file: %w (and failed to close output file: %v)", err, closeErr) | |
| } | |
| if rcCloseErr != nil { | |
| return fmt.Errorf("failed to write file: %w (and failed to close archive file: %v)", err, rcCloseErr) | |
| } | |
| return fmt.Errorf("failed to write file: %w", err) | |
| } | |
| if err := outFile.Close(); err != nil { | |
| rcCloseErr := rc.Close() | |
| if rcCloseErr != nil { | |
| return fmt.Errorf("failed to close output file: %w (and failed to close archive file: %v)", err, rcCloseErr) | |
| } | |
| return fmt.Errorf("failed to close output file: %w", err) | |
| } | |
| if err := rc.Close(); err != nil { | |
| return fmt.Errorf("failed to close archive file: %w", err) | |
| } |
| outFile.Close() | ||
| rc.Close() | ||
|
|
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
File handle may be writable as a result of data flow from a call to OpenFile and closing it may result in data loss upon failure, which is not handled explicitly.
| outFile.Close() | |
| rc.Close() | |
| // Close the archive entry reader first; errors here are less critical | |
| if err := rc.Close(); err != nil { | |
| return fmt.Errorf("failed to close archive file reader: %w", err) | |
| } | |
| // Ensure any buffered data is flushed and detect write/FS errors | |
| if err := outFile.Close(); err != nil { | |
| return fmt.Errorf("failed to close output file: %w", err) | |
| } |
| outFile.Close() | ||
| return err | ||
| } | ||
|
|
||
| _, err = io.Copy(outFile, rc) | ||
| rc.Close() | ||
| outFile.Close() | ||
|
|
||
| if err != nil { | ||
| return err | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
File handle may be writable as a result of data flow from a call to OpenFile and closing it may result in data loss upon failure, which is not handled explicitly.
| outFile.Close() | |
| return err | |
| } | |
| _, err = io.Copy(outFile, rc) | |
| rc.Close() | |
| outFile.Close() | |
| if err != nil { | |
| return err | |
| } | |
| if cerr := outFile.Close(); cerr != nil { | |
| return fmt.Errorf("failed to close output file %q after error opening zip entry %q: %w (original error: %v)", fpath, f.Name, cerr, err) | |
| } | |
| return err | |
| } | |
| copyErr := io.Copy(outFile, rc) | |
| rcErr := rc.Close() | |
| closeErr := outFile.Close() | |
| if copyErr != nil { | |
| return copyErr | |
| } | |
| if rcErr != nil { | |
| return rcErr | |
| } | |
| if closeErr != nil { | |
| return closeErr | |
| } |
| rc.Close() | ||
| outFile.Close() | ||
|
|
||
| if err != nil { | ||
| return err | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
File handle may be writable as a result of data flow from a call to OpenFile and closing it may result in data loss upon failure, which is not handled explicitly.
| rc.Close() | |
| outFile.Close() | |
| if err != nil { | |
| return err | |
| } | |
| rcCloseErr := rc.Close() | |
| outFileCloseErr := outFile.Close() | |
| if err != nil { | |
| return err | |
| } | |
| if outFileCloseErr != nil { | |
| return outFileCloseErr | |
| } | |
| if rcCloseErr != nil { | |
| return rcCloseErr | |
| } |
Introduce functions for installing and uninstalling SLP, along with a comprehensive mod package upload system.
Enhance the UI for managing mods
add related localization
and implement logging for modding activities
updated the API to handle mods and workshop details
add functionality for workshop mod updates
slighly refactored the CLI for improved structure
add new commands for testing and managing mods
improve credits page and add donors Stealthbob & Sumisukyo