From 30e782e29cdd7a0981f1de96fd23f7c6acb3b05a Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Fri, 29 May 2026 19:37:42 +0100
Subject: [PATCH] Disable Save-to-server when storage off, fix QR port 0
(#6473)
---
.../controller/api/misc/ConfigController.java | 23 +++-
.../api/misc/ConfigControllerTest.java | 48 ++++++++
.../public/locales/en-GB/translation.toml | 104 +++++++++---------
.../components/filesPage/FileDetailsPanel.tsx | 36 ++++--
.../core/components/filesPage/FileGrid.tsx | 80 +++++++++++---
.../components/filesPage/FileManagerView.tsx | 34 +++++-
.../shared/BulkUploadToServerModal.tsx | 18 ++-
.../src/core/tests/stubbed/files-page.spec.ts | 46 ++++++++
8 files changed, 305 insertions(+), 84 deletions(-)
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
index 383d05e0e..1b2fcaddf 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
@@ -119,11 +119,30 @@ public class ConfigController {
String localIp = GeneralUtils.getLocalNetworkIp();
if (localIp != null) {
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
- return scheme + "://" + localIp + ":" + appConfig.getServerPort();
+ return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig);
}
return "";
}
+ /**
+ * The port the embedded server is actually listening on. With {@code server.port=0} (an
+ * ephemeral port, which the desktop bundle uses to dodge port clashes) the configured value
+ * stays {@code "0"} while Spring publishes the real bound port as {@code local.server.port}
+ * once the server is up. Advertised URLs (the mobile-scanner QR, share links) must carry the
+ * real port - a literal {@code :0} is unreachable and browsers reject it as ERR_UNSAFE_PORT.
+ */
+ // visible for testing
+ String resolveEffectiveServerPort(AppConfig appConfig) {
+ String configured = appConfig.getServerPort();
+ if (configured == null || "0".equals(configured.trim())) {
+ String actual = applicationContext.getEnvironment().getProperty("local.server.port");
+ if (actual != null && !actual.isBlank()) {
+ return actual;
+ }
+ }
+ return configured;
+ }
+
private static boolean isLoopbackHost(String host) {
return "localhost".equalsIgnoreCase(host)
|| "127.0.0.1".equals(host)
@@ -161,7 +180,7 @@ public class ConfigController {
// Note: Frontend expects "baseUrl" field name for compatibility
configData.put("baseUrl", appConfig.getBackendUrl());
configData.put("contextPath", appConfig.getContextPath());
- configData.put("serverPort", appConfig.getServerPort());
+ configData.put("serverPort", resolveEffectiveServerPort(appConfig));
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java
index 6b5a9b9da..cab1622c6 100644
--- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java
@@ -244,4 +244,52 @@ class ConfigControllerTest {
assertNotNull(result);
assertFalse(result.contains("localhost"));
}
+
+ @Test
+ void resolveFrontendUrl_usesActualPortWhenServerPortIsEphemeral() {
+ System sys = mock(System.class);
+ when(applicationProperties.getSystem()).thenReturn(sys);
+ when(sys.getFrontendUrl()).thenReturn(null);
+
+ // Loopback host forces the detected-LAN-IP branch, which is where an
+ // ephemeral server.port=0 would otherwise leak through as ":0".
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getServerName()).thenReturn("localhost");
+
+ AppConfig appConfig = mock(AppConfig.class);
+ when(appConfig.getBackendUrl()).thenReturn("http://localhost");
+ when(appConfig.getServerPort()).thenReturn("0");
+
+ org.springframework.core.env.Environment environment =
+ mock(org.springframework.core.env.Environment.class);
+ when(applicationContext.getEnvironment()).thenReturn(environment);
+ when(environment.getProperty("local.server.port")).thenReturn("54321");
+
+ String result = configController.resolveFrontendUrl(req, appConfig);
+ assertNotNull(result);
+ assertTrue(result.endsWith(":54321"));
+ assertFalse(result.contains(":0"));
+ }
+
+ @Test
+ void resolveEffectiveServerPort_prefersActualBoundPortWhenConfiguredZero() {
+ AppConfig appConfig = mock(AppConfig.class);
+ when(appConfig.getServerPort()).thenReturn("0");
+
+ org.springframework.core.env.Environment environment =
+ mock(org.springframework.core.env.Environment.class);
+ when(applicationContext.getEnvironment()).thenReturn(environment);
+ when(environment.getProperty("local.server.port")).thenReturn("54321");
+
+ assertEquals("54321", configController.resolveEffectiveServerPort(appConfig));
+ }
+
+ @Test
+ void resolveEffectiveServerPort_keepsConfiguredNonZeroPort() {
+ AppConfig appConfig = mock(AppConfig.class);
+ when(appConfig.getServerPort()).thenReturn("8080");
+
+ // Non-zero configured port is authoritative; the runtime env is never consulted.
+ assertEquals("8080", configController.resolveEffectiveServerPort(appConfig));
+ }
}
diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml
index d5e0280df..9697479bf 100644
--- a/frontend/editor/public/locales/en-GB/translation.toml
+++ b/frontend/editor/public/locales/en-GB/translation.toml
@@ -1506,6 +1506,32 @@ user = "User"
usernameInfo = "Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address."
webOnlyUser = "Web Only User"
+[agents]
+auto_redaction_description = "Redact PII automatically"
+auto_redaction_name = "Auto Redaction"
+back_to_tools = "Back to tools"
+coming_soon = "Coming soon"
+compliance_description = "Audit documents for compliance"
+compliance_name = "Compliance Check"
+data_extraction_description = "Extract tables & structured data"
+data_extraction_name = "Data Extraction"
+doc_summary_description = "Summarise long documents"
+doc_summary_name = "Summariser"
+form_filler_description = "Fill PDF forms intelligently"
+form_filler_name = "Form Filler"
+fullscreen_title = "Stirling Agents"
+pdf_to_markdown_description = "Convert PDFs to clean Markdown"
+pdf_to_markdown_name = "PDF to Markdown"
+section_title = "Agents"
+show_less = "Show less"
+start_chat = "Start chatting"
+stirling_description = "Your general-purpose PDF assistant"
+stirling_full_name = "Stirling General Agent"
+stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
+stirling_name = "Stirling"
+stirling_tooltip = "Stirling agent"
+view_all = "View all agents"
+
[analytics]
disable = "Disable analytics"
enable = "Enable analytics"
@@ -2689,58 +2715,15 @@ title = "Change Permissions"
[changePermissions.tooltip.warning]
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
-[agents]
-section_title = "Agents"
-fullscreen_title = "Stirling Agents"
-stirling_name = "Stirling"
-stirling_full_name = "Stirling General Agent"
-stirling_tooltip = "Stirling agent"
-stirling_description = "Your general-purpose PDF assistant"
-stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
-back_to_tools = "Back to tools"
-coming_soon = "Coming soon"
-view_all = "View all agents"
-show_less = "Show less"
-start_chat = "Start chatting"
-data_extraction_name = "Data Extraction"
-data_extraction_description = "Extract tables & structured data"
-doc_summary_name = "Summariser"
-doc_summary_description = "Summarise long documents"
-auto_redaction_name = "Auto Redaction"
-auto_redaction_description = "Redact PII automatically"
-compliance_name = "Compliance Check"
-compliance_description = "Audit documents for compliance"
-form_filler_name = "Form Filler"
-form_filler_description = "Fill PDF forms intelligently"
-pdf_to_markdown_name = "PDF to Markdown"
-pdf_to_markdown_description = "Convert PDFs to clean Markdown"
-
[chat.header]
-settings = "Agent settings"
agentMenu = "Stirling agent options"
clearChat = "Clear chat"
+settings = "Agent settings"
[chat.input]
+attach = "Attach files"
placeholder = "What do you want to do?"
send = "Send message"
-attach = "Attach files"
-
-[chat.quickActions]
-heading = "Get started"
-openFromComputer = "Open from computer"
-browseYourFiles = "Browse your files"
-rotateOne = "Rotate this document"
-rotateMany = "Rotate these documents"
-compressOne = "Compress this document"
-compressMany = "Compress these documents"
-mergeMany = "Merge these {{count}} documents into 1"
-splitOne = "Split this document"
-convertOne = "Convert this document to PDF"
-convertMany = "Convert these documents to PDF"
-fileSummary_one = "1 file in workbench ({{types}})"
-fileSummary_other = "{{count}} files in workbench ({{types}})"
-moreFiles = "+{{count}} more"
-removeFile = "Remove {{name}}"
[chat.progress]
analyzing = "Analysing your request..."
@@ -2757,14 +2740,31 @@ whole_doc_read_done = "Finished reading the document..."
whole_doc_read_started = "Reading the document..."
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
+[chat.quickActions]
+browseYourFiles = "Browse your files"
+compressMany = "Compress these documents"
+compressOne = "Compress this document"
+convertMany = "Convert these documents to PDF"
+convertOne = "Convert this document to PDF"
+fileSummary_one = "1 file in workbench ({{types}})"
+fileSummary_other = "{{count}} files in workbench ({{types}})"
+heading = "Get started"
+mergeMany = "Merge these {{count}} documents into 1"
+moreFiles = "+{{count}} more"
+openFromComputer = "Open from computer"
+removeFile = "Remove {{name}}"
+rotateMany = "Rotate these documents"
+rotateOne = "Rotate this document"
+splitOne = "Split this document"
+
[chat.responses]
+cannot_continue = "Something went wrong and I can't continue."
+cannot_do = "I'm unable to do that."
done = "Done."
need_clarification = "Could you clarify your request?"
-cannot_do = "I'm unable to do that."
not_found = "I couldn't find the requested information."
-unsupported_capability = "Unsupported capability: {{capability}}"
-cannot_continue = "Something went wrong and I can't continue."
processing = "Processing ({{outcome}})..."
+unsupported_capability = "Unsupported capability: {{capability}}"
[chat.toolsUsed]
summary = "Ran {{count}} tools"
@@ -3915,6 +3915,7 @@ renameFolder = "Rename folder"
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
save = "Save"
saveToServer = "Save to server"
+saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
search = "Search"
searchPlaceholder = "Search this folder & subfolders"
selectAll = "Select all"
@@ -7959,6 +7960,7 @@ bulkTitle = "Upload checked files"
description = "This uploads the current file to server storage for your own access."
errorTitle = "Upload failed"
failure = "Upload failed. Please check your login and storage settings."
+featureDisabled = "Saving to the server isn't enabled on this server."
fileCount = "{{count}} files"
fileLabel = "File"
hint = "Public links and access modes are controlled by your server settings."
@@ -8132,18 +8134,18 @@ viewerMode = "Switch to the file editor to add multiple files."
[toolPanel]
allTools = "All tools"
alpha = "Alpha"
+backToAllTools = "Back to all tools"
+backToDefault = "Back"
backToTools = "Back to tools"
collapse = "Collapse panel"
comingSoon = "Coming soon:"
expand = "Expand panel"
+goBack = "Go back"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
search = "Search tools"
toolsHeader = "Tools"
viewAllTools = "View all tools"
-backToDefault = "Back"
-backToAllTools = "Back to all tools"
-goBack = "Go back"
[toolPanel.fullscreen]
comingSoon = "Coming soon:"
diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx
index d57869d5d..2608e4b30 100644
--- a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx
+++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx
@@ -39,6 +39,8 @@ interface FileDetailsPanelProps {
onRemove: (fileIds: FileId[]) => void;
/** Save to server; only shown when at least one selected file is local-only. */
onSaveToServer?: (files: StirlingFileStub[]) => void;
+ /** When set, Save to server renders disabled with this tooltip (storage off). */
+ saveToServerDisabledReason?: string | null;
}
export function FileDetailsPanel({
@@ -51,6 +53,7 @@ export function FileDetailsPanel({
onMove,
onRemove,
onSaveToServer,
+ saveToServerDisabledReason,
}: FileDetailsPanelProps) {
const { t } = useTranslation();
const { sharingEnabled } = useSharingEnabled();
@@ -319,15 +322,34 @@ export function FileDetailsPanel({
>
{t("filesPage.moveTo", "Move to…")}
- {/* Save to server; shown when any selected file is local-only. */}
+ {/* Save to server; shown when any selected file is local-only. When
+ storage is off it stays visible but disabled with a tooltip (same
+ treatment as Manage sharing above). */}
{onSaveToServer && localOnlyFiles.length > 0 && (
- }
- variant="default"
- onClick={() => onSaveToServer(localOnlyFiles)}
+