Skip to content

Commit 30e782e

Browse files
authored
Disable Save-to-server when storage off, fix QR port 0 (Stirling-Tools#6473)
1 parent 2b09058 commit 30e782e

8 files changed

Lines changed: 305 additions & 84 deletions

File tree

app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,30 @@ String resolveFrontendUrl(HttpServletRequest request, AppConfig appConfig) {
119119
String localIp = GeneralUtils.getLocalNetworkIp();
120120
if (localIp != null) {
121121
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
122-
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
122+
return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig);
123123
}
124124
return "";
125125
}
126126

127+
/**
128+
* The port the embedded server is actually listening on. With {@code server.port=0} (an
129+
* ephemeral port, which the desktop bundle uses to dodge port clashes) the configured value
130+
* stays {@code "0"} while Spring publishes the real bound port as {@code local.server.port}
131+
* once the server is up. Advertised URLs (the mobile-scanner QR, share links) must carry the
132+
* real port - a literal {@code :0} is unreachable and browsers reject it as ERR_UNSAFE_PORT.
133+
*/
134+
// visible for testing
135+
String resolveEffectiveServerPort(AppConfig appConfig) {
136+
String configured = appConfig.getServerPort();
137+
if (configured == null || "0".equals(configured.trim())) {
138+
String actual = applicationContext.getEnvironment().getProperty("local.server.port");
139+
if (actual != null && !actual.isBlank()) {
140+
return actual;
141+
}
142+
}
143+
return configured;
144+
}
145+
127146
private static boolean isLoopbackHost(String host) {
128147
return "localhost".equalsIgnoreCase(host)
129148
|| "127.0.0.1".equals(host)
@@ -161,7 +180,7 @@ public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest reque
161180
// Note: Frontend expects "baseUrl" field name for compatibility
162181
configData.put("baseUrl", appConfig.getBackendUrl());
163182
configData.put("contextPath", appConfig.getContextPath());
164-
configData.put("serverPort", appConfig.getServerPort());
183+
configData.put("serverPort", resolveEffectiveServerPort(appConfig));
165184

166185
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
167186
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));

app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,4 +244,52 @@ void resolveFrontendUrl_fallsThroughOnLoopbackHost() {
244244
assertNotNull(result);
245245
assertFalse(result.contains("localhost"));
246246
}
247+
248+
@Test
249+
void resolveFrontendUrl_usesActualPortWhenServerPortIsEphemeral() {
250+
System sys = mock(System.class);
251+
when(applicationProperties.getSystem()).thenReturn(sys);
252+
when(sys.getFrontendUrl()).thenReturn(null);
253+
254+
// Loopback host forces the detected-LAN-IP branch, which is where an
255+
// ephemeral server.port=0 would otherwise leak through as ":0".
256+
HttpServletRequest req = mock(HttpServletRequest.class);
257+
when(req.getServerName()).thenReturn("localhost");
258+
259+
AppConfig appConfig = mock(AppConfig.class);
260+
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
261+
when(appConfig.getServerPort()).thenReturn("0");
262+
263+
org.springframework.core.env.Environment environment =
264+
mock(org.springframework.core.env.Environment.class);
265+
when(applicationContext.getEnvironment()).thenReturn(environment);
266+
when(environment.getProperty("local.server.port")).thenReturn("54321");
267+
268+
String result = configController.resolveFrontendUrl(req, appConfig);
269+
assertNotNull(result);
270+
assertTrue(result.endsWith(":54321"));
271+
assertFalse(result.contains(":0"));
272+
}
273+
274+
@Test
275+
void resolveEffectiveServerPort_prefersActualBoundPortWhenConfiguredZero() {
276+
AppConfig appConfig = mock(AppConfig.class);
277+
when(appConfig.getServerPort()).thenReturn("0");
278+
279+
org.springframework.core.env.Environment environment =
280+
mock(org.springframework.core.env.Environment.class);
281+
when(applicationContext.getEnvironment()).thenReturn(environment);
282+
when(environment.getProperty("local.server.port")).thenReturn("54321");
283+
284+
assertEquals("54321", configController.resolveEffectiveServerPort(appConfig));
285+
}
286+
287+
@Test
288+
void resolveEffectiveServerPort_keepsConfiguredNonZeroPort() {
289+
AppConfig appConfig = mock(AppConfig.class);
290+
when(appConfig.getServerPort()).thenReturn("8080");
291+
292+
// Non-zero configured port is authoritative; the runtime env is never consulted.
293+
assertEquals("8080", configController.resolveEffectiveServerPort(appConfig));
294+
}
247295
}

frontend/editor/public/locales/en-GB/translation.toml

Lines changed: 53 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,6 +1506,32 @@ user = "User"
15061506
usernameInfo = "Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address."
15071507
webOnlyUser = "Web Only User"
15081508

