Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d476297d | ||
|
|
3456316569 | ||
|
|
215bba39bc | ||
|
|
900b66b030 | ||
|
|
c3795c1a3c | ||
|
|
c8925acee7 | ||
|
|
eb08e60d67 | ||
|
|
18da914bf9 | ||
|
|
8f46ca0d92 | ||
|
|
9a3bc6b47f | ||
|
|
2b05865a84 | ||
|
|
0c503cc41d | ||
|
|
b1fef4c647 | ||
|
|
06254853af | ||
|
|
d9e6041a75 | ||
|
|
8f81fdc762 |
@@ -20,8 +20,8 @@ set -e
|
||||
# - To build the project, use:
|
||||
# ./gradlew build
|
||||
#
|
||||
# - For running pre-commit hooks (if configured), use:
|
||||
# pre-commit run --all-files
|
||||
# - To run the lint/format/secret checks, use:
|
||||
# task pre-commit
|
||||
#
|
||||
# Make sure you are in the project root directory after this script executes.
|
||||
# =============================================================================
|
||||
@@ -70,6 +70,6 @@ echo ""
|
||||
echo " To build the project: "
|
||||
echo -e "\e[34m gradle build\e[0m"
|
||||
echo ""
|
||||
echo " To run pre-commit hooks (if configured):"
|
||||
echo -e "\e[34m pre-commit run --all-files -c .pre-commit-config.yaml\e[0m"
|
||||
echo " To run the lint/format/secret checks:"
|
||||
echo -e "\e[34m task pre-commit\e[0m"
|
||||
echo "=================================================================="
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.12.0
|
||||
pkgver=2.13.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.12.0
|
||||
pkgver=2.13.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pre-commit
|
||||
@@ -1,121 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
|
||||
#
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
# via pre-commit
|
||||
distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
filelock==3.29.0 \
|
||||
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
|
||||
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
identify==2.6.19 \
|
||||
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
|
||||
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
|
||||
# via pre-commit
|
||||
nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
# via pre-commit
|
||||
platformdirs==4.9.6 \
|
||||
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
|
||||
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
pre-commit==4.6.0 \
|
||||
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
|
||||
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
|
||||
# via -r .github/scripts/requirements_pre_commit.in
|
||||
python-discovery==1.2.2 \
|
||||
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
|
||||
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
|
||||
# via virtualenv
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
||||
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
|
||||
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
|
||||
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
|
||||
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
|
||||
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
|
||||
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
|
||||
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
|
||||
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
|
||||
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
|
||||
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
|
||||
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
|
||||
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
|
||||
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
|
||||
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
|
||||
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
|
||||
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
|
||||
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
|
||||
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
|
||||
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
|
||||
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
|
||||
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
|
||||
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
|
||||
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
|
||||
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
|
||||
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
|
||||
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
|
||||
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
|
||||
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
|
||||
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
|
||||
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
|
||||
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
|
||||
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
|
||||
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
|
||||
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
|
||||
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
|
||||
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
|
||||
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
|
||||
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
|
||||
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
|
||||
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
|
||||
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
|
||||
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
|
||||
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
|
||||
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
|
||||
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
|
||||
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
|
||||
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
|
||||
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
|
||||
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
|
||||
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
|
||||
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
|
||||
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
|
||||
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
|
||||
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
|
||||
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
|
||||
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
|
||||
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
|
||||
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
|
||||
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
|
||||
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
|
||||
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
|
||||
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
|
||||
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
|
||||
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
|
||||
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
|
||||
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
virtualenv==21.2.4 \
|
||||
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
|
||||
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
|
||||
# via pre-commit
|
||||
@@ -1,8 +1,7 @@
|
||||
name: Pre-commit
|
||||
|
||||
# Runs `pre-commit run` for ruff / codespell / gitleaks / EOF / trailing-ws.
|
||||
# Called from build.yml on PRs and merge_group; also runnable on demand via
|
||||
# workflow_dispatch for manual local-equivalent linting.
|
||||
# Runs the repo-wide lint/format/secret checks via `task pre-commit`.
|
||||
# Called from build.yml on PRs and merge_group; also runnable on demand via workflow_dispatch.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
@@ -13,10 +12,6 @@ permissions:
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Prevents sdist builds → no tar extraction
|
||||
PIP_ONLY_BINARY: ":all:"
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -29,23 +24,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
|
||||
enable-cache: true
|
||||
|
||||
- name: Run Pre-Commit Hooks
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Run Pre-Commit
|
||||
run: |
|
||||
pre-commit run ruff --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run codespell --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||
git diff --exit-code
|
||||
- name: Run pre-commit checks
|
||||
run: task pre-commit
|
||||
|
||||
@@ -58,15 +58,23 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
|
||||
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
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
|
||||
|
||||
- name: pre-commit run
|
||||
- name: Sort translation TOML files
|
||||
run: |
|
||||
pre-commit run toml-sort-fix --all-files
|
||||
task pre-commit:toml-sort FIX=1
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
|
||||
@@ -46,11 +46,20 @@ app/core/storage/
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
|
||||
app/core/src/main/resources/static/*.html
|
||||
!app/core/src/main/resources/static/api-landing.html
|
||||
# Prerendered nested-route pages (e.g. settings/people.html)
|
||||
app/core/src/main/resources/static/settings/
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/logo/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
app/core/src/main/resources/static/modern-logo/
|
||||
app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/og-metadata.json
|
||||
app/core/src/main/resources/static/sw-folder-retry.js
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/pdfjs/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# PostHog project-level key — phc_ prefix keys are public/client-side by design
|
||||
# PostHog project-level key - phc_ prefix keys are public/client-side by design
|
||||
# (PostHog client-side tracking embeds them in the browser bundle). Committed
|
||||
# intentionally in #6150 so engine/.env has a working default, with real
|
||||
# credentials overridden via engine/.env.local.
|
||||
@@ -12,3 +12,10 @@ app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiK
|
||||
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
|
||||
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:92
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:116
|
||||
|
||||
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
|
||||
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
|
||||
|
||||
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
|
||||
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
|
||||
|
||||
@@ -1,52 +1,14 @@
|
||||
# The actual checks live in .taskfiles/pre-commit.yml (with helper scripts under
|
||||
# scripts/pre-commit/) and are driven by Task. This hook just delegates to `task
|
||||
# pre-commit` so the git pre-commit hook, CI and a manual `task pre-commit` all
|
||||
# run the exact same thing. Requires `task` and `uv` on PATH. To auto-fix instead
|
||||
# of only checking, run `task pre-commit:fix`.
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.14
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
- --fix
|
||||
- --line-length=127
|
||||
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
|
||||
exclude: (split_photos.py)
|
||||
- id: ruff-format
|
||||
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
|
||||
exclude: (split_photos.py)
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.2
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment
|
||||
- --skip="./.*,*.csv,*.json,*.ambr"
|
||||
- --quiet-level=2
|
||||
files: \.(html|css|js|py|md)$
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/editor/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
- id: trailing-whitespace
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
- repo: https://github.com/pappasam/toml-sort
|
||||
rev: v0.24.4
|
||||
hooks:
|
||||
- id: toml-sort-fix
|
||||
files: frontend/editor/public/locales/.*\.toml$
|
||||
args: ['--in-place', '--all', '--ignore-case']
|
||||
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
|
||||
# rev: v16.21.1
|
||||
# hooks:
|
||||
# - id: stylelint
|
||||
# additional_dependencies:
|
||||
# - stylelint@16.21.1
|
||||
# - stylelint-config-standard@38.0.0
|
||||
# - "@stylistic/stylelint-plugin@3.1.3"
|
||||
# files: \.(css)$
|
||||
# args: [--fix]
|
||||
- id: task-pre-commit
|
||||
name: task pre-commit
|
||||
entry: task pre-commit
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
@@ -23,6 +23,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
|
||||
dev:proprietary:
|
||||
@@ -31,13 +32,14 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
@@ -60,12 +62,13 @@ tasks:
|
||||
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
|
||||
PROFILES: '{{.PROFILES | default "dev"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
STIRLING_FLAVOR: saas
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{if .AIENGINE_URL}}true{{else}}false{{end}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
cmds:
|
||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
|
||||
|
||||
@@ -40,6 +40,21 @@ tasks:
|
||||
cmds:
|
||||
- node editor/scripts/generate-icons.js
|
||||
|
||||
prepare:og:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate OG/social-preview metadata from the tool registry"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs
|
||||
sources:
|
||||
- editor/src/core/types/toolId.ts
|
||||
- editor/src/core/utils/urlMapping.ts
|
||||
- editor/src/core/data/useTranslatedToolRegistry.tsx
|
||||
- editor/public/og_images/*.png
|
||||
generates:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: when_changed
|
||||
@@ -49,6 +64,7 @@ tasks:
|
||||
- task: prepare:env
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
- prepare:og
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
@@ -317,9 +333,17 @@ tasks:
|
||||
- task: format:check
|
||||
- task: test
|
||||
|
||||
og:check:
|
||||
desc: "Fail if committed OG/social-preview metadata is out of date"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
cmds:
|
||||
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
|
||||
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
|
||||
- task: og:check
|
||||
- task: typecheck:all
|
||||
- task: lint
|
||||
- task: format:check
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
version: '3'
|
||||
|
||||
# Repo-wide lint/format/secret checks - the single source of truth that the git
|
||||
# pre-commit hook (.pre-commit-config.yaml) and CI (pre_commit.yml) both call.
|
||||
|
||||
vars:
|
||||
GITLEAKS: '8.30.0'
|
||||
|
||||
# File selections as git pathspecs: git does the include/exclude matching, so
|
||||
# there is no grep/xargs and it behaves identically on every platform.
|
||||
PY_FILES: >-
|
||||
'scripts/*.py'
|
||||
'.github/scripts/*.py'
|
||||
'app/core/src/main/resources/static/python/*.py'
|
||||
':(exclude)*split_photos.py'
|
||||
SPELL_FILES: >-
|
||||
'*.html'
|
||||
'*.css'
|
||||
'*.js'
|
||||
'*.py'
|
||||
'*.md'
|
||||
':(exclude).vscode/*'
|
||||
':(exclude).devcontainer/*'
|
||||
':(exclude)app/core/src/main/resources/*'
|
||||
':(exclude)app/proprietary/src/main/resources/*'
|
||||
':(exclude)frontend/editor/public/vendor/*'
|
||||
':(exclude)*Dockerfile*'
|
||||
':(exclude)*pdfjs*'
|
||||
':(exclude)*thirdParty*'
|
||||
':(exclude)*bootstrap*'
|
||||
':(exclude)*.min.*'
|
||||
':(exclude)*diff.js'
|
||||
WS_FILES: >-
|
||||
'*.js'
|
||||
'*.java'
|
||||
'*.py'
|
||||
'*.yml'
|
||||
':(exclude)*pdfjs*'
|
||||
':(exclude)*thirdParty*'
|
||||
':(exclude)*bootstrap*'
|
||||
':(exclude)*.min.*'
|
||||
':(exclude)*diff.js'
|
||||
':(exclude).github/workflows/*'
|
||||
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
|
||||
|
||||
GITLEAKS_BIN: '.task/bin/gitleaks-{{.GITLEAKS}}{{if eq OS "windows"}}.exe{{end}}'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: "Check formatting, spelling, and secrets across the repo"
|
||||
cmds:
|
||||
- task: ruff
|
||||
- task: ruff-format
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
- task: whitespace
|
||||
- task: toml-sort
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
|
||||
cmds:
|
||||
# Auto-fixers first, then the report-only tools (codespell, gitleaks) so a
|
||||
# finding there does not stop the fixers from running.
|
||||
- task: ruff
|
||||
vars: { FIX: '1' }
|
||||
- task: ruff-format
|
||||
vars: { FIX: '1' }
|
||||
- task: whitespace
|
||||
vars: { FIX: '1' }
|
||||
- task: toml-sort
|
||||
vars: { FIX: '1' }
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)"
|
||||
run: once
|
||||
cmds:
|
||||
- uv sync --project scripts/pre-commit --locked
|
||||
sources:
|
||||
- scripts/pre-commit/uv.lock
|
||||
- scripts/pre-commit/pyproject.toml
|
||||
status:
|
||||
- test -d scripts/pre-commit/.venv
|
||||
|
||||
clean:
|
||||
desc: "Remove the cache/build artifacts"
|
||||
cmds:
|
||||
- task: '{{if eq OS "windows"}}clean-windows{{else}}clean-unix{{end}}'
|
||||
|
||||
clean-unix:
|
||||
internal: true
|
||||
cmds:
|
||||
- rm -rf scripts/pre-commit/.venv .task/bin/gitleaks-*
|
||||
|
||||
# On Windows, use PowerShell so it matches the same paths and tolerates absent
|
||||
# files without erroring.
|
||||
clean-windows:
|
||||
internal: true
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- powershell -NoProfile -Command "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue scripts/pre-commit/.venv, .task/bin/gitleaks-*"
|
||||
|
||||
# Individual checks (hidden from `task --list`, but callable, e.g.
|
||||
# `task pre-commit:toml-sort FIX=1`). Pass FIX=1 to auto-fix where supported.
|
||||
ruff:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
|
||||
|
||||
ruff-format:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
|
||||
|
||||
codespell:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
|
||||
|
||||
toml-sort:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}}
|
||||
|
||||
whitespace:
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}$(git ls-files {{.WS_FILES}})
|
||||
|
||||
gitleaks:
|
||||
deps: [gitleaks-bin]
|
||||
# Scan staged changes only, matching the old hook: the git-mode fingerprints
|
||||
# in .gitleaksignore (file:rule:line) still apply, and with nothing staged
|
||||
# this is a no-op. Secrets are never auto-fixed, so FIX has no effect.
|
||||
cmds:
|
||||
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
|
||||
|
||||
gitleaks-bin:
|
||||
internal: true
|
||||
desc: "Ensure the pinned gitleaks binary is cached in .task/bin"
|
||||
status:
|
||||
- test -f {{.GITLEAKS_BIN}}
|
||||
vars:
|
||||
GL_ARCH: '{{if eq ARCH "amd64"}}x64{{else if eq ARCH "arm64"}}arm64{{else if eq ARCH "386"}}x32{{else}}{{ARCH}}{{end}}'
|
||||
GL_PLATFORM: '{{OS}}_{{.GL_ARCH}}'
|
||||
GL_URL: 'https://github.com/gitleaks/gitleaks/releases/download/v{{.GITLEAKS}}/gitleaks_{{.GITLEAKS}}_{{.GL_PLATFORM}}'
|
||||
# SHA-256 of each release asset, from gitleaks_{{.GITLEAKS}}_checksums.txt.
|
||||
GL_SHA: >-
|
||||
{{if eq .GL_PLATFORM "linux_x64"}}79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e
|
||||
{{- else if eq .GL_PLATFORM "linux_arm64"}}b4cbbb6ddf7d1b2a603088cd03a4e3f7ce48ee7fd449b51f7de6ee2906f5fa2f
|
||||
{{- else if eq .GL_PLATFORM "darwin_x64"}}ca221d012d247080c2f6f61f4b7a83bffa2453806b0c195c795bbe9a8c775ed5
|
||||
{{- else if eq .GL_PLATFORM "darwin_arm64"}}b251ab2bcd4cd8ba9e56ff37698c033ebf38582b477d21ebd86586d927cf87e7
|
||||
{{- else if eq .GL_PLATFORM "windows_x64"}}54fe94f644b832dd08e8c3a5915efb3bfa862386d59fb27ca0792cb687a83573
|
||||
{{- end}}
|
||||
cmds:
|
||||
- cmd: bash scripts/pre-commit/install-gitleaks.sh "{{.GL_URL}}.tar.gz" "{{.GL_SHA}}" "{{.GITLEAKS_BIN}}"
|
||||
platforms: [linux, darwin]
|
||||
- cmd: powershell -NoProfile -File scripts/pre-commit/install-gitleaks.ps1 -Url "{{.GL_URL}}.zip" -Sha "{{.GL_SHA}}" -Dest "{{.GITLEAKS_BIN}}"
|
||||
platforms: [windows]
|
||||
@@ -25,6 +25,9 @@ includes:
|
||||
e2e:
|
||||
taskfile: .taskfiles/e2e.yml
|
||||
dir: .
|
||||
pre-commit:
|
||||
taskfile: .taskfiles/pre-commit.yml
|
||||
dir: .
|
||||
|
||||
tasks:
|
||||
# ============================================================
|
||||
@@ -87,6 +90,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
AIENGINE_ENABLED: "true"
|
||||
- task: 'frontend:dev:{{.FRONTEND}}'
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
@@ -173,3 +177,4 @@ tasks:
|
||||
- task: backend:clean
|
||||
- task: frontend:clean
|
||||
- task: engine:clean
|
||||
- task: pre-commit:clean
|
||||
|
||||
@@ -1132,6 +1132,7 @@ public class ApplicationProperties {
|
||||
public static class Ui {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
private boolean defaultHideUnavailableTools = false;
|
||||
private boolean defaultHideUnavailableConversions = false;
|
||||
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
|
||||
@@ -1140,6 +1141,14 @@ public class ApplicationProperties {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
}
|
||||
|
||||
public String getLogoStyle() {
|
||||
// Validate and return either "modern" or "classic"
|
||||
if ("modern".equalsIgnoreCase(logoStyle)) {
|
||||
return "modern";
|
||||
}
|
||||
return "classic"; // default
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class HideDisabledTools {
|
||||
private boolean googleDrive = false;
|
||||
|
||||
@@ -31,7 +31,8 @@ public class RequestUriUtils {
|
||||
|| normalizedUri.startsWith("/locales/")
|
||||
|| normalizedUri.startsWith("/Login/")
|
||||
|| normalizedUri.startsWith("/samples/")
|
||||
|| normalizedUri.startsWith("/logo/")
|
||||
|| normalizedUri.startsWith("/classic-logo/")
|
||||
|| normalizedUri.startsWith("/modern-logo/")
|
||||
|| normalizedUri.startsWith("/og_images/")) {
|
||||
return true;
|
||||
}
|
||||
@@ -41,6 +42,7 @@ public class RequestUriUtils {
|
||||
|| "/favicon.ico".equals(normalizedUri)
|
||||
|| "/manifest.json".equals(normalizedUri)
|
||||
|| "/site.webmanifest".equals(normalizedUri)
|
||||
|| "/manifest-classic.json".equals(normalizedUri)
|
||||
|| "/index.html".equals(normalizedUri)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -218,9 +218,13 @@ def generatedFrontendPaths = [
|
||||
'site.webmanifest.br',
|
||||
'browserconfig.xml.gz',
|
||||
'browserconfig.xml.br',
|
||||
'manifest-classic.json',
|
||||
'manifest-classic.json.gz',
|
||||
'manifest-classic.json.br',
|
||||
'locales',
|
||||
'Login',
|
||||
'logo',
|
||||
'classic-logo',
|
||||
'modern-logo',
|
||||
'og_images',
|
||||
'samples',
|
||||
'pdfium',
|
||||
@@ -326,6 +330,12 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
group = 'frontend'
|
||||
description = 'Remove previously generated frontend assets from static resources'
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
|
||||
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
|
||||
// api-landing.html is a real backend source file, not a generated artifact.
|
||||
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html'])
|
||||
// Nested prerendered route pages (e.g. settings/people.html)
|
||||
delete new File(resourcesStaticDir, 'settings')
|
||||
}
|
||||
|
||||
tasks.register('copyApiLandingPage', Copy) {
|
||||
|
||||
@@ -81,7 +81,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"/mstile-*.png",
|
||||
"/safari-pinned-tab.svg",
|
||||
"/icons/**",
|
||||
"/logo/**",
|
||||
"/modern-logo/**",
|
||||
"/classic-logo/**",
|
||||
"/robots.txt",
|
||||
"/3rdPartyLicenses.json",
|
||||
"/pdfjs/**",
|
||||
@@ -93,7 +94,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"/vendor/**",
|
||||
"/samples/**",
|
||||
"/og_images/**",
|
||||
"/Login/**")
|
||||
"/Login/**",
|
||||
"/manifest-classic.json")
|
||||
.addResourceLocations(
|
||||
staticPath,
|
||||
"classpath:/static/",
|
||||
@@ -119,8 +121,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"classpath:/static/Login/",
|
||||
staticPath + "icons/",
|
||||
"classpath:/static/icons/",
|
||||
staticPath + "logo/",
|
||||
"classpath:/static/logo/")
|
||||
staticPath + "modern-logo/",
|
||||
"classpath:/static/modern-logo/",
|
||||
staticPath + "classic-logo/",
|
||||
"classpath:/static/classic-logo/")
|
||||
.setCacheControl(
|
||||
CacheControl.maxAge(Duration.ofDays(1))
|
||||
.cachePublic()
|
||||
|
||||
@@ -208,6 +208,7 @@ public class ConfigController {
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
|
||||
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
// User preference defaults
|
||||
|
||||
@@ -203,13 +203,13 @@ public class ReactRoutingController {
|
||||
// (Real storage endpoints live under /api/v1/storage/files, already
|
||||
// excluded by the leading `api` token in the same regex.)
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|logo|Login|og_images|samples)[^\\.]*$}")
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
|
||||
@@ -301,6 +301,7 @@ autoPipeline:
|
||||
|
||||
ui:
|
||||
appNameNavbar: "" # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
|
||||
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
|
||||
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
|
||||
|
||||
@@ -2215,6 +2215,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-security-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-servlet",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
@@ -2320,6 +2327,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-security",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
@@ -2438,6 +2452,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "7.0.5",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
"name": "Stirling PDF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "logo/favicon.ico",
|
||||
"src": "modern-logo/favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo/logo192.png",
|
||||
"src": "modern-logo/logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo/logo512.png",
|
||||
"src": "modern-logo/logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -27,6 +28,7 @@ import stirling.software.proprietary.policy.model.Policy;
|
||||
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
|
||||
*/
|
||||
@Component
|
||||
@Profile("saas")
|
||||
public class FolderAccessGuard {
|
||||
|
||||
public static final String FOLDER_TYPE = "folder";
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.config;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.model.Policy;
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyAccessGuard {
|
||||
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -64,6 +65,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
|
||||
@Profile("saas")
|
||||
public class PolicyController {
|
||||
|
||||
private final PolicyRunner policyRunner;
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -53,6 +54,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyEngine {
|
||||
|
||||
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
@@ -28,6 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class PolicyRunRegistry {
|
||||
|
||||
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -26,6 +27,7 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyRunner {
|
||||
|
||||
private final PolicyEngine policyEngine;
|
||||
|
||||
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyValidator {
|
||||
|
||||
private final List<PolicyTrigger> triggers;
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -33,6 +34,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class FolderInputSource implements InputSource {
|
||||
|
||||
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
@@ -30,6 +31,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class FolderOutputSink implements PolicyOutputSink {
|
||||
|
||||
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
@@ -22,6 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class InlineOutputSink implements PolicyOutputSink {
|
||||
|
||||
private static final String TYPE = "inline";
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -18,6 +19,7 @@ import tools.jackson.databind.ObjectMapper;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class JpaPolicyStore implements PolicyStore {
|
||||
|
||||
private final PolicyRepository repository;
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -45,6 +46,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class FolderWatchTrigger implements PolicyTrigger {
|
||||
|
||||
private static final String TYPE = "folder-watch";
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.trigger;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -12,6 +13,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyTriggerManager implements SmartLifecycle {
|
||||
|
||||
private final List<PolicyTrigger> triggers;
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -31,6 +32,7 @@ import tools.jackson.databind.ObjectMapper;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class ScheduleTrigger implements PolicyTrigger {
|
||||
|
||||
private static final String TYPE = "schedule";
|
||||
|
||||
@@ -978,28 +978,33 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
// Lists enabled users for the signing user picker, scoped by storage.signing.userListScope:
|
||||
// 'org' (default) = whole instance, anything else = caller's team only (fail-closed).
|
||||
// Lists enabled users for the signing picker; 'org' scope = instance-wide, else caller's team.
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
|
||||
if (principal == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
|
||||
|
||||
// Anonymous (SaaS) accounts must never enumerate users, in any scope or team.
|
||||
if (callerOpt.map(UserController::isAnonymousUser).orElse(false)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
// Fail-closed: only literal "org" opens the whole instance; anything else scopes to team.
|
||||
String scope = applicationProperties.getStorage().getSigning().getUserListScope();
|
||||
boolean teamScoped = !"org".equalsIgnoreCase(scope == null ? "" : scope.trim());
|
||||
|
||||
List<User> source;
|
||||
if (teamScoped) {
|
||||
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
|
||||
if (callerOpt.isEmpty() || callerOpt.get().getTeam() == null) {
|
||||
// No team: return only the caller rather than leak the org.
|
||||
Team callerTeam = callerOpt.map(User::getTeam).orElse(null);
|
||||
if (callerTeam == null || isSystemTeam(callerTeam)) {
|
||||
// No team or a shared system team: return only the caller, not the team's members.
|
||||
source = callerOpt.map(List::of).orElse(List.of());
|
||||
} else {
|
||||
// KNOWN LIMITATION: scopes the team via the single User.team FK - correct while
|
||||
// acceptInvitation() collapses users to one team; revisit if multi-team enabled.
|
||||
source = userRepository.findAllByTeamId(callerOpt.get().getTeam().getId());
|
||||
// Scopes via the single User.team FK; revisit if multi-team membership is added.
|
||||
source = userRepository.findAllByTeamId(callerTeam.getId());
|
||||
}
|
||||
} else {
|
||||
source = userRepository.findAll();
|
||||
@@ -1011,6 +1016,18 @@ public class UserController {
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
// SaaS anonymous accounts, which must not enumerate users.
|
||||
private static boolean isAnonymousUser(User user) {
|
||||
return AuthenticationType.ANONYMOUS.name().equalsIgnoreCase(user.getAuthenticationType());
|
||||
}
|
||||
|
||||
// System teams (Default/Internal) are not enumerable through the signing picker.
|
||||
private static boolean isSystemTeam(Team team) {
|
||||
String name = team.getName();
|
||||
return TeamService.DEFAULT_TEAM_NAME.equalsIgnoreCase(name)
|
||||
|| TeamService.INTERNAL_TEAM_NAME.equalsIgnoreCase(name);
|
||||
}
|
||||
|
||||
private UserSummaryDTO toUserSummaryDTO(User user) {
|
||||
return new UserSummaryDTO(
|
||||
user.getId(),
|
||||
|
||||
@@ -2,7 +2,6 @@ package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -28,6 +27,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
@@ -195,8 +195,22 @@ class UserControllerTest {
|
||||
.andExpect(jsonPath("$[0].username").value("a@alpha.com"))
|
||||
.andExpect(jsonPath("$[1].username").value("b@alpha.com"));
|
||||
|
||||
// Caller is resolved (for the anonymous-gate) but org scope still uses findAll, not team.
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersForbiddenForAnonymousCaller() throws Exception {
|
||||
// Anonymous SaaS accounts must never enumerate users, regardless of scope.
|
||||
User anon = user(1L, "anon_abc", true, team(1L, TeamService.DEFAULT_TEAM_NAME));
|
||||
anon.setAuthenticationType(AuthenticationType.ANONYMOUS);
|
||||
when(userService.findByUsernameIgnoreCase("anon_abc")).thenReturn(Optional.of(anon));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("anon_abc")))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
verify(userRepository, never()).findAll();
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
verify(userService, never()).findByUsernameIgnoreCase(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -262,6 +276,39 @@ class UserControllerTest {
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersTeamScopeOnDefaultTeamReturnsSelfOnly() throws Exception {
|
||||
// A caller on a shared system team must not enumerate its members.
|
||||
applicationProperties.getStorage().getSigning().setUserListScope("team");
|
||||
Team defaultTeam = team(1L, TeamService.DEFAULT_TEAM_NAME);
|
||||
User caller = user(1L, "new@saas.com", true, defaultTeam);
|
||||
when(userService.findByUsernameIgnoreCase("new@saas.com")).thenReturn(Optional.of(caller));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("new@saas.com")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0].username").value("new@saas.com"));
|
||||
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersTeamScopeOnInternalTeamReturnsSelfOnly() throws Exception {
|
||||
applicationProperties.getStorage().getSigning().setUserListScope("team");
|
||||
Team internalTeam = team(2L, TeamService.INTERNAL_TEAM_NAME);
|
||||
User caller = user(1L, "svc@saas.com", true, internalTeam);
|
||||
when(userService.findByUsernameIgnoreCase("svc@saas.com")).thenReturn(Optional.of(caller));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("svc@saas.com")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0].username").value("svc@saas.com"));
|
||||
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersFailsClosedOnUnrecognisedScope() throws Exception {
|
||||
// Any non-"org" value must restrict to the caller's team, not leak the instance.
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConfigurationProperties(prefix = "credits")
|
||||
public class CreditsProperties {
|
||||
|
||||
/** Whether the credits system is enabled */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** Credit allocations per billing cycle (monthly) */
|
||||
private CycleAllocations cycle = new CycleAllocations();
|
||||
|
||||
/** Reset configuration */
|
||||
private Reset reset = new Reset();
|
||||
|
||||
/** Error tracking configuration */
|
||||
private Errors errors = new Errors();
|
||||
|
||||
/** Cache configuration */
|
||||
private Cache cache = new Cache();
|
||||
|
||||
@Data
|
||||
public static class CycleAllocations {
|
||||
/** Whether admin role has unlimited credits */
|
||||
private boolean adminUnlimited = true;
|
||||
|
||||
/** Credit allocations per billing cycle (monthly) per role */
|
||||
private Map<String, Integer> allocations =
|
||||
Map.of(
|
||||
"ROLE_ADMIN", 1000,
|
||||
"ROLE_PRO_USER", 500,
|
||||
"ROLE_USER", 50,
|
||||
"ROLE_LIMITED_API_USER", 10,
|
||||
"ROLE_EXTRA_LIMITED_API_USER", 20,
|
||||
"ROLE_WEB_ONLY_USER", 0,
|
||||
"ROLE_DEMO_USER", 100);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Reset {
|
||||
/** Cron expression for monthly reset (default: 1st of month 02:00 UTC) */
|
||||
private String cron = "0 0 2 1 * *";
|
||||
|
||||
/** Time zone for the reset schedule */
|
||||
private String zone = "UTC";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Errors {
|
||||
/** How long error counts are tracked (in minutes) */
|
||||
private int ttlMinutes = 60;
|
||||
|
||||
/** Number of free processing errors before charging */
|
||||
private int freeProcessingErrors = 2;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Cache {
|
||||
/** Enable local Caffeine cache for error counts */
|
||||
private boolean localEnabled = true;
|
||||
|
||||
/** Enable Redis cache for multi-instance deployments */
|
||||
private boolean redisEnabled = false;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
public enum ProcessingErrorType {
|
||||
|
||||
/**
|
||||
* Validation errors. should never cost credits. Examples: missing parameters, invalid file
|
||||
* types, size limits exceeded, malformed requests, authentication failures
|
||||
*/
|
||||
VALIDATION_ERROR,
|
||||
|
||||
/**
|
||||
* Processing errors. should cost credits after 3rd attempt per user/endpoint. Examples: corrupt
|
||||
* PDF files, unsupported PDF features, memory issues during processing, OCR failures on valid
|
||||
* PDFs, conversion errors on valid files
|
||||
*/
|
||||
PROCESSING_ERROR,
|
||||
|
||||
/**
|
||||
* System errors. should not cost credits (our fault). Examples: database connection issues,
|
||||
* filesystem problems, service unavailable, internal server errors
|
||||
*/
|
||||
SYSTEM_ERROR;
|
||||
|
||||
/** Determine error type from exception and HTTP status */
|
||||
public static ProcessingErrorType classifyError(
|
||||
Throwable throwable, int httpStatus, String endpoint) {
|
||||
if (throwable == null) {
|
||||
return classifyByHttpStatus(httpStatus);
|
||||
}
|
||||
|
||||
String errorMessage = throwable.getMessage();
|
||||
String exceptionClass = throwable.getClass().getSimpleName();
|
||||
|
||||
// Validation errors (client-side issues)
|
||||
if (httpStatus == 400 || httpStatus == 422) {
|
||||
if (isValidationError(errorMessage, exceptionClass)) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication/Authorization errors
|
||||
if (httpStatus == 401 || httpStatus == 403) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (httpStatus == 429) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
// System errors (our fault)
|
||||
if (httpStatus >= 500 || isSystemError(errorMessage, exceptionClass)) {
|
||||
return SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
// Processing errors (user's data issue but valid request)
|
||||
if (isProcessingError(errorMessage, exceptionClass, endpoint)) {
|
||||
return PROCESSING_ERROR;
|
||||
}
|
||||
|
||||
// Default to validation error to be safe
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
private static ProcessingErrorType classifyByHttpStatus(int httpStatus) {
|
||||
if (httpStatus >= 400 && httpStatus < 500) {
|
||||
return VALIDATION_ERROR;
|
||||
} else if (httpStatus >= 500) {
|
||||
return SYSTEM_ERROR;
|
||||
}
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
private static boolean isValidationError(String errorMessage, String exceptionClass) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] validationKeywords = {
|
||||
"validation", "invalid parameter", "missing parameter", "malformed",
|
||||
"bad request", "illegal argument", "file too large", "unsupported file type",
|
||||
"empty file", "no file provided", "invalid format"
|
||||
};
|
||||
|
||||
String[] validationExceptions = {
|
||||
"IllegalArgumentException",
|
||||
"ValidationException",
|
||||
"BindException",
|
||||
"MethodArgumentNotValidException",
|
||||
"MissingServletRequestParameterException",
|
||||
"HttpMessageNotReadableException",
|
||||
"MaxUploadSizeExceededException"
|
||||
};
|
||||
|
||||
return containsAny(errorMessage, validationKeywords)
|
||||
|| containsAny(exceptionClass, validationExceptions);
|
||||
}
|
||||
|
||||
private static boolean isSystemError(String errorMessage, String exceptionClass) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] systemExceptions = {
|
||||
"SQLException",
|
||||
"IOException",
|
||||
"OutOfMemoryError",
|
||||
"TimeoutException",
|
||||
"ConnectException",
|
||||
"UnknownHostException",
|
||||
"ServiceUnavailableException"
|
||||
};
|
||||
|
||||
return containsAny(exceptionClass, systemExceptions);
|
||||
}
|
||||
|
||||
private static boolean isProcessingError(
|
||||
String errorMessage, String exceptionClass, String endpoint) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] processingExceptions = {
|
||||
"PDFException", "COSVisitorException", "InvalidPDFException",
|
||||
"ConversionException", "OCRException", "ParseException"
|
||||
};
|
||||
|
||||
// If we're checking errors for an endpoint, it's already been identified as a tracked
|
||||
// endpoint
|
||||
// through @AutoJobPostMapping annotation, so we can assume it's a PDF processing endpoint
|
||||
return containsAny(exceptionClass, processingExceptions)
|
||||
|| (endpoint != null && !isValidationError(errorMessage, exceptionClass));
|
||||
}
|
||||
|
||||
private static boolean containsAny(String text, String[] keywords) {
|
||||
if (text == null) return false;
|
||||
String lowerText = text.toLowerCase();
|
||||
for (String keyword : keywords) {
|
||||
if (lowerText.contains(keyword.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_error_tracker")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class UserErrorTracker implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "error_tracker_id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "endpoint")
|
||||
private String endpoint;
|
||||
|
||||
@Column(name = "processing_error_count")
|
||||
private Integer processingErrorCount = 0;
|
||||
|
||||
@Column(name = "last_processing_error")
|
||||
private LocalDateTime lastProcessingError;
|
||||
|
||||
@Column(name = "reset_after")
|
||||
private LocalDateTime resetAfter;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public UserErrorTracker(User user, String endpoint, int ttlMinutes) {
|
||||
this.user = user;
|
||||
this.endpoint = endpoint;
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public boolean shouldChargeForProcessingError(int freeProcessingErrors) {
|
||||
return processingErrorCount != null && processingErrorCount > freeProcessingErrors;
|
||||
}
|
||||
|
||||
public void recordProcessingError(int ttlMinutes) {
|
||||
this.processingErrorCount = (processingErrorCount != null ? processingErrorCount : 0) + 1;
|
||||
this.lastProcessingError = LocalDateTime.now();
|
||||
|
||||
// Refresh TTL on each error
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public void resetErrorCount(int ttlMinutes) {
|
||||
this.processingErrorCount = 0;
|
||||
this.lastProcessingError = null;
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return resetAfter != null && LocalDateTime.now().isAfter(resetAfter);
|
||||
}
|
||||
|
||||
public int getErrorsUntilCharged(int freeProcessingErrors) {
|
||||
int current = processingErrorCount != null ? processingErrorCount : 0;
|
||||
return Math.max(0, freeProcessingErrors + 1 - current);
|
||||
}
|
||||
}
|
||||
@@ -62,20 +62,12 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(paygChargeInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns(
|
||||
"/api/v1/credits/**",
|
||||
"/api/v1/config/**",
|
||||
"/api/v1/info/**",
|
||||
"/api/v1/admin/**")
|
||||
.excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**")
|
||||
.order(INTERCEPTOR_ORDER);
|
||||
|
||||
registry.addInterceptor(entitlementGuard)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns(
|
||||
"/api/v1/credits/**",
|
||||
"/api/v1/config/**",
|
||||
"/api/v1/info/**",
|
||||
"/api/v1/admin/**")
|
||||
.excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**")
|
||||
.order(ENTITLEMENT_GUARD_ORDER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.UserErrorTracker;
|
||||
|
||||
public interface UserErrorTrackerRepository extends JpaRepository<UserErrorTracker, Long> {
|
||||
|
||||
Optional<UserErrorTracker> findByUserAndEndpoint(User user, String endpoint);
|
||||
|
||||
Optional<UserErrorTracker> findByUserIdAndEndpoint(Long userId, String endpoint);
|
||||
|
||||
@Query(
|
||||
"SELECT uet FROM UserErrorTracker uet WHERE uet.user.apiKey = :apiKey AND uet.endpoint = :endpoint")
|
||||
Optional<UserErrorTracker> findByUserApiKeyAndEndpoint(
|
||||
@Param("apiKey") String apiKey, @Param("endpoint") String endpoint);
|
||||
|
||||
@Query("SELECT uet FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
|
||||
List<UserErrorTracker> findExpiredErrorTrackers(
|
||||
@Param("currentDateTime") LocalDateTime currentDateTime);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
|
||||
int deleteExpiredErrorTrackers(@Param("currentDateTime") LocalDateTime currentDateTime);
|
||||
|
||||
@Query(
|
||||
"SELECT uet FROM UserErrorTracker uet WHERE uet.user = :user AND uet.processingErrorCount >= 3")
|
||||
List<UserErrorTracker> findHighErrorCountForUser(@Param("user") User user);
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(uet) FROM UserErrorTracker uet WHERE uet.processingErrorCount >= :threshold")
|
||||
Long countUsersWithHighErrorCount(@Param("threshold") int threshold);
|
||||
}
|
||||
@@ -262,7 +262,10 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
user.setUsername(supabaseUser.getEmail());
|
||||
}
|
||||
try {
|
||||
return userService.saveUser(user);
|
||||
User saved = userService.saveUser(user);
|
||||
// Give the account its own team rather than the shared Default team.
|
||||
saved.setTeam(saasTeamService.ensurePersonalTeam(saved));
|
||||
return saved;
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
log.warn(
|
||||
"Email collision upgrading anonymous user {} to {}: {}",
|
||||
@@ -344,7 +347,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
newUser.setEnabled(true);
|
||||
newUser.setFirstLogin(true);
|
||||
newUser.setRoleName(roleId);
|
||||
newUser.setTeam(teamService.getOrCreateDefaultTeam());
|
||||
// No shared Default team; a per-user personal team is assigned after save (team_id
|
||||
// nullable).
|
||||
newUser.setAuthenticationType(authenticationType);
|
||||
newUser.setSupabaseId(supabaseId);
|
||||
newUser.addAuthority(new Authority(roleId, newUser));
|
||||
@@ -379,8 +383,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
// Only the DB-race winner runs first-time init; the losers skip it.
|
||||
if (weCreatedThisUser) {
|
||||
try {
|
||||
saasTeamService.createPersonalTeam(savedUser);
|
||||
savedUser = userService.findBySupabaseId(supabaseId).orElse(savedUser);
|
||||
savedUser.setTeam(saasTeamService.ensurePersonalTeam(savedUser));
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create personal team for new user {} ({}): {}",
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.model.ProcessingErrorType;
|
||||
import stirling.software.saas.model.UserErrorTracker;
|
||||
import stirling.software.saas.repository.UserErrorTrackerRepository;
|
||||
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class ErrorTrackingService {
|
||||
|
||||
private final UserErrorTrackerRepository errorTrackerRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CreditsProperties creditsProperties;
|
||||
|
||||
/**
|
||||
* Local cache for error counts to reduce database chatter.
|
||||
*
|
||||
* <p>This cache is used to temporarily store error counts for each API key and endpoint,
|
||||
* reducing the frequency of database writes and lookups.
|
||||
*
|
||||
* <p><b>Nullability:</b> This field may be {@code null} if local caching is disabled via {@link
|
||||
* CreditsProperties#getCache()#isLocalEnabled()}. All usages must check for null before
|
||||
* accessing or invoking methods on this cache.
|
||||
*
|
||||
* <p><b>Lifecycle:</b> The cache is initialized in the constructor based on configuration and
|
||||
* remains unchanged for the lifetime of this service instance.
|
||||
*
|
||||
* <p><b>Thread-safety:</b> The underlying Caffeine cache is thread-safe.
|
||||
*/
|
||||
private final Cache<String, ErrorCountCache> errorCountCache;
|
||||
|
||||
public ErrorTrackingService(
|
||||
UserErrorTrackerRepository errorTrackerRepository,
|
||||
UserRepository userRepository,
|
||||
CreditsProperties creditsProperties) {
|
||||
this.errorTrackerRepository = errorTrackerRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.creditsProperties = creditsProperties;
|
||||
|
||||
// Initialize cache based on configuration
|
||||
this.errorCountCache =
|
||||
creditsProperties.getCache().isLocalEnabled()
|
||||
? Caffeine.newBuilder()
|
||||
.maximumSize(10000)
|
||||
.expireAfterWrite(
|
||||
creditsProperties.getErrors().getTtlMinutes(),
|
||||
TimeUnit.MINUTES)
|
||||
.build()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an error and determine if credits should be consumed
|
||||
*
|
||||
* @param apiKey User's API key
|
||||
* @param endpoint The endpoint that failed
|
||||
* @param throwable The exception that occurred
|
||||
* @param httpStatus HTTP response status
|
||||
* @return true if credits should be consumed for this error
|
||||
*/
|
||||
public boolean recordErrorAndShouldConsumeCredit(
|
||||
String apiKey, String endpoint, Throwable throwable, int httpStatus) {
|
||||
ProcessingErrorType errorType =
|
||||
ProcessingErrorType.classifyError(throwable, httpStatus, endpoint);
|
||||
|
||||
// Never charge for validation errors or system errors
|
||||
if (errorType != ProcessingErrorType.PROCESSING_ERROR) {
|
||||
log.debug(
|
||||
"Error classified as {}, no credit consumption for API key: {}, endpoint: {}",
|
||||
errorType,
|
||||
maskApiKey(apiKey),
|
||||
endpoint);
|
||||
return false;
|
||||
}
|
||||
|
||||
String cacheKey = apiKey + "|" + endpoint;
|
||||
|
||||
if (errorCountCache != null) {
|
||||
// Use cache for fast tracking
|
||||
ErrorCountCache cachedCount = errorCountCache.get(cacheKey, k -> new ErrorCountCache());
|
||||
cachedCount.incrementErrorCount();
|
||||
|
||||
boolean shouldCharge =
|
||||
cachedCount.getErrorCount()
|
||||
> creditsProperties.getErrors().getFreeProcessingErrors();
|
||||
|
||||
// Persist to DB when crossing the charging threshold or on first error
|
||||
if (shouldCharge
|
||||
&& cachedCount.getErrorCount()
|
||||
== creditsProperties.getErrors().getFreeProcessingErrors() + 1) {
|
||||
persistErrorToDatabase(apiKey, endpoint);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Processing error recorded (cached) for API key: {}, endpoint: {}, error count: {}, will charge: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint,
|
||||
cachedCount.getErrorCount(),
|
||||
shouldCharge);
|
||||
|
||||
return shouldCharge;
|
||||
} else {
|
||||
// Fallback to direct DB tracking
|
||||
return recordErrorDirectToDatabase(apiKey, endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean recordErrorDirectToDatabase(String apiKey, String endpoint) {
|
||||
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
|
||||
if (userOpt.isEmpty()) {
|
||||
log.warn("User not found for API key: {}", maskApiKey(apiKey));
|
||||
return false;
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
|
||||
|
||||
tracker.recordProcessingError(creditsProperties.getErrors().getTtlMinutes());
|
||||
errorTrackerRepository.save(tracker);
|
||||
|
||||
boolean shouldCharge =
|
||||
tracker.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors());
|
||||
|
||||
log.info(
|
||||
"Processing error recorded (DB) for user: {}, endpoint: {}, error count: {}, will charge: {}",
|
||||
user.getUsername(),
|
||||
endpoint,
|
||||
tracker.getProcessingErrorCount(),
|
||||
shouldCharge);
|
||||
|
||||
return shouldCharge;
|
||||
}
|
||||
|
||||
private void persistErrorToDatabase(String apiKey, String endpoint) {
|
||||
try {
|
||||
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
|
||||
// Set to threshold + 1 to indicate charging has started
|
||||
tracker.setProcessingErrorCount(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors() + 1);
|
||||
tracker.setLastProcessingError(LocalDateTime.now());
|
||||
tracker.setResetAfter(
|
||||
LocalDateTime.now()
|
||||
.plusMinutes(creditsProperties.getErrors().getTtlMinutes()));
|
||||
errorTrackerRepository.save(tracker);
|
||||
log.debug(
|
||||
"Persisted error threshold crossing to DB for API key: {}, endpoint: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to persist error to database for API key: {}, endpoint: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint,
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a user has high error counts that might indicate abuse */
|
||||
public boolean hasHighErrorCount(String apiKey, String endpoint) {
|
||||
Optional<UserErrorTracker> trackerOpt =
|
||||
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
|
||||
return trackerOpt
|
||||
.map(
|
||||
t ->
|
||||
t.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/** Get error information for a user and endpoint */
|
||||
public ErrorInfo getErrorInfo(String apiKey, String endpoint) {
|
||||
String cacheKey = apiKey + "|" + endpoint;
|
||||
|
||||
if (errorCountCache != null) {
|
||||
// Check cache first
|
||||
ErrorCountCache cachedCount = errorCountCache.getIfPresent(cacheKey);
|
||||
if (cachedCount != null) {
|
||||
int currentCount = cachedCount.getErrorCount();
|
||||
int freeErrors = creditsProperties.getErrors().getFreeProcessingErrors();
|
||||
return new ErrorInfo(
|
||||
currentCount,
|
||||
Math.max(0, freeErrors - currentCount),
|
||||
currentCount > freeErrors,
|
||||
cachedCount.getLastErrorTime());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to DB
|
||||
Optional<UserErrorTracker> trackerOpt =
|
||||
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
|
||||
if (trackerOpt.isEmpty()) {
|
||||
return new ErrorInfo(
|
||||
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
|
||||
}
|
||||
|
||||
UserErrorTracker tracker = trackerOpt.get();
|
||||
|
||||
// Reset if expired
|
||||
if (tracker.isExpired()) {
|
||||
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
|
||||
errorTrackerRepository.save(tracker);
|
||||
return new ErrorInfo(
|
||||
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
|
||||
}
|
||||
|
||||
return new ErrorInfo(
|
||||
tracker.getProcessingErrorCount(),
|
||||
tracker.getErrorsUntilCharged(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()),
|
||||
tracker.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()),
|
||||
tracker.getLastProcessingError());
|
||||
}
|
||||
|
||||
private UserErrorTracker getOrCreateErrorTracker(User user, String endpoint) {
|
||||
Optional<UserErrorTracker> existing =
|
||||
errorTrackerRepository.findByUserAndEndpoint(user, endpoint);
|
||||
|
||||
if (existing.isPresent()) {
|
||||
UserErrorTracker tracker = existing.get();
|
||||
|
||||
// Reset if expired
|
||||
if (tracker.isExpired()) {
|
||||
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
|
||||
}
|
||||
|
||||
return tracker;
|
||||
}
|
||||
|
||||
// Create new tracker
|
||||
return new UserErrorTracker(user, endpoint, creditsProperties.getErrors().getTtlMinutes());
|
||||
}
|
||||
|
||||
/** Clean up expired error trackers every hour */
|
||||
@Scheduled(cron = "0 0 * * * *")
|
||||
public void cleanupExpiredErrorTrackers() {
|
||||
try {
|
||||
int deleted = errorTrackerRepository.deleteExpiredErrorTrackers(LocalDateTime.now());
|
||||
if (deleted > 0) {
|
||||
log.debug("Cleaned up {} expired error trackers", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error cleaning up expired error trackers", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String maskApiKey(String apiKey) {
|
||||
if (apiKey == null || apiKey.length() < 8) {
|
||||
return "***";
|
||||
}
|
||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
||||
}
|
||||
|
||||
/** Information about user's error status for an endpoint */
|
||||
public static class ErrorInfo {
|
||||
public final int currentErrorCount;
|
||||
public final int errorsUntilCharged;
|
||||
public final boolean isChargingForErrors;
|
||||
public final LocalDateTime lastError;
|
||||
|
||||
public ErrorInfo(
|
||||
int currentErrorCount,
|
||||
int errorsUntilCharged,
|
||||
boolean isChargingForErrors,
|
||||
LocalDateTime lastError) {
|
||||
this.currentErrorCount = currentErrorCount;
|
||||
this.errorsUntilCharged = errorsUntilCharged;
|
||||
this.isChargingForErrors = isChargingForErrors;
|
||||
this.lastError = lastError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache entry for tracking error counts in memory */
|
||||
private static class ErrorCountCache {
|
||||
private int errorCount = 0;
|
||||
private LocalDateTime lastErrorTime = LocalDateTime.now();
|
||||
|
||||
public void incrementErrorCount() {
|
||||
errorCount++;
|
||||
lastErrorTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public int getErrorCount() {
|
||||
return errorCount;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastErrorTime() {
|
||||
return lastErrorTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,16 @@ public class SaasTeamService {
|
||||
public static final String DEFAULT_TEAM_NAME = "Default";
|
||||
public static final String INTERNAL_TEAM_NAME = "Internal";
|
||||
|
||||
/** Returns the user's personal team, creating one if they have none. Idempotent. */
|
||||
@Transactional
|
||||
public Team ensurePersonalTeam(User user) {
|
||||
Team existing = user.getTeam();
|
||||
if (existing != null && saasTeamExtensionService.isPersonal(existing)) {
|
||||
return existing;
|
||||
}
|
||||
return createPersonalTeam(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create personal team for new user during signup or migrate existing user from Default team
|
||||
*
|
||||
|
||||
@@ -31,6 +31,7 @@ public class SaasUserAccountService {
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
private final SaasUserExtensionService saasUserExtensionService;
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
private final SaasTeamService saasTeamService;
|
||||
|
||||
/**
|
||||
* Resolve a local {@link User} from a Supabase UUID string. Throws if the ID format is invalid
|
||||
@@ -173,6 +174,8 @@ public class SaasUserAccountService {
|
||||
user.setUsername(email);
|
||||
}
|
||||
user = userService.saveUser(user);
|
||||
// Give the upgraded user their own team rather than the shared Default team.
|
||||
user.setTeam(saasTeamService.ensurePersonalTeam(user));
|
||||
log.info(
|
||||
"Upgraded anonymous user {} to {} ({})",
|
||||
user.getId(),
|
||||
|
||||
@@ -30,7 +30,6 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
@@ -155,7 +154,6 @@ class SupabaseAuthenticationFilterTest {
|
||||
when(supabaseUserService.getUser(supabaseId))
|
||||
.thenReturn(supabaseUserMatching(supabaseId, "bob@example.com", false));
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
|
||||
when(userService.saveUser(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
@@ -166,6 +164,9 @@ class SupabaseAuthenticationFilterTest {
|
||||
|
||||
verify(userService, times(1)).saveUser(any(User.class));
|
||||
verify(supabaseUserService).createSupabaseUser(supabaseId, "bob@example.com", false);
|
||||
// New users get their own personal team, never the shared Default team.
|
||||
verify(saasTeamService).ensurePersonalTeam(any(User.class));
|
||||
verify(teamService, never()).getOrCreateDefaultTeam();
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication())
|
||||
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
|
||||
}
|
||||
@@ -179,7 +180,6 @@ class SupabaseAuthenticationFilterTest {
|
||||
when(supabaseUserService.getUser(supabaseId))
|
||||
.thenReturn(supabaseUserMatching(supabaseId, "carol@example.com", false));
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
|
||||
when(userService.saveUser(any(User.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
@@ -208,7 +208,6 @@ class SupabaseAuthenticationFilterTest {
|
||||
when(supabaseUserService.getUser(supabaseId))
|
||||
.thenReturn(supabaseUserMatching(supabaseId, "dave@example.com", false));
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
|
||||
when(userService.saveUser(any(User.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
@@ -237,7 +236,6 @@ class SupabaseAuthenticationFilterTest {
|
||||
when(supabaseUserService.getUser(supabaseId))
|
||||
.thenReturn(supabaseUserMatching(supabaseId, "eve@example.com", false));
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
|
||||
when(userService.saveUser(any(User.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
|
||||
@@ -1,586 +0,0 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.model.UserErrorTracker;
|
||||
import stirling.software.saas.repository.UserErrorTrackerRepository;
|
||||
import stirling.software.saas.service.ErrorTrackingService.ErrorInfo;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ErrorTrackingService}.
|
||||
*
|
||||
* <p>The service has two distinct tracking paths chosen at construction time based on {@link
|
||||
* CreditsProperties.Cache#isLocalEnabled()}: an in-memory Caffeine cache path and a direct-to-DB
|
||||
* fallback. Because the cache field is final and decided in the constructor, each path is exercised
|
||||
* by building the service with a tailored {@link CreditsProperties}. Defaults are {@code
|
||||
* freeProcessingErrors = 2} and {@code ttlMinutes = 60}.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ErrorTrackingServiceTest {
|
||||
|
||||
@Mock private UserErrorTrackerRepository errorTrackerRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
|
||||
private static final String API_KEY =
|
||||
"test-api-key-0001"; // gitleaks:allow - test fixture, not a secret
|
||||
private static final String ENDPOINT = "/api/v1/convert/pdf-to-img";
|
||||
|
||||
/** Build a CreditsProperties with the given cache + error config. */
|
||||
private static CreditsProperties props(
|
||||
boolean localCacheEnabled, int freeProcessingErrors, int ttlMinutes) {
|
||||
CreditsProperties p = new CreditsProperties();
|
||||
p.getCache().setLocalEnabled(localCacheEnabled);
|
||||
p.getErrors().setFreeProcessingErrors(freeProcessingErrors);
|
||||
p.getErrors().setTtlMinutes(ttlMinutes);
|
||||
return p;
|
||||
}
|
||||
|
||||
private ErrorTrackingService cachedService(int freeProcessingErrors) {
|
||||
return new ErrorTrackingService(
|
||||
errorTrackerRepository, userRepository, props(true, freeProcessingErrors, 60));
|
||||
}
|
||||
|
||||
private ErrorTrackingService dbService(int freeProcessingErrors) {
|
||||
return new ErrorTrackingService(
|
||||
errorTrackerRepository, userRepository, props(false, freeProcessingErrors, 60));
|
||||
}
|
||||
|
||||
private static User user(String username, String apiKey) {
|
||||
User u = new User();
|
||||
u.setUsername(username);
|
||||
u.setApiKey(apiKey);
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
* A throwable that classifies as PROCESSING_ERROR when paired with httpStatus 200 and a
|
||||
* non-null endpoint: not a validation/system error, so the endpoint-based processing branch
|
||||
* wins.
|
||||
*/
|
||||
private static Throwable processingThrowable() {
|
||||
return new RuntimeException("corrupt pdf stream while rendering page");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recordErrorAndShouldConsumeCredit - error classification gate")
|
||||
class ClassificationGate {
|
||||
|
||||
@Test
|
||||
@DisplayName("validation error (400) never charges and never touches DB or cache")
|
||||
void validationError_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY,
|
||||
ENDPOINT,
|
||||
new IllegalArgumentException("missing parameter"),
|
||||
400);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
verifyNoInteractions(userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auth error (401) classifies as validation and never charges")
|
||||
void authError_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, new RuntimeException("denied"), 401);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("system error (500) never charges")
|
||||
void systemError_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, new RuntimeException("server boom"), 500);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("system exception type (SQLException) at 200 never charges")
|
||||
void systemExceptionType_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, new SQLException("db down"), 200);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recordErrorAndShouldConsumeCredit - cache path (freeProcessingErrors=2)")
|
||||
class CachePath {
|
||||
|
||||
@Test
|
||||
@DisplayName("first two processing errors are free; the 3rd charges")
|
||||
void firstTwoFree_thirdCharges() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
User u = user("alice", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// count=1 -> not > 2
|
||||
assertThat(call(service)).isFalse();
|
||||
// count=2 -> not > 2
|
||||
assertThat(call(service)).isFalse();
|
||||
// count=3 -> > 2 -> charge, and threshold-crossing persists to DB
|
||||
assertThat(call(service)).isTrue();
|
||||
// count=4 -> still charges, but no second persist (only on the exact crossing)
|
||||
assertThat(call(service)).isTrue();
|
||||
|
||||
// Persisted exactly once: on the threshold-crossing call (count == free + 1).
|
||||
verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("threshold-crossing persists a tracker set to free+1 with future resetAfter")
|
||||
void thresholdCrossing_persistsTrackerWithCorrectCount() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
User u = user("bob", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
LocalDateTime before = LocalDateTime.now();
|
||||
call(service); // 1
|
||||
call(service); // 2
|
||||
call(service); // 3 -> persist
|
||||
|
||||
ArgumentCaptor<UserErrorTracker> captor =
|
||||
ArgumentCaptor.forClass(UserErrorTracker.class);
|
||||
verify(errorTrackerRepository).save(captor.capture());
|
||||
UserErrorTracker saved = captor.getValue();
|
||||
assertThat(saved.getProcessingErrorCount()).isEqualTo(3); // free(2) + 1
|
||||
assertThat(saved.getUser()).isSameAs(u);
|
||||
assertThat(saved.getEndpoint()).isEqualTo(ENDPOINT);
|
||||
assertThat(saved.getLastProcessingError()).isNotNull();
|
||||
assertThat(saved.getResetAfter()).isAfter(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero free errors: first processing error charges immediately")
|
||||
void zeroFree_firstErrorCharges() {
|
||||
ErrorTrackingService service = cachedService(0);
|
||||
User u = user("carol", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// count=1 > free(0) -> charge, and 1 == free+1 -> persist
|
||||
assertThat(call(service)).isTrue();
|
||||
verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("distinct endpoints are tracked independently in the cache")
|
||||
void distinctEndpoints_trackedSeparately() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
// Two errors on endpoint A, two on endpoint B -> neither crosses the free=2 threshold.
|
||||
assertThat(
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, "/a", processingThrowable(), 200))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, "/a", processingThrowable(), 200))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, "/b", processingThrowable(), 200))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, "/b", processingThrowable(), 200))
|
||||
.isFalse();
|
||||
|
||||
// Neither key crossed free+1, so no DB persistence at all.
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cache path does not blow up if the user is absent at persist time")
|
||||
void cachePersist_userAbsent_swallowsAndStillCharges() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
// No user for this key: persistErrorToDatabase finds nothing and saves nothing.
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(call(service)).isFalse(); // 1
|
||||
assertThat(call(service)).isFalse(); // 2
|
||||
assertThat(call(service)).isTrue(); // 3 -> tries persist, user missing -> no save
|
||||
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
/** Convenience: record one processing error on the standard key. */
|
||||
private boolean call(ErrorTrackingService service) {
|
||||
return service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recordErrorAndShouldConsumeCredit - DB fallback path (cache disabled)")
|
||||
class DbFallbackPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown API key returns false and saves nothing")
|
||||
void unknownApiKey_returnsFalse() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"creates a new tracker, records the error and persists; below threshold no charge")
|
||||
void newTracker_belowThreshold_noCharge() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
User u = user("dave", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
assertThat(charge).isFalse(); // count 1, free 2
|
||||
|
||||
ArgumentCaptor<UserErrorTracker> captor =
|
||||
ArgumentCaptor.forClass(UserErrorTracker.class);
|
||||
verify(errorTrackerRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getProcessingErrorCount()).isEqualTo(1);
|
||||
assertThat(captor.getValue().getUser()).isSameAs(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("existing tracker already at the threshold rolls over to charging")
|
||||
void existingTracker_atThreshold_charges() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
User u = user("erin", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
|
||||
UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(2); // at the free limit
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
// recordProcessingError bumps 2 -> 3, which is > free(2) -> charge
|
||||
assertThat(charge).isTrue();
|
||||
assertThat(tracker.getProcessingErrorCount()).isEqualTo(3);
|
||||
verify(errorTrackerRepository).save(tracker);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("expired existing tracker is reset before recording the new error")
|
||||
void expiredTracker_isResetThenRecorded() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
User u = user("finn", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
|
||||
UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(5);
|
||||
tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
// reset to 0, then recordProcessingError -> 1, which is not > free(2)
|
||||
assertThat(charge).isFalse();
|
||||
assertThat(tracker.getProcessingErrorCount()).isEqualTo(1);
|
||||
verify(errorTrackerRepository).save(tracker);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("hasHighErrorCount")
|
||||
class HasHighErrorCount {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when no tracker exists for the key")
|
||||
void noTracker_false() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("true when the tracker's count exceeds the free allowance")
|
||||
void aboveFree_true() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(3); // > 2
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("false when the count is exactly at the free allowance (boundary)")
|
||||
void atFree_false() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(2); // not > 2
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getErrorInfo")
|
||||
class GetErrorInfo {
|
||||
|
||||
@Test
|
||||
@DisplayName("cache hit reflects the live cached count, remaining free and charging flag")
|
||||
void cacheHit_reportsLiveCount() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
// Drive the cache to 3 errors on the key so a subsequent getErrorInfo reads it.
|
||||
for (int i = 0; i < 3; i++) {
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
}
|
||||
|
||||
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
|
||||
|
||||
assertThat(info.currentErrorCount).isEqualTo(3);
|
||||
// Math.max(0, free(2) - 3) == 0
|
||||
assertThat(info.errorsUntilCharged).isZero();
|
||||
assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
|
||||
assertThat(info.lastError).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cache miss falls back to an empty/zeroed ErrorInfo when no DB row exists")
|
||||
void cacheMiss_noDbRow_zeroedInfo() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
|
||||
|
||||
assertThat(info.currentErrorCount).isZero();
|
||||
assertThat(info.errorsUntilCharged).isEqualTo(2); // full free allowance
|
||||
assertThat(info.isChargingForErrors).isFalse();
|
||||
assertThat(info.lastError).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB-backed (cache disabled): live tracker is reported with derived fields")
|
||||
void dbBacked_liveTracker_reported() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
UserErrorTracker tracker = new UserErrorTracker(user("h", API_KEY), ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(3);
|
||||
tracker.setLastProcessingError(LocalDateTime.now());
|
||||
// not expired (constructor set resetAfter ~60m out)
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
|
||||
|
||||
assertThat(info.currentErrorCount).isEqualTo(3);
|
||||
// getErrorsUntilCharged = max(0, free+1 - current) = max(0, 3 - 3) = 0
|
||||
assertThat(info.errorsUntilCharged).isZero();
|
||||
assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
|
||||
assertThat(info.lastError).isNotNull();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB-backed: expired tracker is reset, persisted and reported as zeroed")
|
||||
void dbBacked_expiredTracker_resetAndZeroed() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
UserErrorTracker tracker = new UserErrorTracker(user("i", API_KEY), ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(7);
|
||||
tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
|
||||
|
||||
assertThat(info.currentErrorCount).isZero();
|
||||
assertThat(info.errorsUntilCharged).isEqualTo(2);
|
||||
assertThat(info.isChargingForErrors).isFalse();
|
||||
assertThat(info.lastError).isNull();
|
||||
assertThat(tracker.getProcessingErrorCount()).isZero(); // reset mutated the entity
|
||||
verify(errorTrackerRepository).save(tracker);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupExpiredErrorTrackers")
|
||||
class Cleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("delegates to the repository delete with a 'now' cutoff")
|
||||
void delegatesDelete() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
|
||||
.thenReturn(4);
|
||||
|
||||
service.cleanupExpiredErrorTrackers();
|
||||
|
||||
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("swallows repository exceptions so the scheduler keeps running")
|
||||
void swallowsRepositoryException() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
|
||||
.thenThrow(new RuntimeException("delete blew up"));
|
||||
|
||||
// Must not propagate.
|
||||
service.cleanupExpiredErrorTrackers();
|
||||
|
||||
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero deletions still completes cleanly")
|
||||
void zeroDeletions_ok() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
|
||||
.thenReturn(0);
|
||||
|
||||
service.cleanupExpiredErrorTrackers();
|
||||
|
||||
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ErrorInfo value holder")
|
||||
class ErrorInfoHolder {
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor wires the public fields verbatim")
|
||||
void fieldsWiredVerbatim() {
|
||||
LocalDateTime ts = LocalDateTime.now();
|
||||
ErrorInfo info = new ErrorInfo(5, 1, true, ts);
|
||||
|
||||
assertThat(info.currentErrorCount).isEqualTo(5);
|
||||
assertThat(info.errorsUntilCharged).isEqualTo(1);
|
||||
assertThat(info.isChargingForErrors).isTrue();
|
||||
assertThat(info.lastError).isEqualTo(ts);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("API key masking is exercised without leaking (smoke via logging branches)")
|
||||
class MaskingSmoke {
|
||||
|
||||
@Test
|
||||
@DisplayName("short API keys are tolerated end-to-end on the cache path")
|
||||
void shortApiKey_tolerated() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(userRepository.findByApiKey(anyString())).thenReturn(Optional.empty());
|
||||
|
||||
// "key" is < 8 chars, masked as *** inside the service; must not throw on persist path.
|
||||
boolean c1 =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
"key", ENDPOINT, processingThrowable(), 200);
|
||||
boolean c2 =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
"key", ENDPOINT, processingThrowable(), 200);
|
||||
boolean c3 =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
"key", ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
assertThat(c1).isFalse();
|
||||
assertThat(c2).isFalse();
|
||||
assertThat(c3).isTrue();
|
||||
// user absent -> no save even at threshold crossing
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null API key is tolerated on the DB fallback path")
|
||||
void nullApiKey_tolerated() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
when(userRepository.findByApiKey(eq(null))).thenReturn(Optional.empty());
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
null, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException as a system error type does not charge even at 200")
|
||||
void ioExceptionSystemError_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, new IOException("disk gone"), 200);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class SaasUserAccountServiceTest {
|
||||
@Mock private SupabaseUserService supabaseUserService;
|
||||
@Mock private SaasUserExtensionService saasUserExtensionService;
|
||||
@Mock private SaasTeamExtensionService saasTeamExtensionService;
|
||||
@Mock private SaasTeamService saasTeamService;
|
||||
|
||||
@InjectMocks private SaasUserAccountService service;
|
||||
|
||||
@@ -355,6 +356,8 @@ class SaasUserAccountServiceTest {
|
||||
assertThat(u.getEmail()).isEqualTo("alice@example.com");
|
||||
assertThat(u.getUsername()).isEqualTo("alice@example.com");
|
||||
verify(userService).saveUser(u);
|
||||
// Upgrading from anon gives the user their own team.
|
||||
verify(saasTeamService).ensurePersonalTeam(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -455,6 +458,7 @@ class SaasUserAccountServiceTest {
|
||||
assertThat(u.getUsername()).isEqualTo("existing@example.com");
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("web");
|
||||
verify(userService, never()).saveUser(any());
|
||||
verify(saasTeamService, never()).ensurePersonalTeam(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -78,7 +78,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.12.0'
|
||||
version = '2.13.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
|
||||
@@ -31,9 +31,13 @@ ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
|
||||
# buildWithFrontend=false → backend-only JAR with API landing page.
|
||||
RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=false \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-PprototypesMode=${PROTOTYPES_BUILD} \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -43,9 +43,13 @@ ARG PROTOTYPES_BUILD=false
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
|
||||
RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-PprototypesMode=${PROTOTYPES_BUILD} \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -40,9 +40,13 @@ RUN gradle dependencies --no-daemon || true
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=false \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
|
||||
@@ -39,17 +39,23 @@ RUN ./gradlew dependencies --no-daemon || true
|
||||
# Copy entire project
|
||||
COPY . .
|
||||
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features).
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=true \
|
||||
./gradlew clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
# Stage 2: Runtime image
|
||||
FROM eclipse-temurin:25-jre-alpine
|
||||
# glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked.
|
||||
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29
|
||||
|
||||
ENV LANG=C.UTF-8 \
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
ARG VERSION_TAG
|
||||
@@ -87,22 +93,22 @@ ENV VERSION_TAG=$VERSION_TAG \
|
||||
ENDPOINTS_GROUPS_TO_REMOVE=CLI
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
RUN mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \
|
||||
mkdir -p /usr/share/fonts/opentype/noto && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
procps \
|
||||
util-linux && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \
|
||||
mkdir -p /usr/share/fonts/opentype/noto && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
userdel -r ubuntu 2>/dev/null || true && \
|
||||
groupdel ubuntu 2>/dev/null || true && \
|
||||
groupadd -g 1000 stirlingpdfgroup && useradd -u 1000 -d $HOME -s /bin/bash -g stirlingpdfgroup stirlingpdfuser && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /storage /tmp/stirling-pdf
|
||||
|
||||
# Copy scripts and built artifacts after OS package layer to maximize cache reuse.
|
||||
|
||||
@@ -10,6 +10,9 @@ node_modules/
|
||||
editor/public/vendor/
|
||||
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
|
||||
portal/public/mockServiceWorker.js
|
||||
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
|
||||
editor/public/og-metadata.json
|
||||
editor/src/core/data/ogImageMap.json
|
||||
editor/public/pdfjs*/
|
||||
editor/public/js/thirdParty/
|
||||
editor/public/css/cookieconsent.css
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="%BASE_URL%" />
|
||||
<link rel="icon" href="logo/favicon.ico" />
|
||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="logo/logo192.png" />
|
||||
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||
<title>Stirling PDF</title>
|
||||
|
||||
|
After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg width="82" height="92" viewBox="0 0 82 92" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dddd_3933_98714)">
|
||||
<path d="M33.0874 84.4375L70.2191 75.6383L77.157 43.9963L33.0874 84.4375Z" fill="#D0DBDC"/>
|
||||
<path d="M56.3682 0.557473C59.916 -0.147829 63.2976 2.34683 63.2422 5.67271L63.2139 8.04087L68.7031 6.95689C72.7776 6.15095 76.6575 8.97364 76.7129 12.7782L77.1016 44.0213L54.9326 64.6639L33.084 84.4374L8.27637 81.4891C4.06313 81.0356 0.875008 77.7854 0.875 73.9051V19.3036C0.875108 14.8438 4.28482 10.9886 9.08008 10.0311L56.3682 0.557473ZM46.2773 19.7586C38.7933 16.1052 27.8721 18.8006 27.8721 18.8006C19.5566 21.1692 16.6184 26.6627 15.5928 30.5682C14.8168 33.5665 15.0111 36.6907 16.1475 39.5126C18.559 45.4589 24.4073 47.2732 30.6162 49.037C38.3528 51.2221 36.8902 55.2099 36.8809 55.2352C35.9939 57.9815 33.8044 58.8885 31.9473 59.1405C30.4782 59.3421 28.9533 59.1405 27.5674 58.6366C20.0835 55.8397 17.4502 50.0448 17.4502 50.0448H16.1191L16.6182 63.0711C19.3345 64.6081 24.8785 65.8934 28.0107 66.5233C30.2837 67.002 32.6404 67.1534 34.9688 66.9266C48.1902 65.6162 48.9941 54.202 48.9941 54.202C50.4076 42.0323 38.8767 39.0091 31.8916 37.5477C30.589 37.2706 29.5911 36.9174 28.8428 36.4891C25.6828 34.7505 26.0434 30.3658 29.4805 28.8036C31.8088 27.7456 34.7749 28.0729 36.7705 28.9295C39.9027 30.2903 44.1436 35.3045 44.1436 35.3045H45.9453L46.2773 19.7586Z" fill="white"/>
|
||||
<path d="M63.2779 8.03641L63.2781 57.7846L77.1651 44.2035L76.7771 11.9265C76.7494 8.65091 73.3953 6.02067 69.875 6.72618L63.2779 8.03641Z" fill="#D3D3D3"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dddd_3933_98714" x="-8.34465e-07" y="-4.17233e-07" width="81.1025" height="91.875" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.4375"/>
|
||||
<feGaussianBlur stdDeviation="0.4375"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3933_98714"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="0.4375" dy="1.3125"/>
|
||||
<feGaussianBlur stdDeviation="0.656251"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.09 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_3933_98714" result="effect2_dropShadow_3933_98714"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="0.875001" dy="3.0625"/>
|
||||
<feGaussianBlur stdDeviation="0.875001"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="effect2_dropShadow_3933_98714" result="effect3_dropShadow_3933_98714"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="1.75" dy="5.25"/>
|
||||
<feGaussianBlur stdDeviation="1.09375"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.01 0"/>
|
||||
<feBlend mode="normal" in2="effect3_dropShadow_3933_98714" result="effect4_dropShadow_3933_98714"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow_3933_98714" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,34 @@
|
||||
<svg width="82" height="92" viewBox="0 0 82 92" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.2" filter="url(#filter0_dddd_3933_98709)">
|
||||
<path d="M56.3678 0.557543L9.07973 10.0315C4.2844 10.989 0.875 14.8441 0.875 19.3039V73.9053C0.875 77.7856 4.06265 81.0359 8.27589 81.4895L33.0841 84.4375L54.9327 64.6642L77.1014 44.0219L76.7133 12.778C76.6579 8.97326 72.7773 6.15122 68.7027 6.95752L63.2143 8.04098L63.2421 5.67248C63.2975 2.34651 59.9158 -0.147965 56.3678 0.557543Z" fill="#FF4B4B"/>
|
||||
<path d="M63.269 8.04043V57.5772L77.1561 43.9962L76.7681 11.7192C76.7403 8.44358 73.3864 6.02469 69.8661 6.7302L63.269 8.04043Z" fill="#545454"/>
|
||||
<path d="M33.0873 84.4375L70.2189 75.6383L77.1569 43.9963L33.0873 84.4375Z" fill="#D0DBDC"/>
|
||||
<path d="M44.1432 35.3049C44.1432 35.3049 39.9022 30.2907 36.77 28.9301C34.7743 28.0734 31.8084 27.7459 29.48 28.8041C26.0429 30.3663 25.6826 34.7506 28.8425 36.4891C29.5909 36.9175 30.5888 37.2702 31.8915 37.5474C38.8767 39.0088 50.4076 42.0324 48.994 54.2024C48.994 54.2024 48.1901 65.6166 34.9683 66.9268C32.6399 67.1536 30.2839 67.0024 28.0109 66.5236C24.8787 65.8937 19.335 64.6087 16.6185 63.0717L16.1196 50.045H17.4501C17.4501 50.045 20.0834 55.8402 27.5674 58.6371C28.9534 59.141 30.4779 59.3426 31.947 59.141C33.8041 58.889 35.9939 57.982 36.8809 55.2355C36.8809 55.2355 38.3777 51.2292 30.6165 49.0371C24.4075 47.2733 18.5589 45.4592 16.1473 39.5127C15.0109 36.6907 14.8168 33.5663 15.593 30.5679C16.6185 26.6624 19.5567 21.1695 27.8723 18.801C27.8723 18.801 38.7935 16.105 46.2776 19.7585L45.9449 35.3049H44.1432Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dddd_3933_98709" x="-8.34465e-07" y="-4.17233e-07" width="81.0944" height="91.875" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.4375"/>
|
||||
<feGaussianBlur stdDeviation="0.4375"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3933_98709"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="0.4375" dy="1.3125"/>
|
||||
<feGaussianBlur stdDeviation="0.656251"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.09 0"/>
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_3933_98709" result="effect2_dropShadow_3933_98709"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="0.875001" dy="3.0625"/>
|
||||
<feGaussianBlur stdDeviation="0.875001"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="effect2_dropShadow_3933_98709" result="effect3_dropShadow_3933_98709"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="1.75" dy="5.25"/>
|
||||
<feGaussianBlur stdDeviation="1.09375"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.01 0"/>
|
||||
<feBlend mode="normal" in2="effect3_dropShadow_3933_98709" result="effect4_dropShadow_3933_98709"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow_3933_98709" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="77" height="84" viewBox="0 0 77 84" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.2124 83.9997L69.3441 75.2005L76.282 43.5585L32.2124 83.9997Z" fill="#D0DBDC"/>
|
||||
<path d="M55.4932 0.119995C59.0409 -0.585293 62.4223 1.9086 62.3672 5.23425L62.3389 7.60339L67.8281 6.51941C71.9024 5.71349 75.7821 8.53542 75.8379 12.3397L76.2266 43.5839L54.0576 64.2264L32.209 83.9999L7.40137 81.0516C3.18818 80.5981 8.70906e-05 77.3479 0 73.4677V18.8661C3.4631e-05 14.4063 3.40977 10.5511 8.20508 9.59363L55.4932 0.119995ZM45.4023 19.3212C37.9183 15.6676 26.9971 18.3632 26.9971 18.3632C18.6818 20.7316 15.7434 26.2243 14.7178 30.1298C13.9417 33.1282 14.136 36.253 15.2725 39.0751C17.684 45.0213 23.5324 46.8357 29.7412 48.5995C37.5024 50.7916 36.0059 54.7977 36.0059 54.7977C35.1189 57.544 32.9294 58.451 31.0723 58.703C29.6032 58.9046 28.0783 58.703 26.6924 58.1991C19.2266 55.4091 16.588 49.6354 16.5752 49.6073H15.2441L15.7432 62.6337C18.4595 64.1706 24.0035 65.4559 27.1357 66.0858C29.4087 66.5646 31.7654 66.7159 34.0938 66.4891C47.3152 65.1787 48.1191 53.7645 48.1191 53.7645C49.5327 41.5946 38.0017 38.5707 31.0166 37.1093C29.714 36.8321 28.7161 36.4799 27.9678 36.0516C24.8078 34.3131 25.1684 29.9283 28.6055 28.3661C30.9338 27.3081 33.8999 27.6354 35.8955 28.4921C39.0277 29.8529 43.2686 34.8671 43.2686 34.8671H45.0703L45.4023 19.3212Z" fill="white"/>
|
||||
<path d="M62.394 7.60263V57.1394L76.2811 43.5584L75.8931 11.2814C75.8653 8.00577 72.5114 5.58689 68.9911 6.2924L62.394 7.60263Z" fill="#EEEEEE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -3604,6 +3604,7 @@ unzip = "Unzip"
|
||||
updateOnServer = "Update on Server"
|
||||
uploadSelected = "Upload Files"
|
||||
uploadToServer = "Upload to Server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[files]
|
||||
addFiles = "Add files"
|
||||
@@ -3654,7 +3655,13 @@ searchPlaceholder = "Search files..."
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
closeViewer = "Close viewer"
|
||||
delete = "Delete"
|
||||
moreActions = "More actions"
|
||||
openInViewer = "Open in viewer"
|
||||
savedToServer = "Saved to server"
|
||||
updateOnServer = "Update on server"
|
||||
uploadToServer = "Upload to server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[filesPage]
|
||||
addToWorkspace = "Add to workspace"
|
||||
@@ -3671,6 +3678,11 @@ closeDetails = "Close details"
|
||||
create = "Create"
|
||||
cycleBlocked = "Can't move a folder into one of its own subfolders."
|
||||
delete = "Delete"
|
||||
deleteFilesChoiceBody = "Some of these files are saved both on this device and in the cloud. Where should they be deleted from?"
|
||||
deleteFilesCloudBody = "Delete {{count}} file(s) from the cloud? This cannot be undone."
|
||||
deleteFilesError = "Could not delete. Try again."
|
||||
deleteFilesLocalBody = "Delete {{count}} file(s) from this device? This cannot be undone."
|
||||
deleteFilesTitle = "Delete {{count}} file(s)?"
|
||||
deleteFolder = "Delete folder"
|
||||
deleteFolderBody = "Delete folder \"{{name}}\"?"
|
||||
deleteFolderContents = "Also delete {{count}} file(s) inside the folder"
|
||||
@@ -3688,6 +3700,7 @@ downloadVersion = "Download this version"
|
||||
dropOverlay = "Drop files to upload"
|
||||
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder."
|
||||
file = "File"
|
||||
fileInfo = "File info"
|
||||
fileMenu = "File actions"
|
||||
folder = "Folder"
|
||||
folderItems = "{{count}} items"
|
||||
@@ -3707,10 +3720,8 @@ offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disa
|
||||
open = "Open"
|
||||
openVersionInWorkspace = "Open in workspace"
|
||||
originFilter = "Filter by source"
|
||||
quickView = "Quick view"
|
||||
refresh = "Refresh from server"
|
||||
remove = "Delete"
|
||||
removeConfirm = "Delete {{count}} file(s)? This cannot be undone."
|
||||
removeVersion = "Remove this version"
|
||||
rename = "Rename"
|
||||
renameFolder = "Rename folder"
|
||||
@@ -3735,10 +3746,12 @@ uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put the
|
||||
uploadFromMobile = "Upload from Mobile"
|
||||
versionActions = "Version actions"
|
||||
versionCollapse = "Collapse middle versions"
|
||||
versionHistory = "Version history"
|
||||
versionHistoryEmpty = "This file has no earlier versions."
|
||||
versionOrigin = "Original upload"
|
||||
versionsCount = "{{count}} versions"
|
||||
versionShowHidden = "Show {{count}} earlier versions"
|
||||
viewVersion = "View this version"
|
||||
viewVersionHistory = "Version journey ({{count}})"
|
||||
appearance.colour = "Colour"
|
||||
appearance.icon = "Icon"
|
||||
appearance.title = "Appearance"
|
||||
@@ -3747,6 +3760,12 @@ column.modified = "Modified"
|
||||
column.name = "Name"
|
||||
column.size = "Size"
|
||||
column.type = "Type"
|
||||
deleteScope.cloud = "Cloud only"
|
||||
deleteScope.cloudHint = "Deletes from the server. A copy stays on this device."
|
||||
deleteScope.device = "This device only"
|
||||
deleteScope.deviceHint = "Removes the local copy. The cloud copy is kept."
|
||||
deleteScope.everywhere = "Everywhere"
|
||||
deleteScope.everywhereHint = "Deletes the file from this device and the cloud."
|
||||
empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files."
|
||||
empty.newFolderCta = "Create folder"
|
||||
empty.title = "This folder is empty"
|
||||
@@ -3765,6 +3784,7 @@ empty.sharedByMe.hint = "Create a share link or invite a teammate from any of yo
|
||||
empty.sharedByMe.title = "You haven't shared any files yet"
|
||||
error.actionFailed = "Could not {{action}}."
|
||||
error.actionFailedDetail = "Could not {{action}}: {{message}}"
|
||||
error.cloudDeleteFailed = "Couldn't delete {{count}} file(s) from the cloud."
|
||||
error.deleteFolderFailed = "Could not delete folder."
|
||||
error.deleteFolderFailedDetail = "Could not delete folder: {{message}}"
|
||||
error.folderAppearanceFailed = "Could not update folder appearance."
|
||||
@@ -7897,6 +7917,7 @@ valid = "Valid"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "Cannot Preview File"
|
||||
copyText = "Copy"
|
||||
disableColorFilter = "Disable Colour Filter"
|
||||
dualPageView = "Dual Page View"
|
||||
enableDarkFilter = "Enable Dark Filter"
|
||||
|
||||
@@ -1034,6 +1034,14 @@ description = "Languages that users can select from (leave empty to enable all l
|
||||
label = "Available Languages"
|
||||
placeholder = "Select languages"
|
||||
|
||||
[admin.settings.general.logoStyle]
|
||||
classic = "Classic"
|
||||
classicAlt = "Classic logo"
|
||||
description = "Set the default logo style for all users on this server. Users can override this setting in their personal preferences."
|
||||
label = "Logo Style"
|
||||
modern = "Modern"
|
||||
modernAlt = "Modern logo"
|
||||
|
||||
[admin.settings.general.showUpdate]
|
||||
description = "Display notifications when a new version is available"
|
||||
label = "Show Update Notifications"
|
||||
@@ -2659,9 +2667,9 @@ agentMenu = "Stirling agent options"
|
||||
clearChat = "Clear chat"
|
||||
|
||||
[chat.input]
|
||||
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
|
||||
placeholder = "What do you want to do?"
|
||||
send = "Send message"
|
||||
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
|
||||
|
||||
[chat.progress]
|
||||
analyzing = "Analyzing your request..."
|
||||
@@ -3570,6 +3578,7 @@ unzip = "Unzip"
|
||||
updateOnServer = "Update on Server"
|
||||
uploadSelected = "Upload Files"
|
||||
uploadToServer = "Upload to Server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[files]
|
||||
addFiles = "Add files"
|
||||
@@ -3605,6 +3614,7 @@ workbench = "Workbench"
|
||||
addFiles = "Add files"
|
||||
collapse = "Collapse sidebar"
|
||||
dropHint = "Open files to get started"
|
||||
dropToAdd = "Drop files to add"
|
||||
expand = "Expand sidebar"
|
||||
files = "Files"
|
||||
googleDrive = "Google Drive"
|
||||
@@ -3620,7 +3630,13 @@ searchPlaceholder = "Search files..."
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
closeViewer = "Close viewer"
|
||||
delete = "Delete"
|
||||
moreActions = "More actions"
|
||||
openInViewer = "Open in viewer"
|
||||
savedToServer = "Saved to server"
|
||||
updateOnServer = "Update on server"
|
||||
uploadToServer = "Upload to server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[filesPage]
|
||||
addToWorkspace = "Add to workspace"
|
||||
@@ -3637,6 +3653,11 @@ closeDetails = "Close details"
|
||||
create = "Create"
|
||||
cycleBlocked = "Can't move a folder into one of its own subfolders."
|
||||
delete = "Delete"
|
||||
deleteFilesChoiceBody = "Some of these files are saved both on this device and in the cloud. Where should they be deleted from?"
|
||||
deleteFilesCloudBody = "Delete {{count}} file(s) from the cloud? This cannot be undone."
|
||||
deleteFilesError = "Could not delete. Try again."
|
||||
deleteFilesLocalBody = "Delete {{count}} file(s) from this device? This cannot be undone."
|
||||
deleteFilesTitle = "Delete {{count}} file(s)?"
|
||||
deleteFolder = "Delete folder"
|
||||
deleteFolderBody = "Delete folder \"{{name}}\"?"
|
||||
deleteFolderContents = "Also delete {{count}} file(s) inside the folder"
|
||||
@@ -3654,6 +3675,7 @@ downloadVersion = "Download this version"
|
||||
dropOverlay = "Drop files to upload"
|
||||
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organize them into a folder."
|
||||
file = "File"
|
||||
fileInfo = "File info"
|
||||
fileMenu = "File actions"
|
||||
folder = "Folder"
|
||||
folderItems = "{{count}} items"
|
||||
@@ -3673,10 +3695,8 @@ offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disa
|
||||
open = "Open"
|
||||
openVersionInWorkspace = "Open in workspace"
|
||||
originFilter = "Filter by source"
|
||||
quickView = "Quick view"
|
||||
refresh = "Refresh from server"
|
||||
remove = "Delete"
|
||||
removeConfirm = "Delete {{count}} file(s)? This cannot be undone."
|
||||
removeVersion = "Remove this version"
|
||||
rename = "Rename"
|
||||
renameFolder = "Rename folder"
|
||||
@@ -3701,10 +3721,12 @@ uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put the
|
||||
uploadFromMobile = "Upload from Mobile"
|
||||
versionActions = "Version actions"
|
||||
versionCollapse = "Collapse middle versions"
|
||||
versionHistory = "Version history"
|
||||
versionHistoryEmpty = "This file has no earlier versions."
|
||||
versionOrigin = "Original upload"
|
||||
versionsCount = "{{count}} versions"
|
||||
versionShowHidden = "Show {{count}} earlier versions"
|
||||
viewVersion = "View this version"
|
||||
viewVersionHistory = "Version journey ({{count}})"
|
||||
appearance.colour = "Color"
|
||||
appearance.icon = "Icon"
|
||||
appearance.title = "Appearance"
|
||||
@@ -3713,6 +3735,12 @@ column.modified = "Modified"
|
||||
column.name = "Name"
|
||||
column.size = "Size"
|
||||
column.type = "Type"
|
||||
deleteScope.cloud = "Cloud only"
|
||||
deleteScope.cloudHint = "Deletes from the server. A copy stays on this device."
|
||||
deleteScope.device = "This device only"
|
||||
deleteScope.deviceHint = "Removes the local copy. The cloud copy is kept."
|
||||
deleteScope.everywhere = "Everywhere"
|
||||
deleteScope.everywhereHint = "Deletes the file from this device and the cloud."
|
||||
empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organize your files."
|
||||
empty.newFolderCta = "Create folder"
|
||||
empty.title = "This folder is empty"
|
||||
@@ -3731,6 +3759,7 @@ empty.sharedByMe.hint = "Create a share link or invite a teammate from any of yo
|
||||
empty.sharedByMe.title = "You haven't shared any files yet"
|
||||
error.actionFailed = "Could not {{action}}."
|
||||
error.actionFailedDetail = "Could not {{action}}: {{message}}"
|
||||
error.cloudDeleteFailed = "Couldn't delete {{count}} file(s) from the cloud."
|
||||
error.deleteFolderFailed = "Could not delete folder."
|
||||
error.deleteFolderFailedDetail = "Could not delete folder: {{message}}"
|
||||
error.folderAppearanceFailed = "Could not update folder appearance."
|
||||
@@ -6706,8 +6735,9 @@ logout = "Log out"
|
||||
setAsDefault = "Set as Default"
|
||||
theme = "Theme"
|
||||
themeDark = "Dark"
|
||||
themeDescription = "Switch between light and dark mode"
|
||||
themeDescription = "Choose light, dark, or follow your system"
|
||||
themeLight = "Light"
|
||||
themeSystem = "System"
|
||||
title = "General"
|
||||
user = "User"
|
||||
|
||||
@@ -7682,8 +7712,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"
|
||||
@@ -7860,6 +7888,7 @@ valid = "Valid"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "Cannot Preview File"
|
||||
copyText = "Copy"
|
||||
disableColorFilter = "Disable Color Filter"
|
||||
dualPageView = "Dual Page View"
|
||||
enableDarkFilter = "Enable Dark Filter"
|
||||
|
||||
@@ -1551,7 +1551,7 @@ doc_summary_description = "Riassumi documenti lunghi"
|
||||
doc_summary_name = "Riassuntore"
|
||||
form_filler_description = "Compila i moduli PDF in modo intelligente"
|
||||
form_filler_name = "Compilatore di moduli"
|
||||
fullscreen_title = "Stirling Agents"
|
||||
fullscreen_title = "Agenti Stirling"
|
||||
pdf_to_markdown_description = "Converti PDF in Markdown pulito"
|
||||
pdf_to_markdown_name = "PDF in Markdown"
|
||||
section_title = "Agenti"
|
||||
@@ -4692,9 +4692,9 @@ tags = "layout,disponi,combina"
|
||||
title = "Layout multipagina"
|
||||
|
||||
[home.pdfCommentAgent]
|
||||
desc = "Chiedi all'AI di annotare un PDF con commenti in stile nota adesiva in base al tuo prompt"
|
||||
tags = "AI,agente,commento,annotazione,nota adesiva,revisione,feedback,note"
|
||||
title = "Aggiungi commenti AI"
|
||||
desc = "Chiedi all'IA di annotare un PDF con commenti in stile nota adesiva in base al tuo prompt"
|
||||
tags = "IA,agente,commento,annotazione,nota adesiva,revisione,feedback,note"
|
||||
title = "Aggiungi commenti IA"
|
||||
|
||||
[home.pdfOrganiser]
|
||||
desc = "Rimuovi/Riordina le pagine in qualsiasi ordine."
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "Stirling PDF",
|
||||
"name": "Stirling PDF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "classic-logo/favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "classic-logo/logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "classic-logo/logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -3,17 +3,17 @@
|
||||
"name": "Stirling PDF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "logo/favicon.ico",
|
||||
"src": "modern-logo/favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo/logo192.png",
|
||||
"src": "modern-logo/logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo/logo512.png",
|
||||
"src": "modern-logo/logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 253 B After Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 253 B After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1,640 @@
|
||||
{
|
||||
"default": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"byTool": {
|
||||
"certSign": {
|
||||
"image": "/og_images/certSign.png",
|
||||
"title": "Certificate Sign - Stirling PDF",
|
||||
"description": "Sign PDF documents using digital certificates"
|
||||
},
|
||||
"sign": {
|
||||
"image": "/og_images/sign.png",
|
||||
"title": "Sign - Stirling PDF",
|
||||
"description": "Adds signature to PDF by drawing, text or image"
|
||||
},
|
||||
"addText": {
|
||||
"image": "/og_images/add-text.png",
|
||||
"title": "Add Text - Stirling PDF",
|
||||
"description": "Add custom text anywhere in your PDF"
|
||||
},
|
||||
"addPassword": {
|
||||
"image": "/og_images/add-password.png",
|
||||
"title": "Add Password - Stirling PDF",
|
||||
"description": "Add password protection and restrictions to PDF files"
|
||||
},
|
||||
"removePassword": {
|
||||
"image": "/og_images/remove-password.png",
|
||||
"title": "Remove Password - Stirling PDF",
|
||||
"description": "Remove password protection from PDF documents"
|
||||
},
|
||||
"removePages": {
|
||||
"image": "/og_images/remove.png",
|
||||
"title": "Remove Pages - Stirling PDF",
|
||||
"description": "Remove specific pages from a PDF document"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"image": "/og_images/remove-blank-pages.png",
|
||||
"title": "Remove Blank Pages - Stirling PDF",
|
||||
"description": "Remove blank pages from PDF documents"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"image": "/og_images/remove-annotations.png",
|
||||
"title": "Remove Annotations - Stirling PDF",
|
||||
"description": "Remove annotations and comments from PDF documents"
|
||||
},
|
||||
"removeImage": {
|
||||
"image": "/og_images/remove-image.png",
|
||||
"title": "Remove Images - Stirling PDF",
|
||||
"description": "Remove all images from a PDF document"
|
||||
},
|
||||
"changePermissions": {
|
||||
"image": "/og_images/change-permissions.png",
|
||||
"title": "Change Permissions - Stirling PDF",
|
||||
"description": "Change document restrictions and permissions"
|
||||
},
|
||||
"watermark": {
|
||||
"image": "/og_images/add-watermark.png",
|
||||
"title": "Add Watermark - Stirling PDF",
|
||||
"description": "Add a custom watermark to your PDF document."
|
||||
},
|
||||
"sanitize": {
|
||||
"image": "/og_images/sanitize.png",
|
||||
"title": "Sanitize - Stirling PDF",
|
||||
"description": "Remove potentially harmful elements from PDF files"
|
||||
},
|
||||
"split": {
|
||||
"image": "/og_images/split.png",
|
||||
"title": "Split - Stirling PDF",
|
||||
"description": "Split PDFs into multiple documents"
|
||||
},
|
||||
"merge": {
|
||||
"image": "/og_images/mergePdfs.png",
|
||||
"title": "Merge - Stirling PDF",
|
||||
"description": "Merge multiple PDFs into a single document"
|
||||
},
|
||||
"convert": {
|
||||
"image": "/og_images/convert.png",
|
||||
"title": "Convert - Stirling PDF",
|
||||
"description": "Convert files to and from PDF format"
|
||||
},
|
||||
"ocr": {
|
||||
"image": "/og_images/ocr.png",
|
||||
"title": "OCR - Stirling PDF",
|
||||
"description": "Extract text from scanned PDFs using Optical Character Recognition"
|
||||
},
|
||||
"addImage": {
|
||||
"image": "/og_images/add-image.png",
|
||||
"title": "Add Image - Stirling PDF",
|
||||
"description": "Add images anywhere in your PDF"
|
||||
},
|
||||
"rotate": {
|
||||
"image": "/og_images/rotate.png",
|
||||
"title": "Rotate - Stirling PDF",
|
||||
"description": "Easily rotate your PDFs."
|
||||
},
|
||||
"annotate": {
|
||||
"image": "/og_images/annotate.png",
|
||||
"title": "Annotate - Stirling PDF",
|
||||
"description": "Highlight, draw, add notes, and shapes directly in the viewer"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"image": "/og_images/detect-split-scanned-photos.png",
|
||||
"title": "Detect & Split Scanned Photos - Stirling PDF",
|
||||
"description": "Detect and split scanned photos into separate pages"
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"image": "/og_images/edit-table-of-contents.png",
|
||||
"title": "Edit Table of Contents - Stirling PDF",
|
||||
"description": "Add or edit bookmarks and table of contents in PDF documents"
|
||||
},
|
||||
"scannerEffect": {
|
||||
"image": "/og_images/scanner-effect.png",
|
||||
"title": "Scanner Effect - Stirling PDF",
|
||||
"description": "Create a PDF that looks like it was scanned"
|
||||
},
|
||||
"autoRename": {
|
||||
"image": "/og_images/auto-rename-pdf-file.png",
|
||||
"title": "Auto Rename PDF File - Stirling PDF",
|
||||
"description": "Automatically rename PDF files based on their content"
|
||||
},
|
||||
"pageLayout": {
|
||||
"image": "/og_images/multi-page-layout.png",
|
||||
"title": "Multi-Page Layout - Stirling PDF",
|
||||
"description": "Merge multiple pages of a PDF document into a single page"
|
||||
},
|
||||
"scalePages": {
|
||||
"image": "/og_images/adjust-page-size-scale.png",
|
||||
"title": "Adjust page size/scale - Stirling PDF",
|
||||
"description": "Change the size/scale of a page and/or its contents."
|
||||
},
|
||||
"adjustContrast": {
|
||||
"image": "/og_images/adjust-colors-contrast.png",
|
||||
"title": "Adjust Colors/Contrast - Stirling PDF",
|
||||
"description": "Adjust colors and contrast of PDF documents"
|
||||
},
|
||||
"crop": {
|
||||
"image": "/og_images/cropPdf.png",
|
||||
"title": "Crop PDF - Stirling PDF",
|
||||
"description": "Crop a PDF to reduce its size (maintains text!)"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"image": "/og_images/single-large-page.png",
|
||||
"title": "PDF to Single Large Page - Stirling PDF",
|
||||
"description": "Merges all PDF pages into one large single page"
|
||||
},
|
||||
"repair": {
|
||||
"image": "/og_images/repair.png",
|
||||
"title": "Repair - Stirling PDF",
|
||||
"description": "Repair corrupted or damaged PDF files"
|
||||
},
|
||||
"compare": {
|
||||
"image": "/og_images/compare.png",
|
||||
"title": "Compare - Stirling PDF",
|
||||
"description": "Compare two PDF documents and highlight differences"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"image": "/og_images/add-page-numbers.png",
|
||||
"title": "Add Page Numbers - Stirling PDF",
|
||||
"description": "Add Page numbers throughout a document in a set location"
|
||||
},
|
||||
"redact": {
|
||||
"image": "/og_images/redact.png",
|
||||
"title": "Redact - Stirling PDF",
|
||||
"description": "Permanently remove sensitive information from PDF documents"
|
||||
},
|
||||
"flatten": {
|
||||
"image": "/og_images/flatten.png",
|
||||
"title": "Flatten - Stirling PDF",
|
||||
"description": "Remove all interactive elements and forms from a PDF"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"image": "/og_images/remove-certificate-sign.png",
|
||||
"title": "Remove Certificate Sign - Stirling PDF",
|
||||
"description": "Remove digital signature from PDF documents"
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"image": "/og_images/unlock-pdf-forms.png",
|
||||
"title": "Unlock PDF Forms - Stirling PDF",
|
||||
"description": "Remove read-only property of form fields in a PDF document."
|
||||
},
|
||||
"compress": {
|
||||
"image": "/og_images/compress.png",
|
||||
"title": "Compress - Stirling PDF",
|
||||
"description": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"extractPages": {
|
||||
"image": "/og_images/extract-pages.png",
|
||||
"title": "Extract Pages - Stirling PDF",
|
||||
"description": "Extract specific pages from a PDF document"
|
||||
},
|
||||
"reorganizePages": {
|
||||
"image": "/og_images/reorganize-pages.png",
|
||||
"title": "Reorganize Pages - Stirling PDF",
|
||||
"description": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
|
||||
},
|
||||
"extractImages": {
|
||||
"image": "/og_images/extract-images.png",
|
||||
"title": "Extract Images - Stirling PDF",
|
||||
"description": "Extract images from PDF documents"
|
||||
},
|
||||
"addStamp": {
|
||||
"image": "/og_images/add-stamp.png",
|
||||
"title": "Add Stamp to PDF - Stirling PDF",
|
||||
"description": "Add text or add image stamps at set locations"
|
||||
},
|
||||
"addAttachments": {
|
||||
"image": "/og_images/add-attachments.png",
|
||||
"title": "Add Attachments - Stirling PDF",
|
||||
"description": "Add or remove embedded files (attachments) to/from a PDF"
|
||||
},
|
||||
"changeMetadata": {
|
||||
"image": "/og_images/change-metadata.png",
|
||||
"title": "Change Metadata - Stirling PDF",
|
||||
"description": "Change/Remove/Add metadata from a PDF document"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"image": "/og_images/overlay-pdfs.png",
|
||||
"title": "Overlay Pdfs - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"image": "/og_images/get-all-info-on-pdf.png",
|
||||
"title": "Get ALL Info on PDF - Stirling PDF",
|
||||
"description": "Grabs any and all information possible on PDFs"
|
||||
},
|
||||
"validateSignature": {
|
||||
"image": "/og_images/validate-pdf-signature.png",
|
||||
"title": "Validate PDF Signature - Stirling PDF",
|
||||
"description": "Verify digital signatures and certificates in PDF documents"
|
||||
},
|
||||
"timestampPdf": {
|
||||
"image": "/og_images/timestamp-pdf.png",
|
||||
"title": "Timestamp PDF - Stirling PDF",
|
||||
"description": "Add an RFC 3161 document timestamp to prove when your PDF existed"
|
||||
},
|
||||
"replaceColor": {
|
||||
"image": "/og_images/replace-and-invert-color.png",
|
||||
"title": "Replace & Invert Color - Stirling PDF",
|
||||
"description": "Replace or invert colors in PDF documents"
|
||||
},
|
||||
"showJS": {
|
||||
"image": "/og_images/show-javascript.png",
|
||||
"title": "Show JavaScript - Stirling PDF",
|
||||
"description": "Extract and display JavaScript code from PDF documents"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"image": "/og_images/booklet-imposition.png",
|
||||
"title": "Booklet Imposition - Stirling PDF",
|
||||
"description": "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
},
|
||||
"pdfTextEditor": {
|
||||
"image": "/og_images/pdf-text-editor.png",
|
||||
"title": "PDF Text Editor - Stirling PDF",
|
||||
"description": "Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
|
||||
},
|
||||
"formFill": {
|
||||
"image": "/og_images/form-fill.png",
|
||||
"title": "Fill Form - Stirling PDF",
|
||||
"description": "Fill PDF form fields interactively with a visual editor"
|
||||
},
|
||||
"multiTool": {
|
||||
"image": "/og_images/multi-tool.png",
|
||||
"title": "Multi-Tool - Stirling PDF",
|
||||
"description": "Use multiple tools on a single PDF document"
|
||||
},
|
||||
"read": {
|
||||
"image": "/og_images/read.png",
|
||||
"title": "Read - Stirling PDF",
|
||||
"description": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
|
||||
},
|
||||
"automate": {
|
||||
"image": "/og_images/automate.png",
|
||||
"title": "Automate - Stirling PDF",
|
||||
"description": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
},
|
||||
"devApi": {
|
||||
"image": "/og_images/dev-api.png",
|
||||
"title": "API - Stirling PDF",
|
||||
"description": "Link to API documentation"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"image": "/og_images/dev-folder-scanning.png",
|
||||
"title": "Automated Folder Scanning - Stirling PDF",
|
||||
"description": "Link to automated folder scanning guide"
|
||||
},
|
||||
"devSsoGuide": {
|
||||
"image": "/og_images/dev-sso-guide.png",
|
||||
"title": "SSO Guide - Stirling PDF",
|
||||
"description": "Link to SSO guide"
|
||||
},
|
||||
"devAirgapped": {
|
||||
"image": "/og_images/dev-airgapped.png",
|
||||
"title": "Air-gapped Setup - Stirling PDF",
|
||||
"description": "Link to air-gapped setup guide"
|
||||
},
|
||||
"/login": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Sign In - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/signup": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Sign Up - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/mobile-scanner": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Mobile Scanner - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/files": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Files - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/preferences": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Preferences Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/notifications": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Notifications Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/connections": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Connections Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/account": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Account Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/general": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "General Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/people": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "People Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/teams": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Teams Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/security": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Security Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/identity": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Identity Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/plan": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Plan Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/payments": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Payments Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/requests": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Requests Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/developer": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Developer Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/api-keys": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Api Keys Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/hotkeys": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Hotkeys Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminGeneral": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin General Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminSecurity": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Security Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminConnections": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Connections Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminPrivacy": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Privacy Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminDatabase": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Database Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminAdvanced": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Advanced Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminLegal": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Legal Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminPremium": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Premium Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminFeatures": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Features Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminPlan": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Plan Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminAudit": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Audit Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminUsage": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Usage Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminEndpoints": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Endpoints Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminStorageSharing": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Storage Sharing Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminMcp": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Mcp Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/help": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Help Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/legal": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Legal Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/payg": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Payg Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
}
|
||||
},
|
||||
"byPath": {
|
||||
"/cert-sign": "certSign",
|
||||
"/sign": "sign",
|
||||
"/add-text": "addText",
|
||||
"/add-password": "addPassword",
|
||||
"/remove-password": "removePassword",
|
||||
"/remove-pages": "removePages",
|
||||
"/remove-blanks": "removeBlanks",
|
||||
"/remove-annotations": "removeAnnotations",
|
||||
"/remove-image": "removeImage",
|
||||
"/change-permissions": "changePermissions",
|
||||
"/watermark": "watermark",
|
||||
"/sanitize": "sanitize",
|
||||
"/split": "split",
|
||||
"/merge": "merge",
|
||||
"/convert": "convert",
|
||||
"/ocr": "ocr",
|
||||
"/add-image": "addImage",
|
||||
"/rotate": "rotate",
|
||||
"/annotate": "annotate",
|
||||
"/scanner-image-split": "scannerImageSplit",
|
||||
"/edit-table-of-contents": "editTableOfContents",
|
||||
"/scanner-effect": "scannerEffect",
|
||||
"/auto-rename": "autoRename",
|
||||
"/page-layout": "pageLayout",
|
||||
"/scale-pages": "scalePages",
|
||||
"/adjust-contrast": "adjustContrast",
|
||||
"/crop": "crop",
|
||||
"/pdf-to-single-page": "pdfToSinglePage",
|
||||
"/repair": "repair",
|
||||
"/compare": "compare",
|
||||
"/add-page-numbers": "addPageNumbers",
|
||||
"/redact": "redact",
|
||||
"/flatten": "flatten",
|
||||
"/remove-cert-sign": "removeCertSign",
|
||||
"/unlock-p-d-f-forms": "unlockPDFForms",
|
||||
"/compress": "compress",
|
||||
"/extract-pages": "extractPages",
|
||||
"/reorganize-pages": "reorganizePages",
|
||||
"/extract-images": "extractImages",
|
||||
"/add-stamp": "addStamp",
|
||||
"/add-attachments": "addAttachments",
|
||||
"/change-metadata": "changeMetadata",
|
||||
"/overlay-pdfs": "overlayPdfs",
|
||||
"/get-pdf-info": "getPdfInfo",
|
||||
"/validate-signature": "validateSignature",
|
||||
"/timestamp-pdf": "timestampPdf",
|
||||
"/replace-color": "replaceColor",
|
||||
"/show-j-s": "showJS",
|
||||
"/booklet-imposition": "bookletImposition",
|
||||
"/pdf-text-editor": "pdfTextEditor",
|
||||
"/form-fill": "formFill",
|
||||
"/multi-tool": "multiTool",
|
||||
"/read": "read",
|
||||
"/automate": "automate",
|
||||
"/dev-api": "devApi",
|
||||
"/dev-folder-scanning": "devFolderScanning",
|
||||
"/dev-sso-guide": "devSsoGuide",
|
||||
"/dev-airgapped": "devAirgapped",
|
||||
"/split-pdfs": "split",
|
||||
"/merge-pdfs": "merge",
|
||||
"/compress-pdf": "compress",
|
||||
"/rotate-pdf": "rotate",
|
||||
"/convert-pdf": "convert",
|
||||
"/file-to-pdf": "convert",
|
||||
"/eml-to-pdf": "convert",
|
||||
"/html-to-pdf": "convert",
|
||||
"/markdown-to-pdf": "convert",
|
||||
"/pdf-to-csv": "convert",
|
||||
"/pdf-to-xlsx": "convert",
|
||||
"/pdf-to-img": "convert",
|
||||
"/pdf-to-markdown": "convert",
|
||||
"/pdf-to-pdfa": "convert",
|
||||
"/pdf-to-word": "convert",
|
||||
"/pdf-to-xml": "convert",
|
||||
"/cbr-to-pdf": "convert",
|
||||
"/pdf-to-cbr": "convert",
|
||||
"/cbz-to-pdf": "convert",
|
||||
"/pdf-to-cbz": "convert",
|
||||
"/manage-signatures": "certSign",
|
||||
"/remove-certificate-sign": "removeCertSign",
|
||||
"/unlock-pdf-forms": "unlockPDFForms",
|
||||
"/sanitize-pdf": "sanitize",
|
||||
"/ocr-pdf": "ocr",
|
||||
"/add-watermark": "watermark",
|
||||
"/single-large-page": "pdfToSinglePage",
|
||||
"/auto-split-pdf": "split",
|
||||
"/auto-size-split-pdf": "split",
|
||||
"/annotations": "annotate",
|
||||
"/fake-scan": "scannerEffect",
|
||||
"/replace-color-pdf": "replaceColor",
|
||||
"/show-js": "showJS",
|
||||
"/pdf-organizer": "reorganizePages",
|
||||
"/multi-page-layout": "pageLayout",
|
||||
"/extract-page": "extractPages",
|
||||
"/img-to-pdf": "convert",
|
||||
"/pdf-to-presentation": "convert",
|
||||
"/pdf-to-text": "convert",
|
||||
"/pdf-to-html": "convert",
|
||||
"/auto-redact": "redact",
|
||||
"/stamp": "addStamp",
|
||||
"/view-pdf": "read",
|
||||
"/get-info-on-pdf": "getPdfInfo",
|
||||
"/remove-image-pdf": "removeImage",
|
||||
"/replace-and-invert-color-pdf": "replaceColor",
|
||||
"/pipeline": "automate",
|
||||
"/extract-image-scans": "scannerImageSplit",
|
||||
"/show-javascript": "showJS",
|
||||
"/split-by-size-or-count": "split",
|
||||
"/overlay-pdf": "overlayPdfs",
|
||||
"/split-pdf-by-sections": "split",
|
||||
"/split-pdf-by-chapters": "split",
|
||||
"/login": "/login",
|
||||
"/signup": "/signup",
|
||||
"/mobile-scanner": "/mobile-scanner",
|
||||
"/files": "/files",
|
||||
"/settings": "/settings",
|
||||
"/settings/preferences": "/settings/preferences",
|
||||
"/settings/notifications": "/settings/notifications",
|
||||
"/settings/connections": "/settings/connections",
|
||||
"/settings/account": "/settings/account",
|
||||
"/settings/general": "/settings/general",
|
||||
"/settings/people": "/settings/people",
|
||||
"/settings/teams": "/settings/teams",
|
||||
"/settings/security": "/settings/security",
|
||||
"/settings/identity": "/settings/identity",
|
||||
"/settings/plan": "/settings/plan",
|
||||
"/settings/payments": "/settings/payments",
|
||||
"/settings/requests": "/settings/requests",
|
||||
"/settings/developer": "/settings/developer",
|
||||
"/settings/api-keys": "/settings/api-keys",
|
||||
"/settings/hotkeys": "/settings/hotkeys",
|
||||
"/settings/adminGeneral": "/settings/adminGeneral",
|
||||
"/settings/adminSecurity": "/settings/adminSecurity",
|
||||
"/settings/adminConnections": "/settings/adminConnections",
|
||||
"/settings/adminPrivacy": "/settings/adminPrivacy",
|
||||
"/settings/adminDatabase": "/settings/adminDatabase",
|
||||
"/settings/adminAdvanced": "/settings/adminAdvanced",
|
||||
"/settings/adminLegal": "/settings/adminLegal",
|
||||
"/settings/adminPremium": "/settings/adminPremium",
|
||||
"/settings/adminFeatures": "/settings/adminFeatures",
|
||||
"/settings/adminPlan": "/settings/adminPlan",
|
||||
"/settings/adminAudit": "/settings/adminAudit",
|
||||
"/settings/adminUsage": "/settings/adminUsage",
|
||||
"/settings/adminEndpoints": "/settings/adminEndpoints",
|
||||
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
|
||||
"/settings/adminMcp": "/settings/adminMcp",
|
||||
"/settings/help": "/settings/help",
|
||||
"/settings/legal": "/settings/legal",
|
||||
"/settings/payg": "/settings/payg"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 196 KiB |