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 && ( - + + )}