1509+
[agents]
1510+
auto_redaction_description = "Redact PII automatically"
1511+
auto_redaction_name = "Auto Redaction"
1512+
back_to_tools = "Back to tools"
1513+
coming_soon = "Coming soon"
1514+
compliance_description = "Audit documents for compliance"
1515+
compliance_name = "Compliance Check"
1516+
data_extraction_description = "Extract tables & structured data"
1517+
data_extraction_name = "Data Extraction"
1518+
doc_summary_description = "Summarise long documents"
1519+
doc_summary_name = "Summariser"
1520+
form_filler_description = "Fill PDF forms intelligently"
1521+
form_filler_name = "Form Filler"
1522+
fullscreen_title = "Stirling Agents"
1523+
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
1524+
pdf_to_markdown_name = "PDF to Markdown"
1525+
section_title = "Agents"
1526+
show_less = "Show less"
1527+
start_chat = "Start chatting"
1528+
stirling_description = "Your general-purpose PDF assistant"
1529+
stirling_full_name = "Stirling General Agent"
1530+
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
1531+
stirling_name = "Stirling"
1532+
stirling_tooltip = "Stirling agent"
1533+
view_all = "View all agents"
1534+
15091535
[analytics]
15101536
disable = "Disable analytics"
15111537
enable = "Enable analytics"
@@ -2689,58 +2715,15 @@ title = "Change Permissions"
26892715
[changePermissions.tooltip.warning]
26902716
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
26912717

2692-
[agents]
2693-
section_title = "Agents"
2694-
fullscreen_title = "Stirling Agents"
2695-
stirling_name = "Stirling"
2696-
stirling_full_name = "Stirling General Agent"
2697-
stirling_tooltip = "Stirling agent"
2698-
stirling_description = "Your general-purpose PDF assistant"
2699-
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
2700-
back_to_tools = "Back to tools"
2701-
coming_soon = "Coming soon"
2702-
view_all = "View all agents"
2703-
show_less = "Show less"
2704-
start_chat = "Start chatting"
2705-
data_extraction_name = "Data Extraction"
2706-
data_extraction_description = "Extract tables & structured data"
2707-
doc_summary_name = "Summariser"
2708-
doc_summary_description = "Summarise long documents"
2709-
auto_redaction_name = "Auto Redaction"
2710-
auto_redaction_description = "Redact PII automatically"
2711-
compliance_name = "Compliance Check"
2712-
compliance_description = "Audit documents for compliance"
2713-
form_filler_name = "Form Filler"
2714-
form_filler_description = "Fill PDF forms intelligently"
2715-
pdf_to_markdown_name = "PDF to Markdown"
2716-
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
2717-
27182718
[chat.header]
2719-
settings = "Agent settings"
27202719
agentMenu = "Stirling agent options"
27212720
clearChat = "Clear chat"
2721+
settings = "Agent settings"
27222722

27232723
[chat.input]
2724+
attach = "Attach files"
27242725
placeholder = "What do you want to do?"
27252726
send = "Send message"
2726-
attach = "Attach files"
2727-
2728-
[chat.quickActions]
2729-
heading = "Get started"
2730-
openFromComputer = "Open from computer"
2731-
browseYourFiles = "Browse your files"
2732-
rotateOne = "Rotate this document"
2733-
rotateMany = "Rotate these documents"
2734-
compressOne = "Compress this document"
2735-
compressMany = "Compress these documents"
2736-
mergeMany = "Merge these {{count}} documents into 1"
2737-
splitOne = "Split this document"
2738-
convertOne = "Convert this document to PDF"
2739-
convertMany = "Convert these documents to PDF"
2740-
fileSummary_one = "1 file in workbench ({{types}})"
2741-
fileSummary_other = "{{count}} files in workbench ({{types}})"
2742-
moreFiles = "+{{count}} more"
2743-
removeFile = "Remove {{name}}"
27442727

27452728
[chat.progress]
27462729
analyzing = "Analysing your request..."
@@ -2757,14 +2740,31 @@ whole_doc_read_done = "Finished reading the document..."
27572740
whole_doc_read_started = "Reading the document..."
27582741
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
27592742

2743+
[chat.quickActions]
2744+
browseYourFiles = "Browse your files"
2745+
compressMany = "Compress these documents"
2746+
compressOne = "Compress this document"
2747+
convertMany = "Convert these documents to PDF"
2748+
convertOne = "Convert this document to PDF"
2749+
fileSummary_one = "1 file in workbench ({{types}})"
2750+
fileSummary_other = "{{count}} files in workbench ({{types}})"
2751+
heading = "Get started"
2752+
mergeMany = "Merge these {{count}} documents into 1"
2753+
moreFiles = "+{{count}} more"
2754+
openFromComputer = "Open from computer"
2755+
removeFile = "Remove {{name}}"
2756+
rotateMany = "Rotate these documents"
2757+
rotateOne = "Rotate this document"
2758+
splitOne = "Split this document"
2759+
27602760
[chat.responses]
2761+
cannot_continue = "Something went wrong and I can't continue."
2762+
cannot_do = "I'm unable to do that."
27612763
done = "Done."
27622764
need_clarification = "Could you clarify your request?"
2763-
cannot_do = "I'm unable to do that."
27642765
not_found = "I couldn't find the requested information."
2765-
unsupported_capability = "Unsupported capability: {{capability}}"
2766-
cannot_continue = "Something went wrong and I can't continue."
27672766
processing = "Processing ({{outcome}})..."
2767+
unsupported_capability = "Unsupported capability: {{capability}}"
27682768

