Compare commits

..
Author SHA1 Message Date
Anthony StirlingandGitHub e6d476297d Clean up update dialog UI and fix desktop external links (#6727) 2026-06-18 22:20:02 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
3456316569 Update Backend 3rd Party Licenses (#6719)
Auto-generated by stirlingbot[bot]

This PR updates the backend license report based on dependency changes.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-06-18 19:54:02 +00:00
215bba39bc Give SaaS users their own team and harden the user list endpoint (#6717)
# Description of Changes

Previously, new SaaS users were placed on a shared Default team and then
migrated to their own. A race (or a failed migration, or an
anonymous→registered upgrade) could leave them stuck on that shared
team, where unrelated users could see each other
Instead they now get their own personal team during creation so
unrelated users no longer collide on one team. SaaS-only
(@Profile("saas")); self-host's Default behaviour is untouched.
Also happens during call to avoid uncaught users

Scope GET /api/v1/user/users. Anonymous callers get 403; a caller on a
system team (Default/Internal) gets only themselves, not the team's
members.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 16:27:22 +00:00
900b66b030 chore(saas): remove dead ErrorTrackingService island + credits path exclusion (#6718)
## What

Residual dead-code cleanup following the credits engine teardown
(#6687).

- **Delete the `ErrorTrackingService` dead island** (6 files): the
service, `UserErrorTrackerRepository`, `UserErrorTracker`,
`ProcessingErrorType`, `CreditsProperties`, and
`ErrorTrackingServiceTest`. These formed a self-referential cluster with
**zero external callers** once the credit machinery was removed.
- **Remove `/api/v1/credits/**`** from both `excludePathPatterns` blocks
in `PaygWebMvcConfig` — the credits controller no longer exists, so the
exclusion is defunct. (spotless collapsed the lists to one line.)

## Verification

- `./gradlew :saas:compileJava :saas:compileTestJava` → **BUILD
SUCCESSFUL**
- grep confirms zero dangling references to the deleted types

## Not in scope (deliberately deferred)

Destructive DB drops
(`user_credits`/`team_credits`/`user_subscription_plans` tables, dead
`payg_shadow_charge` columns, `user_error_tracker` table) are gated
behind the post-release soak (`live_ratio==1.0 ≥7d`) and tracked in a
separate bundle.

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 14:32:54 +00:00
c3795c1a3c fix(viewer): wire Ctrl+A to select all text in the PDF (#6517)
# Description of Changes

Allow Ctrl A support in viewer and fix select text to copy issues via a
hovering copy button

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-18 13:50:08 +00:00
c8925acee7 add prerendered Open Graph previews and OG card generator (#6661)
# Description of Changes

add prerendered Open Graph previews and OG card generator
so that /compresss etc shows a pre generated static html file (Since
google etc doenst render javascript)


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 13:49:16 +00:00
James BruntonandGitHub eb08e60d67 Redesign pre-commit commands to run through Task (#6670)
# Description of Changes
The `pre-commit` commands in this repo are inconsistent with the rest of
the dev workflow, as they are impossible to run through Task and they
can cause CI to fail with no way for a developer to run the `pre-commit`
scripts after they've failed. This PR adds `task pre-commit` (and `task
pre-commit:fix`) and then hooks up the existing `pre-commit` hooks and
CI to call the Task rule, so if developers are using pre-commit hooks
then they should still work, but they're also runnable without using
pre-commit at all.

I think it'd be worth reviewing what we're actually running at
pre-commit in the future because I'm not entirely convinced by all of
the scripts that we are running, but this should at least make what we
have properly enforced and usable by all devs.
2026-06-18 12:55:53 +00:00
EthanHealy01andGitHub 18da914bf9 fix theme issues, remove dead rainbow mode code, standardize theme us… (#6668)
Fix issues with the theme of the app that caused some things to persist
in light mode/dark mode whilst the rest of the app was the opposite
theme.

Removed dead rainbow mode code.

Added system theme option to settings.
2026-06-18 12:42:19 +00:00
Reece BrowneandGitHub 8f46ca0d92 feat(policies): lock policies to the SaaS build + profile (#6702)
## What & why

Policies (automation-backed enforcement) execute and bill through the
cloud backend, so the feature should only be available in the hosted
**SaaS** product — not in self-hosted proprietary or core builds. Today
it's enabled in the proprietary build (and the API is exposed in any
proprietary backend), so this locks it to SaaS on both layers.

## Frontend (build-flavor gate)

`POLICIES_ENABLED` is the single gate `usePoliciesEnabled` uses (rail +
auto-run controller).

- `proprietary` flag → **`false`** (self-hosted web no longer shows
policies)
- new `src/saas/constants/featureFlags.ts` → re-exports proprietary
flags, overrides `POLICIES_ENABLED = true`
- new `src/desktop/constants/featureFlags.ts` → same `true` override —
**required**: desktop's `@app` alias has no saas layer, and desktop
already gates policies on `POLICIES_ENABLED && useConfirmedSaaSMode()`,
so without a `true` here that runtime gate could never be satisfied.
Behaviour unchanged: desktop shows policies only when connected to SaaS.
- `PoliciesSidebar.test` mocks the flag on (it tests the component, not
the build gate — same pattern the existing `usePolicyAutoRun.retry.test`
uses).

## Backend (`@Profile("saas")` gate)

The saas backend runs under the `saas` Spring profile (as
`EntitlementGuard`, the AI controllers, etc. already do). The policy
beans are now `@Profile("saas")`, so `/api/v1/policies/*` and the
auto-run triggers exist **only** in the saas backend:

`PolicyController`, `PolicyEngine`, `PolicyRunner`, `PolicyRunRegistry`,
`PolicyValidator`, `JpaPolicyStore`, `FolderInputSource`,
`FolderOutputSink`, `InlineOutputSink`, `PolicyAccessGuard`,
`FolderAccessGuard`, `FolderWatchTrigger`, `ScheduleTrigger`,
`PolicyTriggerManager`.

**Deliberately *not* gated:** `PolicyExecutor` — `AiWorkflowService`
(always-on) injects it to run ad-hoc pipelines, so it stays
profile-free. It only depends on shared infra (`InternalApiClient`,
`ToolMetadataService`, `TempFileManager`, `ObjectMapper`), so leaving it
on is safe. Gating the engine/store/triggers as a set keeps wiring
consistent (nothing un-gated depends on a gated bean).

The saas `PolicyManagementAuthority` impl
(`TeamLeaderPolicyManagementAuthority`, `@Profile("saas")`) satisfies
`PolicyAccessGuard` in the saas context.
`AdminPolicyManagementAuthority` (`@Profile("!saas")`) becomes an unused
orphan in non-saas builds — harmless; left as-is rather than expanding
this PR's scope.

## Testing
- Frontend: full suite **869 pass**; typecheck clean on
proprietary/saas/core.
- Backend: `:proprietary` compiles, spotless clean, policy tests pass,
and the proprietary (non-saas) Spring context still boots with the
policy beans gated out (verified via the MCP `@SpringBootTest`
integration tests — no missing-bean failures).

Net: SaaS web build + desktop-in-SaaS-mode get policies (UI + API);
self-hosted proprietary and core get neither the UI nor the
`/api/v1/policies` endpoints.
2026-06-18 11:05:55 +00:00
Anthony StirlingandGitHub 9a3bc6b47f Add cloud-aware delete and version history to My Files (#6704)
## /files
- Cloud-aware delete: choose device / cloud / both
- `/files` left rail always collapsed
- Details panel: pinned buttons, collapsible info, smaller preview
- Removed redundant Quick view
- New i18n keys added to en-US/en-GB

## Sidebar 
- Sidebar kebab: upload to server, delete, version history (+ cloud
badge)
- Version history modal everywhere files appear
2026-06-18 10:33:18 +00:00
Reece BrowneandGitHub 2b05865a84 Portal: unified-design surfaces (Policies, Users, Components, Agent Builder, Editor deploy) + Settings rebuild (#6696)
Builds the remaining developer-portal surfaces from the unified design
and rebuilds Settings, on top of the portal scaffold merged in #6686.
All tier-aware, mock-driven (MSW), componentised with Storybook
coverage. Touches only `frontend/portal` + `frontend/shared` — the
editor is untouched.

## New surfaces
- **Policies** — org-wide governance across the five categories
(Ingestion / Security / Compliance / Routing / Retention) with a
designer + per-doc-type overrides
- **Users** — members, roles, invite, tier-scaled SSO/SCIM access
- **Components** — embeddable `@stirling/*` SDK catalogue with
per-action pricing
- **Getting Started** — three-step funnel (use case → analyse a document
→ API key + snippets)
- **Agent Builder** — agent lifecycle (scenarios, tool modes,
evals/golden-sets, versions), reached from Sources
- **Editor deployment** — deploy/pair/operate the editor (targets,
pairing, health, credential rotation, air-gapped bundle), reached from
Infrastructure

## Reworks
- **Documents** → review/approval queue (confidence, extractions, audit
drawer, zero-standing-access elevation); the doc-type catalogue is
retained as a second tab
- **Pipelines** → golden-set pass column + "Promoted from the Editor"
section
- **Infrastructure** → new **Models** tab; deeper **Security** (managed
/ BYOK / HYOK + SOC 2 / ISO 27001 / HIPAA / GDPR / PCI attestations)
- **Home** → "What runs on your PDFs" policy summary + tier-aware
processing-status strip + pipeline-fork wizard

## Settings & shared
- New shared **`SettingsShell`** (grouped left-nav + content pane),
modelled on the editor's account-settings modal so both apps can
converge on one layout
- Portal **Settings** rebuilt on it as scoped sections — Account /
Workspace / Admin (Authentication, Active sessions, Early access)

## Brand
- Adopt the editor's brand mark + favicon; sidebar reads **Stirling
Processor**; app-switcher labels the active app "Processor"

## Mock contract
- Every surface follows the 3-layer pattern (typed `api/*` → MSW handler
→ fixtures); new endpoints documented in `MOCKS.md`. The read contract
is backend-ready; writes are marked `// TODO(backend): <METHOD> <path>`.

## Verification
- tsc (portal + shared) ✓ · eslint ✓ · dpdm (no circular) ✓ ·
`build:portal` ✓ · `storybook:build` ✓ · Prettier ✓

## Deferred (noted, not in scope)
- Unified shell / auth / role→surface routing / Workspace=Plan
(architectural epic)
- Tier rename (Editor / Processor / Bespoke) and the Usage flat-pricing
+ PAYG quick-amounts + Bespoke modal
- Editor adopting the shared `SettingsShell`; converting marked
write-stubs into live `api/` seams
2026-06-18 10:28:03 +00:00
James BruntonandGitHub 0c503cc41d Fix all top-level dev tasks treating engine as enabled (#6705)
# Description of Changes
Currently, `task dev` explicitly calls the backend with
`AIENGINE_ENABLED=true` even though it isn't being spawned, so you just
get a dead FAB in the UI. This PR fixes it so that the engine will only
be enabled for tasks that will actually spawn the engine.

It also fixes a bug with the chat which makes it unusable locally. The
API path was not going through `apiClient` so for local dev you end up
with `//api/v1/...` which is not a valid path, so you get CORS errors
when trying to connect to the AI engine.
2026-06-18 10:08:50 +00:00
albanobattistellaandGitHub b1fef4c647 Update Italian translations (#6713) 2026-06-18 08:35:31 +00:00
EthanHealy01andGitHub 06254853af allow drag and drop onto left files section and make top bar slightly smaller (#6711)
<img width="1261" height="984" alt="Screenshot 2026-06-17 at 5 59 30 PM"
src="https://github.com/user-attachments/assets/849dee17-1927-4336-81fc-dff7e91e55e7"
/>
2026-06-18 08:28:11 +00:00
Anthony StirlingandGitHub d9e6041a75 set z-index on config dropdowns so they render above the modal (#6674) 2026-06-18 09:08:50 +01:00
Anthony StirlingandGitHub 8f81fdc762 Use glibc base for ultra-lite and bundle per-arch JPDFium natives (#6706) 2026-06-18 08:32:55 +01:00
485 changed files with 19816 additions and 3916 deletions
+4 -4
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
-121
View File
@@ -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
+9 -24
View File
@@ -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
+11 -3
View File
@@ -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: |
+10 -1
View File
@@ -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/
+8 -1
View File
@@ -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
+12 -50
View File
@@ -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
+6 -3
View File
@@ -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}}"
+24
View File
@@ -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
+159
View File
@@ -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]
+5
View File
@@ -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;
}
+11 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+5 -1
View File
@@ -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
+5 -1
View File
@@ -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
+5 -1
View File
@@ -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
+19 -13
View File
@@ -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.
+3
View File
@@ -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
+2 -2
View File
@@ -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>
Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

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 -3
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

+640
View File
@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 196 KiB

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