Disable Save-to-server when storage off, fix QR port 0 (#6473)

This commit is contained in:
Anthony Stirling
2026-05-29 19:37:42 +01:00
committed by GitHub
parent 2b0905887b
commit 30e782e29c
8 changed files with 305 additions and 84 deletions
@@ -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));
@@ -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));
}
}
@@ -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:"
@@ -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…")}
</Button>
{/* 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 && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
multiline
w={260}
>
<Button
leftSection={<CloudUploadIcon fontSize="small" />}
variant="default"
disabled={Boolean(saveToServerDisabledReason)}
onClick={() => onSaveToServer(localOnlyFiles)}
styles={{
root: {
// Keep tooltip hoverable while button is disabled.
pointerEvents: saveToServerDisabledReason
? "auto"
: undefined,
},
}}
>
{t("filesPage.saveToServer", "Save to server")}
</Button>
</Tooltip>
)}
<Button
leftSection={<DeleteIcon fontSize="small" />}
@@ -78,6 +78,8 @@ interface FileGridProps {
onPromptMoveFiles: (fileIds: FileId[]) => void;
/** Per-file Save to server; hidden when file already has remoteStorageId. */
onSaveToServer?: (file: StirlingFileStub) => void;
/** When set, the Save to server item renders disabled with this tooltip. */
saveToServerDisabledReason?: string | null;
/** When supplied the list-view column headers become sortable. */
sortMode?: FilesPageSortMode;
onChangeSortMode?: (mode: FilesPageSortMode) => void;
@@ -334,6 +336,7 @@ function GridView({
onRemoveFiles,
onPromptMoveFiles,
onSaveToServer,
saveToServerDisabledReason,
}: FileGridProps) {
return (
<div className="files-page-grid" role="list">
@@ -386,6 +389,7 @@ function GridView({
onSaveToServer={
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
}
saveToServerDisabledReason={saveToServerDisabledReason}
/>
);
}
@@ -584,6 +588,8 @@ interface FileCardProps {
onMove: () => void;
/** Kebab Save to server; only fires when file is local-only. */
onSaveToServer?: () => void;
/** When set, the kebab Save to server is disabled with this tooltip. */
saveToServerDisabledReason?: string | null;
}
function FileCard({
@@ -599,6 +605,7 @@ function FileCard({
onRemove,
onMove,
onSaveToServer,
saveToServerDisabledReason,
}: FileCardProps) {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement>(null);
@@ -769,17 +776,33 @@ function FileCard({
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{/* Per-file Save to server; hidden when already on server. */}
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{onSaveToServer && file.remoteStorageId == null && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
<Menu.Divider />
<Menu.Item
@@ -813,6 +836,7 @@ function ListView({
onRenameFolder,
onDeleteFolder,
onSaveToServer,
saveToServerDisabledReason,
onChangeFolderAppearance,
onRemoveFiles,
onPromptMoveFiles,
@@ -946,6 +970,7 @@ function ListView({
onSaveToServer={
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
}
saveToServerDisabledReason={saveToServerDisabledReason}
/>
);
}
@@ -1148,6 +1173,8 @@ interface FileRowProps {
onMove: () => void;
/** Kebab Save to server; only fires when file is local-only. */
onSaveToServer?: () => void;
/** When set, the kebab Save to server is disabled with this tooltip. */
saveToServerDisabledReason?: string | null;
}
function FileRow({
@@ -1163,6 +1190,7 @@ function FileRow({
onRemove,
onMove,
onSaveToServer,
saveToServerDisabledReason,
}: FileRowProps) {
const { t } = useTranslation();
const kebabRef = useRef<HTMLButtonElement>(null);
@@ -1336,17 +1364,33 @@ function FileRow({
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{/* Per-file Save to server; hidden when already on server. */}
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{onSaveToServer && file.remoteStorageId == null && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
<Menu.Divider />
<Menu.Item
@@ -106,6 +106,17 @@ export default function FileManagerView() {
const isMobile = useIsMobile();
const isMobileUploadAvailable =
Boolean(appConfig?.enableMobileScanner) && !isMobile;
// Server storage gate; mirrors ConfigController's storageEnabled
// (enableLogin && storage.isEnabled). When off, Save-to-server stays
// visible but disabled with an explanatory tooltip (discoverability beats
// hiding - mirrors the New folder / Manage sharing gates in this view).
const uploadEnabled = appConfig?.storageEnabled === true;
const saveToServerDisabledReason: string | null = uploadEnabled
? null
: t(
"filesPage.saveToServerDisabledHint",
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
);
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const { actions: navActions } = useNavigationActions();
const { requestNavigation } = useNavigationGuard();
@@ -1185,19 +1196,35 @@ export default function FileManagerView() {
</Button>
</Tooltip>
)}
{/* Save to server; hidden when no local-only file selected. */}
{/* Save to server; shown whenever local-only files are
selected. When storage is off it stays visible but
disabled, tooltip pointing at the admin. */}
{localOnlySelectedStubs.length > 0 && (
<Tooltip
label={t("filesPage.saveToServer", "Save to server")}
label={
saveToServerDisabledReason ??
t("filesPage.saveToServer", "Save to server")
}
withinPortal
multiline={Boolean(saveToServerDisabledReason)}
w={saveToServerDisabledReason ? 240 : undefined}
>
<Button
size="sm"
variant="default"
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={() =>
setSaveToServerTarget(localOnlySelectedStubs)
}
styles={{
root: {
// Keep the tooltip hoverable while disabled.
pointerEvents: saveToServerDisabledReason
? "auto"
: undefined,
},
}}
aria-label={t(
"filesPage.saveToServer",
"Save to server",
@@ -1450,6 +1477,7 @@ export default function FileManagerView() {
onRemoveFiles={handleRemoveFiles}
onPromptMoveFiles={promptMoveFiles}
onSaveToServer={(file) => setSaveToServerTarget([file])}
saveToServerDisabledReason={saveToServerDisabledReason}
// Center-of-grid CTAs when the empty state shows - same
// handlers the corner header buttons use so behaviour
// (disabled tooltips, native file picker, dialog) is
@@ -1495,6 +1523,7 @@ export default function FileManagerView() {
onMove={promptMoveFiles}
onRemove={handleRemoveFiles}
onSaveToServer={(files) => setSaveToServerTarget(files)}
saveToServerDisabledReason={saveToServerDisabledReason}
/>
)}
</div>
@@ -1521,6 +1550,7 @@ export default function FileManagerView() {
onMove={promptMoveFiles}
onRemove={handleRemoveFiles}
onSaveToServer={(files) => setSaveToServerTarget(files)}
saveToServerDisabledReason={saveToServerDisabledReason}
/>
)}
</Drawer>
@@ -89,8 +89,18 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
onClose();
} catch (error) {
console.error("Failed to upload files to server:", error);
// A 403 means the server has storage turned off (or login disabled,
// which gates storage). Say so plainly instead of the generic
// "check your settings" message, which reads as a user mistake.
const status = (error as { response?: { status?: number } })?.response
?.status;
setErrorMessage(
t(
status === 403
? t(
"storageUpload.featureDisabled",
"Saving to the server isn't enabled on this server.",
)
: t(
"storageUpload.failure",
"Upload failed. Please check your login and storage settings.",
),
@@ -284,6 +284,52 @@ test.describe("Files page", () => {
});
});
test.describe("Save to server gating (storage disabled)", () => {
test.beforeEach(async ({ page }) => {
// storageEnabled:false -> Save-to-server stays visible for local-only
// files but is disabled (with an explanatory tooltip), not hidden, so
// users discover the feature and know to ask their admin.
await stubStorageApis(page, { storageEnabled: false });
await seedFiles(page, [
{ id: "local-a", name: "local-a.pdf", remoteStorageId: null },
]);
});
test.use({ autoGoto: false });
test("bulk Save to server is disabled (not hidden) when storage off", async ({
page,
}) => {
await gotoFilesPage(page);
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "local-a.pdf" })
.click();
const saveButtons = page.getByRole("button", {
name: /^Save to server$/i,
});
// Present (toolbar + details panel) and every instance disabled.
const count = await saveButtons.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i += 1) {
await expect(saveButtons.nth(i)).toBeVisible();
await expect(saveButtons.nth(i)).toBeDisabled();
}
});
test("per-file kebab Save to server is disabled (not hidden) when storage off", async ({
page,
}) => {
await gotoFilesPage(page);
const localCard = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "local-a.pdf" });
await localCard.getByRole("button", { name: /File actions/i }).click();
const item = page.getByRole("menuitem", { name: /^Save to server$/i });
await expect(item).toBeVisible();
await expect(item).toBeDisabled();
});
});
test.describe("Upload behaviour", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);