Compare commits

..
Author SHA1 Message Date
Anthony Stirling 2dcac5b340 Restore JDK 25 detection in db-migration test script after Quarkus merge 2026-06-19 17:19:12 +01:00
Anthony Stirling 6512498c70 Exclude merge-pulled-in signature-mismatch tests from Quarkus build 2026-06-19 16:44:44 +01:00
Anthony Stirling 8096d5ed77 Merge main into migration/run-02 with full Spring removal for pulled-in code 2026-06-19 14:48:03 +01:00
James BruntonandGitHub b57958531d Add message when running task with no arguments (#6731)
# Description of Changes
Add message describing the most common tasks when running `task` with no
arguments. I think this should help newcomers because `task --list` is
massive at this point and nobody's going to read through it all. Let me
know if you think any other commands should be in the default message.
2026-06-19 10:44:30 +00:00
LudyandGitHub f8ceca0c3f feat(i18n): sync editor translations with pluralization support and new UI strings (#6565)
# Description of Changes

## What was changed

- Updated editor translation files across multiple locales.
- Migrated numerous count-based translation keys from legacy
`{{plural}}` handling to ICU-style plural forms using `_one`, `_other`,
and where applicable `_zero` variants.
- Added translations and localization keys for newly introduced features
and UI areas, including:
  - Stirling Agents
  - Chat interface and quick actions
  - Files management and folder organization
  - Desktop update workflow
  - Folder scanning warnings
  - Team and workspace management
  - Sharing and upload dialogs
  - Comparison status messages
  - Relative time formatting
  - Additional tool panel and update UI strings
- Added missing translation entries required by recently introduced
frontend functionality.
- Reorganized some translation sections to maintain consistency and key
ordering.

## Why the change was made

- To align locale files with the current frontend feature set.
- To support proper pluralization behavior across languages.
- To prevent missing translation keys and fallback text in newly added
UI components.
- To improve localization consistency and maintainability as the
application grows.

---

## 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.
2026-06-19 10:34:55 +00:00
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
James BruntonandGitHub 13af10a6d1 Redesign policy running (#6609)
# Description of Changes
Redesign policy running so the server is in charge of policy IDs and
running, to make it impossible to have the frontend miss the results.
This solves a minor bug that we currently have in policies, where if you
load a file and then refresh while the policy is running, you'll never
receive the outputted file.
2026-06-17 16:18:50 +00:00
EthanHealy01andGitHub 3750111ffc fix agent overlay chat position when workbench size changes (#6682)
<img width="2056" height="1047" alt="Screenshot 2026-06-16 at 12 42
05 AM"
src="https://github.com/user-attachments/assets/74a38b93-f31f-4263-bb62-24c2334a22e8"
/>
<img width="1443" height="1051" alt="Screenshot 2026-06-16 at 12 42
33 AM"
src="https://github.com/user-attachments/assets/adb3ba47-f3e6-44a7-bbc3-2097e15843b6"
/>
2026-06-17 15:54:09 +00:00
ConnorYohandGitHub 20c88feabb refactor(saas): remove the legacy credits engine (FE + Java) (#6687)
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.

## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.

## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).

**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.

## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).

## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.

## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.

Targets `feat/desktop-cloud-saas-reuse`.
2026-06-17 14:11:06 +00:00
ConnorYohandGitHub 4f26fdeb5c feat(desktop): show the AI assistant in SaaS mode via the cloud kill switch (#6666)
## What & why

Chained on top of #6649 (the `cloud/` refactor). The AI assistant was
effectively dead on desktop:

1. **Hidden** — `ChatFAB` gates on `aiEngineEnabled`, which desktop
reads from the **local** bundled backend's `/api/v1/config/app-config`.
The local backend has no AI engine, so the flag is always `false` and
the FAB never renders.
2. **Mis-routed** — even if shown, AI calls used `getApiBaseUrl()`,
which is empty/local on desktop, so the orchestrate stream and AI
result-file download missed the engine (which only runs in the cloud).

This PR wires AI properly **without hardcoding it on**, so the cloud
keeps the kill switch: flip `aiEngineEnabled` server-side and the
desktop FAB disappears on the next load — no desktop release required.
(Deliberately *not* assume-on, so a future "turn AI off" doesn't strand
shipped versions.)

## Changes

**General SaaS app-config service** (reusable for any cloud flag, not
just AI):
- `desktop/services/saasAppConfigService.ts` — SaaS-mode-only fetch +
5-min cache of the **public** `/api/v1/config/app-config` from the
**SaaS** backend over native HTTP (`@tauri-apps/plugin-http`, no CORS).
Returns `null` outside SaaS mode.
- `desktop/hooks/useSaasAppConfig.ts` — hook over it; reloads on
connection-mode change.

**AI gating + routing seams:**
- `useAiEngineEnabled()` — core reads `useAppConfig()` (web), desktop
reads `useSaasAppConfig()`. `ChatFAB` consumes it.
- `getAiBaseUrl()` — core uses the normal API base (web), desktop points
AI calls at the SaaS backend. `ChatContext` uses it for the orchestrate
stream + result-file download.
- `operationRouter` — route `/api/v1/ai/*` to the SaaS backend
(cloud-only prefix).

**Docs:** AGENTS.md gains a short "cloud feature flags on desktop" note
so the pattern is maintained.

## Verification
- `tsc --noEmit` green for saas / desktop / cloud flavors
- `eslint --max-warnings=0` clean (cloud-layer guardrail respected — the
platform-coupled bits live in `desktop/`)
- New `saasAppConfigService.test.ts` (3 tests) + existing
`operationRouter` / `tauriHttpClient` / `httpErrorHandler` suites green
- 0 stray compiled artifacts

## Not headlessly verifiable — needs a live Tauri smoke
The orchestrate **SSE stream** uses the webview's global `fetch` (native
HTTP can't stream the body the same way), so it's subject to browser
CORS to the SaaS backend. The `SupabaseSecurityConfig` tauri-origin
allowance (from #6649) covers it, but please confirm on a real build:
open the FAB in SaaS mode, run an agent task, watch the stream + a
result-file download succeed.
2026-06-17 14:10:35 +00:00
Anthony Stirling ccf2b88094 Port converter, security, MCP and CDI-infra tests off Spring to Quarkus 2026-06-14 23:59:37 +01:00
Anthony Stirling a7373d0ff2 Port controller/service/filter unit tests off Spring to Quarkus fixtures 2026-06-14 23:29:53 +01:00
Anthony Stirling e55dad4851 Add FileUpload test fixture and port RotationControllerTest (controller-test pattern) 2026-06-14 22:53:22 +01:00
Anthony Stirling e0b898c1a1 Re-enable excluded service/security/util unit tests by porting Spring mocks and types to Quarkus shims 2026-06-14 22:49:03 +01:00
Anthony Stirling 991fdacd52 Re-enable 14 unit tests by porting MultipartFile/Resource mocks to migration shims 2026-06-14 21:26:49 +01:00
Anthony Stirling 5fa8d20612 Restore legacy static assets removed during migration (asset cleanup to be a separate PR) 2026-06-14 21:06:41 +01:00
Anthony Stirling 1d2c5d2a44 Restore demo-user guard via DenyDemoUser interceptor on account/signature endpoints 2026-06-13 22:00:21 +01:00
Anthony Stirling 88075a7f96 Bind AI workflow and cert-sign multipart file uploads via FileUpload 2026-06-13 21:50:50 +01:00
Anthony Stirling 11809519a4 Serve swagger-ui at springdoc path and resolve migration TODOs (auth, audit order, desktop port) 2026-06-13 21:40:09 +01:00
Anthony Stirling 7b433dec22 Fix remaining cucumber regression failures in storage, user and form endpoints 2026-06-13 21:08:16 +01:00
Anthony Stirling a3870c4cf7 Apply endpoint disabling only to /api paths so SPA tool routes still load 2026-06-13 19:36:44 +01:00
Anthony Stirling 63c4d19965 Restore Angle and EditTextOperation OpenAPI schemas for AI engine tool models 2026-06-13 19:28:05 +01:00
Anthony Stirling a310ab8284 Normalize OAuth2 scope list to avoid invalid_scope on comma-space config 2026-06-13 18:59:29 +01:00
Anthony Stirling 907a754d64 Serve .mjs and .wasm static assets with correct MIME type 2026-06-13 18:59:29 +01:00
Anthony Stirling 179bf8c3d6 Replace hardcoded config overlay with generic reflective ApplicationProperties binder 2026-06-13 18:59:28 +01:00
Anthony Stirling c78c6523b6 Enforce settings.yml endpoint disabling under Quarkus 2026-06-13 18:12:28 +01:00
Anthony Stirling 36212b7ae3 Fix enterprise SSO login button, callback session and premium license binding 2026-06-13 17:57:11 +01:00
Anthony Stirling 8d363f838e Regenerate AI engine tool models from Quarkus OpenAPI schema 2026-06-13 17:15:01 +01:00
Anthony Stirling 4ada6e781c Apply spotless formatting to SPA routing comment 2026-06-13 17:15:01 +01:00
Anthony Stirling e410933f95 Serve React bundle from META-INF/resources and fix SPA asset MIME types 2026-06-13 17:10:14 +01:00
Anthony Stirling a77226f2a7 Fix remaining CI checks and restore Spring profile, settings.yml and persist parity 2026-06-13 16:17:51 +01:00
Anthony Stirling 403bf550aa Raise Gradle daemon heap to fix CI OOM on proprietary and saas builds 2026-06-13 13:24:40 +01:00
Anthony Stirling 3543ac97c6 Fix Quarkus migration build failures for core, proprietary and saas flavors 2026-06-13 13:10:10 +01:00
a 61feed02cb Set Secure and SameSite on SSO JWT cookie; apply spotless formatting 2026-06-13 12:06:15 +01:00
a 8cea88e963 Allow Public Domain license for jboss-transaction-spi dependency 2026-06-13 12:06:14 +01:00
a d15dbcf519 Remove redundant META-INF/resources duplicate and dead legacy static assets 2026-06-13 11:56:13 +01:00
a e55c177fd1 Handoff: OAuth2 + SAML2 SSO working end-to-end 2026-06-13 11:24:08 +01:00
a e0b9ef2349 Implement SAML2 login flow (signed AuthnRequest + ACS response validation) 2026-06-13 11:18:30 +01:00
a 57063c51b5 Add SAML2 SP metadata endpoint (OpenSAML 5) 2026-06-13 11:10:27 +01:00
a 33499d537e Implement OAuth2/OIDC login (authorize redirect + callback servlet) end-to-end 2026-06-13 11:02:52 +01:00
a daf392521d Handoff: login-on results, auth foundation done, SSO/SAML plan 2026-06-13 10:39:42 +01:00
a 4008161b93 Bind security/storage config from env and attach User as SecurityIdentity principal 2026-06-13 10:24:12 +01:00
a 5679967b1b Add X-API-KEY auth mechanism to populate SecurityIdentity 2026-06-13 10:12:10 +01:00
a 05d3ba6f48 Add @Transactional to user write/read service methods (Panache needs ambient tx) 2026-06-13 10:12:09 +01:00
a 4c125a2edc Bind fileInput to the real file part when a duplicate text part is present 2026-06-13 10:12:09 +01:00
a e49d0268f2 Update migration handoff with Session 2 fixes and e2e results 2026-06-13 09:46:32 +01:00
a c3a13f264f Default loginAttemptCount=5 and loginResetTimeMinutes=120 to match template 2026-06-13 09:46:32 +01:00
a 96ad92cec9 Add JWT Bearer auth mechanism to populate Quarkus SecurityIdentity 2026-06-13 09:33:27 +01:00
a 69b6a49e3a Drop redundant duplicate fileInput part from split scenarios 2026-06-13 09:20:51 +01:00
a cc40a179a7 Keep Quarkus runner-jar in Docker build context 2026-06-13 09:20:51 +01:00
a 403627101f Make policy store reads transactional for off-request scheduled triggers 2026-06-13 09:20:51 +01:00
a 33dad0f81a Create default admin at startup; fix DataSource proxy and login 401 mapping 2026-06-13 09:20:51 +01:00
a 1bc548d6c0 Add first-class Quarkus e2e Dockerfile + build helper 2026-06-13 08:48:20 +01:00
a b9069a1889 Add Quarkus migration continuation/handoff doc 2026-06-13 00:20:20 +01:00
a a30d524ec2 Fix MultipartFile.transferTo overwrite + maxDPI default 500 (cucumber e2e bugs) 2026-06-13 00:13:27 +01:00
a ee26b35b33 Record Docker + cucumber e2e results (62% scenarios pass) in migration report 2026-06-12 23:40:51 +01:00
a 860bd6e63d Make request-path reactive-safe: audit/job aspects, JobExecutorService, license singleton race 2026-06-12 23:32:05 +01:00
a 4b572852c9 Replace HttpServletRequest with reactive-safe @Context in exception handler, auth/config/user controllers 2026-06-12 23:14:43 +01:00
a 4665cceeb3 Make app boot without Redis: gate Valkey beans at build time, disable redis health, default OIDC off 2026-06-12 22:59:18 +01:00
a 90e8e34199 Document saas-flavor augmentation status in migration report 2026-06-12 22:13:42 +01:00
a fe6c6c0b49 Complete saas module Spring->Quarkus migration (compiles under saas flavor) 2026-06-12 22:12:10 +01:00
a e07eefc013 Adapt FileStorage tests to Instance<JobOwnershipService> constructor 2026-06-12 22:11:27 +01:00
a 10f4ecd09d Update migration report with verified default-flavor results 2026-06-12 22:08:37 +01:00
a 20b25ad760 Fix runtime boot: JSON column mapping, Quartz cron, H2 config 2026-06-12 22:02:23 +01:00
a 185ac88b30 Resolve Quarkus CDI augmentation: app builds on default flavor 2026-06-12 21:54:00 +01:00
a d51228af63 Compile default-flavor tests on Quarkus; exclude Spring-test-infra tests 2026-06-12 21:20:41 +01:00
a 06da9597af Fix all proprietary module compile errors for Quarkus migration 2026-06-12 19:20:56 +01:00
a 611574fb54 Add Spring Security compat shim; clear residual proprietary Spring imports 2026-06-12 18:35:53 +01:00
a 2591faa0ba Convert proprietary module (security/JPA/oauth2/saml2) to Quarkus via workflow (WIP) 2026-06-12 18:26:03 +01:00
a cec00ab78a Fix core compile errors; core flavor (common+core) compiles on Quarkus 2026-06-12 18:04:46 +01:00
a 7e068622c9 Convert core module controllers/services/config to JAX-RS/CDI via workflow (WIP) 2026-06-12 17:55:38 +01:00
a 4d69bcea32 Complete common module migration: AutoJob CDI interceptor, InternalApiClient HttpClient, shims; :common compiles 2026-06-12 17:39:01 +01:00
a 9fe815bbfd Update report: common 69/77 files converted, 8 framework residuals documented 2026-06-12 17:25:13 +01:00
a 638f7e8c6c Convert common residual files (ResponseEntity, streaming, AOP) via workflow 2026-06-12 17:24:03 +01:00
a f0c7fbdac9 Add Resource shim; convert WebResponseUtils to JAX-RS Response/StreamingOutput 2026-06-12 17:20:53 +01:00
a f24c9e0501 Update migration report with common module progress 2026-06-12 16:50:25 +01:00
a 5eec2b4446 Add MultipartFile compatibility shim and swap common imports to it 2026-06-12 16:49:43 +01:00
a 0aad71b127 Convert common module services/utils/config to CDI via workflow (WIP) 2026-06-12 16:47:36 +01:00
a 5157b6ef1c Add migration report 2026-06-12 16:12:19 +01:00
a 5dd6d7be69 Migrate common AppConfig producers to CDI 2026-06-12 16:11:15 +01:00
a 74474fa967 Migrate common module DI, scheduling and API markers to CDI/Quarkus (WIP) 2026-06-12 16:09:28 +01:00
a 266aed750d Migrate build system to Quarkus 2026-06-12 15:33:28 +01:00
1242 changed files with 87913 additions and 101126 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 "=================================================================="
+2
View File
@@ -8,6 +8,8 @@
build/
*/build/
**/build/
# ...but re-include the Quarkus runner-jar so docker/quarkus/Dockerfile can layer it on the base image
!app/core/build/*-runner.jar
out/
target/
**/target/
+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
+7 -5
View File
@@ -58,16 +58,18 @@ jobs:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: ./gradlew :stirling-pdf:bootJar -PnoSpotless --no-daemon
run: ./gradlew :stirling-pdf:quarkusBuild -PnoSpotless --no-daemon
- name: Locate built JAR
id: jar
run: |
jar=$(find app/core/build/libs -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \
| grep -vE '(-plain|-sources)\.jar$' | head -n 1)
# Quarkus (quarkus.package.jar.type=uber-jar) emits a standalone runnable
# jar at app/core/build/<name>-runner.jar, replacing the Spring Boot bootJar
# that used to land in app/core/build/libs.
jar=$(find app/core/build -maxdepth 1 -name '*-runner.jar' 2>/dev/null | head -n 1)
if [[ -z "$jar" ]]; then
echo "::error::No JAR under app/core/build/libs"
ls -lah app/core/build/libs || true
echo "::error::No *-runner.jar under app/core/build"
ls -lah app/core/build || true
exit 1
fi
# Absolute path - the migration script pushd's into a temp workdir
+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: |
+32 -5
View File
@@ -42,10 +42,20 @@ SwaggerDoc.json
# Runtime storage for uploaded files and user data (not Java source code)
app/core/storage/
# Frontend build artifacts copied to backend static resources
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
# Frontend build artifacts copied to Quarkus static resources
# Generated by `npm build` + the copyFrontendAssets/copyFrontendIndexHtml tasks; never committed.
# The React bundle goes to META-INF/resources/ (Quarkus serves these over HTTP); index.html is the
# only generated file in static/ (ReactRoutingController serves it). See app/core/build.gradle.
app/core/src/main/resources/META-INF/resources/
app/core/src/main/resources/static/index.html
# Migration cleanup: earlier builds emitted the whole bundle into static/. Keep these ignored so
# any stale generated assets left in static/ are not accidentally committed.
app/core/src/main/resources/static/assets/
# 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/classic-logo/
@@ -53,13 +63,15 @@ 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/
app/core/src/main/resources/static/vendor/
app/core/src/main/resources/static/**/*.gz
app/core/src/main/resources/static/**/*.br
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Note: Keep backend-managed files like fonts/, css/, js/, etc.
# Gradle
.gradle
@@ -273,8 +285,23 @@ docs/type3/signatures/
**/application-dev-local.properties
# Claude
# AI agent session/local files - may contain tokens and secrets
.claude/
.agents/
.cursor/
.codex/
.opencode/
.copilot/
.cline/
.continue/
.windsurf/
.junie/
.pi/
.roo/
.augment/
.aider*
CLAUDE.local.md
skills-lock.json
# Playwright MCP screenshots / traces
.playwright-mcp/
+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}}"
+36
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
@@ -374,3 +398,15 @@ tasks:
deps: [install]
cmds:
- node editor/scripts/generate-licenses.js
# ============================================================
# Clean
# ============================================================
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
platforms: [linux, darwin]
+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,vertx --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]
+2
View File
@@ -193,6 +193,8 @@ What goes where:
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
+563
View File
@@ -0,0 +1,563 @@
# Stirling-PDF: Spring Boot → Quarkus Migration — Continuation Handoff
> **Purpose:** everything needed to resume this migration in a fresh session. Read this top-to-bottom
> before touching anything. Companion doc `migration-report.md` has the higher-level summary; this
> file is the working/continuation guide with the concrete state, commands, fixed bugs, remaining
> bugs, and the recurring patterns you need to apply.
---
## 0. TL;DR status
- **2026-06-19 — merged `main` (136 commits) into `migration/run-02`.** Resolved 69 conflicts and
did **full Spring removal + Quarkus migration of every newly pulled-in file** (36 Spring-bearing
files: 21 proprietary + 15 saas). Net effect on main: legacy credits engine deleted (#6687,
replaced by PAYG #6589); the proprietary **policy** subsystem and the new **PAYG** subsystem
migrated to CDI/JAX-RS/Panache. Verified: 0 conflict markers, 0 `org.springframework` imports in
any main source. **`core` + `proprietary` compile; `proprietary` Quarkus-augments + boots + serves
real traffic; `saas` now compiles AND augments too** (previously ~28 CDI issues). Cross-file fix:
`PolicyExecutor`/`DownstreamEntitlementError` now carry HTTP status+body via
`jakarta.ws.rs.WebApplicationException` (was Spring `RestClientResponseException`);
`AiWorkflowService.paygLimitResponseOrNull` rewired to it. Repo `save()` shim added to the Panache
repos whose callers/tests expect Spring-Data `save()`.
- **Branch:** `migration/run-01` (all work committed locally, **nothing pushed** — `origin` is the
public `Stirling-Tools/Stirling-PDF` repo; do not push without the owner's say-so).
- **Default flavor (`proprietary`):** compiles, Quarkus-augments, boots, and serves real traffic in
Docker. ✅
- **Cucumber API e2e (full-tool Docker image):** baselines, newest first:
- **Run 2 (login off, this session's fixes, no JWT mechanism): 223 / 258 pass**, 35 failed, 80
skipped. Up from the prior **183 / 258** baseline (+40). Eliminated buckets: split
`PDF corrupted` 8→0, `FileAlreadyExists` 8→0, `Admin login failed (500)` 17→0.
- **Run 3 (login off + `V2=true` + the new JWT Bearer mechanism): the 80 JWT/admin scenarios now
RUN (0 skipped)** because the `login → /me` probe passes. See §6.E / "Session 2". Final tally
recorded in §9.
- **Stack:** Quarkus 3.33.2 LTS, **Java 25** (mandatory — see §2), Hibernate ORM Panache,
quarkus-rest (RESTEasy Reactive), quarkus-oidc, quarkus-undertow (servlet, for filters), OpenSAML 5.
- **`saas` flavor:** compiles but full augmentation has ~28 CDI issues (design-level follow-up).
- **JWT Bearer login:** ✅ works end-to-end (token issue + validate → `SecurityIdentity`, role
mapping, `@RolesAllowed`). **OAuth2/OIDC + SAML2 SSO:** ✅ both work end-to-end against the
`testing/compose` Keycloak stacks; `validate-oauth-test.sh` and `validate-saml-test.sh` both pass
(see §6.F). The default e2e Docker image + build helper are committed at `docker/quarkus/` (§3.3).
---
## 1. Repo / flavor layout
Multi-module Gradle build, three selectable flavors via `STIRLING_FLAVOR` (or `ENABLE_SAAS` /
`DISABLE_ADDITIONAL_FEATURES`):
| Flavor | Modules included | Notes |
|--------|------------------|-------|
| `core` | `:common`, `:stirling-pdf` (core) | OSS only |
| `proprietary` (**default**) | + `:proprietary` | what all the e2e work targets |
| `saas` | + `:saas` | opt-in: `STIRLING_FLAVOR=saas`; not yet augmentable |
Module → directory:
- `:stirling-pdf` → `app/core` (the runnable Quarkus app; applies the `io.quarkus` gradle plugin)
- `:common` → `app/common` (library; CDI beans / JAX-RS / entities)
- `:proprietary` → `app/proprietary` (library)
- `:saas` → `app/saas` (library, only on saas flavor)
Quarkus only discovers beans/entities in dependency jars that carry a **Jandex index**; the library
modules are indexed via `quarkus.index-dependency.*` in
`app/core/src/main/resources/application.properties`.
---
## 2. Java 25 is mandatory (don't regress this)
- The build uses a **JDK 25 toolchain** (`build.gradle` `subprojects { java { toolchain = 25 } }`).
- The app is compiled to **class-file version 69 (Java 25)** — it will NOT run on JDK 21.
- **The host's default `java` on the PATH is JDK 21.** Use the toolchain JDK 25 explicitly:
- `JAVA_HOME` points to a Temurin 25 JDK (`C:\Users\systo\scoop\apps\temurin25-jdk\current`).
- In Git Bash run the jar with `"$JAVA_HOME/bin/java" -jar ...` (host `java` = 21 → `UnsupportedClassVersionError`).
- The Docker base image `stirlingtools/stirling-pdf-base:1.0.2` ships **Temurin 25.0.2** — so the
container runtime is JDK 25 already. Keep it that way; do not switch the base image to a JRE < 25.
- Gradle build images / CI also pin `gradle:9.3.1-jdk25` and `eclipse-temurin:25-jre-noble`.
---
## 3. Build → package → run → test (the exact loop)
### 3.1 Build the runnable jar
```bash
./gradlew :stirling-pdf:quarkusBuild -x test --console=plain
```
- Produces the **runnable uber-jar** at: `app/core/build/stirling-pdf-2.12.0-runner.jar`
- `Main-Class: stirling.software.SPDF.SPDFApplication`.
- ⚠️ **GOTCHA:** `app/core/build/libs/stirling-pdf-2.12.0.jar` is the *plain* (non-runnable) jar with
an empty manifest. The upstream `docker/embedded/Dockerfile` copies `libs/*.jar` — that's now the
WRONG jar. Always use the `-runner.jar`. (`quarkus.package.jar.type=uber-jar` is set in
application.properties.)
- ⚠️ If the build fails with `Unable to delete .../-runner.jar`, a previous `java -jar` is still
holding it. Kill it: PowerShell `Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ?{ $_.CommandLine -like '*stirling-pdf-2.12.0-runner*' } | %{ Stop-Process -Id $_.ProcessId -Force }`.
### 3.2 Run standalone for a quick boot check (host JDK 25, fastest)
```bash
SECURITY_ENABLELOGIN=false QUARKUS_HTTP_PORT=8095 \
QUARKUS_DATASOURCE_JDBC_URL="jdbc:h2:mem:t;DB_CLOSE_DELAY=-1;MODE=PostgreSQL" \
nohup "$JAVA_HOME/bin/java" -jar app/core/build/stirling-pdf-2.12.0-runner.jar > /tmp/boot.log 2>&1 &
# success line in log: "Stirling-PDF running on port: 8095" (this app does NOT print Quarkus' "Listening on")
```
Health: `curl localhost:8095/api/v1/info/status` → `{"version":"2.12.0","status":"UP"}`.
### 3.3 The "normal" Docker image (full tools) — what the cucumber e2e uses
The upstream `docker/embedded/Dockerfile` is **Spring-Boot-specific** (uses
`java -Djarmode=tools -jar app.jar extract --layers` + `spring-boot-loader` layers) and does NOT
work with the Quarkus jar. For e2e I built an ad-hoc image layering the runner-jar on the prebuilt
**base image** (which already has Java 25 + LibreOffice + Tesseract + qpdf + Ghostscript + Calibre +
Python). **This Dockerfile lives in a temp dir and needs to be committed into the repo** (see §6 TODO).
Build context (currently ephemeral at the bash path `/tmp/sp-full` =
`C:\Users\systo\AppData\Local\Temp\sp-full`): `app.jar` (the runner jar), `fonts/*.ttf`, and this
Dockerfile:
```dockerfile
FROM stirlingtools/stirling-pdf-base:1.0.2 # Java 25 + all tools
WORKDIR /app
COPY --chown=1000:1000 app.jar /app/app.jar
COPY fonts/*.ttf /usr/share/fonts/truetype/
RUN fc-cache -f \
&& mkdir -p /storage \
&& chown stirlingpdfuser:stirlingpdfgroup /storage /app \
&& ln -sf /configs /app/configs && ln -sf /logs /app/logs \
&& ln -sf /customFiles /app/customFiles && ln -sf /pipeline /app/pipeline \
&& ln -sf /storage /app/storage \
&& chown -h stirlingpdfuser:stirlingpdfgroup /app/configs /app/logs /app/customFiles /app/pipeline /app/storage
ENV HOME=/home/stirlingpdfuser STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf TEMP=/tmp/stirling-pdf TMP=/tmp/stirling-pdf \
SAL_TMP=/tmp/stirling-pdf/libre DBUS_SESSION_BUS_ADDRESS=/dev/null \
JAVA_OPTS="-XX:+UseG1GC -Djava.awt.headless=true" \
QUARKUS_HTTP_HOST=0.0.0.0 QUARKUS_HTTP_PORT=8080
EXPOSE 8080/tcp
STOPSIGNAL SIGTERM
USER stirlingpdfuser
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
```
Stage + build + run:
```bash
# stage (bash /tmp resolves to %LOCALAPPDATA%\Temp)
mkdir -p /tmp/sp-full/fonts
cp app/core/build/stirling-pdf-2.12.0-runner.jar /tmp/sp-full/app.jar
cp app/core/src/main/resources/static/fonts/*.ttf /tmp/sp-full/fonts/
# (write the Dockerfile above to C:\Users\systo\AppData\Local\Temp\sp-full\Dockerfile)
cd /tmp/sp-full && docker build -t stirling-pdf-quarkus:full .
docker rm -f sp-e2e
docker run -d --name sp-e2e -p 8080:8080 \
-e SECURITY_ENABLELOGIN=false -e METRICS_ENABLED=true \
-e SYSTEM_DEFAULTLOCALE=en-US -e SYSTEM_MAXFILESIZE=100 \
stirling-pdf-quarkus:full
# wait for: curl localhost:8080/api/v1/info/status == 200
```
Base image was pulled with `docker pull stirlingtools/stirling-pdf-base:1.0.2`.
### 3.4 Run the cucumber (behave) suite
- Tests live in `testing/cucumber/` — Python **behave** (BDD), pure HTTP via `requests` (no browser).
- **Target URL is hardcoded `http://localhost:8080`** in `features/steps/step_definitions.py`
(lines ~584/592/601) and `features/environment.py`. Easiest is to run the app on 8080.
- `behave.ini` excludes `features/(enterprise|payg)` and tag `~@manual` by default.
- `environment.py` probes `/api/v1/auth/login` (admin/stirling) at startup; if JWT/login is not
functional (login disabled / V2) it **skips** all `@jwt @login @me @refresh @token @mfa @apikey
@admin_settings @audit @signature @team @user_mgmt` scenarios → ~80 skips. That's expected.
Install deps + run:
```bash
cd testing/cucumber
pip install -r requirements.txt # behave, requests, pypdf, reportlab, psycopg, pillow, ...
TEST_CONTAINER_NAME=sp-e2e TEST_REPORT_DIR=/tmp python -m behave --no-capture --format progress2
# single feature: python -m behave features/general.feature
# one scenario: python -m behave features/general.feature:22 --format plain
```
The official CI driver is `testing/test.sh` (builds images via `docker/embedded/Dockerfile.*` and
runs behave) — it will need the Dockerfile fixes from §6 before it works on Quarkus.
---
## 4. Bugs FIXED this session (with the *why*, so you can spot siblings)
### Session 2 (branch `claude/happy-chaplygin-906fe7`, fast-forwarded from `migration/run-01`)
Newest first. These took the login-off suite **183 → 223** and then wired JWT so the **80 skipped
JWT/admin scenarios run** (run 3, §9):
1. **JWT Bearer → `SecurityIdentity` was never populated** → every user-scoped endpoint that reads
`SecurityIdentity.getPrincipal()` (folders, files, `/me`, user/team settings…) failed, and the
`environment.py` probe (`login → /me`) failed so ~80 scenarios auto-skipped. Added a custom
`HttpAuthenticationMechanism` + `IdentityProvider` in
`app/proprietary/.../security/identity/` (`JwtBearerAuthenticationMechanism`,
`JwtTokenIdentityProvider`): extract `Authorization: Bearer`, validate via the existing
`JwtService` (jjwt + keystore), build a `QuarkusSecurityIdentity` and map the `role` claim
(`ROLE_ADMIN` → also add `ADMIN` so `@RolesAllowed("ADMIN")` matches). Returns no identity when
no Bearer is present, so the X-API-KEY / login-off open-endpoint path is unaffected. **This is the
IdentityProvider that ~10 `// TODO: Migration required` comments across the security/storage code
asked for.** Run with `V2=true`.
2. **No admin user was ever created** → all logins failed "No user found: admin". `InitialSecuritySetup`
was a Spring `@Component` (eagerly constructed, `@PostConstruct` ran every boot); the migration
made it a lazy `@ApplicationScoped` whose `@PostConstruct` never ran. Restored eager init via
`@Observes StartupEvent`. **Pattern: any migrated `@PostConstruct`-on-`@ApplicationScoped` startup
bean with no injector is dead code — grep for them.**
3. **Eager init then exposed two latent bugs** (both real, both now fixed):
- `@Produces @ApplicationScoped DataSource` → Arc generated the client proxy in the JDK-sealed
`javax.sql` package → `NoClassDefFoundError` on first use. Fix: `@Singleton` (pseudo-scope, no
proxy). **Audit other `@Produces @ApplicationScoped` whose return type is a `java.*`/`javax.*`
type.**
- Panache `persist()` in the `StartupEvent` observer ran with no transaction (Spring Data wrapped
`save()` implicitly). Fix: `@Transactional` on the observer.
4. **Login returned 500 instead of 401** for unknown user / bad password. `CustomUserDetailsService`
threw `IllegalArgumentException`, but `AuthController` catches the migration shim
`stirling.software.common.security.UsernameNotFoundException`. Made the service throw the shim
type. **Sibling: the locked-account path still throws `IllegalStateException` — wire it similarly
when needed.**
5. **`@Transactional` missing on policy-store reads** (`JpaPolicyStore.all()`,
`findByTriggerType()`) → the scheduled folder-watch/schedule triggers threw
`ContextNotActiveException` off-request (§6.C). The reads are reached via the CDI proxy so a
method-level `@Transactional` applies even from the background virtual-thread executor.
6. **Split scenarios sent a duplicate `fileInput` text part** (`| fileInput | fileInput |` in
`general.feature`) alongside the file part; Quarkus `@RestForm FileUpload` bound the *text* part
("fileInput", 9 bytes) → "PDF corrupted". Spring ignored the stray part. Removed the redundant
rows (the file is already attached via the generate step). **Real clients send one part, so this
is a test artifact, not a server tolerance gap worth chasing.**
7. **e2e Docker build is now first-class:** `docker/quarkus/Dockerfile` (+ `README.md`,
`build-and-run.sh`) layers the runner-jar on the base image, and `.dockerignore` re-includes
`app/core/build/*-runner.jar` (it was excluded by `**/build/`, so a clean `docker build` had been
silently relying on BuildKit cache).
### Session 1
1. **`MultipartFile.transferTo` didn't overwrite** (`a30d524ec`).
`app/common/.../model/MultipartFile.java` + `.../model/multipart/FileUploadMultipartFile.java`
used `Files.copy(in, dest)` without `REPLACE_EXISTING`. Callers do
`Files.createTempFile(...)` (creates the file) then `transferTo(thatPath)` → `FileAlreadyExistsException`.
Spring's `transferTo` overwrites. **Fixed** by adding `StandardCopyOption.REPLACE_EXISTING`.
Fixes the whole class of `/api/v1/misc/*` failures (scanner-effect, replace-invert, ocr,
update-metadata, unlock-pdf-forms, repair, extract-image-scans, add-page-numbers, …).
2. **`maxDPI` defaulted to 0** (`a30d524ec`).
`ApplicationProperties.System.maxDPI` is a primitive `int` (→ 0 when not bound from settings).
Every DPI guard (`dpi > maxDPI`) then failed with *"maximum safe limit of 0"*. The
`settings.yml.template` default is 500. **Fixed** by `private int maxDPI = 500;`.
⚠️ Root cause hint: this strongly suggests **settings.yml → ApplicationProperties config binding
is incomplete in the Quarkus migration**. Other primitive/unset fields may also be silently
wrong. Worth a dedicated audit (see §5).
3. **Request-path `HttpServletRequest` → `UT000048` "No request is currently active"** (`860bd6e63`,
`4b572852c`). This was the dominant blocker. `quarkus-rest` (RESTEasy Reactive) runs handlers on
reactive/worker threads where the undertow servlet request context is NOT active, so ANY
`HttpServletRequest.getX()` throws. Fixed in:
- `GlobalExceptionHandler` (an `ExceptionMapper` that threw while handling *every* error, masking
the real cause) → `@Context UriInfo` + exception-safe `requestUri()`.
- `ControllerAuditAspect`, `AuditAspect` → route through the already-guarded
`AuditService.getCurrentRequest()` (returns null off-request) + a guarded `safeResponse()`.
- `AutoJobAspect`, `JobExecutorService` → inject `io.quarkus.vertx.http.runtime.CurrentVertxRequest`,
read query-param/method/path/attributes from the Vert.x request, degrade to null/no-op.
- `AuthController`, `UserController`, `ConfigController` → `@Context UriInfo` / `HttpHeaders` /
`io.vertx.core.http.HttpServerRequest`.
This unblocked the entire `@AutoJobPostMapping` chain (most PDF endpoints).
4. **License singleton PK race** (`860bd6e63`). `UserLicenseSettings` has a manually-assigned
`@Id = 1L`. Spring Data `save()` on a non-new (pre-set-id) entity does a **MERGE (upsert)**; the
migration converted it to `persist()` (INSERT-only). The startup license sync raced the first
request, both inserted id=1 → `JdbcSQLIntegrityConstraintViolationException` → app crash. **Fixed**
in `UserLicenseSettingsService.getOrCreateSettings()` with a JVM lock +
`io.quarkus.narayana.jta.QuarkusTransaction.requiringNew()` create-once, then reload into the
caller's tx. **⚠️ This `save()`→`persist()`-should-be-`merge()` bug almost certainly exists for
OTHER manually-`@Id`'d entities — audit them (see §5).**
5. **App couldn't boot without Redis** (`4665cceeb`).
- `quarkus.oidc.enabled=false` default (quarkus-oidc aborts startup without `auth-server-url`;
re-enable for an OAuth2 deployment).
- Valkey backplane beans eagerly injected the inactive `RedisDataSource`. Gated all 7 with
**build-time** `@io.quarkus.arc.properties.IfBuildProperty(name="cluster.backplane", stringValue="valkey")`
(NOT `@LookupIfProperty` — that leaves the bean in the build, so `RedisDataSource` still has a
consumer and Quarkus emits an eager startup observer that fails). Plus
`quarkus.redis.health.enabled=false`.
6. **Runtime boot fixes** (`20b25ad76`): `quarkus.hibernate-orm.mapping.format.global=ignore` (JSON
columns), Quartz cron `0 0 0 * * MON` → `0 0 0 ? * MON` (Quartz rejects `*` in both day fields),
`@Scheduled(every="7d")` → `"P7D"`, `quarkus.arc.fail-on-intercepted-private-method=false`.
7. **CDI augmentation** (`185ac88b3`): interceptor bindings made `@InterceptorBinding`
(`@EnterpriseEndpoint`, `@PremiumEndpoint`), a `tools.jackson.databind.ObjectMapper` producer
added in `AppConfig` (92 injection points), ambiguous beans resolved (`@DefaultBean`),
`Optional<X>`→`Instance<X>`, collection `List<X>`→`@All List<X>`, nested `SAML2` config producer.
8. **Test layer** (`d51228af6`): a content-based exclude in root `build.gradle subprojects` skips any
test still importing `org.springframework`/`com.nimbusds` (self-maintaining), plus an explicit
list for tests asserting changed production signatures.
---
## 5. Recurring patterns / gotchas (apply these everywhere)
- **HttpServletRequest is poison on reactive threads.** ~35 main-source files still reference
`HttpServletRequest` (see §6 list). For each in the request path, replace with:
- path/URI → `@Context jakarta.ws.rs.core.UriInfo` (`uriInfo.getRequestUri().getPath()`), or in a
non-JAX-RS bean inject `io.quarkus.vertx.http.runtime.CurrentVertxRequest`
(`currentVertxRequest.getCurrent().request().path()`), guarded in try/catch returning null/"".
- headers → `@Context jakarta.ws.rs.core.HttpHeaders` (`getHeaderString(name)`).
- remote addr / method → `@Context io.vertx.core.http.HttpServerRequest`.
- request attributes (`get/setAttribute`) → Vert.x `RoutingContext.get/put` via `CurrentVertxRequest`.
- In services that already have a guarded accessor, reuse `AuditService.getCurrentRequest()`.
- **Spring `save()` → Panache:** if the entity uses `@GeneratedValue` (new on insert) → `persist()`.
If the entity has a **manually-assigned `@Id`** (caller sets the id, "upsert" semantics) →
`getEntityManager().merge()` (NOT `persist()`), and consider concurrency.
- **Config gating:** runtime selection that must REMOVE a bean (so its deps don't get wired) →
build-time `@IfBuildProperty`/`@UnlessBuildProperty`. `@LookupIfProperty` only disables *lookup*,
the bean and its injection points stay in the build.
- **`quarkus.*` build-time props** (e.g. `quarkus.oidc.enabled`, `quarkus.hibernate-orm.*`,
`quarkus.arc.*`) can't be overridden by env at runtime — they require a rebuild.
- **settings.yml binding is suspect** (see maxDPI). Audit `ApplicationProperties` for primitive
fields that need non-zero/template defaults, and verify the settings.yml → ApplicationProperties
binding path actually works in Quarkus (it was Spring `@ConfigurationProperties` + a custom YAML
property source — see the `YamlPropertySourceFactory` / `ConfigInitializer` TODOs).
- **Augment gate:** `compileJava` passing ≠ working. `./gradlew :stirling-pdf:quarkusBuild` surfaces
CDI wiring errors; only *running* surfaces the `UT000048` / config / race bugs. Always run.
- **Jackson 2 vs 3 coexist:** ~100 files use `tools.jackson` (Jackson 3, from Spring Boot 4); REST
(de)serialization uses Quarkus' Jackson 2. Don't "fix" `tools.jackson` imports — there's a producer.
---
## 6. REMAINING WORK (prioritized)
### A. Make the e2e Docker build first-class
- [x] **DONE (Session 2):** `docker/quarkus/Dockerfile` (+ `README.md`, `build-and-run.sh`) committed,
uses the runner-jar, copies fonts; `.dockerignore` re-includes `app/core/build/*-runner.jar`.
- [ ] Rewrite/replace `docker/embedded/Dockerfile`, `Dockerfile.fat`, `Dockerfile.ultra-lite` for
Quarkus: drop the Spring-Boot `-Djarmode=tools extract --layers` + `spring-boot-loader` layer
copies; either copy the uber `-runner.jar` to `/app/app.jar` or use the Quarkus fast-jar
(`quarkus-app/`) layout. The stage-1 `gradle clean build -PbuildWithFrontend=true` still builds
the frontend (fine).
- [ ] Update `scripts/init.sh` / `init-without-ocr.sh` — they have Spring-loader fallbacks and AOT
machinery; the primary `java -jar /app.jar` path works for the uber-jar, but verify the AOT
cache + `restart-helper.jar` paths.
- [ ] Then `testing/test.sh` (the official cucumber driver) should work end-to-end.
### B. Real per-endpoint bugs surfaced by cucumber (login-off suite)
Last measured failure buckets (before the transferTo/maxDPI fixes — re-run to refresh):
- [ ] **`PdfCorruptedException` (~48)** on `convert/pdf/{word,vector,presentation,text,pdfa,...}`,
`convert/{html,cbz}/pdf`. Investigate `CustomPDFDocumentFactory` (PDF loading) — is it
misreporting valid PDFs as corrupted, or do these convert paths need LibreOffice/handling that
errors first and gets wrapped? Check one: `python -m behave features/convert_new.feature:NN --format plain`
then read `docker logs sp-e2e` for the real cause.
- [ ] **`ClassCastException: String cannot be cast to ...` (~6)** — form/param binding type mismatch.
Likely a `@RestForm`/`@QueryParam` bound to the wrong type, or a Map/JSON form field. Check
`form/fill`, `form_advanced.feature`.
- [ ] **Remaining `500`s** after A/B fixes — `misc/compress-pdf`, `general/split-pdf-by-chapters`,
`misc/add-image`, etc. Triage each via container logs.
- [ ] **`400`s (~5)** — multipart `@RestForm` binding gaps. The migration left several request DTOs
with `MultipartFile`/POJO-list fields not bound to RESTEasy `FileUpload` (AI/workflow/sign DTOs
explicitly flagged). See `migration-report.md` "Representative deferred code".
- [ ] **temp-file collisions other than transferTo** — also check `GeneralUtils.createTempFile`
(`app/common/.../util/GeneralUtils.java:79/85`) and any `Files.createFile`/`Files.copy`/
`Files.move` without `REPLACE_EXISTING`. `temp<rand>genericNonCustomisableName.pdf` and
`/tmp/stirling-pdf/stirling-pdf-<rand>.pdf` were two such names.
### C. Background scheduled-task errors (log noise, not request-breaking)
- [ ] `FolderWatchTrigger` (reconcile) and `ScheduleTrigger` (sweep) throw
`jakarta.enterprise.context.ContextNotActiveException` ("neither a transaction nor a CDI
request context is active") because they hit Panache/`PolicyRepository` off-request. Add
`@Transactional` (and/or `@ActivateRequestContext`) to those scheduled methods, or wrap the EM
access in `QuarkusTransaction.requiringNew()`. Files:
`app/proprietary/.../policy/trigger/FolderWatchTrigger.java`,
`.../policy/trigger/ScheduleTrigger.java`, `.../policy/store/JpaPolicyStore.java`.
### D. The remaining ~35 `HttpServletRequest` files (apply §5 pattern as they surface)
Not all are in the hot path; fix the ones that throw `UT000048` when their endpoints are exercised.
Get the list any time with:
```bash
grep -rln "HttpServletRequest" app/core/src/main app/proprietary/src/main app/common/src/main --include=*.java
```
Known-fixed already: GlobalExceptionHandler, ControllerAuditAspect, AuditAspect, AutoJobAspect,
JobExecutorService, AuthController, UserController, ConfigController. Everything else is unverified.
High-risk: security filters (`UserAuthenticationFilter`, rate-limit filters, `JwtAuthenticationFilter`),
anything reading headers/cookies/remote-addr per request.
### E. Auth / JWT / login — DONE (Session 2)
The whole Quarkus auth-identity layer is now in place (`app/proprietary/.../security/identity/`):
- [x] **JWT Bearer** → `JwtBearerAuthenticationMechanism` + `JwtTokenIdentityProvider` (validate via
`JwtService`, map `role` claim). Run with `V2=true`.
- [x] **X-API-KEY** → `ApiKeyAuthenticationMechanism` + `ApiKeyAuthenticationRequest` +
`ApiKeyIdentityProvider` (resolve via `userService.getUserByApiKey`). Lets `X-API-KEY` requests
authenticate (e.g. `/me`), and lets the suite run `SECURITY_ENABLELOGIN=true`.
- [x] **User-as-principal** → `UserSecurityIdentityAugmentor` re-loads the `User` and sets it as the
`SecurityIdentity` principal; `User implements Principal`. This satisfies the ~7
`principal instanceof User` sites (folders, file storage, sessions, audit, UserController) — the
augmentor every `// TODO: Migration required` in security/storage asked for.
- [x] **Config binding** → `ApplicationPropertiesConfigOverlay` overlays env/config onto
`ApplicationProperties` at startup (the Spring `@ConfigurationProperties` bind was never
migrated, so `SECURITY_ENABLELOGIN` / `SECURITY_CUSTOMGLOBALAPIKEY` / `STORAGE_ENABLED` were
ignored — root cause of the maxDPI/loginAttemptCount class too). **Currently a focused subset
(auth/storage/SSO toggles); a complete generic bind (all ~445 fields + settings.yml) is still
TODO.**
- Validated on a login-ON probe (`SECURITY_ENABLELOGIN=true V2=true STORAGE_ENABLED=true
SECURITY_CUSTOMGLOBALAPIKEY=123456789`): open PDF endpoints (anon), JWT login+/me, X-API-KEY /me,
folder list/create all work. The 183 open endpoints stay open (no global `quarkus.http.auth.*`
policy), so login-ON does not regress them.
### F. SAML / SSO — DONE (Session 2), both flows work end-to-end
**Both `validate-oauth-test.sh` and `validate-saml-test.sh` pass, and both full login flows were
verified end-to-end against the Keycloak compose** (login -> IdP -> callback/ACS -> auto-created
user -> app JWT cookie -> `/me` 200). Run with `PREMIUM_KEY=<your enterprise license key>`. Tag the Quarkus image as
`docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest` so the compose uses it (or repoint the
`image:`); `start-saml-test.sh` generates the SP certs + fetches Keycloak's cert.
- **OAuth2 / OIDC** (`security/oauth2/`): `OAuth2LoginController` (JAX-RS) serves
`/oauth2/authorization/{id}` -> IdP authorize redirect; `OAuth2CallbackServlet` (`@WebServlet
/login/oauth2/code/*`) does the code exchange + userinfo + auto-create + JWT cookie. **Why a
servlet for the callback:** quarkus-undertow's default servlet owns the `/login/*` prefix and
query-strips/intercepts the extension-less callback before RESTEasy sees it; a registered
`@WebServlet` takes precedence. (Same reason the SAML SP endpoints are servlets.)
- **SAML2** (`security/saml2/`): `Saml2Service` (OpenSAML 5) initialises the library, loads SP
key/cert + IdP cert, builds SP metadata, builds+signs the redirect-binding AuthnRequest, and
validates the SAMLResponse signature (`SAMLSignatureProfileValidator` + `SignatureValidator`
against the IdP cert). `SamlMetadataServlet` -> `/saml2/service-provider-metadata/{id}`;
`SamlSpServlet` -> `/saml2/authenticate/{id}` (login init) + `/login/saml2/sso/{id}` (ACS).
**Gotcha:** the SP entityId must equal the SP-metadata URL (`{backendUrl}/saml2/service-provider-
metadata/{id}`), which is what Keycloak's SAML client is keyed on - NOT the bare
`SECURITY_SAML2_SP_ENTITYID` host (`SamlConfig` derives it). Keycloak's realm has
`saml.client.signature=false` (AuthnRequest signature optional) + `saml.server.signature=true`
(so the ACS validates the response against Keycloak's cert).
- Both flows finish by issuing the app JWT as the `stirling_jwt` cookie, which
`JwtBearerAuthenticationMechanism` now also reads (not just the `Authorization` header) -> feeds
the `UserSecurityIdentityAugmentor` (principal = User).
- Follow-ups: logout/SLO endpoints; encrypted-assertion handling; the `mcp` Keycloak compose;
desktop/Tauri RelayState (`TauriSamlUtils` preserved). Multi-provider (google/github) OAuth uses
the same pattern keyed by registrationId.
### F-old. (superseded) original SAML/SSO scoping
The `validate-*-test.sh` scripts are **endpoint-existence
checks** (Keycloak up + Stirling serves the SSO endpoint), not full browser logins.
Prereqs for any run: the compose files use `image: docker.stirlingpdf.com/.../stirling-pdf:latest`
(the published Spring image) — **repoint to `stirling-pdf-quarkus:jwt`** (or wire
`docker/quarkus/Dockerfile`). The SAML compose mounts `saml-private-key.key`/`saml-public-cert.crt`/
`keycloak-saml-cert.pem` which **do not exist in the repo** — generate them (the SP signing
key/cert; `start-saml-test.sh` may do this). SAML/OAuth need the **Enterprise license** env.
**OAuth2 / OIDC** (more tractable — Quarkus has `quarkus-oidc`):
- [ ] Extend `ApplicationPropertiesConfigOverlay` for `security.oauth2.*` (client issuer/clientId/
clientSecret/scopes/useAsUsername) — currently only the `enabled` toggle is bound.
- [ ] Serve `GET /oauth2/authorization/{registrationId}` → 302 to the IdP authorize URL (login
initiation; the OAuth `validate` script checks this responds). Build from issuer + clientId +
redirect-uri `/login/oauth2/code/{registrationId}`.
- [ ] Serve the callback `GET /login/oauth2/code/{registrationId}` → exchange code (REST Client to
the token endpoint), fetch userinfo, auto-create/login the user (reuse `CustomOAuth2UserService`
logic), issue the app JWT via `JwtService`. `quarkus.oidc.enabled` is **build-time** and aborts
startup with no `auth-server-url`, so either hand-roll the flow (simplest, no build-time gate)
or enable oidc with a runtime-disabled default tenant.
**SAML2** (larger — no Quarkus SAML extension; OpenSAML 5 from scratch):
- [ ] `Saml2Configuration` already loads the SP/IdP certs and computes entityId/ACS/SLO URLs and
customizes the AuthnRequest (all preserved). Build on it:
- [ ] `GET /saml2/service-provider-metadata/{registrationId}` → SP `EntityDescriptor` XML
(ACS=`/login/saml2/sso/{id}`, SP signing cert) marshalled via OpenSAML 5. (The SAML `validate`
script checks this.)
- [ ] login initiation → build+sign an `AuthnRequest` (use `customizeAuthnRequest`) and
redirect/POST to `samlConf.getIdpSingleLoginUrl()`.
- [ ] `POST /login/saml2/sso/{registrationId}` (ACS) → validate the SAML response/assertion against
the IdP cert, extract the NameID/attributes, auto-create/login the user, issue the app JWT.
- Host these as Jakarta `@WebServlet` (quarkus-undertow) or JAX-RS resources; gate on
`security.saml2.enabled`.
- [ ] Both flows then feed the existing `UserSecurityIdentityAugmentor` (principal=User) once they
establish the session/JWT.
### G. `saas` flavor full augmentation (optional, non-default)
- [ ] `STIRLING_FLAVOR=saas ./gradlew :stirling-pdf:quarkusBuild` → ~28 Arc deployment problems
(Supabase second datasource via `quarkus.datasource."supabase".*`, `SecurityFilterChain`/
`JwtDecoder` → `quarkus.http.auth.*`+OIDC, credit `HandlerInterceptor`/`@RestControllerAdvice`
→ JAX-RS `@Provider`/`ExceptionMapper`, `@ConfigurationProperties` → `@ConfigMapping`,
RestTemplate → REST Client). ~90 `// TODO: Migration required` across 34 saas files.
### H. Test suite (unit/integration) re-enablement
- [ ] ~180 test files are excluded from compilation (content filter on `org.springframework`/
`com.nimbusds` imports + an explicit list in `build.gradle`). Port them to `@QuarkusTest`
incrementally; as a file's Spring imports go away it auto-re-enters the build.
### I. Loose ends
- [ ] `/q/openapi` returns 500 (`UT000048`) — known quarkus-undertow + smallrye-openapi interaction;
swagger-ui works, live API works. Affects API-doc tooling only.
- [ ] Jackson 2/3 convergence (drop `tools.jackson`).
- [ ] ~437 `// TODO: Migration required` markers across the codebase document every deferred decision;
`grep -rn "TODO: Migration required" app/*/src/main` to enumerate.
---
## 7. Quick reference — env vars used in e2e
| Var | Value | Why |
|-----|-------|-----|
| `SECURITY_ENABLELOGIN` | `false` | run without auth (most API tests); set `true` for the JWT suite |
| `METRICS_ENABLED` | `true` | enables `/api/v1/info/*` (info.feature) |
| `SYSTEM_DEFAULTLOCALE` | `en-US` | matches default-language change |
| `SYSTEM_MAXFILESIZE` | `100` | upload limit for tests |
| `QUARKUS_HTTP_PORT` | `8080` | cucumber steps hardcode 8080 |
| `QUARKUS_DATASOURCE_JDBC_URL` | `jdbc:h2:mem:...` | use a fresh in-mem DB for clean runs (avoids stale H2 file lock) |
Default datasource (in `application.properties`) is **H2 file** at
`./configs/stirling-pdf-DB-2.3.232` — fine in a container; for repeated host runs override to
`jdbc:h2:mem:...` to dodge the file lock (`Database may be already in use`).
---
## 8. Useful diagnostic one-liners
```bash
# container alive + real error (strip ANSI, drop known background noise)
docker logs sp-e2e 2>&1 | sed 's/\x1b\[[0-9;]*m//g' \
| grep -iE "ERROR|Caused by|Exception" \
| grep -viE "Log4j|LogManager|ForkJoinPool|FolderWatch|ScheduleTrigger|policy-" | tail -30
# categorize cucumber failures
cd testing/cucumber && TEST_CONTAINER_NAME=sp-e2e python -m behave --no-capture --format plain --no-skipped > /tmp/behave.txt 2>&1
grep -oE "Expected status code [0-9]+ but got [0-9]+" /tmp/behave.txt | sort | uniq -c | sort -rn
grep -oE "features/[a-z_]+\.feature" /tmp/behave.txt | sort | uniq -c | sort -rn # rough; use a junit reporter for precise
# what still touches the servlet request
grep -rln "HttpServletRequest" app/*/src/main --include=*.java
# enumerate deferred work
grep -rn "TODO: Migration required" app/*/src/main --include=*.java | wc -l
```
---
## 9. Measured cucumber results — newest first
**Run 5 — LOGIN ON (`SECURITY_ENABLELOGIN=true V2=true STORAGE_ENABLED=true
SECURITY_CUSTOMGLOBALAPIKEY=123456789`):**
```
18 features passed, 7 failed, 0 skipped
304 scenarios passed, 34 failed, 0 skipped <-- folders + user-scoped features now pass
```
X-API-KEY mechanism + User-principal augmentor + config overlay made login-ON work without
regressing the open endpoints (0 folder failures). Trajectory: **183 → 223 → 272 → 291 → 304**.
**Run 4 — login off + lockout fix:** `291 passed, 47 failed, 0 skipped`.
**Run 3 — login off + `V2=true` + JWT Bearer mechanism (Session 2):**
```
17 features passed, 8 failed, 0 skipped
272 scenarios passed, 66 failed, 0 skipped <-- 0 skipped: all JWT/admin scenarios now run
```
The JWT mechanism unskipped all 80 and added +49 passing over run 2 with no regressions. Remaining
66 failures, biggest buckets:
- **~38 = login-lockout cascade (FIXED, pending re-measure).** `loginAttemptCount` defaulted to 0
(template = 5) → admin locked after one failed-login test → every later admin scenario blocked
("Admin login failed" 21×, "Folder list returned" 17×). Fixed the primitive default (same as
maxDPI). **Re-run to confirm; expect ~300+.**
- 10×(200→403) feature-gated/disabled (mostly not bugs).
- 5×(200→401) + 2×(401→403) — auth scenarios asserting specific codes; triage individually.
- 3×(200→500) — real per-endpoint bugs (e.g. `user/get-api-key`). Triage via container logs.
**Run 2 — login off, Session 2 fixes, no JWT mechanism:**
```
16 features passed, 5 failed, 4 skipped
223 scenarios passed, 35 failed, 80 skipped
```
**Run 1 — original baseline (login off):**
```
183 scenarios passed, 75 failed, 80 skipped
```
Trajectory this session: **183 → 223 (boot/login/test fixes) → 272 (JWT mechanism), 0 skipped.**
+1 -1
View File
@@ -60,7 +60,7 @@ For full installation options (including desktop and Kubernetes), see our [Docum
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task install` to get started, or see the [Developer Guide](DeveloperGuide.md) for full details.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task dev` to get started running the editor, run `task` to see the most common commands, or see the [Developer Guide](DeveloperGuide.md) for full details.
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
+23
View File
@@ -25,8 +25,28 @@ includes:
e2e:
taskfile: .taskfiles/e2e.yml
dir: .
pre-commit:
taskfile: .taskfiles/pre-commit.yml
dir: .
tasks:
# ============================================================
# Help (shown when you run `task` with no arguments)
# ============================================================
default:
desc: "List the most common commands"
silent: true
cmds:
- |
echo "Common commands (run 'task --list' to see all):"
echo ""
echo " task dev Start backend & frontend on free ports"
echo " task backend:dev Start backend on default port"
echo " task frontend:dev Start frontend on default port"
echo " task desktop:dev Start desktop app"
echo " task check Quality gate (lint, typecheck, test, etc.)"
# ============================================================
# Setup & Prerequisites
# ============================================================
@@ -87,6 +107,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
AIENGINE_ENABLED: "true"
- task: 'frontend:dev:{{.FRONTEND}}'
vars:
PORT: '{{.FRONTEND_PORT}}'
@@ -171,4 +192,6 @@ tasks:
desc: "Clean all build artifacts"
cmds:
- task: backend:clean
- task: frontend:clean
- task: engine:clean
- task: pre-commit:clean
+4
View File
@@ -1,5 +1,9 @@
{
"allowedLicenses": [
{
"moduleName": "org.jboss:jboss-transaction-spi",
"moduleLicense": "Public Domain"
},
{
"moduleName": ".*",
"moduleLicense": "BSD License"
+34 -7
View File
@@ -1,7 +1,4 @@
// Configure bootRun to disable it or point to a main class
bootRun {
enabled = false
}
// REMOVED: bootRun{enabled=false} - Spring Boot plugin task. :common is a Quarkus library module.
spotless {
java {
target 'src/**/java/**/*.java'
@@ -30,8 +27,29 @@ spotless {
}
dependencies {
api 'com.google.guava:guava:33.6.0-jre'
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
// spring-boot-starter-webmvc -> Quarkus REST stack (api-scoped so downstream modules inherit it).
api 'io.quarkus:quarkus-rest'
api 'io.quarkus:quarkus-rest-jackson'
// Servlet bridge: large amounts of controller/filter code use jakarta.servlet (HttpServletRequest,
// Filter, etc.). quarkus-undertow provides a servlet container on Quarkus so that API resolves and
// runs. TODO: Migration required - longer term, port servlet usage to JAX-RS (ContainerRequestContext)
// and drop quarkus-undertow.
api 'io.quarkus:quarkus-undertow'
// Bean Validation (was transitively in spring-boot-starter-webmvc).
api 'io.quarkus:quarkus-hibernate-validator'
// @Scheduled support (was spring-context scheduling). quarkus-scheduler manages its own
// executor; the former SchedulingConfig TaskScheduler bean is no longer needed.
api 'io.quarkus:quarkus-scheduler'
// BCrypt implementation backing the Spring Security PasswordEncoder compatibility shim
// (replaces spring-security-crypto's BCryptPasswordEncoder). Standalone, no framework.
api 'at.favre.lib:bcrypt:0.10.2'
// Swagger/OpenAPI annotations (io.swagger.v3.oas.annotations.*) used by common's API marker
// interfaces; was transitive via springdoc. Quarkus' SmallRye OpenAPI also understands these.
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
// REMOVED: spring-boot-starter-aspectj. Quarkus has no AspectJ weaving; quarkus-arc provides
// CDI interceptors (@AroundInvoke / interceptor bindings) instead.
// TODO: Migration required - any @Aspect/@Around advice must be rewritten as CDI interceptors.
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
@@ -45,7 +63,8 @@ dependencies {
api 'com.github.junrar:junrar:7.5.10' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// springdoc-openapi-starter-webmvc-ui -> SmallRye OpenAPI (schema at /q/openapi, UI at /q/swagger-ui)
api 'io.quarkus:quarkus-smallrye-openapi'
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
@@ -78,6 +97,14 @@ dependencies {
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
}
// Jackson 3 (tools.jackson) - retained because ~100 files migrated to the Jackson 3 namespace
// under Spring Boot 4. Quarkus integrates Jackson 2 for REST bodies; Jackson 3 coexists here as a
// plain library so those files compile and can still build/parse JSON directly.
// api-scoped so downstream modules (core, proprietary, saas) that import tools.jackson inherit it.
// TODO: Migration required - converge the codebase on a single Jackson major version.
api 'tools.jackson.core:jackson-databind:3.0.0'
api 'tools.jackson.core:jackson-core:3.0.0'
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
@@ -6,15 +6,16 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Service
@ApplicationScoped
@Slf4j
public class EndpointConfiguration {
@@ -52,9 +53,10 @@ public class EndpointConfiguration {
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
@Inject
public EndpointConfiguration(
ApplicationProperties applicationProperties,
@Qualifier("runningProOrHigher") boolean runningProOrHigher) {
@Named("runningProOrHigher") boolean runningProOrHigher) {
this.applicationProperties = applicationProperties;
this.runningProOrHigher = runningProOrHigher;
init();
@@ -9,7 +9,8 @@ import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -47,7 +48,7 @@ import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
* <li>Rotated tables (90°/270° pages) may produce incorrect bounds.
* </ul>
*/
@Service
@ApplicationScoped
@Slf4j
public class TabulaTableParser implements TableParser {
@@ -2,21 +2,27 @@ package stirling.software.common.annotations;
import java.lang.annotation.*;
import org.springframework.core.annotation.AliasFor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import jakarta.enterprise.util.Nonbinding;
import jakarta.interceptor.InterceptorBinding;
import jakarta.ws.rs.core.MediaType;
/**
* Shortcut for a POST endpoint that is executed through the Stirling "auto‑job" framework.
*
* <p>MIGRATION (Spring -> Quarkus): this was a Spring composed meta-annotation that stamped
* {@code @RequestMapping(method=POST)} onto the target via {@code @AliasFor}. JAX-RS does not
* honour {@code @Path}/{@code @POST}/{@code @Consumes} through meta-annotations, so this annotation
* no longer provides routing. It is now a CDI {@link InterceptorBinding} handled by {@code
* AutoJobInterceptor}. <b>Controllers using {@code @AutoJobPostMapping} must additionally declare
* their own JAX-RS {@code @POST} + {@code @Path(value)} + {@code @Consumes(consumes)}.</b> The
* {@link #value()}/{@link #consumes()} members are retained so a scanner/controller can read the
* intended routing.
*
* <p>Behaviour notes:
*
* <ul>
* <li>The endpoint is registered with {@code POST} and, by default, consumes {@code
* multipart/form-data} unless you override {@link #consumes()}.
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
* stirling.software.common.service.JobExecutorService JobExecutorService} where it may be
* queued, retried, tracked and subject to time‑outs. For synchronous (default) invocations
@@ -26,22 +32,26 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
* GET /api/v1/general/job/{id}</code>.
* </ul>
*
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution.
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution. All members are
* {@code @Nonbinding} so the single {@code AutoJobInterceptor} matches every annotated method; the
* interceptor reads the actual values reflectively from the target method.
*/
@Target(ElementType.METHOD)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.POST)
@InterceptorBinding
@RequestBody(required = true)
public @interface AutoJobPostMapping {
/** Alias for {@link RequestMapping#value} – the path mapping of the endpoint. */
@AliasFor(annotation = RequestMapping.class, attribute = "value")
/**
* The path mapping of the endpoint (controllers must mirror this on a JAX-RS {@code @Path}).
*/
@Nonbinding
String[] value() default {};
/** MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. */
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
String[] consumes() default {MediaType.MULTIPART_FORM_DATA_VALUE};
@Nonbinding
String[] consumes() default {MediaType.MULTIPART_FORM_DATA};
/**
* Maximum execution time in milliseconds before the job is aborted. A negative value means "use
@@ -49,6 +59,7 @@ public @interface AutoJobPostMapping {
*
* <p>Only honoured when {@code async=true}.
*/
@Nonbinding
long timeout() default -1;
/**
@@ -57,6 +68,7 @@ public @interface AutoJobPostMapping {
*
* <p>Only honoured when {@code async=true}.
*/
@Nonbinding
int retryCount() default 1;
/**
@@ -64,6 +76,7 @@ public @interface AutoJobPostMapping {
*
* <p>Only honoured when {@code async=true}.
*/
@Nonbinding
boolean trackProgress() default true;
/**
@@ -72,6 +85,7 @@ public @interface AutoJobPostMapping {
*
* <p>Only honoured when {@code async=true}.
*/
@Nonbinding
boolean queueable() default false;
/**
@@ -82,5 +96,6 @@ public @interface AutoJobPostMapping {
* AutoJobPostMappingWeightTest} fails the build if any endpoint leaves it unset. Runtime
* readers clamp the value into {@code [1, 100]}.
*/
@Nonbinding
int resourceWeight() default Integer.MIN_VALUE;
}
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/account")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/account").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Account Security",
description =
@@ -5,19 +5,20 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* Combined annotation for Admin Settings API controllers.
* Includes @RestController, @RequestMapping("/api/v1/admin/settings"), and OpenAPI @Tag.
*
* <p>MIGRATION (Spring -> JAX-RS): JAX-RS/RESTEasy does NOT process {@code @Path} via custom
* meta-annotations (Spring honoured composed {@code @RestController}/{@code @RequestMapping}
* through {@code @AliasFor}; JAX-RS has no equivalent). This annotation therefore now carries only
* the OpenAPI {@code @Tag}. Each controller annotated with {@code @AdminApi} MUST additionally
* declare its own {@code @jakarta.ws.rs.Path("/api/v1/admin/settings")} (the path the removed
* {@code @RequestMapping} used to supply).
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/admin/settings")
@Tag(
name = "Admin Settings",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/admin/server-certificate")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/admin/server-certificate").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Admin - Server Certificate",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/analysis")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/analysis").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Analysis",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/config")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/config").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Config",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/convert")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/convert").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Convert",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/database")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/database").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Database",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/admin/database")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/admin/database").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Database Management",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/filter")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/filter").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Filter",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/general")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/general").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "General",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/info")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/info").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Info",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/invite").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Invite",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/misc")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/misc").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Misc",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/pipeline")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/pipeline").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Pipeline",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -17,8 +14,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/proprietary/ui-data")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/proprietary/ui-data").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Proprietary UI Data",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/security")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/security").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Security",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/settings")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/settings").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Settings",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/team")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/team").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "Team",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/ui-data")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/ui-data").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "UI Data",
description =
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/user")
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
// @jakarta.ws.rs.Path("/api/v1/user").
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
@Tag(
name = "User",
description =
@@ -7,46 +7,101 @@ import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.MDC;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
@Aspect
@Component
@RequiredArgsConstructor
/**
* MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} with {@code @Around} advice on
* {@code @AutoJobPostMapping}. Reworked into a CDI {@link Interceptor} bound by the
* {@code @AutoJobPostMapping} {@code @InterceptorBinding}; {@code @Around}/{@code
* ProceedingJoinPoint} became {@code @AroundInvoke}/{@link InvocationContext}.
* {@code @Priority(20)} now meaningfully orders this interceptor (runs after lower-priority audit
* interceptors populate MDC).
*/
@Interceptor
@AutoJobPostMapping
@Priority(20)
@Slf4j
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
public class AutoJobAspect {
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
private final JobExecutorService jobExecutorService;
private final HttpServletRequest request;
// Reactive-safe access to the current request. The undertow HttpServletRequest proxy throws
// UT000048 ("No request is currently active") on RESTEasy Reactive worker threads, so query
// params / method / path / attributes are read from the Vert.x request instead, degrading to
// null/empty when no request is active.
private final CurrentVertxRequest currentVertxRequest;
private final FileStorage fileStorage;
@Around("@annotation(autoJobPostMapping)")
public Object wrapWithJobExecution(
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) throws Exception {
// This aspect will run before any audit aspects due to @Order(0)
@Inject
public AutoJobAspect(
JobExecutorService jobExecutorService,
CurrentVertxRequest currentVertxRequest,
FileStorage fileStorage) {
this.jobExecutorService = jobExecutorService;
this.currentVertxRequest = currentVertxRequest;
this.fileStorage = fileStorage;
}
private io.vertx.core.http.HttpServerRequest vertxRequest() {
try {
var current = currentVertxRequest.getCurrent();
return current != null ? current.request() : null;
} catch (RuntimeException e) {
return null;
}
}
private String requestParam(String name) {
io.vertx.core.http.HttpServerRequest req = vertxRequest();
return req != null ? req.getParam(name) : null;
}
private String requestMethod() {
io.vertx.core.http.HttpServerRequest req = vertxRequest();
return req != null ? req.method().name() : "";
}
private String requestUri() {
io.vertx.core.http.HttpServerRequest req = vertxRequest();
return req != null ? req.path() : "";
}
private Object requestAttribute(String name) {
try {
var current = currentVertxRequest.getCurrent();
return current != null ? current.get(name) : null;
} catch (RuntimeException e) {
return null;
}
}
@AroundInvoke
public Object wrapWithJobExecution(InvocationContext ctx) throws Exception {
AutoJobPostMapping autoJobPostMapping =
ctx.getMethod().getAnnotation(AutoJobPostMapping.class);
// Extract parameters from the request and annotation
boolean async = Boolean.parseBoolean(request.getParameter("async"));
boolean async = Boolean.parseBoolean(requestParam("async"));
log.debug(
"AutoJobAspect: Processing {} {} with async={}",
request.getMethod(),
request.getRequestURI(),
requestMethod(),
requestUri(),
async);
long timeout = autoJobPostMapping.timeout();
int retryCount = autoJobPostMapping.retryCount();
@@ -61,7 +116,8 @@ public class AutoJobAspect {
trackProgress);
// Process arguments in-place to avoid type mismatch issues
Object[] args = processArgsInPlace(joinPoint.getArgs(), async);
Object[] args = processArgsInPlace(ctx.getParameters(), async);
ctx.setParameters(args);
// Extract queueable and resourceWeight parameters and validate
boolean queueable = autoJobPostMapping.queueable();
@@ -80,7 +136,7 @@ public class AutoJobAspect {
// The trackProgress flag controls whether detailed progress is
// stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
return ctx.proceed();
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
@@ -101,7 +157,7 @@ public class AutoJobAspect {
} else {
// Use retry logic
return executeWithRetries(
joinPoint,
ctx,
args,
async,
timeout,
@@ -113,7 +169,7 @@ public class AutoJobAspect {
}
private Object executeWithRetries(
ProceedingJoinPoint joinPoint,
InvocationContext ctx,
Object[] args,
boolean async,
long timeout,
@@ -158,7 +214,7 @@ public class AutoJobAspect {
}
// Attempt to execute the operation
return joinPoint.proceed(args);
return ctx.proceed();
} catch (Throwable ex) {
lastException = ex;
@@ -292,7 +348,7 @@ public class AutoJobAspect {
private String getJobIdFromContext() {
try {
return (String) request.getAttribute("jobId");
return (String) requestAttribute("jobId");
} catch (Exception e) {
log.debug("Could not retrieve job ID from context: {}", e.getMessage());
return null;
@@ -1,8 +1,7 @@
package stirling.software.common.cluster;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -19,7 +18,7 @@ import stirling.software.common.model.ApplicationProperties.Cluster;
* single-instance install needs no new config.
*/
@Slf4j
@Configuration
@ApplicationScoped
@RequiredArgsConstructor
public class ClusterConfig {
@@ -47,7 +46,7 @@ public class ClusterConfig {
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
+ " Use backplane=valkey for real multi-node deployments.");
} else {
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
// Fail fast on typos like "valky" so CDI doesn't later report a cryptic
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
throw new IllegalStateException(
"cluster.enabled=true with unknown backplane '"
@@ -1,9 +1,9 @@
package stirling.software.common.cluster.inprocess;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.quarkus.arc.DefaultBean;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import lombok.extern.slf4j.Slf4j;
@@ -19,46 +19,58 @@ import stirling.software.common.model.ApplicationProperties;
* Default cluster backplane wiring: every interface gets an {@code InProcess*} bean. Active when
* cluster mode is off or {@code cluster.backplane=inprocess}.
*/
// TODO: Migration required - the original @ConditionalOnExpression
// ("!${cluster.enabled:false} || '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')")
// gated activation of this whole configuration on a SpEL expression over two config properties.
// Quarkus/CDI has no direct equivalent for conditionally registering a producer set based on a
// SpEL boolean. The @DefaultBean producers below now always provide the in-process implementations
// unless another bean of the same type is present. If a non-inprocess backplane is added, ensure
// it is NOT a @DefaultBean so it wins, and consider gating with
// @io.quarkus.arc.lookup.LookupIfProperty
// / @io.quarkus.arc.lookup.LookupUnlessProperty or a build-time @IfBuildProperty per producer.
@Slf4j
@Configuration
@ConditionalOnExpression(
"!${cluster.enabled:false} ||"
+ " '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')")
@ApplicationScoped
public class InProcessClusterConfiguration {
@Bean
@ConditionalOnMissingBean
@Produces
@DefaultBean
@ApplicationScoped
public ClusterBackplane clusterBackplane(ApplicationProperties applicationProperties) {
log.info("Cluster backplane: in-process (single node)");
return new InProcessClusterBackplane(applicationProperties);
}
@Bean
@ConditionalOnMissingBean
@Produces
@DefaultBean
@ApplicationScoped
public JobStore jobStore() {
return new InProcessJobStore();
}
@Bean
@ConditionalOnMissingBean
@Produces
@DefaultBean
@ApplicationScoped
public RateLimitStore rateLimitStore() {
return new InProcessRateLimitStore();
}
@Bean
@ConditionalOnMissingBean
@Produces
@DefaultBean
@ApplicationScoped
public DistributedLock distributedLock() {
return new InProcessDistributedLock();
}
@Bean
@ConditionalOnMissingBean
@Produces
@DefaultBean
@ApplicationScoped
public KeyValueCache keyValueCache() {
return new InProcessKeyValueCache();
}
@Bean
@ConditionalOnMissingBean
@Produces
@DefaultBean
@ApplicationScoped
public InstanceRegistry instanceRegistry() {
return new InProcessInstanceRegistry();
}
@@ -1,10 +1,11 @@
package stirling.software.common.cluster.inprocess;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.DefaultBean;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import stirling.software.common.cluster.FileStore;
@@ -13,17 +14,23 @@ import stirling.software.common.cluster.FileStore;
* cluster.artifactStore=local} (the default; {@code matchIfMissing=true}). The S3 artifact-store
* supplies its own bean when {@code cluster.artifactStore=s3}.
*/
@Configuration
@ConditionalOnProperty(
prefix = "cluster",
name = "artifactStore",
havingValue = "local",
matchIfMissing = true)
// TODO: Migration required - the original class was guarded by Spring's
// @ConditionalOnProperty(prefix="cluster", name="artifactStore", havingValue="local",
// matchIfMissing=true). Quarkus has no runtime equivalent: @io.quarkus.arc.profile.IfBuildProperty
// is build-time only and does not support matchIfMissing semantics. The producer below is now
// unconditional. The "local is the default; S3 supplies its own bean" behavior is preserved via
// @DefaultBean (the S3 artifact-store bean, if present, wins over this default). If a true
// runtime toggle on cluster.artifactStore is needed, gate the producer body on the config value
// and return/short-circuit accordingly.
@ApplicationScoped
public class LocalDiskFileStoreConfiguration {
@Bean
@ConditionalOnMissingBean
public FileStore fileStore(@Value("${stirling.tempDir:/tmp/stirling-files}") String tempDir) {
@Produces
@DefaultBean
@ApplicationScoped
public FileStore fileStore(
@ConfigProperty(name = "stirling.tempDir", defaultValue = "/tmp/stirling-files")
String tempDir) {
return new LocalDiskFileStore(tempDir);
}
}
@@ -3,37 +3,29 @@ package stirling.software.common.config;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.TempFileRegistry;
/**
* Configuration for the temporary file management system. Sets up the necessary beans and
* configures system properties.
*/
@Slf4j
@Configuration
@ApplicationScoped
@RequiredArgsConstructor
public class TempFileConfiguration {
private final ApplicationProperties applicationProperties;
/**
* Create the TempFileRegistry bean.
*
* @return A new TempFileRegistry instance
*/
@Bean
public TempFileRegistry tempFileRegistry() {
return new TempFileRegistry();
}
// MIGRATION: the @Produces TempFileRegistry producer was removed. TempFileRegistry is already
// an
// @ApplicationScoped CDI bean with a no-arg constructor, so the producer was a redundant second
// @Default bean of the same type and made every injection point ambiguous.
@PostConstruct
public void initTempFileConfig() {
@@ -5,8 +5,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Component;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -14,12 +14,12 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileRegistry;
/**
* Handles cleanup of temporary files on application shutdown. Implements Spring's DisposableBean
* interface to ensure cleanup happens during normal application shutdown.
* Handles cleanup of temporary files on application shutdown. Uses a CDI {@code @PreDestroy} method
* (migrated from Spring's {@code DisposableBean}) to ensure cleanup happens during normal shutdown.
*/
@Slf4j
@Component
public class TempFileShutdownHook implements DisposableBean {
@ApplicationScoped
public class TempFileShutdownHook {
private final TempFileRegistry registry;
@@ -31,8 +31,8 @@ public class TempFileShutdownHook implements DisposableBean {
Runtime.getRuntime().addShutdownHook(new Thread(this::cleanupTempFiles));
}
/** Spring's DisposableBean interface method. Called during normal application shutdown. */
@Override
/** CDI pre-destroy callback (was DisposableBean#destroy). Called during normal shutdown. */
@PreDestroy
public void destroy() {
log.info("Application shutting down, cleaning up temporary files");
cleanupTempFiles();
@@ -9,40 +9,69 @@ import java.util.Properties;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.context.annotation.Scope;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.ClassUtils;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Lazy
/**
* Central CDI producer hub (migrated from a Spring {@code @Configuration} class).
*
* <p>MIGRATION NOTES (Spring -> Quarkus CDI):
*
* <ul>
* <li>{@code @Bean} -> {@code @Produces}; {@code @Bean(name="x")} ->
* {@code @Produces @Named("x")}.
* <li>{@code @Value} -> {@code @ConfigProperty}; Spring {@code Environment} -> MicroProfile
* {@code Config}.
* <li>{@code @Profile("default")} flavor-default beans -> {@code @DefaultBean}: the :proprietary
* / :saas modules provide the "real" producer and automatically win when present, exactly
* like the old profile override (this is the Quarkus idiom for "default unless overridden").
* <li>{@code @Scope("request")} on {@code boolean} producers -> {@code @Dependent}. CDI normal
* scopes (e.g. {@code @RequestScoped}) require a client proxy, which is impossible for
* primitives/finals, so Spring's request-scoped primitive beans cannot be reproduced
* directly. {@code @Dependent} recomputes the value at each injection point, which is the
* closest behaviour. TODO: Migration required - if true per-HTTP-request semantics are
* needed, wrap the value in a {@code @RequestScoped} holder object instead of producing a
* bare boolean.
* <li>{@code @Lazy} dropped - CDI beans are initialised lazily by default.
* </ul>
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
@ApplicationScoped
public class AppConfig {
private final Environment env;
private final Config config;
private final ApplicationProperties applicationProperties;
@Getter
@Value("${server.servlet.context-path:/}")
private String contextPath;
@ConfigProperty(name = "server.servlet.context-path", defaultValue = "/")
String contextPath;
@Getter
@Value("${server.port:8080}")
private String serverPort;
@ConfigProperty(name = "quarkus.http.port", defaultValue = "8080")
String serverPort;
@ConfigProperty(name = "v2")
boolean v2Enabled;
@Inject
public AppConfig(Config config, ApplicationProperties applicationProperties) {
this.config = config;
this.applicationProperties = applicationProperties;
}
/**
* Get the backend URL from system configuration. Falls back to http://localhost if not
@@ -55,76 +84,111 @@ public class AppConfig {
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
}
@Value("${v2}")
public boolean v2Enabled;
@Bean
@Produces
@Named("v2Enabled")
public boolean v2Enabled() {
return v2Enabled;
}
@Bean(name = "loginEnabled")
// MIGRATION: many beans inject tools.jackson.databind.ObjectMapper (Jackson 3, inherited from
// Spring Boot 4). Quarkus' container only produces a com.fasterxml.jackson (Jackson 2)
// ObjectMapper for REST (de)serialization, so the Jackson 3 type is an unsatisfied CDI
// dependency. This producer supplies a single application-scoped Jackson 3 mapper built the
// same
// way the codebase builds them ad hoc (JsonMapper.builder().build()). REST bodies still go
// through Quarkus' Jackson 2 mapper; this is only for code that uses the Jackson 3 API
// directly.
// TODO: Migration required - converge the codebase on one Jackson line (drop Jackson 3) later.
@Produces
@ApplicationScoped
public tools.jackson.databind.ObjectMapper jackson3ObjectMapper() {
return tools.jackson.databind.json.JsonMapper.builder().build();
}
@Produces
@Named("contextPath")
public String contextPathBean() {
return contextPath;
}
@Produces
@Named("loginEnabled")
public boolean loginEnabled() {
return applicationProperties.getSecurity().isEnableLogin();
}
@Bean(name = "appName")
// MIGRATION: CDI has no producer for the nested ApplicationProperties.Security.SAML2 config
// object, so beans that inject it directly (e.g. CustomSaml2AuthenticationSuccessHandler) were
// unsatisfied. Expose it from the already-injected ApplicationProperties. May be null/disabled;
// that is fine for injection.
@Produces
public ApplicationProperties.Security.SAML2 saml2Config() {
return applicationProperties.getSecurity().getSaml2();
}
@Produces
@Named("appName")
public String appName() {
return "Stirling PDF";
}
@Bean(name = "appVersion")
@Produces
@Named("appVersion")
public String appVersion() {
Resource resource = new ClassPathResource("version.properties");
// MIGRATION: Spring ClassPathResource -> plain classloader resource lookup.
Properties props = new Properties();
try {
props.load(resource.getInputStream());
return props.getProperty("version");
try (var in = getClass().getClassLoader().getResourceAsStream("version.properties")) {
if (in != null) {
props.load(in);
return props.getProperty("version");
}
} catch (IOException e) {
log.error("exception", e);
}
return "0.0.0";
}
@Bean(name = "homeText")
@Produces
@Named("homeText")
public String homeText() {
return "null";
}
@Bean(name = "languages")
@Produces
@Named("languages")
public List<String> languages() {
return applicationProperties.getUi().getLanguages();
}
@Bean
public String contextPath(@Value("${server.servlet.context-path}") String contextPath) {
return contextPath;
}
@Bean(name = "navBarText")
@Produces
@Named("navBarText")
public String navBarText() {
String navBar = applicationProperties.getUi().getAppNameNavbar();
return (navBar != null) ? navBar : "Stirling PDF";
}
@Bean(name = "enableAlphaFunctionality")
@Produces
@Named("enableAlphaFunctionality")
public boolean enableAlphaFunctionality() {
return applicationProperties.getSystem().isEnableAlphaFunctionality();
}
@Bean(name = "rateLimit")
@Produces
@Named("rateLimit")
public boolean rateLimit() {
String rateLimit = System.getProperty("rateLimit");
if (rateLimit == null) rateLimit = System.getenv("rateLimit");
return Boolean.parseBoolean(rateLimit);
}
@Bean(name = "RunningInDocker")
@Produces
@Named("RunningInDocker")
public boolean runningInDocker() {
return Files.exists(Path.of("/.dockerenv"));
}
@Bean(name = "configDirMounted")
@Produces
@Named("configDirMounted")
public boolean isRunningInDockerWithConfig() {
Path dockerEnv = Path.of("/.dockerenv");
// default to true if not docker
@@ -143,14 +207,23 @@ public class AppConfig {
}
}
@Bean(name = "activeSecurity")
@Produces
@Named("activeSecurity")
public boolean missingActiveSecurity() {
return ClassUtils.isPresent(
"stirling.software.proprietary.security.configuration.SecurityConfiguration",
this.getClass().getClassLoader());
// MIGRATION: Spring ClassUtils.isPresent -> manual Class.forName presence check.
try {
Class.forName(
"stirling.software.proprietary.security.configuration.SecurityConfiguration",
false,
this.getClass().getClassLoader());
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
@Bean(name = "directoryFilter")
@Produces
@Named("directoryFilter")
public Predicate<Path> processOnlyFiles() {
return path -> {
if (Files.isDirectory(path)) {
@@ -161,113 +234,138 @@ public class AppConfig {
};
}
@Bean(name = "termsAndConditions")
@Produces
@Named("termsAndConditions")
public String termsAndConditions() {
return applicationProperties.getLegal().getTermsAndConditions();
}
@Bean(name = "privacyPolicy")
@Produces
@Named("privacyPolicy")
public String privacyPolicy() {
return applicationProperties.getLegal().getPrivacyPolicy();
}
@Bean(name = "cookiePolicy")
@Produces
@Named("cookiePolicy")
public String cookiePolicy() {
return applicationProperties.getLegal().getCookiePolicy();
}
@Bean(name = "impressum")
@Produces
@Named("impressum")
public String impressum() {
return applicationProperties.getLegal().getImpressum();
}
@Bean(name = "accessibilityStatement")
@Produces
@Named("accessibilityStatement")
public String accessibilityStatement() {
return applicationProperties.getLegal().getAccessibilityStatement();
}
@Bean(name = "analyticsPrompt")
@Scope("request")
@Produces
@Dependent
@Named("analyticsPrompt")
public boolean analyticsPrompt() {
return applicationProperties.getSystem().getEnableAnalytics() == null;
}
@Bean(name = "analyticsEnabled")
@Scope("request")
@Produces
@Dependent
@Named("analyticsEnabled")
public boolean analyticsEnabled() {
if (applicationProperties.getPremium().isEnabled()) return true;
return applicationProperties.getSystem().isAnalyticsEnabled();
}
@Bean(name = "StirlingPDFLabel")
@Produces
@Named("StirlingPDFLabel")
public String stirlingPDFLabel() {
return "Stirling-PDF" + " v" + appVersion();
}
@Bean(name = "UUID")
@Produces
@Named("UUID")
public String uuid() {
return applicationProperties.getAutomaticallyGenerated().getUUID();
}
@Bean
@Produces
public ApplicationProperties.Security security() {
return applicationProperties.getSecurity();
}
@Bean
@Produces
public ApplicationProperties.Security.OAUTH2 oAuth2() {
return applicationProperties.getSecurity().getOauth2();
}
@Bean
@Produces
public ApplicationProperties.Premium premium() {
return applicationProperties.getPremium();
}
@Bean
@Produces
public ApplicationProperties.System system() {
return applicationProperties.getSystem();
}
@Bean
@Produces
public ApplicationProperties.Datasource datasource() {
return applicationProperties.getSystem().getDatasource();
}
@Bean(name = "runningProOrHigher")
@Profile("default")
// @IfBuildProfile("core"): these NORMAL/default license @Named beans apply only to the core
// flavor. In proprietary EEAppConfig provides them (security profile) and in saas
// SaasLicenseOverride does (saas profile); registering this producer alongside those trips
// Qute's named-bean validation ("Duplicate key runningEE"), which does not honour @DefaultBean
// suppression - so gate to core outright. (In core, EEAppConfig/SaasLicenseOverride are not
// even on the classpath.)
@Produces
@IfBuildProfile("core")
@Named("runningProOrHigher")
public boolean runningProOrHigher() {
return false;
}
@Bean(name = "runningEE")
@Profile("default")
@Produces
@IfBuildProfile("core")
@Named("runningEE")
public boolean runningEnterprise() {
return false;
}
@Bean(name = "license")
@Profile("default")
@Produces
@IfBuildProfile("core")
@Named("license")
public String licenseType() {
return "NORMAL";
}
@Bean(name = "scarfEnabled")
@Produces
@Named("scarfEnabled")
public boolean scarfEnabled() {
return applicationProperties.getSystem().isScarfEnabled();
}
@Bean(name = "posthogEnabled")
@Produces
@Named("posthogEnabled")
public boolean posthogEnabled() {
return applicationProperties.getSystem().isPosthogEnabled();
}
@Bean(name = "machineType")
@Produces
@Named("machineType")
public String determineMachineType() {
try {
boolean isDocker = runningInDocker();
boolean isKubernetes = System.getenv("KUBERNETES_SERVICE_HOST") != null;
boolean isBrowserOpen = "true".equalsIgnoreCase(env.getProperty("BROWSER_OPEN"));
boolean isBrowserOpen =
"true"
.equalsIgnoreCase(
config.getOptionalValue("BROWSER_OPEN", String.class)
.orElse(null));
if (isKubernetes) {
return "Kubernetes";
@@ -0,0 +1,193 @@
package stirling.software.common.configuration;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.ConfigProvider;
import io.quarkus.arc.ClientProxy;
import io.quarkus.runtime.StartupEvent;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.interceptor.Interceptor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Binds MicroProfile/Quarkus config (env vars, {@code settings.yml} via {@link
* SettingsYamlConfigSource}, {@code application.properties}, system properties) onto the mutable
* {@link ApplicationProperties} bean at startup - the Quarkus replacement for the Spring
* {@code @ConfigurationProperties(prefix = "")} relaxed binding that was lost in the migration.
*
* <p>Rather than hand-listing each property, this walks the whole {@code ApplicationProperties}
* object graph by reflection and, for every scalar / enum / scalar-list field, applies the value
* from config when one is present (so unset fields keep their Java default). The dotted key for a
* field mirrors its path in the tree ({@code security.oauth2.client.keycloak.clientId}, {@code
* endpoints.toRemove}, ...); SmallRye then resolves it from any source - e.g. env var {@code
* SECURITY_OAUTH2_CLIENT_KEYCLOAK_CLIENTID} or the same key in {@code settings.yml} - with the
* usual precedence (sys props &gt; env &gt; settings.yml &gt; application.properties).
*
* <p>This is the behaviour Spring had: every settings.yml / {@code SECURITY_*}/{@code STORAGE_*}
* /{@code PREMIUM_*} value is honoured, fixing the whole {@code maxDPI=0} / {@code enableLogin}
* /{@code endpoints.toRemove} / premium-license class of "ignored config" bugs at once.
*
* <p>Runs with {@code @Priority(APPLICATION)} so it completes before startup consumers read the
* bean: {@code InitialSecuritySetup} (enableLogin / customGlobalAPIKey), {@code
* EndpointConfiguration} (endpoints.toRemove), and {@code LicenseKeyChecker.onApplicationReady}
* (premium.enabled / premium.key, which has the lower default observer priority 2500).
*
* <p>Values are never logged - only key names at DEBUG and a total at INFO - because the tree
* carries secrets (premium key, client secrets, initial-login password, SMTP/Telegram tokens).
*/
@Slf4j
@ApplicationScoped
public class ApplicationPropertiesConfigOverlay {
private static final int MAX_DEPTH = 20;
@Inject ApplicationProperties applicationProperties;
void onStart(@Observes @Priority(Interceptor.Priority.APPLICATION) StartupEvent event) {
Config config = ConfigProvider.getConfig();
// ApplicationProperties is @ApplicationScoped, so the injected reference is a client proxy;
// reflect over the real contextual instance (its getters delegate, but getDeclaredFields()
// on the proxy would not see the model fields).
Object root = applicationProperties;
if (root instanceof ClientProxy proxy) {
root = proxy.arc_contextualInstance();
}
int[] applied = {0};
bind(root, "", config, 0, applied);
log.info(
"Applied {} configuration override(s) onto ApplicationProperties"
+ " (settings.yml + environment)",
applied[0]);
}
private void bind(Object node, String prefix, Config config, int depth, int[] applied) {
if (node == null || depth > MAX_DEPTH) {
return;
}
for (Field field : node.getClass().getDeclaredFields()) {
int mods = field.getModifiers();
if (Modifier.isStatic(mods) || field.isSynthetic()) {
continue;
}
String key = prefix.isEmpty() ? field.getName() : prefix + "." + field.getName();
Class<?> type = field.getType();
try {
field.setAccessible(true);
if (isModelType(type)) {
Object child = field.get(node);
if (child == null) {
child = instantiate(type);
if (child != null) {
field.set(node, child);
}
}
bind(child, key, config, depth + 1, applied);
} else if (List.class.isAssignableFrom(type)) {
Class<?> element = listElementType(field);
if (element != null && isLeaf(element)) {
config.getOptionalValues(key, element)
.ifPresent(value -> apply(field, node, value, key, applied));
}
// List<model-type> has no flat scalar representation here - skip.
} else if (isLeaf(type)) {
config.getOptionalValue(key, box(type))
.ifPresent(value -> apply(field, node, value, key, applied));
}
// Maps and other container/unsupported types are left to their Java defaults.
} catch (Exception ex) {
// Per-field best effort: an unconvertible value or inaccessible field must not
// abort
// the whole overlay. Never include the value (may be a secret).
log.debug("Skipped config binding for {} ({})", key, ex.toString());
}
}
}
private void apply(Field field, Object node, Object value, String key, int[] applied) {
try {
field.set(node, value);
applied[0]++;
// Key name only - the value may be a secret (license key, password, client secret).
log.debug("Applied config override: {}", key);
} catch (Exception ex) {
log.debug("Failed to set {} ({})", key, ex.toString());
}
}
private static boolean isModelType(Class<?> type) {
return type.getName().startsWith("stirling.software") && !type.isEnum();
}
private static boolean isLeaf(Class<?> type) {
return type == String.class
|| type.isEnum()
|| type.isPrimitive()
|| type == Boolean.class
|| type == Integer.class
|| type == Long.class
|| type == Double.class
|| type == Float.class
|| type == Short.class
|| type == Byte.class;
}
private static Class<?> box(Class<?> type) {
if (!type.isPrimitive()) {
return type;
}
if (type == boolean.class) {
return Boolean.class;
}
if (type == int.class) {
return Integer.class;
}
if (type == long.class) {
return Long.class;
}
if (type == double.class) {
return Double.class;
}
if (type == float.class) {
return Float.class;
}
if (type == short.class) {
return Short.class;
}
if (type == byte.class) {
return Byte.class;
}
return type;
}
private static Class<?> listElementType(Field field) {
Type generic = field.getGenericType();
if (generic instanceof ParameterizedType parameterized) {
Type[] args = parameterized.getActualTypeArguments();
if (args.length == 1 && args[0] instanceof Class<?> element) {
return element;
}
}
return null;
}
private static Object instantiate(Class<?> type) {
try {
return type.getDeclaredConstructor().newInstance();
} catch (Exception ex) {
return null;
}
}
}
@@ -1,28 +1,29 @@
package stirling.software.common.configuration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.posthog.java.PostHog;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import lombok.extern.slf4j.Slf4j;
@Configuration
@ApplicationScoped
@Slf4j
public class PostHogConfig {
@Value("${posthog.api.key}")
private String posthogApiKey;
@ConfigProperty(name = "posthog.api.key")
String posthogApiKey;
@Value("${posthog.host}")
private String posthogHost;
@ConfigProperty(name = "posthog.host")
String posthogHost;
private PostHog postHogClient;
@Bean
@Produces
@ApplicationScoped
public PostHog postHogClient() {
postHogClient =
new PostHog.Builder(posthogApiKey)
@@ -1,13 +1,13 @@
package stirling.software.common.configuration;
import org.springframework.stereotype.Component;
import com.posthog.java.PostHogLogger;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@ApplicationScoped
public class PostHogLoggerImpl implements PostHogLogger {
@Override
@@ -10,7 +10,8 @@ import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Configuration;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -24,7 +25,7 @@ import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.UnoServerPool;
@Slf4j
@Configuration
@ApplicationScoped
@Getter
public class RuntimePathConfig {
private final ApplicationProperties properties;
@@ -1,23 +1,19 @@
package stirling.software.common.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
/**
* Configures the scheduler used by all {@code @Scheduled} methods. Uses virtual threads so that
* long-running scheduled tasks (e.g. cleanup, license checks, file monitoring) never block each
* other — each runs on its own lightweight virtual thread.
*
* <p>MIGRATION (Spring -> Quarkus): the custom Spring {@code TaskScheduler} bean has been removed.
* Quarkus' {@code quarkus-scheduler} extension owns the scheduling thread pool, so no application
* bean is required. To keep the "each scheduled task on its own virtual thread" behaviour, annotate
* the individual {@code @io.quarkus.scheduler.Scheduled} methods with
* {@code @io.smallrye.common.annotation.RunOnVirtualThread} (or configure {@code
* quarkus.scheduler.use-virtual-threads=true} where supported).
*
* <p>TODO: Migration required - any injection point that received the former Spring {@code
* TaskScheduler} bean must be rewritten to use the Quarkus scheduler API or a CDI-managed {@code
* java.util.concurrent.ScheduledExecutorService}.
*/
@Configuration
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
scheduler.setVirtualThreads(true);
scheduler.setThreadNamePrefix("scheduled-vt-");
return scheduler;
}
}
public class SchedulingConfig {}
@@ -0,0 +1,141 @@
package stirling.software.common.configuration;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.microprofile.config.spi.ConfigSource;
import org.snakeyaml.engine.v2.api.Load;
import org.snakeyaml.engine.v2.api.LoadSettings;
/**
* Exposes {@code settings.yml} (and {@code custom_settings.yml}, with the bundled {@code
* settings.yml.template} as the default fallback) as a MicroProfile/SmallRye {@link ConfigSource}.
*
* <p>Restores the Spring {@code @ConfigurationProperties} behaviour that bound {@code settings.yml}
* into {@code ApplicationProperties}: without this the YAML was never read under Quarkus, so flags
* like {@code security.enableLogin} fell back to their Java defaults regardless of the file (the
* {@code enableLogin=false}/{@code maxDPI=0}/{@code loginAttemptCount=0} class of bugs). The nested
* YAML is flattened to dotted keys ({@code security.enableLogin -> "true"}); {@link
* ApplicationPropertiesConfigOverlay} and {@code @ConfigProperty} injections then read them.
*
* <p>Ordinal {@value #ORDINAL} sits above {@code application.properties} (250) but below
* environment variables (300) and system properties (400), matching Spring's precedence - e.g.
* {@code SECURITY_ENABLELOGIN} still overrides the file.
*
* <p>Registered via {@code META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource}.
*/
public class SettingsYamlConfigSource implements ConfigSource {
private static final int ORDINAL = 275;
private final Map<String, String> properties;
public SettingsYamlConfigSource() {
this.properties = load();
}
private static Map<String, String> load() {
Map<String, String> flat = new HashMap<>();
// 1. Bundled template provides the defaults (e.g. security.enableLogin: true).
try (InputStream in =
SettingsYamlConfigSource.class
.getClassLoader()
.getResourceAsStream("settings.yml.template")) {
if (in != null) {
flatten("", loadYaml(in), flat);
}
} catch (Exception ignored) {
// best effort - fall through to file overrides / Java defaults
}
// 2. The user's settings.yml overrides the template.
mergeFile(InstallationPathConfig.getSettingsPath(), flat);
// 3. custom_settings.yml overrides settings.yml.
mergeFile(InstallationPathConfig.getCustomSettingsPath(), flat);
return flat;
}
private static void mergeFile(String path, Map<String, String> flat) {
try {
Path p = Path.of(path);
if (Files.isRegularFile(p)) {
try (InputStream in = Files.newInputStream(p)) {
flatten("", loadYaml(in), flat);
}
}
} catch (Exception ignored) {
// unreadable/invalid file - keep whatever defaults were already loaded
}
}
private static Object loadYaml(InputStream in) {
return new Load(LoadSettings.builder().build()).loadFromInputStream(in);
}
private static void flatten(String prefix, Object node, Map<String, String> out) {
if (node instanceof Map<?, ?> map) {
for (Map.Entry<?, ?> e : map.entrySet()) {
String key =
prefix.isEmpty() ? String.valueOf(e.getKey()) : prefix + "." + e.getKey();
flatten(key, e.getValue(), out);
}
} else if (node instanceof List<?> list) {
// Emit scalar lists as a comma-separated value so SmallRye binds them via
// config.getValues()/getOptionalValues() (e.g. endpoints.toRemove, consumed by
// EndpointConfiguration to disable endpoints). Lists containing maps/nested lists have
// no
// flat scalar form, so skip those - their consumers read them structurally, not through
// this overlay. The scalar lists here (endpoint names, group names) contain no commas,
// so
// a plain join round-trips cleanly.
boolean scalarList =
!list.isEmpty()
&& list.stream()
.allMatch(
e ->
e != null
&& !(e instanceof Map)
&& !(e instanceof List));
if (scalarList) {
out.put(
prefix,
list.stream()
.map(String::valueOf)
.collect(java.util.stream.Collectors.joining(",")));
}
return;
} else if (node != null) {
out.put(prefix, String.valueOf(node));
}
// null leaves are left unset so the Java default applies.
}
@Override
public Map<String, String> getProperties() {
return properties;
}
@Override
public Set<String> getPropertyNames() {
return properties.keySet();
}
@Override
public String getValue(String propertyName) {
return properties.get(propertyName);
}
@Override
public String getName() {
return "settings.yml";
}
@Override
public int getOrdinal() {
return ORDINAL;
}
}
@@ -1,22 +0,0 @@
package stirling.software.common.configuration;
import java.util.Properties;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.core.io.support.PropertySourceFactory;
public class YamlPropertySourceFactory implements PropertySourceFactory {
@Override
public PropertySource<?> createPropertySource(String name, EncodedResource encodedResource) {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
factory.setResources(encodedResource.getResource());
Properties properties = factory.getObject();
return new PropertiesPropertySource(
encodedResource.getResource().getFilename(), properties);
}
}
@@ -1,7 +1,6 @@
package stirling.software.common.model;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
@@ -15,22 +14,11 @@ import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.Data;
import lombok.Getter;
@@ -39,9 +27,11 @@ import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.model.io.ClassPathResource;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
import stirling.software.common.model.oauth2.KeycloakProvider;
@@ -51,9 +41,12 @@ import stirling.software.common.util.ValidationUtils;
@Data
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@ConfigurationProperties(prefix = "")
@ApplicationScoped
// TODO: Migration required - rebind via @io.smallrye.config.ConfigMapping or
// @io.quarkus.arc.config.ConfigProperties. Was Spring @ConfigurationProperties(prefix = ""),
// kept here as a plain CDI bean POJO; the property binding is not yet wired in Quarkus.
// TODO: Migration required - Spring @Order(Ordered.HIGHEST_PRECEDENCE) controlled
// configuration-bean ordering; there is no equivalent CDI ordering annotation for this bean.
public class ApplicationProperties {
private Legal legal = new Legal();
@@ -82,38 +75,17 @@ public class ApplicationProperties {
private Cluster cluster = new Cluster();
private Policies policies = new Policies();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
throws IOException {
String configPath = InstallationPathConfig.getSettingsPath();
log.debug("Attempting to load settings from: {}", configPath);
File file = new File(configPath);
if (!file.exists()) {
log.error("Warning: Settings file does not exist at: {}", configPath);
}
Resource resource = new FileSystemResource(configPath);
if (!resource.exists()) {
throw new FileNotFoundException("Settings file not found at: " + configPath);
}
EncodedResource encodedResource = new EncodedResource(resource);
PropertySource<?> propertySource =
new YamlPropertySourceFactory().createPropertySource(null, encodedResource);
boolean saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
if (saasActive) {
// Saas-pinned values in application-saas.properties must beat settings.yml.
environment.getPropertySources().addLast(propertySource);
} else {
environment.getPropertySources().addFirst(propertySource);
}
log.debug("Loaded properties: {}", propertySource.getSource());
return propertySource;
}
// REMOVED (Spring -> Quarkus): dynamicYamlPropertySource(ConfigurableEnvironment).
// This was a Spring @Bean that registered settings.yml as an extra runtime PropertySource on
// the
// ConfigurableEnvironment (added first, or last under the "saas" profile). Quarkus has no
// ConfigurableEnvironment/PropertySource model and the @Bean had already been removed, so the
// method was dead code referencing Spring-only types.
// TODO: Migration required - reimplement external settings.yml loading as a custom
// org.eclipse.microprofile.config.spi.ConfigSource (registered via a ConfigSourceProvider /
// META-INF/services), giving it an ordinal that reproduces the old precedence: higher than the
// application defaults normally, but lower than application-saas.properties under the saas
// profile. Wire it in ConfigInitializer.
/**
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
@@ -522,8 +494,12 @@ public class ApplicationProperties {
private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2();
private int loginAttemptCount;
private long loginResetTimeMinutes;
// Defaults mirror settings.yml.template. These primitives are not bound from the template
// by the current Quarkus config path, so an unset 0 means "lock after 0 attempts" (every
// login blocked, and the lockout never accumulates a window) - same class of bug as
// maxDPI=0. See the settings.yml binding TODO.
private int loginAttemptCount = 5;
private long loginResetTimeMinutes = 120;
private String loginMethod = "all";
private String customGlobalAPIKey;
private Jwt jwt = new Jwt();
@@ -608,8 +584,9 @@ public class ApplicationProperties {
@JsonIgnore
public InputStream getIdpMetadataUri() throws IOException {
if (idpMetadataUri.startsWith("classpath:")) {
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
.getInputStream();
return getClass()
.getClassLoader()
.getResourceAsStream(idpMetadataUri.substring("classpath:".length()));
}
try {
URI uri = new URI(idpMetadataUri);
@@ -622,6 +599,9 @@ public class ApplicationProperties {
}
}
// TODO: Migration required - returns org.springframework.core.io.Resource, a public
// signature relied on by callers. Converting to InputStream/byte[]/java.nio would
// ripple to those call sites, so the Spring Resource type is retained for now.
@JsonIgnore
public Resource getSpCert() {
if (spCert == null) return null;
@@ -632,6 +612,9 @@ public class ApplicationProperties {
}
}
// TODO: Migration required - returns org.springframework.core.io.Resource, a public
// signature relied on by callers. Converting to InputStream/byte[]/java.nio would
// ripple to those call sites, so the Spring Resource type is retained for now.
@JsonIgnore
public Resource getIdpCert() {
if (idpCert == null) return null;
@@ -642,6 +625,9 @@ public class ApplicationProperties {
}
}
// TODO: Migration required - returns org.springframework.core.io.Resource, a public
// signature relied on by callers. Converting to InputStream/byte[]/java.nio would
// ripple to those call sites, so the Spring Resource type is retained for now.
@JsonIgnore
public Resource getPrivateKey() {
if (privateKey == null) return null;
@@ -881,7 +867,10 @@ public class ApplicationProperties {
private Boolean enableDesktopInstallSlide;
private Datasource datasource;
private boolean disableSanitize;
private int maxDPI;
// Default mirrors settings.yml.template (maxDPI: 500). Without an explicit default this
// primitive is 0, which makes every DPI check (dpi > maxDPI) fail with "maximum safe limit
// of 0" when the value is not populated from settings.
private int maxDPI = 500;
private boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
@@ -0,0 +1,70 @@
package stirling.software.common.model;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import stirling.software.common.model.io.Resource;
/**
* Migration compatibility shim for Spring's {@code
* org.springframework.web.multipart.MultipartFile}.
*
* <p>Quarkus/JAX-RS has no drop-in equivalent for the {@code MultipartFile} abstraction that the
* service layer relies on (it exposes {@code org.jboss.resteasy.reactive.multipart.FileUpload} at
* the REST boundary instead). To avoid rewriting the public signatures of dozens of service and
* util methods across every module, this interface mirrors the subset of Spring's API that the
* codebase actually uses. Controllers adapt the inbound {@code FileUpload}/{@code byte[]} to one of
* the implementations ({@link stirling.software.common.model.multipart.ByteArrayMultipartFile},
* {@link stirling.software.common.model.multipart.FileUploadMultipartFile}) and pass it down
* unchanged.
*
* <p>TODO: Migration required - longer term, the REST boundary should standardise on {@code
* FileUpload}/{@code @RestForm} and this shim can be retired.
*/
public interface MultipartFile {
String getName();
String getOriginalFilename();
String getContentType();
boolean isEmpty();
long getSize();
byte[] getBytes() throws IOException;
InputStream getInputStream() throws IOException;
/**
* The content as a {@link Resource}. The default is a stream-backed resource; file-backed
* implementations (e.g. {@code FileUploadMultipartFile}) override this to enable zero-copy fast
* paths.
*/
default Resource getResource() {
try {
return new stirling.software.common.model.io.InputStreamResource(
getInputStream(), getOriginalFilename());
} catch (IOException e) {
throw new java.io.UncheckedIOException(e);
}
}
default void transferTo(File dest) throws IOException {
transferTo(dest.toPath());
}
default void transferTo(Path dest) throws IOException {
try (InputStream in = getInputStream()) {
// Spring's MultipartFile#transferTo overwrites an existing destination. Callers
// commonly
// pass a path from Files.createTempFile(...) (which has already created an empty file),
// so REPLACE_EXISTING is required - a plain Files.copy would throw FileAlreadyExists.
Files.copy(in, dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -1,12 +1,12 @@
package stirling.software.common.model.api;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.MultipartFile;
@Data
@EqualsAndHashCode
public class GeneralFile {
@@ -1,8 +1,5 @@
package stirling.software.common.model.api;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
@@ -11,6 +8,8 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import stirling.software.common.model.MultipartFile;
@Data
@NoArgsConstructor
@EqualsAndHashCode
@@ -18,7 +17,7 @@ public class PDFFile {
@Schema(
description = "The input PDF file",
contentMediaType = MediaType.APPLICATION_PDF_VALUE,
contentMediaType = "application/pdf",
format = "binary")
private MultipartFile fileInput;
@@ -0,0 +1,64 @@
package stirling.software.common.model.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
/** Classpath-backed {@link Resource} (migration shim for Spring's {@code ClassPathResource}). */
public class ClassPathResource implements Resource {
private final String path;
private final ClassLoader classLoader;
public ClassPathResource(String path) {
this(path, ClassPathResource.class.getClassLoader());
}
public ClassPathResource(String path, ClassLoader classLoader) {
this.path = path.startsWith("/") ? path.substring(1) : path;
this.classLoader = classLoader != null ? classLoader : ClassLoader.getSystemClassLoader();
}
@Override
public InputStream getInputStream() throws IOException {
InputStream is = classLoader.getResourceAsStream(path);
if (is == null) {
throw new IOException("class path resource [" + path + "] cannot be opened");
}
return is;
}
@Override
public boolean exists() {
return classLoader.getResource(path) != null;
}
@Override
public String getFilename() {
int sep = path.lastIndexOf('/');
return sep != -1 ? path.substring(sep + 1) : path;
}
@Override
public long contentLength() throws IOException {
try (InputStream is = getInputStream()) {
long count = 0;
byte[] buf = new byte[8192];
int read;
while ((read = is.read(buf)) != -1) {
count += read;
}
return count;
}
}
@Override
public File getFile() throws IOException {
URL url = classLoader.getResource(path);
if (url == null || !"file".equals(url.getProtocol())) {
throw new IOException("class path resource [" + path + "] is not a filesystem file");
}
return new File(url.getFile());
}
}
@@ -0,0 +1,56 @@
package stirling.software.common.model.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
/** File-backed {@link Resource} (migration shim for Spring's {@code FileSystemResource}). */
public class FileSystemResource implements Resource {
private final Path path;
public FileSystemResource(Path path) {
this.path = path;
}
public FileSystemResource(File file) {
this.path = file.toPath();
}
public FileSystemResource(String path) {
this.path = Path.of(path);
}
@Override
public InputStream getInputStream() throws IOException {
return Files.newInputStream(path);
}
@Override
public boolean exists() {
return Files.exists(path);
}
@Override
public String getFilename() {
Path name = path.getFileName();
return name == null ? null : name.toString();
}
@Override
public long contentLength() throws IOException {
return Files.size(path);
}
@Override
public boolean isFile() {
return true;
}
@Override
public File getFile() {
return path.toFile();
}
}
@@ -0,0 +1,50 @@
package stirling.software.common.model.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
/**
* Stream-backed {@link Resource} (migration shim for Spring's {@code InputStreamResource}). As with
* Spring, the stream can only be read once.
*/
public class InputStreamResource implements Resource {
private final InputStream inputStream;
private final String filename;
public InputStreamResource(InputStream inputStream) {
this(inputStream, null);
}
public InputStreamResource(InputStream inputStream, String filename) {
this.inputStream = inputStream;
this.filename = filename;
}
@Override
public InputStream getInputStream() {
return inputStream;
}
@Override
public boolean exists() {
return true;
}
@Override
public String getFilename() {
return filename;
}
@Override
public long contentLength() throws IOException {
// Spring's InputStreamResource also cannot report length without consuming the stream.
return -1;
}
@Override
public File getFile() throws IOException {
throw new IOException("InputStreamResource is not backed by a file");
}
}
@@ -0,0 +1,40 @@
package stirling.software.common.model.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
/**
* Migration compatibility shim for Spring's {@code org.springframework.core.io.Resource}.
*
* <p>Quarkus/Jakarta has no single {@code Resource} abstraction. Rather than rewrite the many
* public method signatures across the codebase that accept or return {@code Resource}, this
* interface mirrors the subset of Spring's API the codebase actually uses ({@code
* getInputStream/exists/getFile/getFilename/contentLength/isFile}) together with the {@link
* FileSystemResource}, {@link InputStreamResource} and {@link ClassPathResource} implementations.
* Converting a file is then just an import swap.
*
* <p>TODO: Migration required - longer term, prefer {@code java.nio.file.Path} / {@code
* InputStream} directly at the boundaries and retire this shim.
*/
public interface Resource {
InputStream getInputStream() throws IOException;
boolean exists();
String getFilename();
long contentLength() throws IOException;
/** Whether this resource is backed by a real file in the filesystem. */
default boolean isFile() {
return false;
}
/**
* @return the underlying file.
* @throws IOException if the resource is not file-backed.
*/
File getFile() throws IOException;
}
@@ -0,0 +1,68 @@
package stirling.software.common.model.multipart;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.io.InputStreamResource;
import stirling.software.common.model.io.Resource;
/**
* In-memory {@link MultipartFile} backed by a byte array. Useful for tests and for code paths that
* synthesize file content (migration shim - see {@link MultipartFile}).
*/
public class ByteArrayMultipartFile implements MultipartFile {
private final String name;
private final String originalFilename;
private final String contentType;
private final byte[] content;
public ByteArrayMultipartFile(
String name, String originalFilename, String contentType, byte[] content) {
this.name = name;
this.originalFilename = originalFilename;
this.contentType = contentType;
this.content = content != null ? content : new byte[0];
}
@Override
public String getName() {
return name;
}
@Override
public String getOriginalFilename() {
return originalFilename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return content.length == 0;
}
@Override
public long getSize() {
return content.length;
}
@Override
public byte[] getBytes() {
return content;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(content);
}
@Override
public Resource getResource() {
return new InputStreamResource(new ByteArrayInputStream(content), originalFilename);
}
}
@@ -0,0 +1,108 @@
package stirling.software.common.model.multipart;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
/**
* Adapts a Quarkus REST {@link FileUpload} (the inbound multipart representation at the JAX-RS
* boundary) to the {@link MultipartFile} migration shim, so controllers can pass uploads down to
* the existing service layer without changing its method signatures.
*/
public class FileUploadMultipartFile implements MultipartFile {
private final FileUpload delegate;
public FileUploadMultipartFile(FileUpload delegate) {
this.delegate = delegate;
}
/** Null-safe factory: returns null when the upload is absent. */
public static MultipartFile of(FileUpload upload) {
return upload == null ? null : new FileUploadMultipartFile(upload);
}
/**
* Null-safe factory for a multipart field that may have multiple parts under the same name.
*
* <p>Spring's MultipartFile binding picked the actual file part even when a client also sent a
* plain text form field of the same name; RESTEasy Reactive's {@code @RestForm FileUpload}
* binds the <em>first</em> part by name instead, so a stray {@code name=value} text part sent
* before the file would shadow the upload. Prefer the part that carries a real filename (the
* file), falling back to the last part, so such requests bind the same way they did under
* Spring.
*/
public static MultipartFile of(List<FileUpload> uploads) {
if (uploads == null || uploads.isEmpty()) {
return null;
}
FileUpload chosen = null;
for (FileUpload upload : uploads) {
if (upload.fileName() != null && !upload.fileName().isBlank()) {
chosen = upload;
break;
}
}
if (chosen == null) {
chosen = uploads.get(uploads.size() - 1);
}
return new FileUploadMultipartFile(chosen);
}
@Override
public String getName() {
return delegate.name();
}
@Override
public String getOriginalFilename() {
return delegate.fileName();
}
@Override
public String getContentType() {
return delegate.contentType();
}
@Override
public boolean isEmpty() {
return getSize() == 0;
}
@Override
public long getSize() {
return delegate.size();
}
@Override
public byte[] getBytes() throws IOException {
return Files.readAllBytes(delegate.uploadedFile());
}
@Override
public InputStream getInputStream() throws IOException {
return Files.newInputStream(delegate.uploadedFile());
}
@Override
public Resource getResource() {
// File-backed: enables FileStorage's zero-copy fast path.
return new FileSystemResource(delegate.uploadedFile());
}
@Override
public void transferTo(Path dest) throws IOException {
// Overwrite semantics like Spring's MultipartFile#transferTo; callers often pass a
// Files.createTempFile(...) path that already exists, so REPLACE_EXISTING is required.
Files.copy(
delegate.uploadedFile(), dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
@@ -0,0 +1,70 @@
package stirling.software.common.security;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Migration compatibility shim for {@code
* org.springframework.security.authentication.AbstractAuthenticationToken}.
*
* <p>Base implementation of {@link Authentication} holding authorities, details and an
* authenticated flag.
*/
public abstract class AbstractAuthenticationToken implements Authentication {
private final List<GrantedAuthority> authorities;
private Object details;
private boolean authenticated = false;
protected AbstractAuthenticationToken(Collection<? extends GrantedAuthority> authorities) {
if (authorities == null) {
this.authorities = Collections.emptyList();
} else {
List<GrantedAuthority> copy = new ArrayList<>(authorities.size());
for (GrantedAuthority authority : authorities) {
copy.add(authority);
}
this.authorities = Collections.unmodifiableList(copy);
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getDetails() {
return details;
}
public void setDetails(Object details) {
this.details = details;
}
@Override
public boolean isAuthenticated() {
return authenticated;
}
@Override
public void setAuthenticated(boolean authenticated) throws IllegalArgumentException {
this.authenticated = authenticated;
}
@Override
public String getName() {
Object principal = getPrincipal();
if (principal instanceof UserDetails) {
return ((UserDetails) principal).getUsername();
}
return principal == null ? null : principal.toString();
}
}
@@ -0,0 +1,28 @@
package stirling.software.common.security;
import java.security.Principal;
import java.util.Collection;
/**
* Migration compatibility shim for {@code org.springframework.security.core.Authentication}.
*
* <p>Represents the token for an authentication request or for an authenticated principal once the
* request has been processed.
*/
public interface Authentication extends Principal {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
@Override
String getName();
}
@@ -0,0 +1,19 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.AuthenticationException}.
*
* <p>Abstract superclass for all exceptions related to an {@link Authentication} object being
* invalid for whatever reason.
*/
public class AuthenticationException extends RuntimeException {
public AuthenticationException(String msg) {
super(msg);
}
public AuthenticationException(String msg, Throwable cause) {
super(msg, cause);
}
}
@@ -0,0 +1,45 @@
package stirling.software.common.security;
import at.favre.lib.crypto.bcrypt.BCrypt;
/**
* Migration compatibility shim for {@code
* org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder}.
*
* <p>Implementation of {@link PasswordEncoder} backed by the {@code at.favre.lib:bcrypt} library.
*/
public class BCryptPasswordEncoder implements PasswordEncoder {
private static final int DEFAULT_STRENGTH = 10;
private final int strength;
public BCryptPasswordEncoder() {
this(DEFAULT_STRENGTH);
}
public BCryptPasswordEncoder(int strength) {
this.strength = strength;
}
@Override
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
return BCrypt.withDefaults().hashToString(strength, rawPassword.toString().toCharArray());
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
if (encodedPassword == null || encodedPassword.isEmpty()) {
return false;
}
return BCrypt.verifyer()
.verify(rawPassword.toString().toCharArray(), encodedPassword)
.verified;
}
}
@@ -0,0 +1,18 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code
* org.springframework.security.authentication.BadCredentialsException}.
*
* <p>Thrown if an authentication request is rejected because the credentials are invalid.
*/
public class BadCredentialsException extends AuthenticationException {
public BadCredentialsException(String msg) {
super(msg);
}
public BadCredentialsException(String msg, Throwable cause) {
super(msg, cause);
}
}
@@ -0,0 +1,17 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code org.springframework.security.core.GrantedAuthority}.
*
* <p>Represents an authority granted to an {@link Authentication} object. Provided so that code
* migrated from Spring Boot to Quarkus compiles without Spring Security on the classpath.
*/
public interface GrantedAuthority {
/**
* Returns a textual representation of the granted authority.
*
* @return the authority string, never {@code null}
*/
String getAuthority();
}
@@ -0,0 +1,20 @@
package stirling.software.common.security;
import java.util.Collection;
import java.util.Map;
/**
* Migration compatibility shim for {@code
* org.springframework.security.oauth2.core.user.OAuth2User}.
*
* <p>Represents a user {@link java.security.Principal} authenticated using OAuth 2.0 or OpenID
* Connect.
*/
public interface OAuth2User {
Map<String, Object> getAttributes();
Collection<? extends GrantedAuthority> getAuthorities();
String getName();
}
@@ -0,0 +1,16 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code
* org.springframework.security.crypto.password.PasswordEncoder}.
*
* <p>Service interface for encoding passwords.
*/
public interface PasswordEncoder {
/** Encodes the raw password. */
String encode(CharSequence rawPassword);
/** Verifies that the encoded password matches the raw password after it too is encoded. */
boolean matches(CharSequence rawPassword, String encodedPassword);
}
@@ -0,0 +1,40 @@
package stirling.software.common.security;
import java.util.Date;
/**
* Migration compatibility shim for {@code
* org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken}.
*
* <p>Holds the persistent remember-me token data for a single series.
*/
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
private final Date date;
public PersistentRememberMeToken(String username, String series, String tokenValue, Date date) {
this.username = username;
this.series = series;
this.tokenValue = tokenValue;
this.date = date;
}
public String getUsername() {
return username;
}
public String getSeries() {
return series;
}
public String getTokenValue() {
return tokenValue;
}
public Date getDate() {
return date;
}
}
@@ -0,0 +1,20 @@
package stirling.software.common.security;
import java.util.Date;
/**
* Migration compatibility shim for {@code
* org.springframework.security.web.authentication.rememberme.PersistentTokenRepository}.
*
* <p>Persists the remember-me tokens used by the persistent token based remember-me services.
*/
public interface PersistentTokenRepository {
void createNewToken(PersistentRememberMeToken token);
void updateToken(String series, String tokenValue, Date lastUsed);
PersistentRememberMeToken getTokenForSeries(String seriesId);
void removeUserTokens(String username);
}
@@ -0,0 +1,14 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.context.SecurityContext}.
*
* <p>Holds the {@link Authentication} associated with the current execution.
*/
public interface SecurityContext {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
@@ -0,0 +1,37 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.context.SecurityContextHolder}.
*
* <p>Associates a {@link SecurityContext} with the current thread of execution using a {@link
* ThreadLocal}.
*/
public final class SecurityContextHolder {
private static final ThreadLocal<SecurityContext> CONTEXT_HOLDER = new ThreadLocal<>();
private SecurityContextHolder() {}
/** Returns the context for the current thread, creating an empty one if none is set. */
public static SecurityContext getContext() {
SecurityContext context = CONTEXT_HOLDER.get();
if (context == null) {
context = createEmptyContext();
CONTEXT_HOLDER.set(context);
}
return context;
}
public static void setContext(SecurityContext context) {
CONTEXT_HOLDER.set(context);
}
public static void clearContext() {
CONTEXT_HOLDER.remove();
}
public static SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}
@@ -0,0 +1,28 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.context.SecurityContextImpl}.
*
* <p>Basic concrete implementation of {@link SecurityContext}.
*/
public class SecurityContextImpl implements SecurityContext {
private Authentication authentication;
public SecurityContextImpl() {}
public SecurityContextImpl(Authentication authentication) {
this.authentication = authentication;
}
@Override
public Authentication getAuthentication() {
return authentication;
}
@Override
public void setAuthentication(Authentication authentication) {
this.authentication = authentication;
}
}
@@ -0,0 +1,47 @@
package stirling.software.common.security;
import java.util.Date;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.session.SessionInformation}.
*
* <p>Represents a record of a session within the application's session registry.
*/
public class SessionInformation {
private final Object principal;
private final String sessionId;
private Date lastRequest;
private boolean expired = false;
public SessionInformation(Object principal, String sessionId, Date lastRequest) {
this.principal = principal;
this.sessionId = sessionId;
this.lastRequest = lastRequest;
}
public Object getPrincipal() {
return principal;
}
public String getSessionId() {
return sessionId;
}
public Date getLastRequest() {
return lastRequest;
}
public boolean isExpired() {
return expired;
}
public void expireNow() {
this.expired = true;
}
public void refreshLastRequest() {
this.lastRequest = new Date();
}
}
@@ -0,0 +1,24 @@
package stirling.software.common.security;
import java.util.List;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.session.SessionRegistry}.
*
* <p>Maintains a registry of currently known principals and their sessions.
*/
public interface SessionRegistry {
List<Object> getAllPrincipals();
List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions);
SessionInformation getSessionInformation(String sessionId);
void refreshLastRequest(String sessionId);
void registerNewSession(String sessionId, Object principal);
void removeSessionInformation(String sessionId);
}
@@ -0,0 +1,45 @@
package stirling.software.common.security;
import java.util.Objects;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.authority.SimpleGrantedAuthority}.
*
* <p>A basic, immutable {@link GrantedAuthority} backed by a single string.
*/
public class SimpleGrantedAuthority implements GrantedAuthority {
private final String authority;
public SimpleGrantedAuthority(String authority) {
this.authority = authority;
}
@Override
public String getAuthority() {
return authority;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof SimpleGrantedAuthority)) {
return false;
}
SimpleGrantedAuthority other = (SimpleGrantedAuthority) obj;
return Objects.equals(authority, other.authority);
}
@Override
public int hashCode() {
return Objects.hashCode(authority);
}
@Override
public String toString() {
return authority;
}
}
@@ -0,0 +1,26 @@
package stirling.software.common.security;
import java.util.Collection;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.userdetails.UserDetails}.
*
* <p>Provides core user information used by the authentication layer.
*/
public interface UserDetails {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
@@ -0,0 +1,19 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.userdetails.UserDetailsService}.
*
* <p>Loads user-specific data, typically as part of an authentication flow.
*/
public interface UserDetailsService {
/**
* Locates the user based on the username.
*
* @param username the username identifying the user whose data is required
* @return a fully populated user record, never {@code null}
* @throws UsernameNotFoundException if the user could not be found
*/
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
@@ -0,0 +1,18 @@
package stirling.software.common.security;
/**
* Migration compatibility shim for {@code
* org.springframework.security.core.userdetails.UsernameNotFoundException}.
*
* <p>Thrown if a {@link UserDetailsService} implementation cannot locate a user by its username.
*/
public class UsernameNotFoundException extends AuthenticationException {
public UsernameNotFoundException(String msg) {
super(msg);
}
public UsernameNotFoundException(String msg, Throwable cause) {
super(msg, cause);
}
}
@@ -0,0 +1,67 @@
package stirling.software.common.security;
import java.util.Collection;
/**
* Migration compatibility shim for {@code
* org.springframework.security.authentication.UsernamePasswordAuthenticationToken}.
*
* <p>An {@link Authentication} implementation designed for simple presentation of a username and
* password.
*/
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
/** Creates an unauthenticated token (typically used as an authentication request). */
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
/** Creates an authenticated token (typically the result of a successful authentication). */
public UsernamePasswordAuthenticationToken(
Object principal,
Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
/** Factory method mirroring Spring Security 6 for creating an unauthenticated token. */
public static UsernamePasswordAuthenticationToken unauthenticated(
Object principal, Object credentials) {
return new UsernamePasswordAuthenticationToken(principal, credentials);
}
/** Factory method mirroring Spring Security 6 for creating an authenticated token. */
public static UsernamePasswordAuthenticationToken authenticated(
Object principal,
Object credentials,
Collection<? extends GrantedAuthority> authorities) {
return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
}
@Override
public Object getCredentials() {
return credentials;
}
@Override
public Object getPrincipal() {
return principal;
}
@Override
public String getName() {
if (principal instanceof UserDetails) {
return ((UserDetails) principal).getUsername();
}
return principal == null ? null : principal.toString();
}
}
@@ -24,17 +24,18 @@ import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
import org.apache.pdfbox.io.ScratchFile;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFileManager;
@Component
@ApplicationScoped
@Slf4j
public class CustomPDFDocumentFactory {
@@ -44,8 +45,8 @@ public class CustomPDFDocumentFactory {
// class without a full Spring context. When null, falls back to Files.createTempFile().
private final TempFileManager tempFileManager;
/** Primary constructor used by Spring. Both collaborators are required in production. */
@Autowired
/** Primary constructor used by CDI. Both collaborators are required in production. */
@Inject
public CustomPDFDocumentFactory(
PdfMetadataService pdfMetadataService, TempFileManager tempFileManager) {
this.pdfMetadataService = pdfMetadataService;
@@ -4,18 +4,20 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
@Service
import stirling.software.common.model.MultipartFile;
@ApplicationScoped
@RequiredArgsConstructor
public class FileOrUploadService {
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
@ConfigProperty(name = "stirling.tempDir", defaultValue = "/tmp/stirling-files")
String tempDirPath;
public Path resolveFilePath(String fileId) {
return Path.of(tempDirPath).resolve(fileId);
@@ -9,22 +9,23 @@ import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.ws.rs.core.StreamingOutput;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.io.Resource;
import stirling.software.common.util.JobContext;
/**
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
* system to handle file references. Disk I/O is delegated to the injected {@link FileStore} bean.
*/
@Service
@ApplicationScoped
@RequiredArgsConstructor
@Slf4j
public class FileStorage {
@@ -34,7 +35,12 @@ public class FileStorage {
private final FileOrUploadService fileOrUploadService;
private final FileStore fileStore;
private final Optional<JobOwnershipService> jobOwnershipService;
// MIGRATION: CDI does not inject java.util.Optional<T>. Optional<JobOwnershipService> is now
// jakarta.enterprise.inject.Instance<JobOwnershipService>, resolved via isResolvable()/get().
// Exactly one JobOwnershipService impl is selected at build time (Impl vs NoOp), so this is
// always resolvable in practice, but Instance<> keeps the previous optional contract.
private final Instance<JobOwnershipService> jobOwnershipService;
public String storeFile(MultipartFile file) throws IOException {
String owner = resolveOwner();
@@ -97,7 +103,7 @@ public class FileStorage {
return new StoredFile(stored.fileId(), stored.size());
}
public String storeFromStreamingBody(StreamingResponseBody body, String originalName)
public String storeFromStreamingBody(StreamingOutput body, String originalName)
throws IOException {
String owner = resolveOwner();
// Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.)
@@ -113,7 +119,7 @@ public class FileStorage {
executor.submit(
() -> {
try {
body.writeTo(out);
body.write(out);
} catch (Throwable ex) {
bodyError.set(ex);
} finally {
@@ -142,10 +148,9 @@ public class FileStorage {
throw ioe;
}
throw new IOException(
"StreamingResponseBody writer failed: " + writerErr.getMessage(),
writerErr);
"StreamingOutput writer failed: " + writerErr.getMessage(), writerErr);
}
log.debug("Stored StreamingResponseBody with ID: {}", stored.fileId());
log.debug("Stored StreamingOutput with ID: {}", stored.fileId());
return stored.fileId();
} finally {
// Interrupt and join the writer task: shutdown() alone returns immediately and a
@@ -194,11 +199,14 @@ public class FileStorage {
if (propagated != null) {
return propagated;
}
return jobOwnershipService.flatMap(JobOwnershipService::getCurrentUserId).orElse(null);
if (!jobOwnershipService.isResolvable()) {
return null;
}
return jobOwnershipService.get().getCurrentUserId().orElse(null);
}
private void enforceOwnership(String fileId) {
if (jobOwnershipService.isEmpty()) {
if (!jobOwnershipService.isResolvable()) {
return;
}
Optional<String> currentUser = jobOwnershipService.get().getCurrentUserId();
@@ -1,31 +1,36 @@
package stirling.software.common.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import org.eclipse.microprofile.config.Config;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.servlet.ServletContext;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -33,14 +38,21 @@ import stirling.software.common.util.TempFileManager;
* Dispatches HTTP POST requests to internal Stirling API endpoints via loopback. Used by
* PipelineProcessor and AiWorkflowService to execute tool operations programmatically without
* leaving the JVM network stack.
*
* <p>MIGRATION (Spring -> Quarkus): the HTTP dispatch was rebuilt on {@link
* java.net.http.HttpClient} (replacing Spring's {@code RestTemplate}/{@code
* SimpleClientHttpRequestFactory}). The multipart body is encoded manually; {@code
* MultiValueMap<String,Object>} became {@code Map<String,List<Object>>} and {@code
* ResponseEntity<Resource>} became {@link Response}. {@code ResourceAccessException} timeout
* handling is now driven by {@link HttpTimeoutException}.
*/
@Service
@ApplicationScoped
@Slf4j
public class InternalApiClient {
// Allowlist for internal dispatch. Matches fixed namespace prefixes,
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
// character that could alter the resolved endpoint on the local Spring server.
// character that could alter the resolved endpoint on the local server.
//
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
@@ -63,30 +75,30 @@ public class InternalApiClient {
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final TempFileManager tempFileManager;
private final Environment environment;
private final Config config;
private final Duration readTimeout;
private final RestTemplate restTemplate;
private final HttpClient httpClient;
public InternalApiClient(
ServletContext servletContext,
@Autowired(required = false) UserServiceInterface userService,
Instance<UserServiceInterface> userService,
TempFileManager tempFileManager,
Environment environment,
Config config,
ApplicationProperties applicationProperties) {
this.servletContext = servletContext;
this.userService = userService;
this.userService = userService.isResolvable() ? userService.get() : null;
this.tempFileManager = tempFileManager;
this.environment = environment;
this.config = config;
ApplicationProperties.InternalApi internalApi = applicationProperties.getInternalApi();
// A bounded read timeout is what protects the workflow when an internal tool hangs
// (e.g. an infinite loop in a PDF processing service). The connect timeout is short
// because this is a loopback call; if connecting takes longer than a few seconds the
// local server is itself unhealthy.
this.readTimeout = Duration.ofSeconds(internalApi.getReadTimeoutSeconds());
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(internalApi.getConnectTimeoutSeconds()));
factory.setReadTimeout(readTimeout);
this.restTemplate = new RestTemplate(factory);
this.httpClient =
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(internalApi.getConnectTimeoutSeconds()))
.build();
}
/**
@@ -94,78 +106,110 @@ public class InternalApiClient {
* prefixes (e.g. {@code /api/v1/misc/compress-pdf}).
*
* @param endpointPath API path (e.g. {@code /api/v1/general/rotate-pdf})
* @param body multipart form body (fileInput + parameters)
* @return response with the result file as a {@link TempFileResource} body
* @param body multipart form body (fileInput + parameters): each value is either a {@link
* Resource} (file part) or a {@code String} (form field)
* @return JAX-RS {@link Response} with the result file as a {@link TempFileResource} entity
*/
public ResponseEntity<Resource> post(String endpointPath, MultiValueMap<String, Object> body) {
public Response post(String endpointPath, Map<String, List<Object>> body) {
validateUrl(endpointPath);
String url = getBaseUrl() + endpointPath;
HttpHeaders headers = new HttpHeaders();
String boundary = "----StirlingBoundary" + Long.toHexString(System.nanoTime());
byte[] multipartBody = encodeMultipart(body, boundary);
HttpRequest.Builder requestBuilder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(readTimeout)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(multipartBody));
String apiKey = getApiKeyForUser();
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
requestBuilder.header("X-API-KEY", apiKey);
}
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
// every caller of this dispatcher is an automation surface by design.
headers.add(AUTOMATION_HEADER, "true");
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
boolean hasFilePart =
body.values().stream()
.flatMap(java.util.List::stream)
.anyMatch(v -> v instanceof Resource);
if (isAiTool && !hasFilePart) {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
}
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
requestBuilder.header(AUTOMATION_HEADER, "true");
try {
return restTemplate.execute(
url,
HttpMethod.POST,
requestCallback,
response -> {
try {
TempFile tempFile =
tempFileManager.createManagedTempFile("internal-api");
Files.copy(
response.getBody(),
tempFile.getPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
String filename = extractFilename(response.getHeaders());
TempFileResource resource = new TempFileResource(tempFile, filename);
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.body(resource);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (ResourceAccessException e) {
// RestTemplate wraps low-level I/O failures in ResourceAccessException. Only the
// SocketTimeoutException-rooted case is a real timeout; other I/O failures (connection
// refused, DNS, etc.) propagate as-is so the upstream generic handler can describe
// them accurately.
if (e.getCause() instanceof java.net.SocketTimeoutException) {
throw new InternalApiTimeoutException(endpointPath, readTimeout, e);
HttpResponse<InputStream> response =
httpClient.send(
requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
try (InputStream responseBody = response.body()) {
TempFile tempFile = tempFileManager.createManagedTempFile("internal-api");
Files.copy(
responseBody,
tempFile.getPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
String filename =
extractFilename(
response.headers().firstValue("Content-Disposition").orElse(null));
TempFileResource resource = new TempFileResource(tempFile, filename);
Response.ResponseBuilder rb =
Response.status(response.statusCode()).entity(resource);
response.headers().map().forEach((k, vs) -> vs.forEach(v -> rb.header(k, v)));
return rb.build();
}
throw e;
} catch (HttpTimeoutException e) {
throw new InternalApiTimeoutException(endpointPath, readTimeout, e);
} catch (ConnectException e) {
throw new UncheckedIOException(new IOException("Internal API connection failed", e));
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Internal API dispatch interrupted", e);
}
}
/**
* Extract the filename from a response's {@code Content-Disposition} header. Returns {@code
* null} if the header is missing or has no filename.
* Encode a multipart/form-data body. File parts are {@link Resource}; others are form fields.
*/
private static String extractFilename(HttpHeaders headers) {
String contentDisposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
private static byte[] encodeMultipart(Map<String, List<Object>> body, String boundary) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
for (Map.Entry<String, List<Object>> entry : body.entrySet()) {
String name = entry.getKey();
for (Object value : entry.getValue()) {
baos.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
if (value instanceof Resource resource) {
String fn = resource.getFilename() != null ? resource.getFilename() : name;
baos.write(
("Content-Disposition: form-data; name=\""
+ name
+ "\"; filename=\""
+ fn
+ "\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
try (InputStream in = resource.getInputStream()) {
in.transferTo(baos);
}
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
} else {
baos.write(
("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
baos.write(String.valueOf(value).getBytes(StandardCharsets.UTF_8));
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
}
}
baos.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return baos.toByteArray();
}
/**
* Extract the filename from a {@code Content-Disposition} header value. Returns {@code null} if
* the header is missing or has no filename.
*/
private static String extractFilename(String contentDisposition) {
if (contentDisposition == null || contentDisposition.isBlank()) {
return null;
}
@@ -184,12 +228,13 @@ public class InternalApiClient {
}
private String getBaseUrl() {
// Resolve the port lazily so desktop mode (server.port=0, OS-assigned) dispatches to the
// actual bound port. Spring publishes local.server.port once the web server is up; fall
// back to the configured server.port for early calls (tests, non-web contexts).
String port = environment.getProperty("local.server.port");
// Resolve the port lazily so desktop mode dispatches to the actual bound port.
// TODO: Migration required - verify Quarkus exposes the bound port via config. Quarkus uses
// "quarkus.http.port" and, for random-port test/dev runs, "quarkus.http.test-port"; the old
// "local.server.port"/"server.port" keys came from Spring Boot's WebServerInitializedEvent.
String port = config.getOptionalValue("quarkus.http.port", String.class).orElse(null);
if (port == null) {
port = environment.getProperty("server.port", "8080");
port = config.getOptionalValue("server.port", String.class).orElse("8080");
}
return "http://localhost:" + port + servletContext.getContextPath();
}
@@ -9,51 +9,56 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.io.Resource;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.util.ExecutorFactory;
import stirling.software.common.util.RegexPatternUtils;
/** Service for executing jobs asynchronously or synchronously */
@Service
@ApplicationScoped
@Slf4j
public class JobExecutorService {
private static final String APPLICATION_PDF_VALUE = "application/pdf";
private final TaskManager taskManager;
private final FileStorage fileStorage;
private final HttpServletRequest request;
// Reactive-safe: the undertow HttpServletRequest proxy throws UT000048 on RESTEasy Reactive
// worker threads, so the per-request "jobId" attribute is stored on the Vert.x RoutingContext
// instead (read back via AutoJobAspect). Off a live request this degrades to a no-op.
private final io.quarkus.vertx.http.runtime.CurrentVertxRequest currentVertxRequest;
private final ResourceMonitor resourceMonitor;
private final JobQueue jobQueue;
private final ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
private final long effectiveTimeoutMs;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
@jakarta.inject.Inject Instance<JobOwnershipService> jobOwnershipService;
public JobExecutorService(
TaskManager taskManager,
FileStorage fileStorage,
HttpServletRequest request,
io.quarkus.vertx.http.runtime.CurrentVertxRequest currentVertxRequest,
ResourceMonitor resourceMonitor,
JobQueue jobQueue,
@Value("${spring.mvc.async.request-timeout:1200000}") long asyncRequestTimeoutMs,
@Value("${server.servlet.session.timeout:30m}") String sessionTimeout) {
@ConfigProperty(name = "spring.mvc.async.request-timeout", defaultValue = "1200000")
long asyncRequestTimeoutMs,
@ConfigProperty(name = "server.servlet.session.timeout", defaultValue = "30m")
String sessionTimeout) {
this.taskManager = taskManager;
this.fileStorage = fileStorage;
this.request = request;
this.currentVertxRequest = currentVertxRequest;
this.resourceMonitor = resourceMonitor;
this.jobQueue = jobQueue;
@@ -63,16 +68,15 @@ public class JobExecutorService {
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
public Response runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
public ResponseEntity<?> runJobGeneric(
boolean async, Supplier<Object> work, long customTimeoutMs) {
public Response runJobGeneric(boolean async, Supplier<Object> work, long customTimeoutMs) {
return runJobGeneric(async, work, customTimeoutMs, false, 50);
}
public ResponseEntity<?> runJobGeneric(
public Response runJobGeneric(
boolean async,
Supplier<Object> work,
long customTimeoutMs,
@@ -83,15 +87,20 @@ public class JobExecutorService {
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
if (request != null) {
request.setAttribute("jobId", scopedJobKey);
try {
var current = currentVertxRequest.getCurrent();
if (current != null) {
current.put("jobId", scopedJobKey);
}
} catch (RuntimeException ignored) {
// No active request (e.g. async/background execution) - jobId attribute is optional.
}
String jobId = scopedJobKey;
final String jobOwner =
jobOwnershipService != null
? jobOwnershipService.getCurrentUserId().orElse(null)
jobOwnershipService.isResolvable()
? jobOwnershipService.get().getCurrentUserId().orElse(null)
: null;
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
@@ -141,10 +150,10 @@ public class JobExecutorService {
}
};
CompletableFuture<ResponseEntity<?>> future =
CompletableFuture<Response> future =
jobQueue.queueJob(jobId, resourceWeight, wrappedWork, timeoutToUse);
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
return Response.ok(new JobResponse<>(true, jobId, null)).build();
} else if (async) {
taskManager.createTask(jobId);
@@ -173,7 +182,7 @@ public class JobExecutorService {
}
});
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
return Response.ok(new JobResponse<>(true, jobId, null)).build();
} else {
try {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
@@ -181,15 +190,16 @@ public class JobExecutorService {
stirling.software.common.util.JobContext.setJobId(jobId);
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
if (result instanceof ResponseEntity) {
return (ResponseEntity<?>) result;
if (result instanceof Response) {
return (Response) result;
}
return handleResultForSyncJob(result);
} catch (TimeoutException te) {
log.error("Synchronous job timed out after {} ms", timeoutToUse);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Job timed out after " + timeoutToUse + " ms"))
.build();
} catch (RuntimeException e) {
Throwable cause = e.getCause();
if (e instanceof IllegalArgumentException
@@ -203,12 +213,14 @@ public class JobExecutorService {
throw e;
}
log.error("Error executing synchronous job: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Job failed: " + e.getMessage()))
.build();
} catch (Exception e) {
log.error("Error executing synchronous job: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Job failed: " + e.getMessage()))
.build();
} finally {
stirling.software.common.util.JobContext.clear();
}
@@ -219,12 +231,11 @@ public class JobExecutorService {
try {
if (result instanceof byte[]) {
String fileId = fileStorage.storeBytes((byte[]) result, "result.pdf");
taskManager.setFileResult(
jobId, fileId, "result.pdf", MediaType.APPLICATION_PDF_VALUE);
taskManager.setFileResult(jobId, fileId, "result.pdf", APPLICATION_PDF_VALUE);
log.debug("Stored byte[] result with fileId: {}", fileId);
} else if (result instanceof ResponseEntity) {
ResponseEntity<?> response = (ResponseEntity<?>) result;
Object body = response.getBody();
} else if (result instanceof Response) {
Response response = (Response) result;
Object body = response.getEntity();
if (body instanceof byte[]) {
String filename = extractResponseFilename(response);
@@ -232,23 +243,23 @@ public class JobExecutorService {
String fileId = fileStorage.storeBytes((byte[]) body, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored ResponseEntity<byte[]> result with fileId: {}", fileId);
} else if (body instanceof StreamingResponseBody streamingBody) {
log.debug("Stored Response<byte[]> result with fileId: {}", fileId);
} else if (body instanceof StreamingOutput streamingBody) {
// JAX-RS Response carries a StreamingOutput for streamed bodies (migrated from
// Spring's StreamingResponseBody).
String filename = extractResponseFilename(response);
String contentType = extractResponseContentType(response);
String fileId = fileStorage.storeFromStreamingBody(streamingBody, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug(
"Stored ResponseEntity<StreamingResponseBody> result with fileId: {}",
fileId);
log.debug("Stored Response<StreamingOutput> result with fileId: {}", fileId);
} else if (body instanceof Resource resource) {
String filename = extractResponseFilename(response);
String contentType = extractResponseContentType(response);
String fileId = fileStorage.storeFromResource(resource, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored ResponseEntity<Resource> result with fileId: {}", fileId);
log.debug("Stored Response<Resource> result with fileId: {}", fileId);
} else {
if (body != null && body.toString().contains("fileId")) {
try {
@@ -258,7 +269,7 @@ public class JobExecutorService {
if (fileId != null && !fileId.isEmpty()) {
String filename = "result.pdf";
String contentType = MediaType.APPLICATION_PDF_VALUE;
String contentType = APPLICATION_PDF_VALUE;
try {
java.lang.reflect.Method getOriginalFileName =
@@ -312,7 +323,7 @@ public class JobExecutorService {
if (fileId != null && !fileId.isEmpty()) {
String filename = "result.pdf";
String contentType = MediaType.APPLICATION_PDF_VALUE;
String contentType = APPLICATION_PDF_VALUE;
try {
java.lang.reflect.Method getOriginalFileName =
@@ -358,31 +369,35 @@ public class JobExecutorService {
}
}
private ResponseEntity<?> handleResultForSyncJob(Object result) throws IOException {
private Response handleResultForSyncJob(Object result) throws IOException {
if (result instanceof byte[]) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
return Response.ok(result)
.type(MediaType.valueOf(APPLICATION_PDF_VALUE))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\"result.pdf\"")
.body(result);
.build();
} else if (result instanceof MultipartFile file) {
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.getContentType()))
return Response.ok(file.getBytes())
.type(MediaType.valueOf(file.getContentType()))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\""
+ file.getOriginalFilename()
+ "\"")
.body(file.getBytes());
.build();
} else {
return ResponseEntity.ok(result);
return Response.ok(result).build();
}
}
private static String extractResponseFilename(ResponseEntity<?> response) {
if (response.getHeaders().getContentDisposition() != null) {
String filename = response.getHeaders().getContentDisposition().getFilename();
private static String extractResponseFilename(Response response) {
// JAX-RS exposes Content-Disposition as a raw header string; parse the filename token out
// of
// it (Spring previously used ContentDisposition#getFilename()).
String contentDisposition = response.getHeaderString(HttpHeaders.CONTENT_DISPOSITION);
if (contentDisposition != null) {
String filename = parseFilenameFromContentDisposition(contentDisposition);
if (filename != null && !filename.isEmpty()) {
return filename;
}
@@ -390,9 +405,23 @@ public class JobExecutorService {
return "result.pdf";
}
private static String extractResponseContentType(ResponseEntity<?> response) {
MediaType mediaType = response.getHeaders().getContentType();
return mediaType != null ? mediaType.toString() : MediaType.APPLICATION_PDF_VALUE;
private static String parseFilenameFromContentDisposition(String contentDisposition) {
for (String part : contentDisposition.split(";")) {
String trimmed = part.trim();
if (trimmed.regionMatches(true, 0, "filename=", 0, "filename=".length())) {
String value = trimmed.substring("filename=".length()).trim();
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
value = value.substring(1, value.length() - 1);
}
return value;
}
}
return null;
}
private static String extractResponseContentType(Response response) {
MediaType mediaType = response.getMediaType();
return mediaType != null ? mediaType.toString() : APPLICATION_PDF_VALUE;
}
private long parseSessionTimeout(String timeout) {
@@ -463,8 +492,8 @@ public class JobExecutorService {
}
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
if (jobOwnershipService.isResolvable()) {
return jobOwnershipService.get().createScopedJobKey(baseJobId);
}
return baseJobId;
}
@@ -5,10 +5,14 @@ import java.util.Map;
import java.util.concurrent.*;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.SmartLifecycle;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.runtime.StartupEvent;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.ws.rs.core.Response;
import lombok.AllArgsConstructor;
import lombok.Data;
@@ -22,25 +26,33 @@ import stirling.software.common.util.SpringContextHolder;
* Manages a queue of jobs with dynamic sizing based on system resources. Used when system resources
* are limited to prevent overloading.
*/
@Service
// TODO: Migration required - the original class implemented Spring's SmartLifecycle, which has no
// direct Quarkus equivalent. start() is now driven by a StartupEvent observer and stop() by
// @PreDestroy. The SmartLifecycle phase/auto-startup ordering semantics (getPhase()==10) cannot be
// expressed in CDI; if precise startup/shutdown ordering relative to other beans is required,
// revisit using @Priority on the observer or @io.quarkus.runtime.Startup with an ordering strategy.
@ApplicationScoped
@Slf4j
public class JobQueue implements SmartLifecycle {
public class JobQueue {
private volatile boolean running = false;
private final ResourceMonitor resourceMonitor;
@Value("${stirling.job.queue.base-capacity:10}")
private int baseQueueCapacity = 10;
// Field-default values mirror the @ConfigProperty defaults so they hold sane values during
// construction (the configured values are injected by CDI only after the constructor runs, and
// the constructor below sizes the queue from baseQueueCapacity/minQueueCapacity).
@ConfigProperty(name = "stirling.job.queue.base-capacity", defaultValue = "10")
int baseQueueCapacity = 10;
@Value("${stirling.job.queue.min-capacity:2}")
private int minQueueCapacity = 2;
@ConfigProperty(name = "stirling.job.queue.min-capacity", defaultValue = "2")
int minQueueCapacity = 2;
@Value("${stirling.job.queue.check-interval-ms:1000}")
private long queueCheckIntervalMs = 1000;
@ConfigProperty(name = "stirling.job.queue.check-interval-ms", defaultValue = "1000")
long queueCheckIntervalMs = 1000;
@Value("${stirling.job.queue.max-wait-time-ms:600000}")
private long maxWaitTimeMs = 600000; // 10 minutes
@ConfigProperty(name = "stirling.job.queue.max-wait-time-ms", defaultValue = "600000")
long maxWaitTimeMs = 600000; // 10 minutes
private volatile BlockingQueue<QueuedJob> jobQueue;
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
@@ -67,20 +79,23 @@ public class JobQueue implements SmartLifecycle {
private final Supplier<Object> work;
private final long timeoutMs;
private final Instant queuedAt;
private CompletableFuture<ResponseEntity<?>> future;
private CompletableFuture<Response> future;
private volatile boolean cancelled = false;
}
public JobQueue(ResourceMonitor resourceMonitor) {
this.resourceMonitor = resourceMonitor;
// Initialize with dynamic capacity
// Initialize the queue with a dynamic capacity in the constructor (not in
// initializeSchedulers) so it is usable immediately after construction, before the
// StartupEvent observer starts the schedulers. Uses the field-default capacities since the
// configured values are injected only after construction; updateQueueCapacity() re-sizes
// once the configured values are available.
int capacity =
resourceMonitor.calculateDynamicQueueCapacity(baseQueueCapacity, minQueueCapacity);
this.jobQueue = new LinkedBlockingQueue<>(capacity);
}
// Remove @PostConstruct to let SmartLifecycle control startup
private void initializeSchedulers() {
log.debug(
"Starting job queue with base capacity {}, min capacity {}",
@@ -99,7 +114,6 @@ public class JobQueue implements SmartLifecycle {
TimeUnit.MILLISECONDS);
}
// Remove @PreDestroy to let SmartLifecycle control shutdown
private void shutdownSchedulers() {
log.info("Shutting down job queue");
shuttingDown = true;
@@ -136,9 +150,12 @@ public class JobQueue implements SmartLifecycle {
rejectedJobs);
}
// SmartLifecycle methods
// Lifecycle methods (migrated from Spring SmartLifecycle)
void onStart(@Observes StartupEvent event) {
start();
}
@Override
public void start() {
log.info("Starting JobQueue lifecycle");
if (!running) {
@@ -147,29 +164,17 @@ public class JobQueue implements SmartLifecycle {
}
}
@Override
@PreDestroy
public void stop() {
log.info("Stopping JobQueue lifecycle");
shutdownSchedulers();
running = false;
}
@Override
public boolean isRunning() {
return running;
}
@Override
public int getPhase() {
// Start earlier than most components, but shutdown later
return 10;
}
@Override
public boolean isAutoStartup() {
return true;
}
/**
* Queues a job for execution when resources permit.
*
@@ -179,11 +184,11 @@ public class JobQueue implements SmartLifecycle {
* @param timeoutMs The timeout in milliseconds
* @return A CompletableFuture that will complete when the job is executed
*/
public CompletableFuture<ResponseEntity<?>> queueJob(
public CompletableFuture<Response> queueJob(
String jobId, int resourceWeight, Supplier<Object> work, long timeoutMs) {
// Create a CompletableFuture to track this job's completion
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
CompletableFuture<Response> future = new CompletableFuture<>();
// Create the queued job
QueuedJob job =
@@ -378,10 +383,10 @@ public class JobQueue implements SmartLifecycle {
Object result = executeWithTimeout(job.work, job.timeoutMs);
// Process the result
if (result instanceof ResponseEntity) {
job.future.complete((ResponseEntity<?>) result);
if (result instanceof Response) {
job.future.complete((Response) result);
} else {
job.future.complete(ResponseEntity.ok(result));
job.future.complete(Response.ok(result).build());
}
} catch (Exception e) {
@@ -10,17 +10,19 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.MultipartFile;
/**
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
*/
@Service
@ApplicationScoped
@Slf4j
public class MobileScannerService {
@@ -252,7 +254,7 @@ public class MobileScannerService {
}
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
@Scheduled(fixedRate = 5 * 60 * 1000)
@Scheduled(every = "5m")
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
List<String> expiredSessions = new ArrayList<>();
@@ -9,7 +9,8 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -28,7 +29,7 @@ import stirling.software.common.model.api.comments.StickyNoteSpec;
* </ul>
*/
@Slf4j
@Service
@ApplicationScoped
public class PdfAnnotationService {
/** Yellow sticky-note fill colour (R, G, B in 0..1 range). */
@@ -7,26 +7,32 @@ import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.PdfMetadata;
@Service
@ApplicationScoped
public class PdfMetadataService {
private final ApplicationProperties applicationProperties;
private final String stirlingPDFLabel;
private final UserServiceInterface userService;
// MIGRATION: Spring's @Autowired(required=false) optional bean -> CDI Instance<>
// (UserServiceInterface
// is only present in security-enabled flavors). Resolve via isResolvable()/get().
private final Instance<UserServiceInterface> userService;
private final boolean runningProOrHigher;
@Inject
public PdfMetadataService(
ApplicationProperties applicationProperties,
@Qualifier("StirlingPDFLabel") String stirlingPDFLabel,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
@Autowired(required = false) UserServiceInterface userService) {
@Named("StirlingPDFLabel") String stirlingPDFLabel,
@Named("runningProOrHigher") boolean runningProOrHigher,
Instance<UserServiceInterface> userService) {
this.applicationProperties = applicationProperties;
this.stirlingPDFLabel = stirlingPDFLabel;
this.userService = userService;
@@ -168,8 +174,8 @@ public class PdfMetadataService {
.getCustomMetadata()
.getAuthor();
if (userService != null) {
String username = userService.getCurrentUsername();
if (userService.isResolvable()) {
String username = userService.get().getCurrentUsername();
if (username != null) {
author = author.replace("username", username);
}
@@ -15,39 +15,44 @@ import java.util.Map;
import java.util.TimeZone;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import org.eclipse.microprofile.config.Config;
import com.posthog.java.PostHog;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import stirling.software.common.model.ApplicationProperties;
@Service
@ApplicationScoped
public class PostHogService {
private final PostHog postHog;
private final String uniqueId;
private final String appVersion;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
private final Environment env;
// MIGRATION: optional bean (@Autowired(required=false)) -> CDI Instance<>.
private final Instance<UserServiceInterface> userService;
// MIGRATION: Spring Environment -> MicroProfile Config.
private final Config config;
private boolean configDirMounted;
@Inject
public PostHogService(
PostHog postHog,
@Qualifier("UUID") String uuid,
@Qualifier("configDirMounted") boolean configDirMounted,
@Qualifier("appVersion") String appVersion,
@Named("UUID") String uuid,
@Named("configDirMounted") boolean configDirMounted,
@Named("appVersion") String appVersion,
ApplicationProperties applicationProperties,
@Autowired(required = false) UserServiceInterface userService,
Environment env) {
Instance<UserServiceInterface> userService,
Config config) {
this.postHog = postHog;
this.uniqueId = uuid;
this.appVersion = appVersion;
this.applicationProperties = applicationProperties;
this.userService = userService;
this.env = env;
this.config = config;
this.configDirMounted = configDirMounted;
captureSystemInfo();
}
@@ -79,7 +84,9 @@ public class PostHogService {
// Application version
metrics.put("app_version", appVersion);
String deploymentType = "JAR"; // default
if ("true".equalsIgnoreCase(env.getProperty("BROWSER_OPEN"))) {
if ("true"
.equalsIgnoreCase(
config.getOptionalValue("BROWSER_OPEN", String.class).orElse(null))) {
deploymentType = "EXE";
} else if (isRunningInDocker()) {
deploymentType = "DOCKER";
@@ -148,8 +155,8 @@ public class PostHogService {
}
metrics.put("application_properties", captureApplicationProperties());
if (userService != null) {
metrics.put("total_users_created", userService.getTotalUsersCount());
if (userService.isResolvable()) {
metrics.put("total_users_created", userService.get().getTotalUsersCount());
}
} catch (Exception e) {
@@ -11,11 +11,11 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -24,24 +24,24 @@ import lombok.extern.slf4j.Slf4j;
* Monitors system resources (CPU, memory) to inform job scheduling decisions. Provides information
* about available resources to prevent overloading the system.
*/
@Service
@ApplicationScoped
@Slf4j
public class ResourceMonitor {
@Value("${stirling.resource.memory.critical-threshold:0.9}")
private double memoryCriticalThreshold = 0.9; // 90% usage is critical
@ConfigProperty(name = "stirling.resource.memory.critical-threshold", defaultValue = "0.9")
double memoryCriticalThreshold; // 90% usage is critical
@Value("${stirling.resource.memory.high-threshold:0.75}")
private double memoryHighThreshold = 0.75; // 75% usage is high
@ConfigProperty(name = "stirling.resource.memory.high-threshold", defaultValue = "0.75")
double memoryHighThreshold; // 75% usage is high
@Value("${stirling.resource.cpu.critical-threshold:0.9}")
private double cpuCriticalThreshold = 0.9; // 90% usage is critical
@ConfigProperty(name = "stirling.resource.cpu.critical-threshold", defaultValue = "0.9")
double cpuCriticalThreshold; // 90% usage is critical
@Value("${stirling.resource.cpu.high-threshold:0.75}")
private double cpuHighThreshold = 0.75; // 75% usage is high
@ConfigProperty(name = "stirling.resource.cpu.high-threshold", defaultValue = "0.75")
double cpuHighThreshold; // 75% usage is high
@Value("${stirling.resource.monitor.interval-ms:60000}")
private long monitorIntervalMs = 60000; // 60 seconds
@ConfigProperty(name = "stirling.resource.monitor.interval-ms", defaultValue = "60000")
long monitorIntervalMs; // 60 seconds
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor(
@@ -8,7 +8,7 @@ import java.net.UnknownHostException;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -16,7 +16,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RegexPatternUtils;
@Service
@ApplicationScoped
@RequiredArgsConstructor
@Slf4j
public class SsrfProtectionService {

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