From 535c95b1cb0a923c1a92e261b794519055378cad Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:56:24 +0100 Subject: [PATCH] restart func --- .../software/common/util/AppArgsCapture.java | 27 ++++ .../software/common/util/JarPathUtil.java | 86 ++++++++++++ .../api/AdminSettingsController.java | 111 +++++++++++++++ build.gradle | 33 ++++- docker/backend/Dockerfile | 3 +- docker/backend/Dockerfile.fat | 3 +- docker/backend/Dockerfile.ultra-lite | 3 +- .../config/RestartConfirmationModal.tsx | 65 +++++++++ .../configSections/AdminAdvancedSection.tsx | 16 ++- .../AdminConnectionsSection.tsx | 16 ++- .../configSections/AdminEndpointsSection.tsx | 16 ++- .../configSections/AdminGeneralSection.tsx | 17 ++- .../configSections/AdminLegalSection.tsx | 34 ++++- .../configSections/AdminMailSection.tsx | 16 ++- .../configSections/AdminPremiumSection.tsx | 16 ++- .../configSections/AdminPrivacySection.tsx | 16 ++- .../configSections/AdminSecuritySection.tsx | 16 ++- .../shared/config/useRestartServer.ts | 60 ++++++++ scripts/RestartHelper.java | 129 ++++++++++++++++++ 19 files changed, 633 insertions(+), 50 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java create mode 100644 app/common/src/main/java/stirling/software/common/util/JarPathUtil.java create mode 100644 frontend/src/components/shared/config/RestartConfirmationModal.tsx create mode 100644 frontend/src/components/shared/config/useRestartServer.ts create mode 100644 scripts/RestartHelper.java diff --git a/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java b/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java new file mode 100644 index 000000000..3501a541a --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java @@ -0,0 +1,27 @@ +package stirling.software.common.util; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** + * Captures application command-line arguments at startup so they can be reused for restart + * operations. This allows the application to restart with the same configuration. + */ +@Slf4j +@Component +public class AppArgsCapture implements ApplicationRunner { + + public static final AtomicReference> APP_ARGS = new AtomicReference<>(List.of()); + + @Override + public void run(ApplicationArguments args) { + APP_ARGS.set(List.of(args.getSourceArgs())); + log.debug("Captured {} application arguments for restart capability", args.getSourceArgs().length); + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java new file mode 100644 index 000000000..b7d8e17be --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java @@ -0,0 +1,86 @@ +package stirling.software.common.util; + +import java.io.File; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import lombok.extern.slf4j.Slf4j; + +/** + * Utility class to locate JAR files at runtime for restart operations + */ +@Slf4j +public class JarPathUtil { + + /** + * Gets the path to the currently running JAR file + * + * @return Path to the current JAR, or null if not running from a JAR + */ + public static Path currentJar() { + try { + Path jar = + Paths.get( + JarPathUtil.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()) + .toAbsolutePath(); + + // Check if we're actually running from a JAR (not from IDE/classes directory) + if (jar.toString().endsWith(".jar")) { + log.debug("Current JAR located at: {}", jar); + return jar; + } else { + log.warn("Not running from JAR, current location: {}", jar); + return null; + } + } catch (URISyntaxException e) { + log.error("Failed to determine current JAR location", e); + return null; + } + } + + /** + * Gets the path to the restart-helper.jar file Expected to be in the same directory as the + * main JAR + * + * @return Path to restart-helper.jar, or null if not found + */ + public static Path restartHelperJar() { + Path appJar = currentJar(); + if (appJar == null) { + return null; + } + + Path helperJar = appJar.getParent().resolve("restart-helper.jar"); + + if (Files.isRegularFile(helperJar)) { + log.debug("Restart helper JAR located at: {}", helperJar); + return helperJar; + } else { + log.warn("Restart helper JAR not found at: {}", helperJar); + return null; + } + } + + /** + * Gets the java binary path for the current JVM + * + * @return Path to java executable + */ + public static String javaExecutable() { + String javaHome = System.getProperty("java.home"); + String javaBin = javaHome + File.separator + "bin" + File.separator + "java"; + + // On Windows, add .exe extension + if (System.getProperty("os.name").toLowerCase().contains("win")) { + javaBin += ".exe"; + } + + return javaBin; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 8282cf073..80b10b711 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -1,19 +1,27 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -32,7 +40,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.AdminApi; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.AppArgsCapture; import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.JarPathUtil; import stirling.software.proprietary.security.model.api.admin.SettingValueResponse; import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest; import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest; @@ -45,6 +55,7 @@ public class AdminSettingsController { private final ApplicationProperties applicationProperties; private final ObjectMapper objectMapper; + private final ApplicationContext applicationContext; // Track settings that have been modified but not yet applied (require restart) private static final ConcurrentHashMap pendingChanges = @@ -388,6 +399,106 @@ public class AdminSettingsController { } } + @PostMapping("/restart") + @Operation( + summary = "Restart the application", + description = + "Triggers a graceful restart of the Spring Boot application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Restart initiated successfully"), + @ApiResponse( + responseCode = "403", + description = "Access denied - Admin role required"), + @ApiResponse( + responseCode = "500", + description = "Failed to initiate restart") + }) + public ResponseEntity restartApplication() { + try { + log.warn("Admin initiated application restart"); + + // Get paths to current JAR and restart helper + Path appJar = JarPathUtil.currentJar(); + Path helperJar = JarPathUtil.restartHelperJar(); + + if (appJar == null) { + log.error("Cannot restart: not running from JAR (likely development mode)"); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body( + "Restart not available in development mode. Please restart the application manually."); + } + + if (helperJar == null || !Files.isRegularFile(helperJar)) { + log.error("Cannot restart: restart-helper.jar not found at expected location"); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body( + "Restart helper not found. Please restart the application manually."); + } + + // Get current application arguments + List appArgs = AppArgsCapture.APP_ARGS.get(); + + // Write args to temp file to avoid command-line quoting issues + Path argsFile = Files.createTempFile("stirling-app-args-", ".txt"); + Files.write(argsFile, appArgs, StandardCharsets.UTF_8); + + // Get current process PID and java executable + long pid = ProcessHandle.current().pid(); + String javaBin = JarPathUtil.javaExecutable(); + + // Build command to launch restart helper + List cmd = new ArrayList<>(); + cmd.add(javaBin); + cmd.add("-jar"); + cmd.add(helperJar.toString()); + cmd.add("--pid"); + cmd.add(Long.toString(pid)); + cmd.add("--app"); + cmd.add(appJar.toString()); + cmd.add("--argsFile"); + cmd.add(argsFile.toString()); + cmd.add("--backoffMs"); + cmd.add("1000"); + + log.info("Launching restart helper: {}", String.join(" ", cmd)); + + // Launch restart helper process + new ProcessBuilder(cmd) + .directory(appJar.getParent().toFile()) + .inheritIO() // Forward logs + .start(); + + // Clear pending changes since we're restarting + pendingChanges.clear(); + + // Give the HTTP response time to complete, then exit + new Thread( + () -> { + try { + Thread.sleep(1000); + log.info("Shutting down for restart..."); + SpringApplication.exit(applicationContext, () -> 0); + System.exit(0); + } catch (InterruptedException e) { + log.error("Restart interrupted: {}", e.getMessage(), e); + Thread.currentThread().interrupt(); + } + }) + .start(); + + return ResponseEntity.ok( + "Application restart initiated. The server will be back online shortly."); + + } catch (Exception e) { + log.error("Failed to initiate restart: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to initiate application restart: " + e.getMessage()); + } + } + private Object getSectionData(String sectionName) { if (sectionName == null || sectionName.trim().isEmpty()) { return null; diff --git a/build.gradle b/build.gradle index c2980cb5e..9f2dba488 100644 --- a/build.gradle +++ b/build.gradle @@ -629,9 +629,40 @@ tasks.named('bootRun') { tasks.named('build') { group = 'build' description = 'Delegates to :stirling-pdf:bootJar' - dependsOn ':stirling-pdf:bootJar' + dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper' doFirst { println "Delegating to :stirling-pdf:bootJar" } } + +// Task to compile RestartHelper.java +tasks.register('compileRestartHelper', JavaCompile) { + group = 'build' + description = 'Compiles the RestartHelper utility' + + source = fileTree(dir: 'scripts', include: 'RestartHelper.java') + classpath = files() + destinationDirectory = file("${buildDir}/restart-helper-classes") + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +// Task to create restart-helper.jar +tasks.register('buildRestartHelper', Jar) { + group = 'build' + description = 'Builds the restart-helper.jar' + dependsOn 'compileRestartHelper' + + from "${buildDir}/restart-helper-classes" + archiveFileName = 'restart-helper.jar' + destinationDirectory = file("${buildDir}/libs") + + manifest { + attributes 'Main-Class': 'RestartHelper' + } + + doLast { + println "restart-helper.jar created at: ${destinationDirectory.get()}/restart-helper.jar" + } +} diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 58655dfdb..666e18bd3 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -30,6 +30,7 @@ COPY scripts /scripts COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ # first /app directory is for the build stage, second is for the final image COPY --from=build /app/app/core/build/libs/*.jar app.jar +COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar ARG VERSION_TAG @@ -113,7 +114,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ - chown stirlingpdfuser:stirlingpdfgroup /app.jar + chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar EXPOSE 8080/tcp diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat index bd12e3063..4e63393e8 100644 --- a/docker/backend/Dockerfile.fat +++ b/docker/backend/Dockerfile.fat @@ -30,6 +30,7 @@ COPY scripts /scripts COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ # first /app directory is for the build stage, second is for the final image COPY --from=build /app/app/core/build/libs/*.jar app.jar +COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar ARG VERSION_TAG @@ -104,7 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ - chown stirlingpdfuser:stirlingpdfgroup /app.jar + chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar EXPOSE 8080/tcp # Set user and run command diff --git a/docker/backend/Dockerfile.ultra-lite b/docker/backend/Dockerfile.ultra-lite index 0b74e3b0a..0b4b7a939 100644 --- a/docker/backend/Dockerfile.ultra-lite +++ b/docker/backend/Dockerfile.ultra-lite @@ -45,6 +45,7 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh COPY scripts/installFonts.sh /scripts/installFonts.sh COPY --from=build /app/app/core/build/libs/*.jar app.jar +COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar # Set up necessary directories and permissions RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ @@ -65,7 +66,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et chmod +x /scripts/*.sh && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf && \ - chown stirlingpdfuser:stirlingpdfgroup /app.jar + chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar # Set environment variables ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI diff --git a/frontend/src/components/shared/config/RestartConfirmationModal.tsx b/frontend/src/components/shared/config/RestartConfirmationModal.tsx new file mode 100644 index 000000000..4f32626b0 --- /dev/null +++ b/frontend/src/components/shared/config/RestartConfirmationModal.tsx @@ -0,0 +1,65 @@ +import { Modal, Text, Group, Button, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import ScheduleIcon from '@mui/icons-material/Schedule'; + +interface RestartConfirmationModalProps { + opened: boolean; + onClose: () => void; + onRestart: () => void; +} + +export default function RestartConfirmationModal({ + opened, + onClose, + onRestart, +}: RestartConfirmationModalProps) { + const { t } = useTranslation(); + + return ( + + {t('admin.settings.restart.title', 'Restart Required')} + + } + centered + size="md" + > + + + {t( + 'admin.settings.restart.message', + 'Settings have been saved successfully. A server restart is required for the changes to take effect.' + )} + + + + {t( + 'admin.settings.restart.question', + 'Would you like to restart the server now or later?' + )} + + + + + + + + + ); +} diff --git a/frontend/src/components/shared/config/configSections/AdminAdvancedSection.tsx b/frontend/src/components/shared/config/configSections/AdminAdvancedSection.tsx index c2af91edc..d282fa2b2 100644 --- a/frontend/src/components/shared/config/configSections/AdminAdvancedSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminAdvancedSection.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput } from '@mantine/core'; import { alert } from '../../../toast'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface AdvancedSettingsData { enableAlphaFunctionality?: boolean; @@ -16,6 +18,7 @@ export default function AdminAdvancedSection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({}); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); useEffect(() => { fetchSettings(); @@ -65,11 +68,7 @@ export default function AdminAdvancedSection() { }); if (response.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -197,6 +196,13 @@ export default function AdminAdvancedSection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx index e4da8dd24..3fb2a3620 100644 --- a/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, Badge, PasswordInput } from '@mantine/core'; import { alert } from '../../../toast'; import LocalIcon from '../../LocalIcon'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface OAuth2Settings { enabled?: boolean; @@ -34,6 +36,7 @@ export default function AdminConnectionsSection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({}); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); useEffect(() => { fetchSettings(); @@ -90,11 +93,7 @@ export default function AdminConnectionsSection() { }); if (response.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -336,6 +335,13 @@ export default function AdminConnectionsSection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/configSections/AdminEndpointsSection.tsx b/frontend/src/components/shared/config/configSections/AdminEndpointsSection.tsx index 6942eb1a7..b88c7938c 100644 --- a/frontend/src/components/shared/config/configSections/AdminEndpointsSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminEndpointsSection.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core'; import { alert } from '../../../toast'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface EndpointsSettingsData { toRemove?: string[]; @@ -13,6 +15,7 @@ export default function AdminEndpointsSection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({}); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); useEffect(() => { fetchSettings(); @@ -47,11 +50,7 @@ export default function AdminEndpointsSection() { }); if (response.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -175,6 +174,13 @@ export default function AdminEndpointsSection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/src/components/shared/config/configSections/AdminGeneralSection.tsx index 111d0b169..be123351c 100644 --- a/frontend/src/components/shared/config/configSections/AdminGeneralSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminGeneralSection.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core'; import { alert } from '../../../toast'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface GeneralSettingsData { ui: { @@ -23,6 +25,7 @@ export default function AdminGeneralSection() { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const [settings, setSettings] = useState({ ui: {}, system: {}, @@ -75,11 +78,8 @@ export default function AdminGeneralSection() { ]); if (uiResponse.ok && systemResponse.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + // Show restart confirmation modal + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -245,6 +245,13 @@ export default function AdminGeneralSection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/configSections/AdminLegalSection.tsx b/frontend/src/components/shared/config/configSections/AdminLegalSection.tsx index 6fb4d606e..364990041 100644 --- a/frontend/src/components/shared/config/configSections/AdminLegalSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminLegalSection.tsx @@ -1,7 +1,10 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { TextInput, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core'; +import { TextInput, Button, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core'; +import WarningIcon from '@mui/icons-material/Warning'; import { alert } from '../../../toast'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface LegalSettingsData { termsAndConditions?: string; @@ -16,6 +19,7 @@ export default function AdminLegalSection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({}); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); useEffect(() => { fetchSettings(); @@ -50,11 +54,7 @@ export default function AdminLegalSection() { }); if (response.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -86,6 +86,21 @@ export default function AdminLegalSection() { + {/* Legal Disclaimer */} + } + title={t('admin.settings.legal.disclaimer.title', 'Legal Responsibility Warning')} + color="yellow" + variant="light" + > + + {t( + 'admin.settings.legal.disclaimer.message', + 'By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations.' + )} + + +
@@ -145,6 +160,13 @@ export default function AdminLegalSection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/configSections/AdminMailSection.tsx b/frontend/src/components/shared/config/configSections/AdminMailSection.tsx index aefb24a2e..c44da4c43 100644 --- a/frontend/src/components/shared/config/configSections/AdminMailSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminMailSection.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput } from '@mantine/core'; import { alert } from '../../../toast'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface MailSettingsData { enabled?: boolean; @@ -17,6 +19,7 @@ export default function AdminMailSection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({}); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); useEffect(() => { fetchSettings(); @@ -51,11 +54,7 @@ export default function AdminMailSection() { }); if (response.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -158,6 +157,13 @@ export default function AdminMailSection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/configSections/AdminPremiumSection.tsx b/frontend/src/components/shared/config/configSections/AdminPremiumSection.tsx index ca7bf994d..6fc6d7947 100644 --- a/frontend/src/components/shared/config/configSections/AdminPremiumSection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminPremiumSection.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core'; import { alert } from '../../../toast'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface PremiumSettingsData { key?: string; @@ -29,6 +31,7 @@ export default function AdminPremiumSection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({}); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); useEffect(() => { fetchSettings(); @@ -63,11 +66,7 @@ export default function AdminPremiumSection() { }); if (response.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -305,6 +304,13 @@ export default function AdminPremiumSection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/configSections/AdminPrivacySection.tsx b/frontend/src/components/shared/config/configSections/AdminPrivacySection.tsx index 58e0d5426..fb396788b 100644 --- a/frontend/src/components/shared/config/configSections/AdminPrivacySection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminPrivacySection.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core'; import { alert } from '../../../toast'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface PrivacySettingsData { enableAnalytics?: boolean; @@ -14,6 +16,7 @@ export default function AdminPrivacySection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [settings, setSettings] = useState({}); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); useEffect(() => { fetchSettings(); @@ -66,11 +69,7 @@ export default function AdminPrivacySection() { }); if (response.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -161,6 +160,13 @@ export default function AdminPrivacySection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/components/shared/config/configSections/AdminSecuritySection.tsx index a7f614323..2b2086455 100644 --- a/frontend/src/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/components/shared/config/configSections/AdminSecuritySection.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, PasswordInput } from '@mantine/core'; import { alert } from '../../../toast'; +import RestartConfirmationModal from '../RestartConfirmationModal'; +import { useRestartServer } from '../useRestartServer'; interface SecuritySettingsData { enableLogin?: boolean; @@ -26,6 +28,7 @@ export default function AdminSecuritySection() { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const [settings, setSettings] = useState({}); useEffect(() => { @@ -61,11 +64,7 @@ export default function AdminSecuritySection() { }); if (response.ok) { - alert({ - alertType: 'success', - title: t('admin.success', 'Success'), - body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'), - }); + showRestartModal(); } else { throw new Error('Failed to save'); } @@ -271,6 +270,13 @@ export default function AdminSecuritySection() { {t('admin.settings.save', 'Save Changes')} + + {/* Restart Confirmation Modal */} + ); } diff --git a/frontend/src/components/shared/config/useRestartServer.ts b/frontend/src/components/shared/config/useRestartServer.ts new file mode 100644 index 000000000..8cb6fc212 --- /dev/null +++ b/frontend/src/components/shared/config/useRestartServer.ts @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { alert } from '../../toast'; + +export function useRestartServer() { + const { t } = useTranslation(); + const [restartModalOpened, setRestartModalOpened] = useState(false); + + const showRestartModal = () => { + setRestartModalOpened(true); + }; + + const closeRestartModal = () => { + setRestartModalOpened(false); + }; + + const restartServer = async () => { + try { + setRestartModalOpened(false); + + alert({ + alertType: 'info', + title: t('admin.settings.restarting', 'Restarting Server'), + body: t( + 'admin.settings.restartingMessage', + 'The server is restarting. Please wait a moment...' + ), + }); + + const response = await fetch('/api/v1/admin/settings/restart', { + method: 'POST', + }); + + if (response.ok) { + // Wait a moment then reload the page + setTimeout(() => { + window.location.reload(); + }, 3000); + } else { + throw new Error('Failed to restart'); + } + } catch (error) { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t( + 'admin.settings.restartError', + 'Failed to restart server. Please restart manually.' + ), + }); + } + }; + + return { + restartModalOpened, + showRestartModal, + closeRestartModal, + restartServer, + }; +} diff --git a/scripts/RestartHelper.java b/scripts/RestartHelper.java new file mode 100644 index 000000000..322beb522 --- /dev/null +++ b/scripts/RestartHelper.java @@ -0,0 +1,129 @@ +import java.io.*; +import java.nio.file.*; +import java.util.*; + +/** + * RestartHelper - Lightweight utility to restart Stirling-PDF + * + * This helper waits for the old process to exit, then starts the app again + * with the same arguments. It's only active during restart and then exits. + * + * Usage: + * java -jar restart-helper.jar --pid 1234 --app /path/app.jar + * [--java /path/to/java] [--argsFile /path/args.txt] + * [--backoffMs 1000] + */ +public class RestartHelper { + public static void main(String[] args) { + try { + Map cli = parseArgs(args); + + long pid = Long.parseLong(req(cli, "pid")); + Path appJar = Paths.get(req(cli, "app")).toAbsolutePath().normalize(); + String javaBin = cli.getOrDefault("java", "java"); + Path argsFile = cli.containsKey("argsFile") ? Paths.get(cli.get("argsFile")) : null; + long backoffMs = Long.parseLong(cli.getOrDefault("backoffMs", "1000")); + + if (!Files.isRegularFile(appJar)) { + fail("App jar not found: " + appJar); + } + + System.out.println("[restart-helper] Waiting for PID " + pid + " to exit..."); + waitForPidToExit(pid); + + // Brief pause to allow ports/files to release + if (backoffMs > 0) { + Thread.sleep(backoffMs); + } + + List cmd = new ArrayList<>(); + cmd.add(javaBin); + cmd.add("-jar"); + cmd.add(appJar.toString()); + + // Load application arguments from file if provided + if (argsFile != null && Files.isRegularFile(argsFile)) { + for (String line : Files.readAllLines(argsFile)) { + if (!line.isBlank()) { + cmd.add(line.trim()); + } + } + // Clean up args file after reading + try { + Files.deleteIfExists(argsFile); + } catch (IOException ignored) { + // Best effort cleanup + } + } + + System.out.println("[restart-helper] Starting app: " + String.join(" ", cmd)); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.inheritIO(); // Forward logs to same console/service logs + pb.start(); + + // Exit immediately - leave app running + System.out.println("[restart-helper] App restarted successfully. Helper exiting."); + System.exit(0); + + } catch (Exception e) { + System.err.println("[restart-helper] ERROR: " + e.getMessage()); + e.printStackTrace(); + System.exit(2); + } + } + + /** + * Wait for the specified PID to exit + */ + private static void waitForPidToExit(long pid) throws InterruptedException { + try { + // Java 9+: ProcessHandle API + Optional ph = ProcessHandle.of(pid); + while (ph.isPresent() && ph.get().isAlive()) { + Thread.sleep(300); + ph = ProcessHandle.of(pid); + } + } catch (Throwable t) { + // Fallback for older JDKs or if ProcessHandle isn't available + // Just sleep a bit - by the time main exits, socket should be freed + System.out.println("[restart-helper] ProcessHandle not available, using fallback wait"); + Thread.sleep(2000); + } + } + + /** + * Parse command-line arguments in --key value format + */ + private static Map parseArgs(String[] args) { + Map map = new HashMap<>(); + for (int i = 0; i < args.length; i++) { + if (args[i].startsWith("--")) { + String key = args[i].substring(2); + String val = (i + 1 < args.length && !args[i + 1].startsWith("--")) + ? args[++i] + : "true"; + map.put(key, val); + } + } + return map; + } + + /** + * Get required parameter or fail + */ + private static String req(Map map, String key) { + String val = map.get(key); + if (val == null || val.isBlank()) { + fail("Missing required parameter: --" + key); + } + return val; + } + + /** + * Print error and exit + */ + private static void fail(String message) { + System.err.println("[restart-helper] ERROR: " + message); + System.exit(2); + } +}