merge main

This commit is contained in:
Frooodle
2026-06-25 09:32:39 +01:00
121 changed files with 2727 additions and 228 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.13.1
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.13.1
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
@@ -222,7 +222,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
@@ -43,7 +43,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.6.0
cache-disabled: true
- name: Set up Python
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Set up Docker Buildx
@@ -349,7 +349,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+3 -3
View File
@@ -73,7 +73,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -148,7 +148,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -252,7 +252,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
+2 -2
View File
@@ -78,7 +78,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Set up Docker Buildx
id: buildx
@@ -155,9 +155,9 @@ jobs:
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth).
build-args: |
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
BASE_VERSION=1.0.0
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
+2 -2
View File
@@ -122,7 +122,7 @@ jobs:
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: frontend/editor/src-tauri
# Stable key shared across workflows so the nightly warmer.
# Stable key shared across workflows so the nightly warmer.
# rust-cache still appends OS + rustc + Cargo.lock.
shared-key: tauri-${{ matrix.name }}
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -150,7 +150,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Build with Gradle
run: ./gradlew build
+25 -2
View File
@@ -5,6 +5,11 @@ vars:
# NoClassDefFoundError: jdk/dynalink/Namespace at runtime in get-info-on-pdf and verify-pdf
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported,jdk.dynalink"
# Minimum Java major the bundled JRE must be. Keep in sync with build.gradle
# `modernJavaVersion` - the app JAR is compiled for this, so an older runtime
# fails at launch with UnsupportedClassVersionError. Enforced by jlink:verify.
REQUIRED_JAVA: "25"
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
JPDFIUM_PLATFORMS:
sh: |
@@ -102,6 +107,20 @@ tasks:
jlink:
desc: "Build backend JAR and create JLink runtime for Tauri"
deps: [jlink:jar, jlink:runtime]
# Runs after the runtime is in place. Lives here (not in jlink:runtime's
# cmds) so it still fires when jlink:runtime short-circuits on its `status:`
# check and reuses an existing runtime/jre - that reuse path is exactly how
# a stale, too-old JRE slips through.
cmds:
- task: jlink:verify
jlink:verify:
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
dir: editor
env:
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
cmds:
- node scripts/verify-bundled-jre.mjs src-tauri/runtime/jre/release
jlink:jar:
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
@@ -127,9 +146,13 @@ tasks:
cmds:
- rm -rf runtime/jre
- mkdir -p runtime
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
- |
JLINK_COMPRESS="$(jlink --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
jlink \
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
+6 -6
View File
@@ -29,13 +29,13 @@ spotless {
}
}
dependencies {
api 'com.google.guava:guava:33.6.0-jre'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.20.0'
api "org.apache.commons:commons-lang3:${commonsLang3}"
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
@@ -60,7 +60,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson'
}
api 'com.stirling:jpdfium:1.0.2'
api "com.stirling:jpdfium:${jpdfiumVersion}"
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
@@ -75,12 +75,12 @@ dependencies {
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
}
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
}
@@ -514,6 +514,14 @@ public class ApplicationProperties {
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
private LoginAgreement loginAgreement = new LoginAgreement();
@Data
public static class LoginAgreement {
private boolean enabled = false;
private boolean showInAnonymousMode = true;
private String fallbackText = "";
}
}
@Data
@@ -869,8 +877,8 @@ public class ApplicationProperties {
public static class System {
private String defaultLocale;
private boolean googlevisibility;
private boolean showUpdate;
private boolean showUpdateOnlyAdmin;
private boolean showUpdate = true;
private boolean showUpdateOnlyAdmin = true;
private boolean showSettingsWhenNoLogin = true;
private boolean customHTMLFiles;
private String tessdataDir;
@@ -0,0 +1,204 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
// enable/visibility come from the legal.loginAgreement settings.
@Service
@Slf4j
public class LoginAgreementService {
// Locale codes only: rejects path separators and dots so the value can never escape the
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
private static final Pattern LOCALE_PATTERN =
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
// from recursing far enough to overflow the stack on a hostile over-length input.
private static final int MAX_LOCALE_LENGTH = 35;
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
// wholesale into heap on every public request.
private static final long MAX_FILE_BYTES = 256 * 1024;
private final ApplicationProperties applicationProperties;
public LoginAgreementService(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
public boolean isEnabled() {
return config().isEnabled();
}
public boolean isShowInAnonymousMode() {
return config().isShowInAnonymousMode();
}
/**
* Resolve the markdown to show for the requested language, falling back through the base
* language, the configured default locale (and its base), then the configured fallbackText.
* Returns an empty string when nothing is configured.
*/
public String resolveContent(String requestedLang) {
List<String> candidates = new ArrayList<>();
addLocaleCandidates(candidates, requestedLang);
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
for (String candidate : candidates) {
String content = readFileIfExists(candidate);
if (content != null && !content.isBlank()) {
return content;
}
}
String fallback = config().getFallbackText();
return fallback == null ? "" : fallback;
}
/**
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
*/
public String readRawForLocale(String locale) {
if (!isValidLocale(locale)) {
return null;
}
String content = readFileIfExists(locale);
return content == null ? "" : content;
}
/** Admin write. Blank content deletes the file so it falls back cleanly. */
public void writeForLocale(String locale, String content) throws IOException {
Path file = resolveLocaleFile(locale);
if (file == null) {
throw new IllegalArgumentException("Invalid locale: " + locale);
}
if (content == null || content.isBlank()) {
Files.deleteIfExists(file);
return;
}
Files.createDirectories(file.getParent());
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
try {
Files.writeString(tmp, content, StandardCharsets.UTF_8);
try {
Files.move(
tmp,
file,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(tmp);
}
}
/** Locales that currently have a markdown file, for the admin editor. */
public Set<String> listLocalesWithContent() {
Set<String> result = new TreeSet<>();
Path dir = disclaimerDir();
if (!Files.isDirectory(dir)) {
return result;
}
try (Stream<Path> files = Files.list(dir)) {
files.filter(Files::isRegularFile)
.map(path -> path.getFileName().toString())
.filter(name -> name.endsWith(".md"))
.map(name -> name.substring(0, name.length() - ".md".length()))
.filter(this::isValidLocale)
.forEach(result::add);
} catch (IOException e) {
log.warn("Failed listing login agreement files", e);
}
return result;
}
private ApplicationProperties.Legal.LoginAgreement config() {
return applicationProperties.getLegal().getLoginAgreement();
}
private Path disclaimerDir() {
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
}
private void addLocaleCandidates(List<String> out, String locale) {
if (!isValidLocale(locale)) {
return;
}
if (!out.contains(locale)) {
out.add(locale);
}
String base = locale.split("[_-]", 2)[0];
if (!base.equals(locale) && !out.contains(base)) {
out.add(base);
}
}
private String readFileIfExists(String locale) {
Path file = resolveLocaleFile(locale);
if (file == null) {
return null;
}
try {
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
// planted symlink can't expose files outside the disclaimer dir via the public read.
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
if (Files.size(file) > MAX_FILE_BYTES) {
log.warn(
"Login agreement file for locale {} exceeds {} bytes; ignoring",
locale,
MAX_FILE_BYTES);
return null;
}
return Files.readString(file, StandardCharsets.UTF_8);
}
} catch (IOException e) {
log.warn("Failed reading login agreement file for locale {}", locale, e);
}
return null;
}
private Path resolveLocaleFile(String locale) {
if (!isValidLocale(locale)) {
return null;
}
Path dir = disclaimerDir();
Path file = dir.resolve(locale + ".md").normalize();
// Defence in depth: the regex already blocks separators, but confirm containment.
if (!file.startsWith(dir)) {
return null;
}
return file;
}
private boolean isValidLocale(String locale) {
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
return locale != null
&& locale.length() <= MAX_LOCALE_LENGTH
&& LOCALE_PATTERN.matcher(locale).matches();
}
}
@@ -244,10 +244,7 @@ public class SvgSanitizer {
return false;
}
return normalized.startsWith("http://")
|| normalized.startsWith("https://")
|| normalized.startsWith("//")
|| normalized.startsWith("file:");
return true;
}
private boolean isUrlAllowed(String url) {
@@ -0,0 +1,201 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
/**
* Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from
* {@code <customFiles>/disclaimer/<locale>.md}; here {@link
* InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is
* isolated.
*/
class LoginAgreementServiceTest {
@TempDir Path customFilesDir;
private ApplicationProperties properties;
private ApplicationProperties.Legal.LoginAgreement config;
private LoginAgreementService service;
private Path disclaimerDir;
@BeforeEach
void setUp() {
properties = new ApplicationProperties();
config = properties.getLegal().getLoginAgreement();
service = new LoginAgreementService(properties);
disclaimerDir = customFilesDir.resolve("disclaimer");
}
/**
* Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir.
*/
private void withMockedPath(Runnable action) {
try (MockedStatic<InstallationPathConfig> mocked =
mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getCustomFilesPath)
.thenReturn(customFilesDir.toString());
action.run();
}
}
private void writeFile(String locale, String content) throws IOException {
Files.createDirectories(disclaimerDir);
Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8);
}
@Test
void flagsReflectConfig() {
config.setEnabled(true);
config.setShowInAnonymousMode(false);
assertTrue(service.isEnabled());
assertFalse(service.isShowInAnonymousMode());
}
@Test
void resolveContentReturnsExactLocaleFile() throws IOException {
writeFile("fr-FR", "# Avis");
withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR")));
}
@Test
void resolveContentFallsBackToBaseLanguage() throws IOException {
// Only a language-only file exists; a region-specific request should fall back to it.
writeFile("de", "# Hinweis");
withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE")));
}
@Test
void resolveContentFallsBackToDefaultLocale() throws IOException {
properties.getSystem().setDefaultLocale("en-GB");
writeFile("en-GB", "# Notice");
// No file for the requested locale -> falls through to the configured default locale.
withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES")));
}
@Test
void resolveContentFallsBackToFallbackTextWhenNoFile() {
config.setFallbackText("# Fallback");
withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP")));
}
@Test
void resolveContentReturnsEmptyWhenNothingConfigured() {
withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP")));
}
@Test
void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException {
// Plant a file outside the disclaimer dir; a traversal-style locale must not read it.
Files.writeString(
customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8);
config.setFallbackText("safe");
withMockedPath(
() -> {
assertEquals("safe", service.resolveContent("../secret"));
assertEquals("safe", service.resolveContent("..%2Fsecret"));
assertEquals("safe", service.resolveContent("/etc/passwd"));
});
}
@Test
void readRawRejectsInvalidLocale() {
withMockedPath(
() -> {
assertNull(service.readRawForLocale("../secret"));
assertNull(service.readRawForLocale("en/GB"));
assertNull(service.readRawForLocale("C:\\x"));
assertNull(service.readRawForLocale(null));
});
}
@Test
void readRawReturnsEmptyForValidButAbsentLocale() {
withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR")));
}
@Test
void overlongLocaleIsRejectedWithoutStackOverflow() {
// Guards against the regex-recursion stack overflow on unbounded input.
String hostile = "en" + "-ab".repeat(4000);
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.readRawForLocale(hostile));
assertNull(service.readRawForLocale(hostile));
assertDoesNotThrow(() -> service.resolveContent(hostile));
});
}
@Test
void writeThenReadRoundTrips() throws IOException {
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour"));
assertEquals("# Bonjour", service.readRawForLocale("fr-FR"));
});
assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md")));
}
@Test
void writeBlankDeletesFile() throws IOException {
writeFile("fr-FR", "# Bonjour");
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " "));
assertEquals("", service.readRawForLocale("fr-FR"));
});
assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md")));
}
@Test
void writeRejectsInvalidLocale() {
withMockedPath(
() ->
assertThrows(
IllegalArgumentException.class,
() -> service.writeForLocale("../escape", "x")));
}
@Test
void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException {
writeFile("en-GB", "a");
writeFile("fr-FR", "b");
Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
withMockedPath(
() -> {
var locales = service.listLocalesWithContent();
assertTrue(locales.contains("en-GB"));
assertTrue(locales.contains("fr-FR"));
assertEquals(2, locales.size());
});
}
@Test
void oversizedFileIsIgnored() throws IOException {
// Files beyond the read cap are skipped rather than loaded into heap.
byte[] big = new byte[300 * 1024];
java.util.Arrays.fill(big, (byte) 'x');
Files.createDirectories(disclaimerDir);
Files.write(disclaimerDir.resolve("en-GB.md"), big);
config.setFallbackText("small-fallback");
properties.getSystem().setDefaultLocale("en-GB");
withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB")));
}
}
@@ -97,4 +97,51 @@ class SvgSanitizerTest {
byte[] invalid = "not xml at all".getBytes(StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> sanitizer.sanitize(invalid));
}
@Test
void testSanitize_removesRootRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"/tmp/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("/tmp/image.png"), "Root-relative local path must be stripped");
}
@Test
void testSanitize_removesRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"../../assets/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped");
}
@Test
void testSanitize_removesRootRelativeWindowsDrivePath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" "
+ "xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
+ "<image xlink:href=\"/C:/Users/x/external-image.svg\""
+ " width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(
output.contains("external-image"), "Root-relative Windows path must be stripped");
}
@Test
void testSanitize_keepsInDocumentFragmentReference() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<use href=\"#gradient\"/><rect width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertTrue(
output.contains("#gradient"), "In-document fragment references must be preserved");
}
}
+6 -7
View File
@@ -67,7 +67,7 @@ dependencies {
exclude group: 'com.fasterxml.jackson.jaxrs'
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
}
implementation 'commons-io:commons-io:2.22.0'
implementation "commons-io:commons-io:$commonsIoVersion"
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core'
@@ -81,25 +81,24 @@ dependencies {
implementation 'org.verapdf:validation-model:1.28.2'
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
implementation 'org.mozilla:rhino:1.9.1'
implementation "org.mozilla:rhino:${rhinoVersion}"
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation 'com.google.code.gson:gson:2.13.2'
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
implementation "org.apache.xmlgraphics:batik-bridge:${batikVersion}"
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
runtimeOnly "org.apache.xmlgraphics:batik-transcoder:${batikVersion}"
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
@@ -22,6 +22,7 @@ import stirling.software.SPDF.config.InitialSetup;
import stirling.software.SPDF.controller.api.security.TimestampController;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@@ -37,6 +38,7 @@ public class ConfigController {
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
private final ShowAdminInterface showAdmin;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
@@ -48,6 +50,8 @@ public class ConfigController {
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
ShowAdminInterface showAdmin,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
@@ -56,6 +60,7 @@ public class ConfigController {
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
this.showAdmin = showAdmin;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
}
@@ -315,6 +320,10 @@ public class ConfigController {
configData.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().isEnableAlphaFunctionality());
boolean shouldShowUpdate =
applicationProperties.getSystem().isShowUpdate()
&& (showAdmin == null || showAdmin.getShowUpdateOnlyAdmins());
configData.put("shouldShowUpdate", shouldShowUpdate);
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
@@ -0,0 +1,49 @@
package stirling.software.SPDF.controller.api.misc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.service.LoginAgreementService;
/**
* Serves the login agreement / disclaimer for the frontend. Shares the /api/v1/config access rules:
* it requires authentication when login is enabled (the modal is shown after login, never on the
* login screen) and is permit-all in anonymous/no-login mode and in SaaS. The text is read live
* from disk, so admin edits take effect on the next login without a restart.
*/
@ConfigApi
@Hidden
@RequiredArgsConstructor
public class LoginDisclaimerController {
private final LoginAgreementService loginAgreementService;
@GetMapping("/login-disclaimer")
@Operation(
summary = "Get the login agreement/disclaimer",
description =
"Returns whether the login agreement is enabled and, if so, the markdown to"
+ " display for the requested language.")
public LoginDisclaimerResponse getLoginDisclaimer(
@RequestParam(name = "lang", required = false) String lang) {
boolean showInAnonymousMode = loginAgreementService.isShowInAnonymousMode();
if (!loginAgreementService.isEnabled()) {
return new LoginDisclaimerResponse(false, showInAnonymousMode, "", "markdown");
}
String content = loginAgreementService.resolveContent(lang);
// Enabled but no resolvable text (no file for any candidate locale and no fallbackText):
// report disabled so clients don't try to render an empty agreement.
boolean hasContent = content != null && !content.isBlank();
return new LoginDisclaimerResponse(
hasContent, showInAnonymousMode, hasContent ? content : "", "markdown");
}
public record LoginDisclaimerResponse(
boolean enabled, boolean showInAnonymousMode, String content, String format) {}
}
@@ -45,6 +45,10 @@ public class SvgOverlayUtil {
@Override
public void checkLoadExternalResource(
ParsedURL resourceURL, ParsedURL docURL) {
// Inline data: URIs are self-contained (no network/file fetch).
if (resourceURL != null && "data".equals(resourceURL.getProtocol())) {
return;
}
throw new SecurityException(
"External resource loading is disabled for SVG overlays: "
+ resourceURL);
@@ -19,6 +19,7 @@ import org.apache.batik.bridge.GVTBuilder;
import org.apache.batik.bridge.UserAgent;
import org.apache.batik.bridge.UserAgentAdapter;
import org.apache.batik.gvt.GraphicsNode;
import org.apache.batik.util.ParsedURL;
import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -63,7 +64,7 @@ public class SvgToPdf {
}
// 2. Build the GVT (Graphics Vector Tree) with timeout protection
UserAgent userAgent = new UserAgentAdapter();
UserAgent userAgent = createSecureUserAgent();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -94,6 +95,21 @@ public class SvgToPdf {
}
}
private UserAgent createSecureUserAgent() {
return new UserAgentAdapter() {
@Override
public void checkLoadExternalResource(ParsedURL resourceURL, ParsedURL docURL) {
// Inline data: URIs are self-contained (no network/file fetch) - allow them.
if (resourceURL != null && "data".equals(resourceURL.getProtocol())) {
return;
}
throw new SecurityException(
"External resource loading is disabled for SVG to PDF conversion: "
+ resourceURL);
}
};
}
private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc)
throws IOException {
GVTBuilder builder = new GVTBuilder();
@@ -202,7 +218,7 @@ public class SvgToPdf {
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
}
UserAgent userAgent = new UserAgentAdapter();
UserAgent userAgent = createSecureUserAgent();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -165,13 +165,17 @@ legal:
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
loginAgreement:
enabled: false # set to 'true' to show a login agreement/disclaimer popup after login (and on app launch when login is disabled). Per-language text is read from customFiles/disclaimer/<locale>.md (e.g. en-GB.md, fr-FR.md)
showInAnonymousMode: true # when login is disabled, set to 'false' to suppress the agreement in anonymous (no-login) mode
fallbackText: "" # optional markdown used for any language that has no customFiles/disclaimer/<locale>.md file (also settable via the LEGAL_LOGINAGREEMENT_FALLBACKTEXT env var for single-language headless installs)
system:
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: false # see when a new update is available
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
showUpdate: true # see when a new update is available
showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
showSettingsWhenNoLogin: true # set to 'false' to hide settings button when login is disabled (enableLogin: false). Only applies when login is disabled.
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: "" # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
@@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.ExternalAppDepConfig;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.LicenseServiceInterface;
import stirling.software.common.service.ServerCertificateServiceInterface;
@@ -45,6 +46,7 @@ class ConfigControllerMoreTest {
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private ServerCertificateServiceInterface serverCertificateService;
@Mock private UserServiceInterface userService;
@Mock private ShowAdminInterface showAdmin;
@Mock private LicenseServiceInterface licenseService;
@Mock private ExternalAppDepConfig externalAppDepConfig;
@Mock private AppConfig appConfig;
@@ -70,6 +72,7 @@ class ConfigControllerMoreTest {
endpointConfiguration,
serverCertificateService,
userService,
showAdmin,
licenseService,
externalAppDepConfig);
}
@@ -21,6 +21,7 @@ import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.System;
import stirling.software.common.service.LicenseServiceInterface;
@@ -35,6 +36,7 @@ class ConfigControllerTest {
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private ServerCertificateServiceInterface serverCertificateService;
@Mock private UserServiceInterface userService;
@Mock private ShowAdminInterface showAdmin;
@Mock private LicenseServiceInterface licenseService;
private ConfigController configController;
@@ -48,6 +50,7 @@ class ConfigControllerTest {
endpointConfiguration,
serverCertificateService,
userService,
showAdmin,
licenseService,
mock(stirling.software.SPDF.config.ExternalAppDepConfig.class));
}
@@ -0,0 +1,63 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.SPDF.controller.api.misc.LoginDisclaimerController.LoginDisclaimerResponse;
import stirling.software.common.service.LoginAgreementService;
@ExtendWith(MockitoExtension.class)
class LoginDisclaimerControllerTest {
@Mock LoginAgreementService loginAgreementService;
@InjectMocks LoginDisclaimerController controller;
@Test
void disabledReturnsEmptyContent() {
when(loginAgreementService.isEnabled()).thenReturn(false);
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("en-GB");
assertFalse(resp.enabled());
assertEquals("", resp.content());
assertTrue(resp.showInAnonymousMode());
assertEquals("markdown", resp.format());
}
@Test
void enabledWithContentReturnsIt() {
when(loginAgreementService.isEnabled()).thenReturn(true);
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(false);
when(loginAgreementService.resolveContent("fr-FR")).thenReturn("# Avis");
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("fr-FR");
assertTrue(resp.enabled());
assertEquals("# Avis", resp.content());
assertFalse(resp.showInAnonymousMode());
}
@Test
void enabledButBlankContentReportsDisabled() {
// No file for any candidate locale and no fallbackText -> report disabled so clients
// don't render an empty agreement.
when(loginAgreementService.isEnabled()).thenReturn(true);
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
when(loginAgreementService.resolveContent("ja-JP")).thenReturn(" ");
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("ja-JP");
assertFalse(resp.enabled());
assertEquals("", resp.content());
}
}
@@ -2,12 +2,23 @@ package stirling.software.SPDF.utils;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import javax.imageio.ImageIO;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.junit.jupiter.api.Test;
class SvgToPdfTest {
@@ -124,4 +135,91 @@ class SvgToPdfTest {
List<byte[]> svgs = Arrays.asList(null, null, new byte[0]);
assertThrows(IOException.class, () -> SvgToPdf.combineIntoPdf(svgs));
}
@Test
void convert_doesNotEmbedExternalFileResource() throws Exception {
Path external = Files.createTempFile("svg-external", ".png");
BufferedImage red = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
Graphics2D g = red.createGraphics();
g.setColor(Color.RED);
g.fillRect(0, 0, 100, 100);
g.dispose();
ImageIO.write(red, "png", external.toFile());
try {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" "
+ "xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\">"
+ "<image x=\"0\" y=\"0\" width=\"100\" height=\"100\" xlink:href=\""
+ external.toUri()
+ "\"/></svg>";
byte[] pdf;
try {
pdf = SvgToPdf.convert(svg.getBytes(StandardCharsets.UTF_8));
} catch (IOException blocked) {
return;
}
try (PDDocument doc = Loader.loadPDF(pdf)) {
BufferedImage page = new PDFRenderer(doc).renderImageWithDPI(0, 72);
int rgb = page.getRGB(page.getWidth() / 2, page.getHeight() / 2);
int r = (rgb >> 16) & 0xff;
int gg = (rgb >> 8) & 0xff;
int b = rgb & 0xff;
assertFalse(
r > 200 && gg < 60 && b < 60,
"External file image must not be rendered into the PDF");
}
} finally {
Files.deleteIfExists(external);
}
}
// A self-contained SVG whose only content is an inline base64 data: image (a solid-red vector
// SVG). Vector data: images decode via batik-bridge without batik-codec, so this exercises the
// data: security allowance independent of raster codecs.
private static String svgWithInlineRedImage() {
String innerSvg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\">"
+ "<rect width=\"100\" height=\"100\" fill=\"red\"/></svg>";
String dataUri =
"data:image/svg+xml;base64,"
+ Base64.getEncoder()
.encodeToString(innerSvg.getBytes(StandardCharsets.UTF_8));
return "<svg xmlns=\"http://www.w3.org/2000/svg\" "
+ "xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\">"
+ "<image x=\"0\" y=\"0\" width=\"100\" height=\"100\" xlink:href=\""
+ dataUri
+ "\"/></svg>";
}
@Test
void convert_rendersInlineDataUriImage() throws IOException {
byte[] pdf = SvgToPdf.convert(svgWithInlineRedImage().getBytes(StandardCharsets.UTF_8));
try (PDDocument doc = Loader.loadPDF(pdf)) {
BufferedImage page = new PDFRenderer(doc).renderImageWithDPI(0, 72);
int rgb = page.getRGB(page.getWidth() / 2, page.getHeight() / 2);
int r = (rgb >> 16) & 0xff;
int gg = (rgb >> 8) & 0xff;
int b = rgb & 0xff;
assertTrue(
r > 200 && gg < 60 && b < 60,
"Inline data: image must be rendered into the PDF (center rgb="
+ Integer.toHexString(rgb)
+ ")");
}
}
@Test
void combineIntoPdf_keepsPageWithInlineDataUriImage() throws IOException {
List<byte[]> svgs =
List.of(
SIMPLE_SVG.getBytes(StandardCharsets.UTF_8),
svgWithInlineRedImage().getBytes(StandardCharsets.UTF_8));
byte[] pdf = SvgToPdf.combineIntoPdf(svgs);
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertEquals(2, doc.getNumberOfPages(), "inline data: image page must not be dropped");
}
}
}
+13 -19
View File
@@ -3,12 +3,6 @@ repositories {
maven { url = "https://build.shibboleth.net/maven/releases" }
}
ext {
jwtVersion = '0.13.0'
awsSdkVersion = '2.44.12'
testcontainersMinioVersion = '1.21.4'
}
bootRun {
enabled = false
}
@@ -41,7 +35,7 @@ spotless {
}
dependencies {
implementation project(':common')
api 'com.google.guava:guava:33.6.0-jre'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework:spring-jdbc'
api 'org.springframework:spring-webmvc'
@@ -61,37 +55,37 @@ dependencies {
api 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
// Lettuce-backed Bucket4j ProxyManager used by ValkeyRateLimitStore for cluster-wide
// token-bucket rate limiting (parity with in-process Bucket4j semantics; no fixed-window
// boundary doubling).
implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.19.0'
implementation "com.bucket4j:bucket4j_jdk17-lettuce:${bucket4jVersion}"
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation 'com.google.code.gson:gson:2.13.2'
implementation "com.google.code.gson:gson:${gsonVersion}"
api 'io.micrometer:micrometer-registry-prometheus'
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jwtVersion}"
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database - file format incompatible with 2.4.x, would break existing user databases
runtimeOnly 'org.postgresql:postgresql:42.7.11'
implementation('com.coveo:saml-client:5.0.0') {
exclude group: 'org.opensaml', module: 'opensaml-core'
}
implementation "software.amazon.awssdk:s3:$awsSdkVersion"
implementation "software.amazon.awssdk:url-connection-client:$awsSdkVersion"
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
// Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without
// manually-started instances. Tests skip cleanly when Docker is unavailable.
testImplementation "org.testcontainers:testcontainers:$testcontainersMinioVersion"
testImplementation "org.testcontainers:minio:$testcontainersMinioVersion"
testImplementation "org.testcontainers:localstack:$testcontainersMinioVersion"
testImplementation "org.testcontainers:junit-jupiter:$testcontainersMinioVersion"
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:minio:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:localstack:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}"
}
tasks.register('prepareKotlinBuildScriptModel') {}
@@ -0,0 +1,74 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.LoginAgreementService;
/**
* Admin editing of the per-language login agreement markdown files
* (customFiles/disclaimer/&lt;locale&gt;.md). The enable/visibility flags are managed through the
* normal admin settings endpoints; only the live-edited text is handled here.
*/
@RestController
@RequestMapping("/api/v1/admin/login-agreement")
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@Tag(name = "Admin Settings", description = "Login agreement text management")
@Hidden
@Slf4j
public class AdminLoginAgreementController {
private final LoginAgreementService loginAgreementService;
@GetMapping
@Operation(summary = "List locales that currently have login agreement text")
public Set<String> listLocales() {
return loginAgreementService.listLocalesWithContent();
}
@GetMapping("/{locale}")
@Operation(summary = "Read the login agreement markdown for a locale")
public ResponseEntity<Map<String, String>> read(@PathVariable String locale) {
String content = loginAgreementService.readRawForLocale(locale);
if (content == null) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(Map.of("locale", locale, "content", content));
}
@PutMapping("/{locale}")
@Operation(summary = "Write the login agreement markdown for a locale (blank clears it)")
public ResponseEntity<Void> write(
@PathVariable String locale, @RequestBody DisclaimerContentRequest request) {
try {
loginAgreementService.writeForLocale(
locale, request == null ? null : request.content());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} catch (IOException e) {
log.error("Failed writing login agreement for locale {}", locale, e);
return ResponseEntity.internalServerError().build();
}
}
public record DisclaimerContentRequest(String content) {}
}
@@ -90,6 +90,20 @@ public class DatabaseService implements DatabaseServiceInterface {
Pattern.compile("(?i)\\bFALSE\\b"),
Pattern.compile("(?i)\\bNULL\\b"));
private static final java.util.List<Pattern> DENIED_PATTERNS =
java.util.List.of(
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?ALIAS\\b"),
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?TRIGGER\\b"),
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?AGGREGATE\\b"),
Pattern.compile("(?i)\\bCREATE\\s+LINKED\\s+TABLE\\b"),
Pattern.compile("(?i)\\bFILE_WRITE\\s*\\("),
Pattern.compile("(?i)\\bFILE_READ\\s*\\("),
Pattern.compile("(?i)\\bCSVWRITE\\s*\\("),
Pattern.compile("(?i)\\bCSVREAD\\s*\\("),
Pattern.compile("(?i)\\bLINK_SCHEMA\\s*\\("),
Pattern.compile("(?i)\\bRUNSCRIPT\\b"),
Pattern.compile("(?i)\\bSCRIPT\\s+TO\\b"));
private final ApplicationProperties.Datasource datasourceProps;
private final DataSource dataSource;
private final DatabaseNotificationServiceInterface backupNotificationService;
@@ -508,6 +522,17 @@ public class DatabaseService implements DatabaseServiceInterface {
String content = Files.readString(scriptPath);
String normalizedContent = normalizeSqlContent(content);
String codeOnly = stripStringLiterals(normalizedContent);
for (Pattern deniedPattern : DENIED_PATTERNS) {
if (deniedPattern.matcher(codeOnly).find()) {
log.error(
"Blocked disallowed SQL in backup file matching: {}",
deniedPattern.pattern());
throw new IllegalArgumentException(
"SQL script contains disallowed operations and was rejected.");
}
}
// Validate that content only contains allowed operations (whitelist approach)
// Split by semicolons to check individual statements
String[] statements = normalizedContent.split(";");
@@ -558,6 +583,10 @@ public class DatabaseService implements DatabaseServiceInterface {
return sql.trim();
}
private String stripStringLiterals(String sql) {
return sql.replaceAll("'(?:[^']|'')*'", "''");
}
/**
* Checks for invalid characters or sequences
*
@@ -0,0 +1,76 @@
package stirling.software.proprietary.security.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.common.service.LoginAgreementService;
import stirling.software.proprietary.security.controller.api.AdminLoginAgreementController.DisclaimerContentRequest;
@ExtendWith(MockitoExtension.class)
class AdminLoginAgreementControllerTest {
@Mock LoginAgreementService loginAgreementService;
@InjectMocks AdminLoginAgreementController controller;
@Test
void listDelegatesToService() {
when(loginAgreementService.listLocalesWithContent()).thenReturn(Set.of("en-GB", "fr-FR"));
assertEquals(Set.of("en-GB", "fr-FR"), controller.listLocales());
}
@Test
void readReturnsContentForValidLocale() {
when(loginAgreementService.readRawForLocale("fr-FR")).thenReturn("# Avis");
ResponseEntity<?> resp = controller.read("fr-FR");
assertEquals(HttpStatus.OK, resp.getStatusCode());
}
@Test
void readReturnsBadRequestForInvalidLocale() {
// Service returns null for an invalid locale.
when(loginAgreementService.readRawForLocale("../escape")).thenReturn(null);
ResponseEntity<?> resp = controller.read("../escape");
assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode());
}
@Test
void writeDelegatesAndReturnsNoContent() throws IOException {
ResponseEntity<Void> resp = controller.write("fr-FR", new DisclaimerContentRequest("# Hi"));
assertEquals(HttpStatus.NO_CONTENT, resp.getStatusCode());
verify(loginAgreementService).writeForLocale("fr-FR", "# Hi");
}
@Test
void writeReturnsBadRequestOnInvalidLocale() throws IOException {
doThrow(new IllegalArgumentException("Invalid locale"))
.when(loginAgreementService)
.writeForLocale(eq("../escape"), eq("x"));
ResponseEntity<Void> resp =
controller.write("../escape", new DisclaimerContentRequest("x"));
assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode());
}
@Test
void writeReturnsServerErrorOnIoException() throws IOException {
doThrow(new IOException("disk full"))
.when(loginAgreementService)
.writeForLocale(eq("fr-FR"), eq("x"));
ResponseEntity<Void> resp = controller.write("fr-FR", new DisclaimerContentRequest("x"));
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
}
}
@@ -0,0 +1,93 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
class AppUpdateAuthServiceTest {
private final UserRepository userRepository = mock(UserRepository.class);
private AppUpdateAuthService service(boolean showUpdate, boolean onlyAdmin) {
ApplicationProperties props = new ApplicationProperties();
ApplicationProperties.System system = new ApplicationProperties.System();
system.setShowUpdate(showUpdate);
system.setShowUpdateOnlyAdmin(onlyAdmin);
props.setSystem(system);
return new AppUpdateAuthService(userRepository, props);
}
private void withAuthentication(Authentication auth, Runnable assertions) {
try (MockedStatic<SecurityContextHolder> holder = mockStatic(SecurityContextHolder.class)) {
SecurityContext context = mock(SecurityContext.class);
when(context.getAuthentication()).thenReturn(auth);
holder.when(SecurityContextHolder::getContext).thenReturn(context);
assertions.run();
}
}
private Authentication authenticatedAs(String username, String roles) {
Authentication auth = mock(Authentication.class);
when(auth.isAuthenticated()).thenReturn(true);
when(auth.getName()).thenReturn(username);
User user = mock(User.class);
when(user.getRolesAsString()).thenReturn(roles);
when(userRepository.findByUsername(username)).thenReturn(Optional.of(user));
return auth;
}
@Test
void hidesForEveryoneWhenShowUpdateOff() {
withAuthentication(
authenticatedAs("admin", "ROLE_ADMIN"),
() -> assertFalse(service(false, true).getShowUpdateOnlyAdmins()));
}
@Test
void showsToAllAuthenticatedWhenNotAdminOnly() {
withAuthentication(
authenticatedAs("bob", "ROLE_USER"),
() -> assertTrue(service(true, false).getShowUpdateOnlyAdmins()));
}
@Test
void hidesFromAnonymousWhenAdminOnly() {
withAuthentication(null, () -> assertFalse(service(true, true).getShowUpdateOnlyAdmins()));
}
@Test
void hidesFromAnonymousUserPrincipalWhenAdminOnly() {
Authentication auth = mock(Authentication.class);
when(auth.isAuthenticated()).thenReturn(true);
when(auth.getName()).thenReturn("anonymousUser");
withAuthentication(auth, () -> assertFalse(service(true, true).getShowUpdateOnlyAdmins()));
}
@Test
void showsToAdminWhenAdminOnly() {
withAuthentication(
authenticatedAs("admin", "ROLE_ADMIN"),
() -> assertTrue(service(true, true).getShowUpdateOnlyAdmins()));
}
@Test
void hidesFromNonAdminWhenAdminOnly() {
withAuthentication(
authenticatedAs("bob", "ROLE_USER"),
() -> assertFalse(service(true, true).getShowUpdateOnlyAdmins()));
}
}
@@ -168,4 +168,61 @@ class DatabaseServiceMoreTest {
assertThat(resolved.startsWith(tempDir)).isTrue();
}
}
@Nested
@DisplayName("SQL import validation")
class SqlValidation {
private Path writeScript(String sql) throws IOException {
Path script = Files.createTempFile("import", ".sql");
Files.writeString(script, sql);
return script;
}
@Test
@DisplayName("rejects FILE_WRITE")
void rejectsFileWrite() throws IOException {
Path script = writeScript("CREATE TABLE X AS SELECT FILE_WRITE('data', '/tmp/out');");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("rejects FILE_READ in an INSERT")
void rejectsFileRead() throws IOException {
Path script = writeScript("INSERT INTO PUBLIC.X(D) VALUES (FILE_READ('/tmp/in'));");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("rejects CREATE ALIAS")
void rejectsCreateAlias() throws IOException {
Path script =
writeScript(
"CREATE ALIAS MYALIAS AS 'String run(String c) throws Exception"
+ " { return c; }';");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("rejects RUNSCRIPT FROM")
void rejectsRunscript() throws IOException {
Path script = writeScript("RUNSCRIPT FROM '/tmp/other.sql';");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("allows a backup whose data mentions a keyword")
void allowsKeywordInsideStringData() throws IOException {
Path script =
writeScript(
"CREATE TABLE PUBLIC.NOTES(ID INT, BODY CHARACTER VARYING);"
+ " INSERT INTO PUBLIC.NOTES(ID, BODY)"
+ " VALUES(1, 'plain text with FILE_WRITE() inside');");
assertThat(databaseService.importDatabaseFromUI(script)).isTrue();
}
}
}
+1 -1
View File
@@ -41,5 +41,5 @@ dependencies {
api 'org.flywaydb:flyway-core'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
}
+22 -10
View File
@@ -30,6 +30,19 @@ ext {
commonmarkVersion = "0.28.0"
googleJavaFormatVersion = "1.28.0"
logback = "1.5.32"
commonsIoVersion = "2.22.0"
commonsLang3 = "3.20.0"
rhinoVersion = "1.9.1"
okhttpBomVersion = "5.3.2"
gsonVersion = "2.14.0"
guavaVersion = "33.6.0-jre"
bucket4jVersion = "8.19.0"
archunitVersion = "1.4.2"
batikVersion = "1.19"
jpdfiumVersion = "1.0.2"
jwtVersion = "0.13.0"
awsSdkVersion = "2.44.12"
testcontainersMinioVersion = "1.21.4"
// junit-platform-launcher version managed by Spring Boot BOM
modernJavaVersion = 25
}
@@ -78,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.13.1'
version = '2.13.2'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
@@ -203,12 +216,12 @@ subprojects {
// - CVE-2022-25647: gson 2.8.9+ (explicit dependency overrides tabula 2.8.7)
// - CVE-2025-66453: rhino 1.7.15 (explicit dependency overrides verapdf 1.7.13)
// Fallback strategy force declarations for additional safety:
resolutionStrategy.force 'com.google.code.gson:gson:2.13.2'
resolutionStrategy.force 'org.mozilla:rhino:1.9.1'
resolutionStrategy.force "com.google.code.gson:gson:${gsonVersion}"
resolutionStrategy.force "org.mozilla:rhino:${rhinoVersion}"
// CVE-2025-48924: commons-lang3 3.20.0 DoS prevention
resolutionStrategy.force 'org.apache.commons:commons-lang3:3.20.0'
// CVE-2024-47554: commons-io 2.21.0 DoS prevention
resolutionStrategy.force 'commons-io:commons-io:2.21.0'
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
// CVE-2024-47554: commons-io DoS prevention
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
@@ -221,7 +234,7 @@ subprojects {
}
dependencies {
// Override BOM-managed commons-lang3 for CVE-2025-48924 fix
dependency 'org.apache.commons:commons-lang3:3.20.0'
dependency "org.apache.commons:commons-lang3:$commonsLang3"
}
}
@@ -243,7 +256,7 @@ subprojects {
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
testImplementation platform("com.squareup.okhttp3:okhttp-bom:${okhttpBomVersion}")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
@@ -599,7 +612,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
testImplementation platform("com.squareup.okhttp3:okhttp-bom:${okhttpBomVersion}")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
@@ -670,7 +683,6 @@ tasks.register('buildRestartHelper', Jar) {
}
}
tasks.withType(Test).configureEach {
// maxParallelForks: parallelise JUnit across cores
// Half of available CPUs is a safe default; bump if your tests are I/O-bound.
+1 -1
View File
@@ -4,7 +4,7 @@ ARG BASE_VERSION=1.0.2
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application (backend only, no frontend)
FROM gradle:9.3.1-jdk25@sha256:85aec999629f4774a383cb792da4b598bdf5a7e69c4b9570bb70c0f919179183 AS app-build
FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
+1 -1
View File
@@ -5,7 +5,7 @@ ARG BASE_VERSION=1.0.2
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS app-build
FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build
ARG TASK_VERSION=3.49.1
RUN apt-get update \
+1 -1
View File
@@ -6,7 +6,7 @@ ARG BASE_VERSION=1.0.2
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS app-build
FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build
ARG TASK_VERSION=3.49.1
RUN apt-get update \
+1 -1
View File
@@ -2,7 +2,7 @@
# Single JAR contains both frontend and backend with minimal dependencies
# Stage 1: Build application with embedded frontend
FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS build
FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS build
# Install Node.js and npm for frontend build
ARG TASK_VERSION=3.49.1
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "إغلاق"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "إغلاق"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "كل الإصدارات"
breaking = "تغييرات كاسرة"
breakingChanges = "تغييرات كاسرة للتوافق"
breakingChangesDefault = "يحتوي هذا الإصدار على تغييرات كاسرة للتوافق."
breakingChangesDetected = "تم اكتشاف تغييرات كاسرة للتوافق"
breakingChangesMessage = "تحتوي بعض الإصدارات على تغييرات كاسرة للتوافق. يرجى مراجعة أدلة الترحيل أدناه قبل التحديث."
close = "إغلاق"
closeModal = "إغلاق نافذة التحديث"
current = "الإصدار الحالي"
@@ -8606,6 +8606,7 @@ disableByAdmin = "تعطيل MFA"
[workspace.people.roleDescriptions]
admin = "يمكنه إدارة الإعدادات ودعوة الأعضاء مع صلاحيات إدارية كاملة."
currentRole = "الدور المعيّن الحالي."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Bağla"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Bağla"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Bütün buraxılışlar"
breaking = "Uyğunluğu pozan"
breakingChanges = "Uyğunluğu pozan dəyişikliklər"
breakingChangesDefault = "Bu versiya uyğunluğu pozan dəyişikliklər ehtiva edir."
breakingChangesDetected = "Uyğunluğu pozan dəyişikliklər aşkarlandı"
breakingChangesMessage = "Bəzi versiyalar uyğunluğu pozan dəyişikliklər ehtiva edir. Yeniləməzdən əvvəl aşağıdakı miqrasiya bələdçilərini nəzərdən keçirin."
close = "Bağla"
closeModal = "Yeniləmə pəncərəsini bağla"
current = "Cari versiya"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA-nı deaktiv et"
[workspace.people.roleDescriptions]
admin = "Ayarları idarə edə və üzvləri dəvət edə bilər, tam inzibati girişə malikdir."
currentRole = "Hazırda təyin olunmuş rol."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Затвори"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Затвори"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Всички издания"
breaking = "Несъвместими промени"
breakingChanges = "Несъвместими промени"
breakingChangesDefault = "Тази версия съдържа несъвместими промени."
breakingChangesDetected = "Открити несъвместими промени"
breakingChangesMessage = "Някои версии съдържат несъвместими промени. Прегледайте ръководствата за миграция по-долу, преди да актуализирате."
close = "Затвори"
closeModal = "Затвори прозореца за актуализация"
current = "Текуща версия"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Деактивиране на MFA"
[workspace.people.roleDescriptions]
admin = "Може да управлява настройки и да кани членове, с пълен административен достъп."
currentRole = "Текущо назначена роля."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "སྒོ་བསྡམས"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "སྒོ་བསྡམས"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "གསར་བརྗེ་ཚང་མ"
breaking = "མཚམས་སྐྱེད"
breakingChanges = "འགྱུར་ལྡོག་འགལ་བ།"
breakingChangesDefault = "ཐོན་རིམ་འདིའི་ནང་ལ་འགྱུར་ལྡོག་གི་བསྒྱུར་བཅོས་ཡོད།"
breakingChangesDetected = "བརྡབ་པའི་འགྱུར་བ།"
breakingChangesMessage = "ཐོན་རིམ་ཁ་ཤས་ལ་བསྒྱུར་བཅོས་ཆག་པ་ཡོད། གསར་བརྗེ་མ་བྱས་གོང་གཤམ་གྱི་གནས་སྤོ་ལམ་སྟོན་ལ་ཞིབ་བཤེར་གནང་རོགས།"
close = "གཏན་པ"
closeModal = "གསར་སྒྱུར་སྒོ་ཁ་རྒྱག"
current = "ད་ལྟའི་ཐོན་རིམ།"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA སྤྱོད་སྒོ་བརྒྱབ"
[workspace.people.roleDescriptions]
admin = "སྒྲིག་སྟངས་འཛིན་སྐྱོང་བྱེད་ཐུབ་པ་དང་ཚོགས་མི་གདན་འདྲེན་ཞུ་ཐུབ།"
currentRole = "ད་ལྟའི་བསྐོས་པའི་ལས་འགན།"
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Tanca"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Tanca"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Totes les versions"
breaking = "Canvis incompatibles"
breakingChanges = "Canvis incompatibles"
breakingChangesDefault = "Aquesta versió conté canvis incompatibles."
breakingChangesDetected = "S'han detectat canvis incompatibles"
breakingChangesMessage = "Algunes versions contenen canvis incompatibles. Revisa les guies de migració abans d'actualitzar."
close = "Tanca"
closeModal = "Tanca la finestra d'actualització"
current = "Versió actual"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Desactiva l'MFA"
[workspace.people.roleDescriptions]
admin = "Pot gestionar paràmetres i convidar membres, amb accés administratiu complet."
currentRole = "Rol assignat actual."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Zavřít"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Zavřít"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Všechny verze"
breaking = "Zpětně nekompatibilní"
breakingChanges = "Nekompatibilní změny"
breakingChangesDefault = "Tato verze obsahuje nekompatibilní změny."
breakingChangesDetected = "Zjištěny nekompatibilní změny"
breakingChangesMessage = "Některé verze obsahují nekompatibilní změny. Před aktualizací si prosím prostudujte migrační průvodce níže."
close = "Zavřít"
closeModal = "Zavřít okno aktualizace"
current = "Aktuální verze"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Zakázat MFA"
[workspace.people.roleDescriptions]
admin = "Může spravovat nastavení a zvát členy, s plným administrativním přístupem."
currentRole = "Aktuálně přiřazená role."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Luk"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Luk"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Alle udgivelser"
breaking = "Breaking"
breakingChanges = "Inkompatible ændringer"
breakingChangesDefault = "Denne version indeholder inkompatible ændringer."
breakingChangesDetected = "Inkompatible ændringer registreret"
breakingChangesMessage = "Nogle versioner indeholder inkompatible ændringer. Gennemgå venligst migrationsvejledningerne nedenfor før opdatering."
close = "Luk"
closeModal = "Luk opdateringsvindue"
current = "Nuværende version"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Deaktivér MFA"
[workspace.people.roleDescriptions]
admin = "Kan administrere indstillinger og invitere medlemmer med fuld administrativ adgang."
currentRole = "Nuværende tildelt rolle."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -55,6 +55,10 @@ info = "Informationen"
invalidUndoData = "Rückgängig nicht möglich: ungültige Vorgangsdaten"
keepWorking = "Weiterarbeiten"
loading = "Laden..."
loginAgreementAccept = "Akzeptieren"
loginAgreementDecline = "Ablehnen"
loginAgreementProvider = "Dieser Hinweis stammt von Ihrem Administrator, nicht von Stirling PDF Inc."
loginAgreementTitle = "Anmeldevereinbarung"
logOut = "Abmelden"
marginTooltip = "Abstand zwischen der Seitenzahl und dem Seitenrand."
moreOptions = "Weitere Optionen"
@@ -5854,6 +5858,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Schließen"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5912,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Schließen"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7790,6 @@ allReleases = "Alle Versionen"
breaking = "Breaking"
breakingChanges = "Inkompatible Änderungen"
breakingChangesDefault = "Diese Version enthält inkompatible Änderungen."
breakingChangesDetected = "Inkompatible Änderungen erkannt"
breakingChangesMessage = "Einige Versionen enthalten inkompatible Änderungen. Bitte prüfen Sie vor dem Update die folgenden Migrationshinweise."
close = "Schließen"
closeModal = "Update-Dialog schließen"
current = "Aktuelle Version"
@@ -8606,6 +8610,7 @@ disableByAdmin = "MFA deaktivieren"
[workspace.people.roleDescriptions]
admin = "Kann Einstellungen verwalten und Mitglieder einladen, mit vollständigem administrativem Zugriff."
currentRole = "Aktuell zugewiesene Rolle."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Κλείσιμο"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Κλείσιμο"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Όλες οι εκδόσεις"
breaking = "Ασυμβατό"
breakingChanges = "Ασύμβατες αλλαγές"
breakingChangesDefault = "Αυτή η έκδοση περιέχει ασύμβατες αλλαγές."
breakingChangesDetected = "Εντοπίστηκαν ασύμβατες αλλαγές"
breakingChangesMessage = "Ορισμένες εκδόσεις περιέχουν ασύμβατες αλλαγές. Ελέγξτε τους οδηγούς μετεγκατάστασης παρακάτω πριν ενημερώσετε."
close = "Κλείσιμο"
closeModal = "Κλείσιμο παραθύρου ενημέρωσης"
current = "Τρέχουσα έκδοση"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Απενεργοποίηση MFA"
[workspace.people.roleDescriptions]
admin = "Μπορεί να διαχειρίζεται ρυθμίσεις και να προσκαλεί μέλη, με πλήρη δικαιώματα διαχείρισης."
currentRole = "Τρέχων ανατεθειμένος ρόλος."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -55,6 +55,10 @@ info = "Info"
invalidUndoData = "Cannot undo: invalid operation data"
keepWorking = "Keep Working"
loading = "Loading..."
loginAgreementAccept = "Accept"
loginAgreementDecline = "Decline"
loginAgreementProvider = "This notice is provided by your administrator, not Stirling PDF Inc."
loginAgreementTitle = "Login Agreement"
logOut = "Log out"
marginTooltip = "Distance between the page number and the edge of the page."
moreOptions = "More Options"
@@ -5854,6 +5858,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Close"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5912,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Close"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7790,6 @@ allReleases = "All Releases"
breaking = "Breaking"
breakingChanges = "Breaking Changes"
breakingChangesDefault = "This version contains breaking changes."
breakingChangesDetected = "Breaking Changes Detected"
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
close = "Close"
closeModal = "Close update modal"
current = "Current Version"
@@ -8606,6 +8610,7 @@ disableByAdmin = "Disable MFA"
[workspace.people.roleDescriptions]
admin = "Can manage settings and invite users, with full administrative access."
currentRole = "Current assigned role."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -55,6 +55,10 @@ info = "Info"
invalidUndoData = "Cannot undo: invalid operation data"
keepWorking = "Keep Working"
loading = "Loading..."
loginAgreementAccept = "Accept"
loginAgreementDecline = "Decline"
loginAgreementProvider = "This notice is provided by your administrator, not Stirling PDF Inc."
loginAgreementTitle = "Login Agreement"
logOut = "Log out"
marginTooltip = "Distance between the page number and the edge of the page."
moreOptions = "More Options"
@@ -1072,6 +1076,30 @@ title = "Legal Responsibility Warning"
description = "URL or filename to impressum (required in some jurisdictions)"
label = "Impressum"
[admin.settings.legal.loginAgreement]
defaultLocaleHint = "— used as the fallback when a language has no file."
defaultLocaleLink = "Configure the default locale"
description = "Show a disclaimer users must accept after logging in. The text follows each user's language."
discardConfirm = "You have unsaved changes that will be lost. Switch language anyway?"
editLabel = "Markdown"
emptyPreview = "Nothing to preview yet."
language = "Language"
languageHelp = "Each language has its own file. If a user's language has no file, the agreement falls back to the default locale's file, then to the fallback text."
loadError = "Failed to load the agreement for {{locale}}. Switch language and back to retry."
previewLabel = "Preview"
restartNote = "Enabling or disabling the agreement applies after a restart, like other settings. Text edits below apply immediately."
save = "Save text"
saved = "Saved"
savedBody = "Login agreement updated for {{locale}}"
textDescription = "Saved to customFiles/disclaimer/{{locale}}.md and shown live on the next login. Leave blank to remove this language."
title = "Login Agreement"
[admin.settings.legal.loginAgreement.anonymous]
label = "Show in anonymous (no-login) mode"
[admin.settings.legal.loginAgreement.enabled]
label = "Enable login agreement"
[admin.settings.legal.privacyPolicy]
description = "URL or filename to privacy policy"
label = "Privacy Policy"
@@ -55,6 +55,10 @@ info = "Información"
invalidUndoData = "No se puede deshacer: datos de la operación no válidos"
keepWorking = "Seguir trabajando"
loading = "Cargando..."
loginAgreementAccept = "Aceptar"
loginAgreementDecline = "Rechazar"
loginAgreementProvider = "Este aviso lo proporciona su administrador, no Stirling PDF Inc."
loginAgreementTitle = "Acuerdo de acceso"
logOut = "Cerrar sesión"
marginTooltip = "Distancia entre el número de página y el borde de la página."
moreOptions = "Más Opciones"
@@ -5854,6 +5858,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Cerrar"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5912,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Cerrar"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7790,6 @@ allReleases = "Todas las versiones"
breaking = "Incompatible"
breakingChanges = "Cambios incompatibles"
breakingChangesDefault = "Esta versión contiene cambios incompatibles."
breakingChangesDetected = "Se detectaron cambios incompatibles"
breakingChangesMessage = "Algunas versiones contienen cambios incompatibles. Revise las guías de migración a continuación antes de actualizar."
close = "Cerrar"
closeModal = "Cerrar modal de actualización"
current = "Versión actual"
@@ -8606,6 +8610,7 @@ disableByAdmin = "Deshabilitar MFA"
[workspace.people.roleDescriptions]
admin = "Puede gestionar ajustes e invitar miembros, con acceso administrativo completo."
currentRole = "Rol asignado actual."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Itxi"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Itxi"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Argitalpen guztiak"
breaking = "Aldaketa haustzaileak"
breakingChanges = "Aldaketa haustzaileak"
breakingChangesDefault = "Bertsio honek aldaketa haustzaileak ditu."
breakingChangesDetected = "Bateragarritasuna hausten duten aldaketak atzeman dira"
breakingChangesMessage = "Bertsio batzuek bateragarritasuna hausten duten aldaketak dituzte. Irakurri beheko migrazio-gidak eguneratu aurretik."
close = "Itxi"
closeModal = "Itxi eguneratze-modal"
current = "Uneko bertsioa"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Desgaitu MFA"
[workspace.people.roleDescriptions]
admin = "Ezarpenak kudeatu eta kideak gonbidatu ditzake, administrazio-eskumen osoekin."
currentRole = "Uneko esleitutako rola."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "بستن"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "בستن"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "همهٔ نسخه‌ها"
breaking = "تغییرات مخرب"
breakingChanges = "تغییرات ناسازگار"
breakingChangesDefault = "این نسخه شامل تغییرات ناسازگار است."
breakingChangesDetected = "تغییرات ناسازگار شناسایی شد"
breakingChangesMessage = "برخی نسخه‌ها شامل تغییرات ناسازگارند. لطفاً پیش از به‌روزرسانی، راهنماهای مهاجرت زیر را مرور کنید."
close = "بستن"
closeModal = "بستن پنجرهٔ به‌روزرسانی"
current = "نسخه فعلی"
@@ -8606,6 +8606,7 @@ disableByAdmin = "غیرفعال کردن MFA"
[workspace.people.roleDescriptions]
admin = "می‌تواند تنظیمات را مدیریت و اعضا را دعوت کند، با دسترسی کامل مدیریتی."
currentRole = "نقش تخصیص‌یافتهٔ فعلی."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -55,6 +55,10 @@ info = "Informations"
invalidUndoData = "Impossible d’annuler : données d’opération invalides"
keepWorking = "Continuer à travailler"
loading = "Chargement..."
loginAgreementAccept = "Accepter"
loginAgreementDecline = "Refuser"
loginAgreementProvider = "Cet avis est fourni par votre administrateur, et non par Stirling PDF Inc."
loginAgreementTitle = "Accord de connexion"
logOut = "Se déconnecter"
marginTooltip = "Distance entre le numéro de page et le bord de la page."
moreOptions = "Plus d’options"
@@ -5854,6 +5858,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Fermer"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5912,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Fermer"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7790,6 @@ allReleases = "Toutes les versions"
breaking = "Incompatible"
breakingChanges = "Modifications majeures"
breakingChangesDefault = "Cette version contient des modifications majeures."
breakingChangesDetected = "Modifications majeures détectées"
breakingChangesMessage = "Certaines versions contiennent des modifications majeures. Veuillez consulter les guides de migration ci-dessous avant de mettre à jour."
close = "Fermer"
closeModal = "Fermer la fenêtre de mise à jour"
current = "Version actuelle"
@@ -8606,6 +8610,7 @@ disableByAdmin = "Désactiver la MFA"
[workspace.people.roleDescriptions]
admin = "Peut gérer les paramètres et inviter des membres, avec un accès administratif complet."
currentRole = "Rôle actuellement attribué."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Dún"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Dún"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Gach Eisiúint"
breaking = "Briseadh comhoiriúnachta"
breakingChanges = "Athruithe Bristeacha"
breakingChangesDefault = "Tá athruithe bristeacha sa leagan seo."
breakingChangesDetected = "Aimsíodh Athruithe Bristeacha"
breakingChangesMessage = "Tá athruithe bristeacha i roinnt leaganacha. Déan athbhreithniú ar na treoracha imirce thíos sula nuashonraíonn tú."
close = "Dún"
closeModal = "Dún an modail nuashonraithe"
current = "Leagan Reatha"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Díchumasaigh MFA"
[workspace.people.roleDescriptions]
admin = "Is féidir socruithe a bhainistiú agus baill a thabhairt isteach, le rochtain riaracháin iomlán."
currentRole = "Ról sannta faoi láthair."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "बंद करें"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "बंद करें"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "सभी रिलीज़"
breaking = "ब्रेकिंग"
breakingChanges = "असंगत परिवर्तन"
breakingChangesDefault = "इस संस्करण में असंगत परिवर्तन शामिल हैं।"
breakingChangesDetected = "असंगत परिवर्तन मिले"
breakingChangesMessage = "कुछ संस्करणों में असंगत परिवर्तन हैं। अपडेट से पहले नीचे दिए गए माइग्रेशन गाइड्स देखें।"
close = "बंद करें"
closeModal = "अपडेट मोडल बंद करें"
current = "वर्तमान संस्करण"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA अक्षम करें"
[workspace.people.roleDescriptions]
admin = "सेटिंग्स और आमंत्रण प्रबंधित कर सकता है, पूर्ण प्रशासनिक पहुंच के साथ।"
currentRole = "वर्तमान सौंपी गई भूमिका।"
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Zatvori"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Zatvori"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Sva izdanja"
breaking = "Nekompatibilno"
breakingChanges = "Nekompatibilne promjene"
breakingChangesDefault = "Ova verzija sadrži nekompatibilne promjene."
breakingChangesDetected = "Otkrivene nekompatibilne promjene"
breakingChangesMessage = "Neke verzije sadrže nekompatibilne promjene. Prije ažuriranja pregledajte vodiče za migraciju u nastavku."
close = "Zatvori"
closeModal = "Zatvori prozor ažuriranja"
current = "Trenutačna verzija"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Onemogući MFA"
[workspace.people.roleDescriptions]
admin = "Može upravljati postavkama i pozivati članove, s punim administrativnim pristupom."
currentRole = "Trenutno dodijeljena uloga."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Bezárás"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Bezárás"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Összes kiadás"
breaking = "Kompatibilitást törő"
breakingChanges = "Inkompatibilis változások"
breakingChangesDefault = "Ez a verzió inkompatibilis változásokat tartalmaz."
breakingChangesDetected = "Inkompatibilis változások észlelve"
breakingChangesMessage = "Egyes verziók inkompatibilis változásokat tartalmaznak. Frissítés előtt tekintse át az alábbi átállási útmutatókat."
close = "Bezárás"
closeModal = "Frissítési ablak bezárása"
current = "Jelenlegi verzió"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA letiltása"
[workspace.people.roleDescriptions]
admin = "Kezelheti a beállításokat és meghívhat tagokat, teljes adminisztratív hozzáféréssel."
currentRole = "Jelenleg hozzárendelt szerepkör."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Tutup"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Tutup"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Semua Rilis"
breaking = "Perubahan besar"
breakingChanges = "Perubahan Signifikan"
breakingChangesDefault = "Versi ini berisi perubahan yang memutus kompatibilitas."
breakingChangesDetected = "Perubahan Signifikan Terdeteksi"
breakingChangesMessage = "Beberapa versi berisi perubahan yang memutus kompatibilitas. Harap tinjau panduan migrasi di bawah sebelum memperbarui."
close = "Tutup"
closeModal = "Tutup modal pembaruan"
current = "Versi Saat Ini"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Nonaktifkan MFA"
[workspace.people.roleDescriptions]
admin = "Dapat mengelola pengaturan dan mengundang anggota, dengan akses administratif penuh."
currentRole = "Peran yang ditetapkan saat ini."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Chiudi"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Chiudi"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Tutti i rilasci"
breaking = "Breaking"
breakingChanges = "Modifiche incompatibili"
breakingChangesDefault = "Questa versione contiene modifiche incompatibili."
breakingChangesDetected = "Rilevate modifiche incompatibili"
breakingChangesMessage = "Alcune versioni contengono modifiche incompatibili. Consulta le guide di migrazione qui sotto prima di aggiornare."
close = "Chiudi"
closeModal = "Chiudi finestra aggiornamento"
current = "Versione attuale"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Disabilita MFA"
[workspace.people.roleDescriptions]
admin = "Può gestire impostazioni e invitare membri, con pieno accesso amministrativo."
currentRole = "Ruolo attualmente assegnato."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "閉じる"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "閉じる"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "すべてのリリース"
breaking = "重大変更"
breakingChanges = "互換性のない変更"
breakingChangesDefault = "このバージョンには互換性のない変更が含まれます。"
breakingChangesDetected = "互換性のない変更を検出"
breakingChangesMessage = "一部のバージョンには互換性のない変更が含まれます。更新前に以下の移行ガイドを確認してください。"
close = "閉じる"
closeModal = "更新モーダルを閉じる"
current = "現在のバージョン"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA を無効化"
[workspace.people.roleDescriptions]
admin = "設定の管理とメンバーの招待が可能。管理者としてのフルアクセス権があります。"
currentRole = "現在割り当てられているロール。"
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "닫기"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "닫기"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "모든 릴리스"
breaking = "중대한 변경"
breakingChanges = "호환성 파괴 변경"
breakingChangesDefault = "이 버전에는 호환성 파괴 변경이 포함되어 있습니다."
breakingChangesDetected = "호환성 파괴 변경 감지됨"
breakingChangesMessage = "일부 버전에 호환성 파괴 변경이 포함되어 있습니다. 업데이트 전에 아래 마이그레이션 가이드를 검토하세요."
close = "닫기"
closeModal = "업데이트 모달 닫기"
current = "현재 버전"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA 비활성화"
[workspace.people.roleDescriptions]
admin = "설정을 관리하고 멤버를 초대할 수 있으며, 전체 관리자 권한을 가집니다."
currentRole = "현재 할당된 역할입니다."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "അടയ്ക്കുക"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "അടയ്ക്കുക"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "എല്ലാ റിലീസുകളും"
breaking = "ബ്രേക്കിംഗ്"
breakingChanges = "ബ്രേക്കിംഗ് മാറ്റങ്ങൾ"
breakingChangesDefault = "ഈ പതിപ്പിൽ Breaking Changes ഉണ്ട്."
breakingChangesDetected = "Breaking Changes കണ്ടെത്തി"
breakingChangesMessage = "ചില പതിപ്പുകളിൽ Breaking Changes ഉൾപ്പെടുന്നു. അപ്‌ഡേറ്റ് ചെയ്യുന്നതിന് മുമ്പ് താഴെയുള്ള മൈഗ്രേഷൻ ഗൈഡുകൾ പരിശോധിക്കുക."
close = "അടയ്ക്കുക"
closeModal = "അപ്‌ഡേറ്റ് മോഡൽ അടയ്ക്കുക"
current = "ഇപ്പോഴത്തെ പതിപ്പ്"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA പ്രവർത്തനരഹിതമാക്കു
[workspace.people.roleDescriptions]
admin = "പൂർണ്ണ അഡ്മിനിസ്ട്രേറ്റീവ് ആക്സസോടുകൂടി ക്രമീകരണങ്ങളും അംഗ ക്ഷണങ്ങളും നിയന്ത്രിക്കാം."
currentRole = "നിലവിൽ നിയോഗിച്ചിരിക്കുന്ന പദവി."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Sluiten"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Sluiten"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Alle releases"
breaking = "Breaking"
breakingChanges = "Incompatibele wijzigingen"
breakingChangesDefault = "Deze versie bevat incompatibele wijzigingen."
breakingChangesDetected = "Incompatibele wijzigingen gedetecteerd"
breakingChangesMessage = "Sommige versies bevatten incompatibele wijzigingen. Bekijk onderstaande migratiehandleidingen voordat u update."
close = "Sluiten"
closeModal = "Updatevenster sluiten"
current = "Huidige versie"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA uitschakelen"
[workspace.people.roleDescriptions]
admin = "Kan instellingen beheren en leden uitnodigen, met volledige beheerdersrechten."
currentRole = "Momenteel toegewezen rol."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Lukk"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Lukk"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Alle utgivelser"
breaking = "Brytende"
breakingChanges = "Inkompatible endringer"
breakingChangesDefault = "Denne versjonen inneholder inkompatible endringer."
breakingChangesDetected = "Inkompatible endringer oppdaget"
breakingChangesMessage = "Noen versjoner inneholder inkompatible endringer. Les migreringsveiledningene nedenfor før du oppdaterer."
close = "Lukk"
closeModal = "Lukk oppdateringsvindu"
current = "Nåværende versjon"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Deaktiver MFA"
[workspace.people.roleDescriptions]
admin = "Kan administrere innstillinger og invitere medlemmer, med full administrativ tilgang."
currentRole = "Gjeldende tildelt rolle."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Zamknij"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Zamknij"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Wszystkie wydania"
breaking = "Niekompatybilne zmiany"
breakingChanges = "Zmiany niekompatybilne"
breakingChangesDefault = "Ta wersja zawiera zmiany niekompatybilne."
breakingChangesDetected = "Wykryto zmiany niekompatybilne"
breakingChangesMessage = "Niektóre wersje zawierają zmiany niekompatybilne. Przejrzyj poniższe przewodniki migracji przed aktualizacją."
close = "Zamknij"
closeModal = "Zamknij okno aktualizacji"
current = "Bieżąca wersja"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Wyłącz MFA"
[workspace.people.roleDescriptions]
admin = "Może zarządzać ustawieniami i zapraszać członków, z pełnym dostępem administracyjnym."
currentRole = "Aktualnie przypisana rola."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Fechar"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Fechar"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "All Releases"
breaking = "Breaking"
breakingChanges = "Breaking Changes"
breakingChangesDefault = "This version contains breaking changes."
breakingChangesDetected = "Breaking Changes Detected"
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
close = "Close"
closeModal = "Close update modal"
current = "Current Version"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Disable MFA"
[workspace.people.roleDescriptions]
admin = "Can manage settings and invite members, with full administrative access."
currentRole = "Função atribuída atualmente."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Fechar"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Fechar"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Todas as versões"
breaking = "Alterações incompatíveis"
breakingChanges = "Alterações incompatíveis"
breakingChangesDefault = "Esta versão contém alterações incompatíveis."
breakingChangesDetected = "Alterações incompatíveis detetadas"
breakingChangesMessage = "Algumas versões contêm alterações incompatíveis. Consulte os guias de migração abaixo antes de atualizar."
close = "Fechar"
closeModal = "Fechar modal de atualização"
current = "Versão atual"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Desativar MFA"
[workspace.people.roleDescriptions]
admin = "Pode gerir definições e convidar membros, com acesso administrativo total."
currentRole = "Função atualmente atribuída."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Închide"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Închide"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Toate versiunile"
breaking = "Schimbări majore"
breakingChanges = "Modificări incompatibile"
breakingChangesDefault = "Această versiune conține modificări incompatibile."
breakingChangesDetected = "Modificări incompatibile detectate"
breakingChangesMessage = "Unele versiuni conțin modificări incompatibile. Consultă ghidurile de migrare de mai jos înainte de a actualiza."
close = "Închide"
closeModal = "Închideți fereastra de actualizare"
current = "Versiune curentă"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Dezactivați MFA"
[workspace.people.roleDescriptions]
admin = "Poate gestiona setările și invita membri, cu acces administrativ complet."
currentRole = "Rolul atribuit în prezent."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Закрыть"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Закрыть"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Все релизы"
breaking = "Несовместимые"
breakingChanges = "Ломающие изменения"
breakingChangesDefault = "Эта версия содержит ломающие изменения."
breakingChangesDetected = "Обнаружены ломающие изменения"
breakingChangesMessage = "Некоторые версии содержат ломающие изменения. Перед обновлением ознакомьтесь с руководствами по миграции ниже."
close = "Закрыть"
closeModal = "Закрыть окно обновления"
current = "Текущая версия"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Отключить MFA"
[workspace.people.roleDescriptions]
admin = "Может управлять настройками и приглашать участников, имеет полный административный доступ."
currentRole = "Текущая назначенная роль."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Zavrieť"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Zavrieť"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Všetky vydania"
breaking = "Nezlučiteľné"
breakingChanges = "Nezlučiteľné zmeny"
breakingChangesDefault = "Táto verzia obsahuje nezlučiteľné zmeny."
breakingChangesDetected = "Zistené nezlučiteľné zmeny"
breakingChangesMessage = "Niektoré verzie obsahujú nezlučiteľné zmeny. Pred aktualizáciou si pozrite migračné príručky nižšie."
close = "Zavrieť"
closeModal = "Zavrieť okno aktualizácie"
current = "Aktuálna verzia"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Zakázať MFA"
[workspace.people.roleDescriptions]
admin = "Môže spravovať nastavenia a pozývať členov, s plným administratívnym prístupom."
currentRole = "Aktuálne priradená rola."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Zapri"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Zapri"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Vse izdaje"
breaking = "Prelomne"
breakingChanges = "Prelomne spremembe"
breakingChangesDefault = "Ta različica vsebuje prelomne spremembe."
breakingChangesDetected = "Zaznane prelomne spremembe"
breakingChangesMessage = "Nekatere različice vsebujejo prelomne spremembe. Pred posodobitvijo preglejte spodnje vodiče za migracijo."
close = "Zapri"
closeModal = "Zapri okno posodobitev"
current = "Trenutna različica"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Onemogoči MFA"
[workspace.people.roleDescriptions]
admin = "Lahko upravlja nastavitve in vabi člane s polnim skrbniškim dostopom."
currentRole = "Trenutno dodeljena vloga."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Zatvori"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Zatvori"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Sva izdanja"
breaking = "Nekompatibilne promene"
breakingChanges = "Nekompatibilne promene"
breakingChangesDefault = "Ova verzija sadrži nekompatibilne promene."
breakingChangesDetected = "Otkrivene nekompatibilne promene"
breakingChangesMessage = "Neke verzije sadrže nekompatibilne promene. Pregledajte vodiče za migraciju ispod pre ažuriranja."
close = "Zatvori"
closeModal = "Zatvori prozor ažuriranja"
current = "Trenutna verzija"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Onemogući MFA"
[workspace.people.roleDescriptions]
admin = "Može da upravlja podešavanjima i poziva članove, sa punim administratorskim pristupom."
currentRole = "Trenutno dodeljena uloga."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Stäng"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Stäng"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Alla utgåvor"
breaking = "Brytande"
breakingChanges = "Brytande ändringar"
breakingChangesDefault = "Denna version innehåller brytande ändringar."
breakingChangesDetected = "Brytande ändringar upptäckta"
breakingChangesMessage = "Vissa versioner innehåller brytande ändringar. Läs migreringsguiderna nedan innan du uppdaterar."
close = "Stäng"
closeModal = "Stäng uppdateringsfönster"
current = "Aktuell version"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Inaktivera MFA"
[workspace.people.roleDescriptions]
admin = "Kan hantera inställningar och bjuda in medlemmar, med full administrativ åtkomst."
currentRole = "Aktuell tilldelad roll."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "ปิด"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "ปิด"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "รุ่นทั้งหมด"
breaking = "Breaking"
breakingChanges = "การเปลี่ยนแปลงที่มีผลกระทบ"
breakingChangesDefault = "เวอร์ชันนี้มีการเปลี่ยนแปลงที่มีผลกระทบ"
breakingChangesDetected = "พบการเปลี่ยนแปลงที่มีผลกระทบ"
breakingChangesMessage = "บางเวอร์ชันมีการเปลี่ยนแปลงที่มีผลกระทบ โปรดตรวจสอบคู่มือการย้ายด้านล่างก่อนอัปเดต"
close = "ปิด"
closeModal = "ปิดหน้าต่างอัปเดต"
current = "เวอร์ชันปัจจุบัน"
@@ -8606,6 +8606,7 @@ disableByAdmin = "ปิดใช้งาน MFA"
[workspace.people.roleDescriptions]
admin = "สามารถจัดการการตั้งค่าและเชิญสมาชิก พร้อมการเข้าถึงระดับผู้ดูแลทั้งหมด"
currentRole = "บทบาทที่ได้รับมอบหมายในปัจจุบัน."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Kapat"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Kapat"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Tüm Sürümler"
breaking = "Uyumsuz"
breakingChanges = "Kırıcı Değişiklikler"
breakingChangesDefault = "Bu sürüm kırıcı değişiklikler içeriyor."
breakingChangesDetected = "Kırıcı Değişiklikler Tespit Edildi"
breakingChangesMessage = "Bazı sürümler kırıcı değişiklikler içerir. Lütfen güncellemeden önce aşağıdaki geçiş kılavuzlarını inceleyin."
close = "Kapat"
closeModal = "Güncelleme penceresini kapat"
current = "Geçerli Sürüm"
@@ -8606,6 +8606,7 @@ disableByAdmin = "MFA'yı devre dışı bırak"
[workspace.people.roleDescriptions]
admin = "Ayarları yönetebilir ve üyeleri davet edebilir; tam yönetici erişimine sahiptir."
currentRole = "Geçerli atanmış rol."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Закрити"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Закрити"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Усі релізи"
breaking = "Критичні зміни"
breakingChanges = "Несумісні зміни"
breakingChangesDefault = "Ця версія містить несумісні зміни."
breakingChangesDetected = "Виявлено несумісні зміни"
breakingChangesMessage = "Деякі версії містять несумісні зміни. Перед оновленням перегляньте настанови з міграції нижче."
close = "Закрити"
closeModal = "Закрити модальне вікно оновлення"
current = "Поточна версія"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Вимкнути MFA"
[workspace.people.roleDescriptions]
admin = "Може керувати налаштуваннями й запрошувати учасників, має повний адміністративний доступ."
currentRole = "Поточна призначена роль."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "Đóng"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "Đóng"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "Tất cả bản phát hành"
breaking = "Thay đổi phá vỡ"
breakingChanges = "Thay đổi phá vỡ"
breakingChangesDefault = "Phiên bản này có thay đổi phá vỡ."
breakingChangesDetected = "Phát hiện thay đổi phá vỡ"
breakingChangesMessage = "Một số phiên bản có thay đổi phá vỡ. Vui lòng xem hướng dẫn chuyển đổi bên dưới trước khi cập nhật."
close = "Đóng"
closeModal = "Đóng cửa sổ cập nhật"
current = "Phiên bản hiện tại"
@@ -8606,6 +8606,7 @@ disableByAdmin = "Tắt MFA"
[workspace.people.roleDescriptions]
admin = "Có thể quản lý cài đặt và mời thành viên, với quyền quản trị đầy đủ."
currentRole = "Vai trò hiện được gán."
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "关闭"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "关闭"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "所有发布"
breaking = "重大变更"
breakingChanges = "破坏性变更"
breakingChangesDefault = "此版本包含破坏性变更。"
breakingChangesDetected = "检测到破坏性变更"
breakingChangesMessage = "某些版本包含破坏性变更。请在更新前查看以下迁移指南。"
close = "关闭"
closeModal = "关闭更新窗口"
current = "当前版本"
@@ -8606,6 +8606,7 @@ disableByAdmin = "禁用 MFA"
[workspace.people.roleDescriptions]
admin = "可管理设置和邀请成员,拥有完整管理权限。"
currentRole = "当前分配的角色。"
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "关闭"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "关闭"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "所有版本"
breaking = "重大变更"
breakingChanges = "重大变更"
breakingChangesDefault = "此版本包含重大变更。"
breakingChangesDetected = "检测到重大变更"
breakingChangesMessage = "某些版本包含重大变更。请在更新前查看下方迁移指南。"
close = "关闭"
closeModal = "关闭更新窗口"
current = "当前版本"
@@ -8606,6 +8606,7 @@ disableByAdmin = "禁用 MFA"
[workspace.people.roleDescriptions]
admin = "可管理设置并邀请成员,具有完整管理权限。"
currentRole = "当前分配的角色。"
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -5854,6 +5854,7 @@ routing = "Routing"
security = "Security"
[policies.detail]
close = "關閉"
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organisation. Contact a team leader to change this policy."
@@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
close = "關閉"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
@@ -7784,8 +7786,6 @@ allReleases = "所有版本"
breaking = "重大變更"
breakingChanges = "重大變更"
breakingChangesDefault = "此版本包含重大變更。"
breakingChangesDetected = "偵測到重大變更"
breakingChangesMessage = "部分版本包含重大變更。更新前請先查看下方的遷移指南。"
close = "關閉"
closeModal = "關閉更新視窗"
current = "目前版本"
@@ -8606,6 +8606,7 @@ disableByAdmin = "停用 MFA"
[workspace.people.roleDescriptions]
admin = "可管理設定並邀請成員,具完整的管理存取權。"
currentRole = "目前指派的角色。"
user = "Can view and edit shared files, but cannot manage workspace settings or users."
[workspace.people.toggleEnabled]
@@ -0,0 +1,42 @@
// Fail the desktop build if the bundled jlink runtime is older than the Java
// version the app JAR is compiled for. A too-old runtime ships happily today
// (the jlink task short-circuits on a stale runtime/jre, and nothing checks the
// version), then dies at launch with UnsupportedClassVersionError -> the
// backend never starts and every tool shows "backend offline".
//
// Reads JAVA_VERSION from the jlink `release` file (always present in a jlink
// output) so it needs no shell and behaves identically on Windows/macOS/Linux.
//
// Required major comes from REQUIRED_JAVA (wired from .taskfiles/desktop.yml,
// which mirrors build.gradle `modernJavaVersion`). Keep them in sync.
import { readFileSync } from "node:fs";
const required = Number(process.env.REQUIRED_JAVA ?? "25");
const releasePath = process.argv[2] ?? "runtime/jre/release";
let raw;
try {
raw = readFileSync(releasePath, "utf8");
} catch (err) {
console.error(
`FATAL: cannot read bundled JRE release file at "${releasePath}": ${err.message}. ` +
`Is the runtime built? Run 'task desktop:jlink'.`,
);
process.exit(1);
}
const match = raw.match(/JAVA_VERSION="?(\d+)/);
const major = match ? Number(match[1]) : 0;
console.log(
`Bundled JRE major: ${major || "unknown"} (required >= ${required})`,
);
if (!major || major < required) {
console.error(
`FATAL: bundled runtime/jre is Java ${major || "unknown"} but the app JAR requires ` +
`Java ${required}. Run 'task desktop:jlink:clean' and rebuild with JDK ${required} active ` +
`(check 'java -version' / JAVA_HOME).`,
);
process.exit(1);
}
@@ -10,6 +10,8 @@ struct ProvisioningConfig<'a> {
server_url: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
lock_connection_mode: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
login_agreement_enabled: Option<bool>,
/// Optional headless-install update policy.
/// One of `"prompt"` (default), `"auto"`, or `"disabled"`.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -44,6 +46,7 @@ fn main() -> Result<(), String> {
let mut output: Option<PathBuf> = None;
let mut url: Option<String> = None;
let mut lock_value: Option<String> = None;
let mut login_agreement_value: Option<String> = None;
let mut update_mode_arg: Option<String> = None;
let mut args = env::args().skip(1);
@@ -67,6 +70,12 @@ fn main() -> Result<(), String> {
.ok_or_else(|| "--lock requires a value".to_string())?;
lock_value = Some(value);
}
"--login-agreement" => {
let value = args
.next()
.ok_or_else(|| "--login-agreement requires a value".to_string())?;
login_agreement_value = Some(value);
}
"--update-mode" => {
let value = args
.next()
@@ -84,6 +93,15 @@ fn main() -> Result<(), String> {
let url = url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
// Treat an empty/whitespace value as "not supplied" (None), matching url and
// update-mode. The MSI always passes --login-agreement "[STIRLING_LOGIN_AGREEMENT]",
// which expands to "" when the property is unset; that must NOT write
// loginAgreementEnabled:false and clobber a previously-provisioned true.
let login_agreement = login_agreement_value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(parse_bool);
let update_mode = update_mode_arg
.as_deref()
@@ -91,9 +109,10 @@ fn main() -> Result<(), String> {
.transpose()?
.flatten();
// Nothing to write — avoid clobbering an existing provisioning file when
// the MSI is invoked without any of STIRLING_SERVER_URL / STIRLING_UPDATE_MODE.
if url.is_none() && update_mode.is_none() {
// Nothing to write — avoid clobbering an existing provisioning file when the
// MSI is invoked without any provisioning directives
// (STIRLING_SERVER_URL / STIRLING_LOGIN_AGREEMENT / STIRLING_UPDATE_MODE).
if url.is_none() && login_agreement.is_none() && update_mode.is_none() {
return Ok(());
}
@@ -111,6 +130,7 @@ fn main() -> Result<(), String> {
let config = ProvisioningConfig {
server_url: url.as_deref(),
lock_connection_mode: lock,
login_agreement_enabled: login_agreement,
update_mode,
};
@@ -203,7 +203,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
// Define all Java options with Tauri-specific paths
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
let java_options = vec![
let mut java_options = vec![
"-Xmx2g",
"-DBROWSER_OPEN=false",
"-DSTIRLING_PDF_TAURI_MODE=true",
@@ -212,10 +212,16 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
"-Dserver.port=0", // Let OS assign an available port
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
"-jar",
jar_path.to_str().unwrap(),
];
// Enable the login agreement on local desktop installs when it has been provisioned.
if crate::commands::connection::login_agreement_enabled(app) {
java_options.push("-Dlegal.loginAgreement.enabled=true");
}
java_options.push("-jar");
java_options.push(jar_path.to_str().unwrap());
// Log the equivalent command for external testing
let java_command = format!(
"TAURI_PARENT_PID={} \"{}\" {}",
@@ -308,7 +314,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
let mut port_guard = BACKEND_PORT.lock().unwrap();
*port_guard = Some(port);
add_log(format!("🎉 Backend started on port: {}", port));
add_log(format!("🔌 Navigate to: http://localhost:{}/", port));
add_log(format!("🔌 Navigate to: http://127.0.0.1:{}/", port));
}
}
@@ -15,6 +15,7 @@ const FIRST_LAUNCH_KEY: &str = "setup_completed";
const CONNECTION_MODE_KEY: &str = "connection_mode";
const SERVER_CONFIG_KEY: &str = "server_config";
const LOCK_CONNECTION_KEY: &str = "lock_connection_mode";
const LOGIN_AGREEMENT_KEY: &str = "login_agreement_enabled";
pub(crate) const UPDATE_MODE_KEY: &str = "update_mode";
/// When `true` the update mode was written by a provisioning file and cannot
/// be changed from the UI. Only another provisioning file (from MDM) can
@@ -182,6 +183,7 @@ pub async fn set_connection_mode(
struct ProvisioningConfig {
server_url: Option<String>,
lock_connection_mode: Option<bool>,
login_agreement_enabled: Option<bool>,
/// Optional headless-install update policy (`"prompt"`, `"auto"`, `"disabled"`).
/// When omitted the existing stored mode is left unchanged.
update_mode: Option<UpdateMode>,
@@ -236,14 +238,35 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin
let parsed: ProvisioningConfig = serde_json::from_str(&raw)
.map_err(|e| format!("Failed to parse provisioning file: {}", e))?;
// Login agreement can be provisioned independently of a server URL so it also applies to
// local, no-login desktop installs. Persist it before the server-URL handling below, which
// may early-return when no URL is present.
if let Some(login_agreement_enabled) = parsed.login_agreement_enabled {
if let Ok(store) = app_handle.store(STORE_FILE) {
store.set(LOGIN_AGREEMENT_KEY, serde_json::json!(login_agreement_enabled));
let _ = store.save();
}
add_log(format!(
"🧩 Provisioned login agreement enabled = {}",
login_agreement_enabled
));
}
let server_url = parsed
.server_url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if server_url.is_none() && parsed.update_mode.is_none() {
// Only short-circuit when there is nothing left to apply. login_agreement is handled above,
// but it must still be in this guard so a login-agreement-only file falls through to the
// deletion block at the end (otherwise the per-user file would linger and re-apply forever).
if server_url.is_none()
&& parsed.update_mode.is_none()
&& parsed.login_agreement_enabled.is_none()
{
add_log(
"⚠️ Provisioning file has neither serverUrl nor updateMode; skipping apply".to_string(),
"⚠️ Provisioning file has no actionable fields (serverUrl/updateMode/loginAgreement); skipping apply"
.to_string(),
);
return Ok(());
}
@@ -335,6 +358,17 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin
Ok(())
}
/// Whether the login agreement was provisioned as enabled. Read by the backend launcher to pass
/// the `-Dlegal.loginAgreement.enabled` flag to the bundled JVM in local desktop mode.
pub fn login_agreement_enabled(app_handle: &AppHandle) -> bool {
app_handle
.store(STORE_FILE)
.ok()
.and_then(|store| store.get(LOGIN_AGREEMENT_KEY))
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
#[tauri::command]
pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
let store = app_handle
@@ -0,0 +1,147 @@
//! Fast-path transport for LOCALHOST binary traffic to the bundled backend.
//!
//! `@tauri-apps/plugin-http` marshals request/response bodies across the IPC
//! bridge as a JSON array of per-byte numbers (~3.5x size bloat plus heavy GC),
//! which is painful for large PDF uploads/downloads. This command moves the
//! raw bytes across IPC as `InvokeBody::Raw` instead, carrying the request and
//! response metadata in a small length-prefixed frame alongside the untouched
//! body:
//!
//! ```text
//! frame = [u32 BE meta_len][meta JSON (utf-8)][raw body bytes]
//! ```
//!
//! It is intentionally scoped to loopback URLs only (validated by parsing the
//! host to an `IpAddr`), and the shared client disables redirects, so it can
//! never be used as a general outbound request primitive — a 3xx pointing
//! off-host is not followed (no SSRF / arbitrary-URL fetch).
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use tauri::ipc::{InvokeBody, Request, Response};
use tauri_plugin_http::reqwest::header::{HeaderName, HeaderValue};
use tauri_plugin_http::reqwest::{redirect::Policy, Client, Method};
use url::{Host, Url};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProxyRequestMeta {
method: String,
url: String,
headers: Vec<(String, String)>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyResponseMeta {
status: u16,
status_text: String,
headers: Vec<(String, String)>,
}
/// Process-wide client: pools connections / keeps them alive across the many
/// calls a session makes, and disables redirects so the loopback guarantee
/// can't be escaped via a 3xx `Location` to a non-loopback host.
fn http_client() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.redirect(Policy::none())
.build()
.expect("failed to build local-proxy reqwest client")
})
}
/// Only loopback hosts are permitted — this is a bundled-backend shortcut, not
/// a general HTTP proxy. Parsing to an `IpAddr` covers all of 127.0.0.0/8 and
/// `::1` in any spelling, rather than matching a few exact strings.
fn is_loopback_host(url: &Url) -> bool {
match url.host() {
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
None => false,
}
}
/// Proxy a single request to the bundled localhost backend, moving the body as
/// raw bytes instead of the plugin-http number-array. See the module docs for
/// the frame layout.
#[tauri::command]
pub async fn proxy_local_pdf_request(request: Request<'_>) -> Result<Response, String> {
let frame = match request.body() {
InvokeBody::Raw(bytes) => bytes,
InvokeBody::Json(_) => {
return Err("proxy_local_pdf_request: expected a raw body".into());
}
};
if frame.len() < 4 {
return Err("proxy_local_pdf_request: frame too short".into());
}
let meta_len = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
let meta_end = 4usize
.checked_add(meta_len)
.ok_or("proxy_local_pdf_request: meta length overflow")?;
if frame.len() < meta_end {
return Err("proxy_local_pdf_request: frame truncated".into());
}
let meta: ProxyRequestMeta =
serde_json::from_slice(&frame[4..meta_end]).map_err(|e| e.to_string())?;
let body = frame[meta_end..].to_vec();
let url = Url::parse(&meta.url).map_err(|e| e.to_string())?;
if !is_loopback_host(&url) {
return Err(format!(
"proxy_local_pdf_request: refusing non-loopback url: {}",
meta.url
));
}
let method = Method::from_bytes(meta.method.as_bytes()).map_err(|e| e.to_string())?;
let mut builder = http_client().request(method, url);
for (name, value) in &meta.headers {
// Build the header explicitly and skip any invalid pair, rather than
// letting one malformed header fail the whole request opaquely at send().
if let (Ok(header_name), Ok(header_value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
builder = builder.header(header_name, header_value);
}
}
if !body.is_empty() {
builder = builder.body(body);
}
let response = builder.send().await.map_err(|e| e.to_string())?;
let status = response.status();
let status_text = status.canonical_reason().unwrap_or("").to_string();
let headers: Vec<(String, String)> = response
.headers()
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|v| (name.as_str().to_string(), v.to_string()))
})
.collect();
let body_bytes = response.bytes().await.map_err(|e| e.to_string())?;
let meta_out = ProxyResponseMeta {
status: status.as_u16(),
status_text,
headers,
};
let meta_json = serde_json::to_vec(&meta_out).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(4 + meta_json.len() + body_bytes.len());
out.extend_from_slice(&(meta_json.len() as u32).to_be_bytes());
out.extend_from_slice(&meta_json);
out.extend_from_slice(&body_bytes);
Ok(Response::new(out))
}
@@ -3,6 +3,7 @@ pub mod files;
pub mod connection;
pub mod auth;
pub mod default_app;
pub mod local_proxy;
pub mod platform;
pub mod print;
pub mod updater;
@@ -40,6 +41,7 @@ pub use auth::{
start_oauth_login,
};
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
pub use local_proxy::proxy_local_pdf_request;
pub use platform::get_desktop_os;
pub use print::print_pdf_file_native;
pub use updater::{
+2
View File
@@ -25,6 +25,7 @@ use commands::{
get_user_info,
is_first_launch,
login,
proxy_local_pdf_request,
reset_setup_completion,
save_auth_token,
save_refresh_token,
@@ -177,6 +178,7 @@ pub fn run() {
is_first_launch,
reset_setup_completion,
login,
proxy_local_pdf_request,
save_auth_token,
get_auth_token,
clear_auth_token,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.13.1",
"version": "2.13.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -3,6 +3,8 @@
<Fragment>
<Property Id="STIRLING_SERVER_URL" Secure="yes" />
<Property Id="STIRLING_LOCK_CONNECTION" Secure="yes" />
<!-- STIRLING_LOGIN_AGREEMENT — "1"/"true" enables the login agreement popup on this install. -->
<Property Id="STIRLING_LOGIN_AGREEMENT" Secure="yes" />
<!--
STIRLING_UPDATE_MODE — headless auto-update policy for MDM/Intune deploys.
Accepts "prompt" (default, user-interactive), "auto" (silent download+install on
@@ -71,24 +73,25 @@
<CustomAction
Id="SetWriteProvisioningFilePerUser"
Property="WriteProvisioningFilePerUser"
Value="--output &quot;[AppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
Value="--output &quot;[AppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --login-agreement &quot;[STIRLING_LOGIN_AGREEMENT]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
/>
<CustomAction
Id="SetWriteProvisioningFileAllUsers"
Property="WriteProvisioningFileAllUsers"
Value="--output &quot;[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
Value="--output &quot;[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --login-agreement &quot;[STIRLING_LOGIN_AGREEMENT]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
/>
<!--
Run the provisioner when EITHER STIRLING_SERVER_URL or STIRLING_UPDATE_MODE is set.
The provisioner is a no-op if both values end up empty, so passing neither is safe.
Run the provisioner when ANY of STIRLING_SERVER_URL, STIRLING_LOGIN_AGREEMENT, or
STIRLING_UPDATE_MODE is set. The provisioner is a no-op if all values end up empty,
so passing none is safe.
-->
<InstallExecuteSequence>
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_LOGIN_AGREEMENT &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_LOGIN_AGREEMENT &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_LOGIN_AGREEMENT &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_LOGIN_AGREEMENT &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
</InstallExecuteSequence>
</Fragment>
</Wix>
@@ -1,6 +1,7 @@
import { ReactNode } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import NavigationWarningModal from "@app/components/shared/NavigationWarningModal";
import LoginAgreementModal from "@app/components/shared/LoginAgreementModal";
interface AppLayoutProps {
children: ReactNode;
@@ -27,6 +28,7 @@ export function AppLayout({ children }: AppLayoutProps) {
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
</div>
<NavigationWarningModal />
<LoginAgreementModal />
</>
);
}

Some files were not shown because too many files have changed in this diff Show More