27692769
[chat.toolsUsed]
27702770
summary = "Ran {{count}} tools"
@@ -3915,6 +3915,7 @@ renameFolder = "Rename folder"
39153915
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
39163916
save = "Save"
39173917
saveToServer = "Save to server"
3918+
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
39183919
search = "Search"
39193920
searchPlaceholder = "Search this folder & subfolders"
39203921
selectAll = "Select all"
@@ -7959,6 +7960,7 @@ bulkTitle = "Upload checked files"
79597960
description = "This uploads the current file to server storage for your own access."
79607961
errorTitle = "Upload failed"
79617962
failure = "Upload failed. Please check your login and storage settings."
7963+
featureDisabled = "Saving to the server isn't enabled on this server."
79627964
fileCount = "{{count}} files"
79637965
fileLabel = "File"
79647966
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."
81328134
[toolPanel]
81338135
allTools = "All tools"
81348136
alpha = "Alpha"
8137+
backToAllTools = "Back to all tools"
8138+
backToDefault = "Back"
81358139
backToTools = "Back to tools"
81368140
collapse = "Collapse panel"
81378141
comingSoon = "Coming soon:"
81388142
expand = "Expand panel"
8143+
goBack = "Go back"
81398144
placeholder = "Choose a tool to get started"
81408145
premiumFeature = "Premium feature:"
81418146
search = "Search tools"
81428147
toolsHeader = "Tools"
81438148
viewAllTools = "View all tools"
8144-
backToDefault = "Back"
8145-
backToAllTools = "Back to all tools"
8146-
goBack = "Go back"
81478149

81488150
[toolPanel.fullscreen]
81498151
comingSoon = "Coming soon:"

frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ interface FileDetailsPanelProps {
3939
onRemove: (fileIds: FileId[]) => void;
4040
/** Save to server; only shown when at least one selected file is local-only. */
4141
onSaveToServer?: (files: StirlingFileStub[]) => void;
42+
/** When set, Save to server renders disabled with this tooltip (storage off). */
43+
saveToServerDisabledReason?: string | null;
4244
}
4345

4446
export function FileDetailsPanel({
@@ -51,6 +53,7 @@ export function FileDetailsPanel({
5153
onMove,
5254
onRemove,
5355
onSaveToServer,
56+
saveToServerDisabledReason,
5457
}: FileDetailsPanelProps) {
5558
const { t } = useTranslation();
5659
const { sharingEnabled } = useSharingEnabled();
@@ -319,15 +322,34 @@ export function FileDetailsPanel({
319322
>
320323
{t("filesPage.moveTo", "Move to…")}
321324
</Button>
322-
{/* Save to server; shown when any selected file is local-only. */}
325+
{/* Save to server; shown when any selected file is local-only. When
326+
storage is off it stays visible but disabled with a tooltip (same
327+
treatment as Manage sharing above). */}
323328
{onSaveToServer && localOnlyFiles.length > 0 && (
324-
<Button
325-
leftSection={<CloudUploadIcon fontSize="small" />}
326-
variant="default"
327-
onClick={() => onSaveToServer(localOnlyFiles)}
329+
<Tooltip
330+
label={saveToServerDisabledReason}
331+
disabled={!saveToServerDisabledReason}
332+
withinPortal
333+
multiline
334+
w={260}
328335
>
329-
{t("filesPage.saveToServer", "Save to server")}
330-
</Button>
336+
<Button
337+
leftSection={<CloudUploadIcon fontSize="small" />}
338+
variant="default"
339+
disabled={Boolean(saveToServerDisabledReason)}
340+
onClick={() => onSaveToServer(localOnlyFiles)}
341+
styles={{
342+
root: {
343+
// Keep tooltip hoverable while button is disabled.
344+
pointerEvents: saveToServerDisabledReason
345+
? "auto"
346+
: undefined,
347+
},
348+
}}
349+
>
350+
{t("filesPage.saveToServer", "Save to server")}
351+
</Button>
352+
</Tooltip>
331353
)}
332354
<Button
333355
leftSection={<DeleteIcon fontSize="small" />}

0 commit comments

Comments
 (0)