Compare commits

...
Author SHA1 Message Date
EthanHealy01 14ef08e8f4 stashing for now, this adds the ability to fetch the username if it exists 2026-03-25 20:41:37 +00:00
c46156f37f Bump/embed pdfv2.8.0 (#5921)
please merge #5919, alternatively, just push this and delete that PR
because this is a continuation of that.

This PR bumps the embed PDF version to 2.8.0 and also adds comments
functionaliy

---------

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-03-23 14:35:39 +00:00
Reece Browne 41945543e0 Fix save converted files (#5971)
Fix saving converted files on tauri
2026-03-23 13:51:40 +00:00
James Brunton e5f6180dbe Remove cmd-r override for rotation because it interferes with refresh (#5981)
# Description of Changes
Currently, cmd-r is set to rotate the PDF in the viewer instead of
perform refresh in the browser. This is unintuitive and confusing for
Mac users, and for Windows users (who are less used to doing ctrl-r for
refresh) it only works some of the time, if the Viewer is active, so
removing the override is no great loss.
2026-03-23 13:26:10 +00:00
James Brunton 57c810ab9a Add frontend developer guide describing the path alias architecture (#5964)
# Description of Changes
Add frontend developer guide describing the path alias architecture.
There's probably more needed in here which we should flesh out over
time, but this is a start.
2026-03-23 10:16:52 +00:00
brios b012f18a40 fix(gradle): bump gradle jar version to 9.3.1-bin (#5938) 2026-03-20 12:00:01 +00:00
Anthony Stirling 9e8606cab4 XSS for eml and others (#5967) 2026-03-20 11:55:23 +00:00
Achieve3318andJames Brunton 55bcb92810 Add explicit Save As button for desktop viewer (issue #5928) (#5959)
## Description

Adds an explicit **“Save As”** button to the desktop viewer so users can
always save a copy of the current PDF to a different location, even if
the original file already has a local path.

This complements the existing smart **Save/Download** behavior:
- The existing download button continues to either save back to the
original path (when available) or prompt for a path when needed.
- The new **Save As** button always opens a save dialog to choose a
location/name for a new copy.

## Changes

- **RightRail (viewer controls)**
- Added a new **Save As** action icon in the right rail settings
section.
  - The button:
- Uses `viewerContext.exportActions.saveAsCopy()` to get the current
viewer state as a PDF.
- Calls `downloadFile` without a `localPath`, ensuring the desktop app
shows a **Save As** dialog.
- Picks the first selected file (if any) or the first active file as the
source for the filename.
- **Desktop / Web behavior**
  - In the desktop app (Tauri), clicking **Save As**:
- Opens a native save dialog so the user can choose a different folder
and filename.
- Writes a new copy without changing the existing file’s `localFilePath`
or dirty state.
- In the web app, the button behaves like a standard download of a copy
(browser-controlled save dialog / download).

## Motivation

- Users often want to apply operations on a PDF while **keeping the
original unmodified**.
- The existing smart Save behavior chooses between Save and Save As
automatically, but there was no way to explicitly request **Save As**.
- This change gives desktop users a clear, dedicated **“Save As”**
control while preserving the current Save/Download behavior.

## Notes

- No backend changes.
- No changes to the existing Save / Download button behavior.
- The new button uses existing viewer export and download utilities,
minimizing new logic.

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2026-03-20 09:32:24 +00:00
Aarón Rosa Díaz a7f2abcb22 Update Spanish translation (translation.toml) (#5965) 2026-03-19 17:15:42 +00:00
Anthony Stirling 3376a87f15 speaking! (#5925) 2026-03-19 14:11:36 +00:00
PandaMan 2b9f03237a Fix non-ASCII characters in headers being rejected (#5377) (#5699) 2026-03-17 19:23:18 +00:00
ConnorYoh 214dc20c2e Hotfix-cant-run-tools-when-no-credits (#5955)
Tested:
* Can sign in on saas -> can run local tools with or without credits->
can run saas only tools (if credits) -> can't run saas only tools
without credits
* Can sign in self-hosted -> can run all tools on remote if available ->
can run local when self-hosted unavailable

Clouds show on saas tools when connected
Tools are disabled when connected to self-hosted but cannot find server.
You also get banner


#cantwaitforplaywritetests
2026-03-17 13:01:08 +00:00
unlair b656e1e2d1 Fix Docker builds on Debian (#5936) 2026-03-16 22:22:16 +00:00
James BruntonandAnthony Stirling 7f9bbebe5b Unify creditCosts.ts files (#5952)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-03-16 22:05:02 +00:00
James Brunton dbff05814f Fix any type usage in the saas/ folder (#5934)
# Description of Changes
Ages ago I made #4835 to try and fix all the `any` type usage in the
system but never got it finished, and there were just too many to review
and ensure it still worked. There's even more now.

My new tactic is to fix folder by folder. This fixes the `any` typing in
the `saas/` folder, and also enables `no-unnecessary-type-assertion`,
which really helps reduce pointless `as` casts that AI generates when
the type is already known. I hope to expand both of these to the rest of
the folders soon, but one folder is better than none.
2026-03-16 11:51:16 +00:00
Rafael Roseira Machado 1722733802 fix jumping cursor bug (#5937) 2026-03-16 11:44:23 +00:00
dependabot[bot] 85d5bb5dc2 build(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#5939)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 11:15:15 +00:00
dependabot[bot] 2e64d7cca6 build(deps): bump dorny/paths-filter from 3.0.2 to 4.0.1 (#5943)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 11:15:12 +00:00
dependabot[bot] 3908e258c8 build(deps): bump github/codeql-action from 4.32.4 to 4.32.6 (#5941)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 11:14:49 +00:00
dependabot[bot] 9b5714277a build(deps): bump srvaroa/labeler from 1.13.0 to 1.14.0 (#5942)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 11:14:29 +00:00
dependabot[bot] 9df4692648 build(deps): bump actions/cache from 4.3.0 to 5.0.3 (#5940)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 11:14:10 +00:00
James Brunton c58a6092ec Add SaaS AI engine (#5907) 2026-03-16 11:01:50 +00:00
Anthony Stirling cddc8e6df0 Delete code from invalid license (#5947) 2026-03-16 11:01:31 +00:00
James Brunton 971321fb19 Fix printing on Mac desktop (#5920)
# Description of Changes
Fix #5164 

As I mentioned on the bug
https://github.com/Stirling-Tools/Stirling-PDF/issues/5164#issuecomment-4045170827,
it's impossible to print on Mac currently because
`iframe.contentWindow?.print()` silently does nothing in Tauri on Mac,
but [it seems unlikely that this will be
fixed](https://github.com/tauri-apps/tauri/issues/13451#issuecomment-4048075861).

Instead, I've linked directly to the Mac `PDFKit` framework in Rust to
use its printing functionality instead of Safari's. I believe that
`PDFKit` is what `Preview.app` is using and the print UI that it
generates seems to perform identically, so this should solve the issue
on Mac. Hopefully one day the TS iframe print API will be fixed and
we'll be able to get rid of this code, or [there'll be an official Tauri
plugin for printing which we can use
instead](https://github.com/tauri-apps/plugins-workspace/issues/293).

This implementation should be entirely Mac-specific. Windows & Linux
will continue to use their TS printing (which comes from EmbedPDF)
unless we have a good reason to change them to use a native solution as
well.
2026-03-16 10:49:45 +00:00
Balázs Szücs f384e765fb feat(http2): add jetty-alpn-java-server dependency for HTTP/2 support (#5945) 2026-03-15 20:10:34 +00:00
dependabot[bot] 400ee16e83 build(deps): bump actions/download-artifact from 7.0.0 to 8.0.0 (#5887)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 19:56:25 +00:00
dependabot[bot] f777efdd1c build(deps): bump actions/setup-python from 6.1.0 to 6.2.0 (#5886)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 19:55:26 +00:00
dependabot[bot] 1d62f7ec23 build(deps): bump docker/metadata-action from 5.10.0 to 6.0.0 (#5889)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 19:55:12 +00:00
dependabot[bot] c5b202f2a1 build(deps): bump crazy-max/ghaction-github-runtime from 3.1.0 to 4.0.0 (#5890)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 19:54:58 +00:00
dependabot[bot]andAnthony Stirling a2b0d1122c build(deps): bump step-security/harden-runner from 2.14.0 to 2.15.1 (#5896)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-03-13 15:39:07 +00:00
stirlingbot[bot] 34c629dcb4 Update Backend 3rd Party Licenses (#5930)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-03-13 14:32:54 +00:00
stirlingbot[bot]andAnthony Stirling 4726f42030 Update Backend 3rd Party Licenses (#5798)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-03-13 14:29:27 +00:00
EthanHealy01 c9d693f1eb Improve annotations (#5919)
* Text box/notes movement improvements 
* Fix the issue where hiding, then showing annotations looses progress 
* Fix the issue where hidig/showing annotations jumps you back up to the
top of your open document 
* Support ctrl+c and  ctrl+v and backspace to delete 
* Better handling when moving to different tool from annotate 
* Added a color picker eyedropper button  
* Auto-switch to Select after note/text placement, so users can quickly
place and type 
2026-03-13 14:03:27 +00:00
ConnorYoh 44e036da5a Check if saas before blocking credit insufficiencies (#5929)
fixes #5926
2026-03-13 10:28:39 +00:00
albanobattistella 9969fe5a6d Update Italian translations (#5884) 2026-03-12 20:27:03 +00:00
ConnorYohandAnthony Stirling 0545c3f997 Cleanup-conversion-translations (#5906)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-03-12 20:23:13 +00:00
Reece Browne b68b406a2a Fix rotate failing on large documents (#5917) 2026-03-12 17:57:43 +00:00
James Brunton 8674765528 Add system for managing env vars (#5902)
# Description of Changes
Previously, `VITE_*` environment variables were scattered across the
codebase with hardcoded fallback values inline (e.g.
`import.meta.env.VITE_STRIPE_KEY || 'pk_live_...'`). This made it
unclear which variables
were required, what they were for, and caused real keys to be silently
used in builds where they hadn't been explicitly configured.

## What's changed

I've added `frontend/.env.example` and `frontend/.env.desktop.example`,
which declare every `VITE_*` variable the app uses, with comments
explaining each one and sensible defaults where applicable. These
are the source of truth for what's required.

I've added a setup script which runs before `npm run dev`, `build`,
`tauri-dev`, and all `tauri-build*` commands. It:
- Creates your local `.env` / `.env.desktop` from the example files on
first run, so you don't need to do anything manually
- Errors if you're missing keys that the example defines (e.g. after
pulling changes that added a new variable). These can either be
manually-set env vars, or in your `.env` file (env vars take precedence
over `.env` file vars when running)
- Warns if you have `VITE_*` variables set in your environment that
aren't listed in any example file

I've removed all `|| 'hardcoded-value'` defaults from source files
because they are not necessary in this system, as all variables must be
explicitly set (they can be set to `VITE_ENV_VAR=`, just as long as the
variable actually exists). I think this system will make it really
obvious exactly what you need to set and what's actually running in the
code.

I've added a test that checks that every `import.meta.env.VITE_*`
reference found in source is present in at least one example file, so
new variables can't be added without being documented.

## For contributors

New contributors shouldn't need to do anything - `npm run dev` will
create your `.env` automatically.

If you already have a `.env` file in the `frontend/` folder, you may
well need to update it to make the system happy. Here's an example
output from running `npm run dev` with an old `.env` file:

```
$ npm run dev

> frontend@0.1.0 dev
> npm run prep && vite


> frontend@0.1.0 prep
> tsx scripts/setup-env.ts && npm run generate-icons

setup-env: see frontend/README.md#environment-variables for documentation
setup-env: .env is missing keys from config/.env.example:
  VITE_GOOGLE_DRIVE_CLIENT_ID
  VITE_GOOGLE_DRIVE_API_KEY
  VITE_GOOGLE_DRIVE_APP_ID
  VITE_PUBLIC_POSTHOG_KEY
  VITE_PUBLIC_POSTHOG_HOST
  Add them manually or delete your local file to re-copy from the example.
setup-env: the following VITE_ vars are set but not listed in any example file:
  VITE_DEV_BYPASS_AUTH
  Add them to config/.env.example or config/.env.desktop.example if they are required.
```

If you add a new `VITE_*` variable to the codebase, add it to the
appropriate `frontend/config/.env.example` file or the test will fail.
2026-03-12 13:03:44 +00:00
ConnorYoh d5d03b9ada Manage state of price-lookup calls (#5915)
Now calls stripe-price-lookup once when prices are required rather then
bombarding on every rerender
2026-03-11 13:53:49 +00:00
James Brunton 32cf6866f3 Move AI advice to AGENTS.md and add symlink from CLAUDE.md (#5914)
# Description of Changes
Inspired by https://github.com/pydantic/pydantic-ai/pull/4169, this PR
moves our `CLAUDE.md` advice to the more generic `AGENTS.md` file (which
works on Codex, Gemini, etc). It also adds a symlink from `CLAUDE.md` to
`AGENTS.md`, which Claude follows properly, so all AIs should get the
same advice and we only need to keep one file up-to-date.
2026-03-11 13:43:30 +00:00
James Brunton fa8c52b2be Add SaaS frontend code (#5879)
# Description of Changes
Adds the code for the SaaS frontend as proprietary code to the OSS repo.
This version of the code is adapted from 22/1/2026, which was the last
SaaS version based on the 'V2' design. This will move us closer to being
able to have the OSS products understand whether the user has a SaaS
account, and provide the correct UI in those cases.
2026-03-11 11:53:54 +00:00
ConnorYoh 8bc37bf5ae Desktop: Fallback to local backend if self-hosted server is offline (#5880)
* Adds a fallback mechanism so the desktop app routes tool operations to
the local bundled backend when the user's self-hosted Stirling-PDF
server goes offline, and disables tools in the UI that aren't supported
locally.

* `selfHostedServerMonitor.ts` independently polls the self-hosted
server every 15s and exposes which tool endpoints are unavailable when
it goes offline
* `operationRouter.ts` intercepts operations destined for the
self-hosted server and reroutes them to the local bundled backend when
the monitor reports it offline
* `useSelfHostedToolAvailability.ts` feeds the offline tool set into
useToolManagement, disabling affected tools in the UI with a
selfHostedOffline reason and banner warning

- `SelfHostedOfflineBanner `is a dismissable (session-only) gray bar
shown at the top of the UI when in self-hosted mode and the server goes
offline. It shows:
2026-03-10 10:04:56 +00:00
James Brunton 6d9fc59bc5 Get rid of bad description for file association on Windows (#5905)
# Description of Changes
Explorer currently shows this on Windows:

<img width="380" height="43" alt="image"
src="https://github.com/user-attachments/assets/a892d827-8e0a-4f85-a035-52a454eaadd8"
/>

This PR just removes the description for the file association so we use
the default behaviour. This string is only used on Windows according to
the [Tauri docs](https://v2.tauri.app/reference/config/#description-1).
2026-03-09 15:02:10 +00:00
ConnorYohandJames Brunton ff31b2f9ca Posthog-fixes (#5901)
PostHog is now initialized with persistence: 'memory' so no cookies are
written on first load. Consent is handled in a PostHogConsentSync
component that switches to localStorage+cookie persistence only when the
user accepts, using the official @posthog/react package (cherry-picked
from 14aaf64)

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2026-03-09 12:13:09 +00:00
Brian Banerjee 81596f0299 Limit PostHog cookie to Stirling PDF's subdomain only (#5882) 2026-03-08 21:03:10 +00:00
Reece Browne 63d38e382d Chore/v2/transforms as root (#5868)
Any task that changes file type or produces more/fewer files than the
input are now consumed as root files not incremented versions of the
input.
2026-03-06 13:46:40 +00:00
Anthony Stirling a57e336675 Add searchable settings selector to AppConfigModal and improve nav behavior (#5873) 2026-03-06 11:11:17 +00:00
Anthony Stirling 456106195e translations and version bump (#5878) 2026-03-06 11:09:18 +00:00
Anthony Stirling 7c1eb4183b stop enabling english (#5874) 2026-03-06 10:09:51 +00:00
Anthony StirlingandEthanHealy01 7d640e9ce6 option to hide google drive and add settings (#5863)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-03-06 10:09:33 +00:00
ConnorYoh cafcee6c99 Add the production billing portal link for static plan page (#5860) 2026-03-06 10:08:38 +00:00
ConnorYoh 7fdd100abf Fix signatures not showing (#5872) 2026-03-06 00:43:29 +00:00
dependabot[bot] 086b55b0bb build(deps): bump pypdf from 6.7.4 to 6.7.5 in /testing/cucumber in the pip group across 1 directory (#5853)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 00:15:46 +00:00
RenzoandRenzoMXD c77242d943 fix: merge pdf pipeline validation (#5799)
Co-authored-by: RenzoMXD <RenzoMXD@users.noreply.github.com>
2026-03-06 00:14:30 +00:00
dependabot[bot] 30b0924d6b build(deps): bump digicert/ssm-code-signing from 1.2.0 to 1.2.1 (#5692)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 00:13:59 +00:00
dependabot[bot] 2d6f206c36 build(deps): bump actions/setup-node from 4.4.0 to 6.2.0 (#5691)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 00:13:35 +00:00
Anthony Stirling 6c83da6417 Audit fixes and improvements (#5835) 2026-03-05 22:00:44 +00:00
Anthony Stirling 879ffc066f tauri notifications (#5875) 2026-03-05 18:30:20 +00:00
Anthony Stirling 0f7ee5c5b0 settings menu reworks (#5864) 2026-03-05 16:20:20 +00:00
Anthony Stirling ba2d10a75b Persist Tauri window state between launches (#5871) 2026-03-05 16:18:25 +00:00
ConnorYoh 98835ce7b5 Don't build mac if you don't have the secrets (#5861)
Don't build mac if signing secrets unnavailable. 

No point in trying to build without signing as you cannot install it on
a mac without signature.
2026-03-04 15:59:42 +00:00
stirlingbot[bot] 2f2ced321a 🤖 format everything with pre-commit by stirlingbot (#5775)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-03-03 21:51:44 +00:00
Anthony Stirling bfe655fecb Bump version from 2.5.3 to 2.6.0 2026-03-03 21:46:31 +00:00
606 changed files with 80310 additions and 11648 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ indent_size = 4
max_line_length = 100
[*.py]
indent_size = 2
indent_size = 4
[*.gradle]
indent_size = 4
+3 -3
View File
@@ -35,7 +35,7 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -111,7 +111,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -359,7 +359,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -40,7 +40,7 @@ jobs:
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -128,7 +128,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -159,7 +159,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Run Gradle Command
run: |
@@ -372,7 +372,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
+83
View File
@@ -0,0 +1,83 @@
name: AI Engine CI
on:
push:
branches: [main]
pull_request:
jobs:
engine:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
defaults:
run:
working-directory: engine
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install dependencies
run: make install
- name: Run fixers
# Ignore errors here because we're going to add comments for them in the following steps before actually failing
run: make fix || true
- name: Check for fixer changes
id: fixer_changes
run: |
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Post fixer suggestions
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
uses: reviewdog/action-suggester@v1
continue-on-error: true
with:
tool_name: engine-make-fix
github_token: ${{ secrets.GITHUB_TOKEN }}
filter_mode: file
fail_level: any
level: info
- name: Comment on fixer suggestions
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: "The Python code in your PR has formatting/linting issues. Consider running `make fix` locally or setting up your editor's Ruff integration to auto-format and lint your files as you go, or commit the suggested changes on this PR.",
});
- name: Verify fixer changes are committed
if: steps.fixer_changes.outputs.changed == 'true'
run: |
if ! git diff --exit-code; then
echo "Fixes are out of date."
echo "Apply the reviewdog suggestions or run 'make fix' from engine/ and commit the updated files."
git --no-pager diff --stat
exit 1
fi
- name: Run linting
run: make lint
- name: Run type checking
run: make typecheck
- name: Run tests
run: make test
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -29,7 +29,7 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0
- uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0
with:
config_path: .github/labeler-config-srvaroa.yml
use_local_config: false
+28 -28
View File
@@ -32,14 +32,14 @@ jobs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
filters: .github/config/.files.yaml
@@ -56,7 +56,7 @@ jobs:
spring-security: [true, false]
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Checkout repository
@@ -69,7 +69,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -80,7 +80,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
@@ -111,7 +111,7 @@ jobs:
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
@@ -140,7 +140,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -154,7 +154,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -165,7 +165,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: Generate OpenAPI documentation
@@ -177,7 +177,7 @@ jobs:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: openapi-docs
path: ./SwaggerDoc.json
@@ -188,13 +188,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
cache: "npm"
@@ -202,7 +202,7 @@ jobs:
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Type-check frontend
run: cd frontend && npm run prebuild && npm run typecheck:all
run: cd frontend && npm run prep && npm run typecheck:all
- name: Lint frontend
run: cd frontend && npm run lint
- name: Build frontend
@@ -210,7 +210,7 @@ jobs:
- name: Run frontend tests
run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: frontend-build
path: frontend/dist/
@@ -222,7 +222,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -236,7 +236,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -247,7 +247,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: check the licenses for compatibility
@@ -264,7 +264,7 @@ jobs:
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
@@ -295,7 +295,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -309,7 +309,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -320,7 +320,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: Set up Docker Buildx
@@ -328,7 +328,7 @@ jobs:
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
- name: Install Docker Compose
run: |
@@ -336,7 +336,7 @@ jobs:
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
@@ -360,7 +360,7 @@ jobs:
- name: Upload Cucumber Report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cucumber-report
path: testing/cucumber/report.html
@@ -395,7 +395,7 @@ jobs:
cache-scope: stirling-pdf-fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -416,7 +416,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -427,7 +427,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: Build application
@@ -461,7 +461,7 @@ jobs:
- name: Upload Reports
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: reports-docker-${{ matrix.artifact-suffix }}
path: |
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -196,7 +196,7 @@ jobs:
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -25,7 +25,7 @@ jobs:
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -33,7 +33,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
filters: .github/config/.files.yaml
@@ -49,7 +49,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -77,7 +77,7 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
cache: "npm"
@@ -312,7 +312,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -339,7 +339,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Check licenses and generate report
id: license-check
@@ -369,7 +369,7 @@ jobs:
- name: Upload artifact on license issues
if: env.LICENSE_WARNINGS_EXIST == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: backend-dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
+17 -17
View File
@@ -36,7 +36,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -49,7 +49,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/caches
@@ -61,7 +61,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Get version number
id: versionNumber
@@ -119,7 +119,7 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -134,11 +134,11 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: "npm"
@@ -162,7 +162,7 @@ jobs:
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
- name: Upload JAR artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar
@@ -179,7 +179,7 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -193,7 +193,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: "npm"
@@ -214,7 +214,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Build Java backend with JLink
working-directory: ./
@@ -299,7 +299,7 @@ jobs:
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
@@ -542,7 +542,7 @@ jobs:
fi
- name: Upload build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
@@ -556,30 +556,30 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Download all Tauri artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: Stirling-PDF-*
path: ./artifacts/tauri
- name: Download JAR artifact (default)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: jar
path: ./artifacts/jars
- name: Download JAR artifact (with login)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: jar-with-login
path: ./artifacts/jars
- name: Download JAR artifact (server only)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: jar-server
path: ./artifacts/jars
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -39,7 +39,7 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.12
cache: "pip" # caching pip dependencies
@@ -68,7 +68,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Build with Gradle
run: ./gradlew build
+6 -6
View File
@@ -33,7 +33,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -46,7 +46,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/caches
@@ -58,7 +58,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Set up Docker Buildx
id: buildx
@@ -106,7 +106,7 @@ jobs:
- name: Generate tags for latest
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -151,7 +151,7 @@ jobs:
- name: Generate tags for latest-fat
id: meta-fat
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
@@ -195,7 +195,7 @@ jobs:
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
+3 -3
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -67,7 +67,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: SARIF file
path: results.sarif
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5
with:
sarif_file: results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -42,7 +42,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -51,7 +51,7 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
+40 -25
View File
@@ -14,7 +14,7 @@ on:
- macos
- linux
pull_request:
branches: [main, V2-tauri-windows]
branches: [main]
types: [opened, reopened, synchronize, ready_for_review]
paths:
- "frontend/src-tauri/**"
@@ -37,33 +37,48 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Determine build matrix
id: set-matrix
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
run: |
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
# Resolve requested platform (non-dispatch events always build all)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
PLATFORM="${{ github.event.inputs.platform }}"
else
# For PR/push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
PLATFORM="all"
fi
# Build candidate list
case "$PLATFORM" in
windows) ENTRIES=("$WINDOWS") ;;
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
linux) ENTRIES=("$LINUX") ;;
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
esac
# Drop macOS entries when Apple certificate secret is unavailable
if [ -z "$APPLE_CERTIFICATE" ]; then
echo "⚠️ APPLE_CERTIFICATE secret not available - skipping macOS builds"
FILTERED=()
for entry in "${ENTRIES[@]}"; do
[[ "$entry" != *'"macos'* ]] && FILTERED+=("$entry")
done
ENTRIES=("${FILTERED[@]}")
fi
JOINED=$(IFS=','; echo "${ENTRIES[*]}")
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
build:
needs: determine-matrix
strategy:
@@ -76,7 +91,7 @@ jobs:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -90,7 +105,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: "npm"
@@ -111,7 +126,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Build Java backend with JLink
working-directory: ./
@@ -197,7 +212,7 @@ jobs:
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
@@ -591,7 +606,7 @@ jobs:
}
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
@@ -652,7 +667,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -744,7 +759,7 @@ jobs:
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
+7 -7
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -41,7 +41,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Build with Gradle
run: ./gradlew build
@@ -131,14 +131,14 @@ jobs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
filters: ".github/config/.files.yaml"
@@ -150,14 +150,14 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -186,7 +186,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
+1
View File
@@ -158,6 +158,7 @@ __pycache__/
# Virtual environments
.env*
!.env*.example
.venv*
env*/
venv*/
+361
View File
@@ -0,0 +1,361 @@
# AGENTS.md
This file provides guidance to AI Agents when working with code in this repository.
## Common Development Commands
### Build and Test
- **Build project**: `./gradlew clean build`
- **Run locally**: `./gradlew bootRun`
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
### Docker Development
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
- **Example compose files**: Located in `exampleYmlFiles/` directory
### Security Mode Development
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
### Python Development
Development for the AI engine happens in the `engine/` folder. It's built with Langchain and Pydantic and allows for the creation and editing of PDF documents. The frontend calls the Python via Java as a proxy.
- Python version is 3.13; use modern Python features (type aliases, pattern matching, dataclasses, etc.) where they help clarity.
- Write fully type-correct code; keep pyright clean and avoid `Any` unless strictly necessary.
- JSON handling: deserialize into fully typed Pydantic models as early as possible, and serialize back from Pydantic models as late as possible.
- Use Makefile commands for Python work:
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting & formatting issues.
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed appropriately there, followed by running `make install`.
- Prefer using classes to nesting functions, and make other similar architectural decisions to improve testability. Do not nest classes or functions unless specifically required to for the code construct (like a decorator).
- All environment variables used within the code must begin with the `STIRLING_` prefix in order to keep them unique and easier to find.
### Frontend Development
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
- **Package Installation**: DO NOT run npm install commands - package management handled separately
- **Deployment Options**:
- **Desktop App**: `npm run tauri-build` (native desktop application)
- **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate example file:
- `frontend/config/.env.example` — core, proprietary, and shared vars
- `frontend/config/.env.saas.example` — SaaS-only vars
- `frontend/config/.env.desktop.example` — desktop (Tauri)-only vars
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the example files
- `npm run prep` / `prep:saas` / `prep:desktop` auto-create the env files from examples on first run, and error if any required keys are missing
- These prep scripts run automatically at the start of all `dev*`, `build*`, and `tauri*` commands
- See `frontend/README.md#environment-variables` for full documentation
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, see [frontend/DeveloperGuide.md](frontend/DeveloperGuide.md).
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
import { useFileContext } from "@app/contexts/FileContext";
import { FileContext } from "@app/contexts/FileContext";
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
import { AppLayout } from "@core/components/AppLayout";
import { useFileContext } from "@proprietary/contexts/FileContext";
```
**Only use explicit aliases when:**
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
**How it works:**
1. Core defines stub component (returns null or no-op)
2. Desktop/proprietary overrides with same path/name
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
**Example - Desktop-specific footer:**
```typescript
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
return null; // Stub - does nothing in web builds
}
```
```tsx
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
import { Box } from '@mantine/core';
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
return (
<Box className={className}>
<BackendHealthIndicator />
</Box>
);
}
```
```tsx
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
export function RightRail() {
return (
<div>
{/* In web builds: renders nothing (stub returns null) */}
{/* In desktop builds: renders BackendHealthIndicator */}
<RightRailFooterExtensions className="right-rail-footer" />
</div>
);
}
```
**Build resolution:**
- **Core build**: `@app/*``core/*` → Gets stub (returns null)
- **Desktop build**: `@app/*``desktop/*` → Gets real implementation (shadows core)
**Benefits:**
- No runtime checks or feature flags
- Type-safe across all builds
- Clean, readable code
- Build-time optimization (dead code elimination)
#### Multi-Tool Workflow Architecture
Frontend designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
- File state and processing results persist across tool switches
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
**Location**: `frontend/src/core/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
- **IndexedDB persistence**: File storage with thumbnail caching
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
#### Processing Services
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
- **fileStorage**: IndexedDB with LRU cache management
#### Memory Management Strategy
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
- PDF.js documents that need explicit .destroy() calls
- Blob URLs from tool outputs that need revocation
- Web Workers that need termination
Without cleanup: browser crashes with memory leaks.
#### Tool Development
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
- **Supporting Hooks**:
- **useToolState**: UI state management (loading, progress, error, files)
- **useToolApiCalls**: HTTP requests and file processing
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
- **Utilities**:
- **toolErrorHandler**: Standardized error extraction and i18n support
- **toolResponseProcessor**: API response handling (single/zip/custom)
- **toolOperationTracker**: FileContext integration utilities
**Three Tool Patterns**:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
```typescript
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
});
```
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
```typescript
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_',
});
```
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
```typescript
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
});
```
**Benefits**:
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
- **Consistent**: All tools follow same pattern and interface
- **Maintainable**: Single responsibility hooks, easy to test and modify
- **i18n Ready**: Built-in internationalization support
- **Type Safe**: Full TypeScript support with generic interfaces
- **Memory Safe**: Automatic resource cleanup and blob URL management
## Architecture Overview
### Project Structure
- **Backend**: Spring Boot application
- **Frontend**: React-based SPA in `/frontend` directory
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
- **Internationalization**: JSON-based translations (converted from backend .properties)
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
- **Configuration**: YAML-based configuration with environment variable overrides
### Controller Architecture
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
- Organized by function: converters, security, misc, pipeline
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
### Key Components
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
- **ConfigInitializer**: Handles runtime configuration and settings files
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
- **Security Layer**: Authentication, authorization, and user management (when enabled)
### Frontend Directory Structure
The frontend is organized with a clear separation of concerns:
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
- **`core/components/`**: React components organized by feature
- `core/components/tools/`: Individual PDF tool implementations
- `core/components/viewer/`: PDF viewer components
- `core/components/pageEditor/`: Page manipulation UI
- `core/components/tooltips/`: Help tooltips for tools
- `core/components/shared/`: Reusable UI components
- **`core/contexts/`**: React Context providers
- `FileContext.tsx`: Central file state management
- `file/`: File reducer and selectors
- `toolWorkflow/`: Tool workflow state
- **`core/hooks/`**: Custom React hooks
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
- **`core/constants/`**: Application constants and configuration
- **`core/data/`**: Static data (tool taxonomy, etc.)
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/src/proprietary/`**: Proprietary/licensed features
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/public/`**: Static assets served directly
- `public/locales/`: Translation JSON files
### Component Architecture
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
- Conversion Script: `scripts/convert_properties_to_json.py`
### Configuration Modes
- **Ultra-lite**: Basic PDF operations only
- **Standard**: Full feature set
- **Fat**: Pre-downloaded dependencies for air-gapped environments
- **Security Mode**: Adds authentication, user management, and enterprise features
### Testing Strategy
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
- **Docker Testing**: `test.sh` validates all Docker variants
- **Manual Testing**: No unit tests currently - relies on UI and API testing
## Development Workflow
1. **Local Development**:
- Backend: `./gradlew bootRun` (runs on localhost:8080)
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
2. **Docker Testing**: Use `./test.sh` before submitting PRs
3. **Code Style**: Spotless enforces Google Java Format automatically
4. **Translations**:
- Backend: Use helper scripts in `/scripts` for multi-language updates
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
## Frontend Architecture Status
- **Core Status**: React SPA architecture complete with multi-tool workflow support
- **State Management**: FileContext handles all file operations and tool navigation
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- Translation files are located in `frontend/public/locales/`
## Important Notes
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
- **Lombok**: Used extensively - ensure IDE plugin is installed
- **File Persistence**:
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
## Communication Style
- Be direct and to the point
- No apologies or conversational filler
- Answer questions directly without preamble
- Explain reasoning concisely when asked
- Avoid unnecessary elaboration
## Decision Making
- Ask clarifying questions before making assumptions
- Stop and ask when uncertain about project-specific details
- Confirm approach before making structural changes
- Request guidance on preferences (cross-platform vs specific tools, etc.)
- Verify understanding of requirements before proceeding
-337
View File
@@ -1,337 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Build and Test
- **Build project**: `./gradlew clean build`
- **Run locally**: `./gradlew bootRun`
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
### Docker Development
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
- **Example compose files**: Located in `exampleYmlFiles/` directory
### Security Mode Development
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
### Frontend Development
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
- **Package Installation**: DO NOT run npm install commands - package management handled separately
- **Deployment Options**:
- **Desktop App**: `npm run tauri-build` (native desktop application)
- **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
import { useFileContext } from "@app/contexts/FileContext";
import { FileContext } from "@app/contexts/FileContext";
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
import { AppLayout } from "@core/components/AppLayout";
import { useFileContext } from "@proprietary/contexts/FileContext";
```
**Only use explicit aliases when:**
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
**How it works:**
1. Core defines stub component (returns null or no-op)
2. Desktop/proprietary overrides with same path/name
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
**Example - Desktop-specific footer:**
```typescript
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
return null; // Stub - does nothing in web builds
}
```
```typescript
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
import { Box } from '@mantine/core';
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
return (
<Box className={className}>
<BackendHealthIndicator />
</Box>
);
}
```
```typescript
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
export function RightRail() {
return (
<div>
{/* In web builds: renders nothing (stub returns null) */}
{/* In desktop builds: renders BackendHealthIndicator */}
<RightRailFooterExtensions className="right-rail-footer" />
</div>
);
}
```
**Build resolution:**
- **Core build**: `@app/*``core/*` → Gets stub (returns null)
- **Desktop build**: `@app/*``desktop/*` → Gets real implementation (shadows core)
**Benefits:**
- No runtime checks or feature flags
- Type-safe across all builds
- Clean, readable code
- Build-time optimization (dead code elimination)
#### Multi-Tool Workflow Architecture
Frontend designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
- File state and processing results persist across tool switches
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
**Location**: `frontend/src/core/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
- **IndexedDB persistence**: File storage with thumbnail caching
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
#### Processing Services
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
- **fileStorage**: IndexedDB with LRU cache management
#### Memory Management Strategy
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
- PDF.js documents that need explicit .destroy() calls
- Blob URLs from tool outputs that need revocation
- Web Workers that need termination
Without cleanup: browser crashes with memory leaks.
#### Tool Development
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
- **Supporting Hooks**:
- **useToolState**: UI state management (loading, progress, error, files)
- **useToolApiCalls**: HTTP requests and file processing
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
- **Utilities**:
- **toolErrorHandler**: Standardized error extraction and i18n support
- **toolResponseProcessor**: API response handling (single/zip/custom)
- **toolOperationTracker**: FileContext integration utilities
**Three Tool Patterns**:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
```typescript
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
});
```
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
```typescript
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_',
});
```
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
```typescript
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
});
```
**Benefits**:
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
- **Consistent**: All tools follow same pattern and interface
- **Maintainable**: Single responsibility hooks, easy to test and modify
- **i18n Ready**: Built-in internationalization support
- **Type Safe**: Full TypeScript support with generic interfaces
- **Memory Safe**: Automatic resource cleanup and blob URL management
## Architecture Overview
### Project Structure
- **Backend**: Spring Boot application
- **Frontend**: React-based SPA in `/frontend` directory
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
- **Internationalization**: JSON-based translations (converted from backend .properties)
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
- **Configuration**: YAML-based configuration with environment variable overrides
### Controller Architecture
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
- Organized by function: converters, security, misc, pipeline
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
### Key Components
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
- **ConfigInitializer**: Handles runtime configuration and settings files
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
- **Security Layer**: Authentication, authorization, and user management (when enabled)
### Frontend Directory Structure
The frontend is organized with a clear separation of concerns:
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
- **`core/components/`**: React components organized by feature
- `core/components/tools/`: Individual PDF tool implementations
- `core/components/viewer/`: PDF viewer components
- `core/components/pageEditor/`: Page manipulation UI
- `core/components/tooltips/`: Help tooltips for tools
- `core/components/shared/`: Reusable UI components
- **`core/contexts/`**: React Context providers
- `FileContext.tsx`: Central file state management
- `file/`: File reducer and selectors
- `toolWorkflow/`: Tool workflow state
- **`core/hooks/`**: Custom React hooks
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
- **`core/constants/`**: Application constants and configuration
- **`core/data/`**: Static data (tool taxonomy, etc.)
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/src/proprietary/`**: Proprietary/licensed features
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/public/`**: Static assets served directly
- `public/locales/`: Translation JSON files
### Component Architecture
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
- Conversion Script: `scripts/convert_properties_to_json.py`
### Configuration Modes
- **Ultra-lite**: Basic PDF operations only
- **Standard**: Full feature set
- **Fat**: Pre-downloaded dependencies for air-gapped environments
- **Security Mode**: Adds authentication, user management, and enterprise features
### Testing Strategy
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
- **Docker Testing**: `test.sh` validates all Docker variants
- **Manual Testing**: No unit tests currently - relies on UI and API testing
## Development Workflow
1. **Local Development**:
- Backend: `./gradlew bootRun` (runs on localhost:8080)
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
2. **Docker Testing**: Use `./test.sh` before submitting PRs
3. **Code Style**: Spotless enforces Google Java Format automatically
4. **Translations**:
- Backend: Use helper scripts in `/scripts` for multi-language updates
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
## Frontend Architecture Status
- **Core Status**: React SPA architecture complete with multi-tool workflow support
- **State Management**: FileContext handles all file operations and tool navigation
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- Translation files are located in `frontend/public/locales/`
## Important Notes
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
- **Lombok**: Used extensively - ensure IDE plugin is installed
- **File Persistence**:
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
## Communication Style
- Be direct and to the point
- No apologies or conversational filler
- Answer questions directly without preamble
- Explain reasoning concisely when asked
- Avoid unnecessary elaboration
## Decision Making
- Ask clarifying questions before making assumptions
- Stop and ask when uncertain about project-specific details
- Confirm approach before making structural changes
- Request guidance on preferences (cross-platform vs specific tools, etc.)
- Verify understanding of requirements before proceeding
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+4
View File
@@ -6,10 +6,14 @@ Portions of this software are licensed as follows:
* All content that resides under the "app/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
* All content that resides under the "engine/" directory of this repository,
if that directory exists, is licensed under the license defined in "engine/LICENSE".
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
* All content that resides under the "frontend/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
* All content that resides under the "frontend/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
@@ -373,7 +373,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Other", REMOVE_BLANKS);
addEndpointToGroup("Other", "remove-annotations");
addEndpointToGroup("Other", "get-info-on-pdf");
addEndpointToGroup("Other", "remove-image-pdf");
addEndpointToGroup("Other", "add-attachments");
addEndpointToGroup("Other", "replace-invert-pdf");
addEndpointToGroup("Other", "edit-table-of-contents");
@@ -488,7 +487,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", REMOVE_BLANKS);
addEndpointToGroup("Java", "remove-annotations");
addEndpointToGroup("Java", "pdf-to-text");
addEndpointToGroup("Java", "remove-image-pdf");
addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf");
@@ -2,12 +2,14 @@ package stirling.software.common.aop;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.HashMap;
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;
@@ -26,7 +28,7 @@ import stirling.software.common.service.JobExecutorService;
@Component
@RequiredArgsConstructor
@Slf4j
@Order(0) // Highest precedence - executes before audit aspects
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
public class AutoJobAspect {
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
@@ -70,26 +72,29 @@ public class AutoJobAspect {
// No retries needed, simple execution
return jobExecutorService.runJobGeneric(
async,
() -> {
try {
// Note: Progress tracking is handled in TaskManager/JobExecutorService
// The trackProgress flag controls whether detailed progress is stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
// Rethrow RuntimeException as-is to preserve exception type
if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(ex);
}
},
wrapWithMDC(
() -> {
try {
// Note: Progress tracking is handled in
// TaskManager/JobExecutorService
// The trackProgress flag controls whether detailed progress is
// stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
// Rethrow RuntimeException as-is to preserve exception type
if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(ex);
}
}),
timeout,
queueable,
resourceWeight);
@@ -123,114 +128,108 @@ public class AutoJobAspect {
return jobExecutorService.runJobGeneric(
async,
() -> {
// Use iterative approach instead of recursion to avoid stack overflow
Throwable lastException = null;
wrapWithMDC(
() -> {
// Use iterative approach instead of recursion to avoid stack overflow
Throwable lastException = null;
// Attempt counter starts at 1 for first try
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
try {
if (trackProgress && async) {
// Get jobId for progress tracking in TaskManager
// This enables REST API progress queries, not WebSocket
if (jobIdRef.get() == null) {
jobIdRef.set(getJobIdFromContext());
}
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Tracking progress for job {} (attempt {}/{})",
jobId,
// Attempt counter starts at 1 for first try
for (int currentAttempt = 1;
currentAttempt <= maxRetries;
currentAttempt++) {
try {
if (trackProgress && async) {
// Get jobId for progress tracking in TaskManager
// This enables REST API progress queries, not WebSocket
if (jobIdRef.get() == null) {
jobIdRef.set(getJobIdFromContext());
}
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Tracking progress for job {} (attempt {}/{})",
jobId,
currentAttempt,
maxRetries);
// Progress is tracked in TaskManager for REST API
// access
// No WebSocket notifications sent here
}
}
// Attempt to execute the operation
return joinPoint.proceed(args);
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution (attempt"
+ " {}/{}): {}",
currentAttempt,
maxRetries);
// Progress is tracked in TaskManager for REST API access
// No WebSocket notifications sent here
}
}
maxRetries,
ex.getMessage(),
ex);
// Attempt to execute the operation
return joinPoint.proceed(args);
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
"Retrying operation, attempt {}/{}",
currentAttempt + 1,
maxRetries);
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution (attempt"
+ " {}/{}): {}",
currentAttempt,
maxRetries,
ex.getMessage(),
ex);
if (trackProgress && async) {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API
// access
}
}
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
"Retrying operation, attempt {}/{}",
currentAttempt + 1,
maxRetries);
// Use sleep for retry delay
// For sync jobs, both sleep and async are blocking at this
// point
// For async jobs, the delay occurs in the executor thread
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
if (trackProgress && async) {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API access
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.debug(
"Retry delay interrupted for attempt {}/{}",
currentAttempt,
maxRetries);
break;
}
} else {
// No more retries, we'll throw the exception after the loop
break;
}
}
// Use non-blocking delay for all retry attempts to avoid blocking
// threads
// For sync jobs this avoids starving the tomcat thread pool under
// load
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
// Execute the retry after a delay through the JobExecutorService
// rather than blocking the current thread with sleep
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
// Use a delayed executor for non-blocking delay
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
.execute(
() -> {
// Continue the retry loop in the next iteration
// We can't return from here directly since
// we're in a Runnable
delayedRetry.complete(null);
});
// Wait for the delay to complete before continuing
try {
delayedRetry.join();
} catch (Exception e) {
Thread.currentThread().interrupt();
break;
}
} else {
// No more retries, we'll throw the exception after the loop
break;
}
}
}
// If we get here, all retries failed
if (lastException != null) {
// Rethrow RuntimeException as-is to preserve exception type
if (lastException instanceof RuntimeException) {
throw (RuntimeException) lastException;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(
"Job failed after "
+ maxRetries
+ " attempts: "
+ lastException.getMessage(),
lastException);
}
// If we get here, all retries failed
if (lastException != null) {
// Rethrow RuntimeException as-is to preserve exception type
if (lastException instanceof RuntimeException) {
throw (RuntimeException) lastException;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(
"Job failed after "
+ maxRetries
+ " attempts: "
+ lastException.getMessage(),
lastException);
}
// This should never happen if lastException is properly tracked
throw new RuntimeException("Job failed but no exception was recorded");
},
// This should never happen if lastException is properly tracked
throw new RuntimeException("Job failed but no exception was recorded");
}),
timeout,
queueable,
resourceWeight);
@@ -299,4 +298,32 @@ public class AutoJobAspect {
return null;
}
}
/**
* Wraps a supplier to propagate MDC context to background threads. Captures MDC on request
* thread and restores it in the background thread. Ensures proper cleanup to prevent context
* leakage across jobs in thread pools.
*/
private <T> Supplier<T> wrapWithMDC(Supplier<T> supplier) {
final Map<String, String> captured = MDC.getCopyOfContextMap();
return () -> {
final Map<String, String> previous = MDC.getCopyOfContextMap();
try {
// Set the captured context (or clear if none was captured)
if (captured != null) {
MDC.setContextMap(new HashMap<>(captured));
} else {
MDC.clear();
}
return supplier.get();
} finally {
// Restore previous state (or clear if there was none)
if (previous != null) {
MDC.setContextMap(previous);
} else {
MDC.clear();
}
}
};
}
}
@@ -696,7 +696,8 @@ public class ApplicationProperties {
@Override
public String toString() {
return """
return
"""
Driver {
driverName='%s'
}
@@ -712,6 +713,7 @@ public class ApplicationProperties {
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
private boolean defaultHideUnavailableTools = false;
private boolean defaultHideUnavailableConversions = false;
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
@@ -724,6 +726,12 @@ public class ApplicationProperties {
}
return "classic"; // default
}
@Data
public static class HideDisabledTools {
private boolean googleDrive = false;
private boolean mobileQRScanner = false;
}
}
@Data
@@ -911,6 +919,15 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class GoogleDrive {
private boolean enabled = false;
private String clientId = "";
private String apiKey = "";
private String appId = "";
}
@Data
public static class CustomMetadata {
@@ -960,6 +977,12 @@ public class ApplicationProperties {
private boolean enabled = true;
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
private int retentionDays = 90;
private boolean captureFileHash =
false; // Capture SHA-256 hash of files (increases processing time)
private boolean capturePdfAuthor =
false; // Capture PDF author metadata (increases processing time)
private boolean captureOperationResults =
false; // Capture operation return values (not recommended, high volume)
}
@Data
@@ -169,7 +169,10 @@ public class PdfMetadataService {
.getAuthor();
if (userService != null) {
author = author.replace("username", userService.getCurrentUsername());
String username = userService.getCurrentUsername();
if (username != null) {
author = author.replace("username", username);
}
}
}
pdf.getDocumentInformation().setAuthor(author);
@@ -226,10 +226,11 @@ public class SsrfProtectionService {
}
private boolean isPrivateIPv4Range(String ip) {
// Includes RFC1918, loopback, link-local, and unspecified addresses
// Includes RFC1918, RFC6598, loopback, link-local, and unspecified addresses
return ip.startsWith("10.")
|| ip.startsWith("192.168.")
|| (ip.startsWith("172.") && isInRange172(ip))
|| (ip.startsWith("100.") && isInRange100(ip))
|| ip.startsWith("169.254.")
|| ip.startsWith("127.")
|| "0.0.0.0".equals(ip);
@@ -247,6 +248,18 @@ public class SsrfProtectionService {
return false;
}
private boolean isInRange100(String ip) {
String[] parts = ip.split("\\.");
if (parts.length >= 2) {
try {
int secondOctet = Integer.parseInt(parts[1]);
return secondOctet >= 64 && secondOctet <= 127;
} catch (NumberFormatException e) {
}
}
return false;
}
private boolean isCloudMetadataAddress(String ip) {
String normalizedIp = normalizeIpv4MappedAddress(ip);
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
@@ -341,7 +341,8 @@ public class EmlProcessingUtils {
}
private String getFallbackStyles() {
return """
return
"""
/* Minimal fallback - main CSS resource failed to load */
body {
font-family: var(--font-family, Helvetica, sans-serif);
@@ -13,11 +13,17 @@ public class EmlToPdf {
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
throws IOException {
return convertEmlToHtml(emlBytes, request, null);
}
public static String convertEmlToHtml(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, null);
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, customHtmlSanitizer);
}
public static byte[] convertEmlToPdf(
@@ -873,6 +873,36 @@ public class GeneralUtils {
settingsYaml.saveOverride(settingsPath);
}
/**
* Updates multiple settings in a single transaction. This ensures that nested settings (e.g.,
* oauth2.client.google.*) don't lose sibling values when partial updates are made.
*
* <p>Instead of multiple read-update-write cycles (which could cause race conditions), this
* method loads the YAML once, applies all updates, and saves once.
*
* @param settingsMap Map of dotted-notation keys to values to update
* @throws IOException if file read/write fails
*/
public void updateSettingsTransactional(Map<String, Object> settingsMap) throws IOException {
if (settingsMap == null || settingsMap.isEmpty()) {
return;
}
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
YamlHelper settingsYaml = new YamlHelper(settingsPath);
// Apply all updates to the same YamlHelper instance
for (Map.Entry<String, Object> entry : settingsMap.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
String[] keyArray = key.split("\\.");
settingsYaml.updateValue(Arrays.asList(keyArray), value);
}
// Save only once after all updates are applied
settingsYaml.saveOverride(settingsPath);
}
/*
* Machine fingerprint generation with better error logging and fallbacks.
*
@@ -1,44 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.springframework.ui.Model;
import org.springframework.web.servlet.ModelAndView;
public class ErrorUtilsTest {
@Test
public void testExceptionToModel() {
// Create a mock Model
Model model = new org.springframework.ui.ExtendedModelMap();
// Create a test exception
Exception ex = new Exception("Test Exception");
// Call the method under test
Model resultModel = ErrorUtils.exceptionToModel(model, ex);
// Verify the result
assertNotNull(resultModel);
assertEquals("Test Exception", resultModel.getAttribute("errorMessage"));
assertNotNull(resultModel.getAttribute("stackTrace"));
}
@Test
public void testExceptionToModelView() {
// Create a mock Model
Model model = new org.springframework.ui.ExtendedModelMap();
// Create a test exception
Exception ex = new Exception("Test Exception");
// Call the method under test
ModelAndView modelAndView = ErrorUtils.exceptionToModelView(model, ex);
// Verify the result
assertNotNull(modelAndView);
assertEquals("Test Exception", modelAndView.getModel().get("errorMessage"));
assertNotNull(modelAndView.getModel().get("stackTrace"));
}
}
@@ -1,194 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.util.List;
import java.util.function.Predicate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.configuration.RuntimePathConfig;
@ExtendWith(MockitoExtension.class)
class FileMonitorTest {
@TempDir Path tempDir;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private Predicate<Path> pathFilter;
private FileMonitor fileMonitor;
@BeforeEach
void setUp() throws IOException {
when(runtimePathConfig.getPipelineWatchedFoldersPaths())
.thenReturn(List.of(tempDir.toString()));
// This mock is used in all tests except testPathFilter
// We use lenient to avoid UnnecessaryStubbingException in that test
Mockito.lenient().when(pathFilter.test(any())).thenReturn(true);
fileMonitor = new FileMonitor(pathFilter, runtimePathConfig);
}
@Test
void testIsFileReadyForProcessing_OldFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("test-file.txt");
Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// File should be ready for processing as it was modified more than 5 seconds ago
assertTrue(fileMonitor.isFileReadyForProcessing(testFile));
}
@Test
void testIsFileReadyForProcessing_RecentFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("recent-file.txt");
Files.write(testFile, "test content".getBytes());
// Set modified time to just now (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(testTime));
// File should not be ready for processing as it was just modified
assertFalse(fileMonitor.isFileReadyForProcessing(testFile));
}
@Test
void testIsFileReadyForProcessing_NonExistentFile() {
// Create a path to a file that doesn't exist
Path nonExistentFile = tempDir.resolve("non-existent-file.txt");
// Non-existent file should not be ready for processing
assertFalse(fileMonitor.isFileReadyForProcessing(nonExistentFile));
}
@Test
void testIsFileReadyForProcessing_LockedFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("locked-file.txt");
Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago (relative to test start time) to make sure it passes
// the time check
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// Verify the file is considered ready when it meets the time criteria
assertTrue(
fileMonitor.isFileReadyForProcessing(testFile),
"File should be ready for processing when sufficiently old");
}
@Test
void testPathFilter() throws IOException {
// Use a simple lambda instead of a mock for better control
Predicate<Path> pdfFilter = path -> path.toString().endsWith(".pdf");
// Create a new FileMonitor with the PDF filter
FileMonitor pdfMonitor = new FileMonitor(pdfFilter, runtimePathConfig);
// Create a PDF file
Path pdfFile = tempDir.resolve("test.pdf");
Files.write(pdfFile, "pdf content".getBytes());
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
// Create a TXT file
Path txtFile = tempDir.resolve("test.txt");
Files.write(txtFile, "text content".getBytes());
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
// PDF file should be ready for processing
assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile));
// Note: In the current implementation, FileMonitor.isFileReadyForProcessing()
// doesn't check file filters directly - it only checks criteria like file existence
// and modification time. The filtering is likely handled elsewhere in the workflow.
// To avoid test failures, we'll verify that the filter itself works correctly
assertFalse(pdfFilter.test(txtFile), "PDF filter should reject txt files");
assertTrue(pdfFilter.test(pdfFile), "PDF filter should accept pdf files");
}
@Test
void testIsFileReadyForProcessing_FileInUse() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("in-use-file.txt");
Files.write(testFile, "initial content".getBytes());
// Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// First check that the file is ready when meeting time criteria
assertTrue(
fileMonitor.isFileReadyForProcessing(testFile),
"File should be ready for processing when sufficiently old");
// After modifying the file to simulate closing, it should still be ready
Files.write(testFile, "updated content".getBytes());
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
assertTrue(
fileMonitor.isFileReadyForProcessing(testFile),
"File should be ready for processing after updating");
}
@Test
void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("absolute-path-file.txt");
Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// File should be ready for processing as it was modified more than 5 seconds ago
// Use the absolute path to make sure it's handled correctly
assertTrue(fileMonitor.isFileReadyForProcessing(testFile.toAbsolutePath()));
}
@Test
void testIsFileReadyForProcessing_DirectoryInsteadOfFile() throws IOException {
// Create a test directory
Path testDir = tempDir.resolve("test-directory");
Files.createDirectory(testDir);
// Set modified time to 10 seconds ago
Files.setLastModifiedTime(testDir, FileTime.from(Instant.ofEpochMilli(1000000L)));
// A directory should not be considered ready for processing
boolean isReady = fileMonitor.isFileReadyForProcessing(testDir);
assertFalse(isReady, "A directory should not be considered ready for processing");
}
}
@@ -1,115 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.SsrfProtectionService;
public class FileToPdfTest {
private CustomHtmlSanitizer customHtmlSanitizer;
@BeforeEach
void setUp() {
SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class);
stirling.software.common.model.ApplicationProperties mockApplicationProperties =
mock(stirling.software.common.model.ApplicationProperties.class);
stirling.software.common.model.ApplicationProperties.System mockSystem =
mock(stirling.software.common.model.ApplicationProperties.System.class);
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(true);
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
when(mockSystem.isDisableSanitize()).thenReturn(false);
customHtmlSanitizer =
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
}
/**
* Test the HTML to PDF conversion. This test expects an IOException when an empty HTML input is
* provided.
*/
@Test
public void testConvertHtmlToPdf() {
HTMLToPdfRequest request = new HTMLToPdfRequest();
byte[] fileBytes = new byte[0]; // Sample file bytes (empty input)
String fileName = "test.html"; // Sample file name indicating an HTML file
TempFileManager tempFileManager = mock(TempFileManager.class); // Mock TempFileManager
// Mock the temp file creation to return real temp files
try {
when(tempFileManager.createTempFile(anyString()))
.thenReturn(Files.createTempFile("test", ".pdf").toFile())
.thenReturn(Files.createTempFile("test", ".html").toFile());
} catch (IOException e) {
throw new RuntimeException(e);
}
// Expect an IOException to be thrown due to empty input or invalid weasyprint path
Throwable thrown =
assertThrows(
Exception.class,
() ->
FileToPdf.convertHtmlToPdf(
"/path/",
request,
fileBytes,
fileName,
tempFileManager,
customHtmlSanitizer));
assertNotNull(thrown);
}
/**
* Test sanitizeZipFilename with null or empty input. It should return an empty string in these
* cases.
*/
@Test
public void testSanitizeZipFilename_NullOrEmpty() {
assertEquals("", FileToPdf.sanitizeZipFilename(null));
assertEquals("", FileToPdf.sanitizeZipFilename(" "));
}
/**
* Test sanitizeZipFilename to ensure it removes path traversal sequences. This includes
* removing both forward and backward slash sequences.
*/
@Test
public void testSanitizeZipFilename_RemovesTraversalSequences() {
String input = "../some/../path/..\\to\\file.txt";
String expected = "some/path/to/file.txt";
// Expect that the method replaces backslashes with forward slashes
// and removes path traversal sequences
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
}
/** Test sanitizeZipFilename to ensure that it removes leading drive letters and slashes. */
@Test
public void testSanitizeZipFilename_RemovesLeadingDriveAndSlashes() {
String input = "C:\\folder\\file.txt";
String expected = "folder/file.txt";
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
input = "/folder/file.txt";
expected = "folder/file.txt";
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
}
/** Test sanitizeZipFilename to verify that safe filenames remain unchanged. */
@Test
public void testSanitizeZipFilename_NoChangeForSafeNames() {
String input = "folder/subfolder/file.txt";
assertEquals(input, FileToPdf.sanitizeZipFilename(input));
}
}
@@ -1,80 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import org.junit.jupiter.api.Test;
public class ImageProcessingUtilsTest {
private static void fillImageWithColor(BufferedImage image) {
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
image.setRGB(x, y, Color.RED.getRGB());
}
}
}
@Test
void testConvertColorTypeToGreyscale() {
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
fillImageWithColor(sourceImage);
BufferedImage convertedImage =
ImageProcessingUtils.convertColorType(sourceImage, "greyscale");
assertNotNull(convertedImage);
assertEquals(BufferedImage.TYPE_BYTE_GRAY, convertedImage.getType());
assertEquals(sourceImage.getWidth(), convertedImage.getWidth());
assertEquals(sourceImage.getHeight(), convertedImage.getHeight());
// Check if a pixel is correctly converted to greyscale
Color grey = new Color(convertedImage.getRGB(0, 0));
assertEquals(grey.getRed(), grey.getGreen());
assertEquals(grey.getGreen(), grey.getBlue());
}
@Test
void testConvertColorTypeToBlackWhite() {
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
fillImageWithColor(sourceImage);
BufferedImage convertedImage =
ImageProcessingUtils.convertColorType(sourceImage, "blackwhite");
assertNotNull(convertedImage);
assertEquals(BufferedImage.TYPE_BYTE_BINARY, convertedImage.getType());
assertEquals(sourceImage.getWidth(), convertedImage.getWidth());
assertEquals(sourceImage.getHeight(), convertedImage.getHeight());
// Check if a pixel is converted correctly (binary image will be either black or white)
int rgb = convertedImage.getRGB(0, 0);
assertTrue(rgb == Color.BLACK.getRGB() || rgb == Color.WHITE.getRGB());
}
@Test
void testConvertColorTypeToFullColor() {
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
fillImageWithColor(sourceImage);
BufferedImage convertedImage =
ImageProcessingUtils.convertColorType(sourceImage, "fullcolor");
assertNotNull(convertedImage);
assertEquals(sourceImage, convertedImage);
}
@Test
void testConvertColorTypeInvalid() {
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
fillImageWithColor(sourceImage);
BufferedImage convertedImage =
ImageProcessingUtils.convertColorType(sourceImage, "invalidtype");
assertNotNull(convertedImage);
assertEquals(sourceImage, convertedImage);
}
}
@@ -1,736 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.RenderedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.imageio.ImageIO;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.ImageType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
public class PdfUtilsTest {
@Test
void testTextToPageSize() {
assertEquals(PDRectangle.A0, PdfUtils.textToPageSize("A0"));
assertEquals(PDRectangle.A1, PdfUtils.textToPageSize("A1"));
assertEquals(PDRectangle.A2, PdfUtils.textToPageSize("A2"));
assertEquals(PDRectangle.A3, PdfUtils.textToPageSize("A3"));
assertEquals(PDRectangle.A4, PdfUtils.textToPageSize("A4"));
assertEquals(PDRectangle.A5, PdfUtils.textToPageSize("A5"));
assertEquals(PDRectangle.A6, PdfUtils.textToPageSize("A6"));
assertEquals(PDRectangle.LETTER, PdfUtils.textToPageSize("LETTER"));
assertEquals(PDRectangle.LEGAL, PdfUtils.textToPageSize("LEGAL"));
assertThrows(IllegalArgumentException.class, () -> PdfUtils.textToPageSize("INVALID"));
}
@Test
void testGetAllImages() throws Exception {
// Root resources
PDResources root = mock(PDResources.class);
COSName im1 = COSName.getPDFName("Im1");
COSName form1 = COSName.getPDFName("Form1");
COSName other1 = COSName.getPDFName("Other1");
when(root.getXObjectNames()).thenReturn(Arrays.asList(im1, form1, other1));
// Direct image at root
PDImageXObject imgXObj1 = mock(PDImageXObject.class);
BufferedImage img1 = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
when(imgXObj1.getImage()).thenReturn(img1);
when(root.getXObject(im1)).thenReturn(imgXObj1);
// "Other" XObject that should be ignored
PDXObject otherXObj = mock(PDXObject.class);
when(root.getXObject(other1)).thenReturn(otherXObj);
// Form XObject with its own resources
PDFormXObject formXObj = mock(PDFormXObject.class);
PDResources formRes = mock(PDResources.class);
when(formXObj.getResources()).thenReturn(formRes);
when(root.getXObject(form1)).thenReturn(formXObj);
// Inside the form: one image and a nested form
COSName im2 = COSName.getPDFName("Im2");
COSName nestedForm = COSName.getPDFName("NestedForm");
when(formRes.getXObjectNames()).thenReturn(Arrays.asList(im2, nestedForm));
PDImageXObject imgXObj2 = mock(PDImageXObject.class);
BufferedImage img2 = new BufferedImage(3, 3, BufferedImage.TYPE_INT_RGB);
when(imgXObj2.getImage()).thenReturn(img2);
when(formRes.getXObject(im2)).thenReturn(imgXObj2);
PDFormXObject nestedFormXObj = mock(PDFormXObject.class);
PDResources nestedRes = mock(PDResources.class);
when(nestedFormXObj.getResources()).thenReturn(nestedRes);
when(formRes.getXObject(nestedForm)).thenReturn(nestedFormXObj);
// Deep nest: another image
COSName im3 = COSName.getPDFName("Im3");
when(nestedRes.getXObjectNames()).thenReturn(List.of(im3));
PDImageXObject imgXObj3 = mock(PDImageXObject.class);
BufferedImage img3 = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
when(imgXObj3.getImage()).thenReturn(img3);
when(nestedRes.getXObject(im3)).thenReturn(imgXObj3);
// Act
List<RenderedImage> result = PdfUtils.getAllImages(root);
// Assert
assertEquals(
3, result.size(), "It should find exactly 3 images (root + form + nested form).");
assertTrue(
result.containsAll(List.of(img1, img2, img3)),
"All expected images must be present.");
}
@Test
void testPageCountComparators() throws Exception {
PDDocument doc1 = new PDDocument();
doc1.addPage(new PDPage());
doc1.addPage(new PDPage());
doc1.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc1, 2, "greater"));
PDDocument doc2 = new PDDocument();
doc2.addPage(new PDPage());
doc2.addPage(new PDPage());
doc2.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc2, 3, "equal"));
PDDocument doc3 = new PDDocument();
doc3.addPage(new PDPage());
doc3.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc3, 5, "less"));
PDDocument doc4 = new PDDocument();
doc4.addPage(new PDPage());
assertThrows(IllegalArgumentException.class, () -> PdfUtils.pageCount(doc4, 1, "bad"));
}
@Test
void testPageSize() throws Exception {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDRectangle rect = page.getMediaBox();
String expected = rect.getWidth() + "x" + rect.getHeight();
assertTrue(PdfUtils.pageSize(doc, expected));
}
@Test
void testOverlayImage() throws Exception {
PDDocument doc = new PDDocument();
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream pdfOut = new ByteArrayOutputStream();
doc.save(pdfOut);
doc.close();
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(Color.RED);
g.fillRect(0, 0, 10, 10);
g.dispose();
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
ImageIO.write(image, "png", imgOut);
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
byte[] result =
PdfUtils.overlayImage(
factory, pdfOut.toByteArray(), imgOut.toByteArray(), 0, 0, false);
try (PDDocument resultDoc = factory.load(result)) {
assertEquals(1, resultDoc.getNumberOfPages());
}
}
// ===============================================================
// Additional tests (added without modifying existing ones)
// ===============================================================
/* Helper: create a colored test image */
private static BufferedImage createImage(int w, int h, Color color) {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(color);
g.fillRect(0, 0, w, h);
g.dispose();
return img;
}
/* Helper: create a factory like in existing tests */
private static CustomPDFDocumentFactory factory() {
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
return new CustomPDFDocumentFactory(meta);
}
@Test
@DisplayName("convertPdfToPdfImage: creates image-PDF with same page count")
void convertPdfToPdfImage_shouldCreateImagePdfWithSamePageCount() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("Hello PDF");
cs.endText();
}
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p2);
PDDocument out = PdfUtils.convertPdfToPdfImage(doc);
assertNotNull(out);
assertEquals(2, out.getNumberOfPages(), "Page count should be preserved");
out.close();
}
}
@Test
@DisplayName("imageToPdf: PNG -> single-page PDF (static ImageProcessingUtils mocked)")
void imageToPdf_shouldCreatePdfFromPng() throws Exception {
BufferedImage img = createImage(320, 200, Color.RED);
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
ImageIO.write(img, "png", pngOut);
MockMultipartFile file =
new MockMultipartFile("files", "test.png", "image/png", pngOut.toByteArray());
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
// Assume: loadImageWithExifOrientation/convertColorType exist static mock
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
.thenReturn(img);
mocked.when(
() ->
ImageProcessingUtils.convertColorType(
any(BufferedImage.class), anyString()))
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
byte[] pdfBytes =
PdfUtils.imageToPdf(
new MockMultipartFile[] {file},
"maintainAspectRatio",
true,
"RGB",
factory());
try (PDDocument result = factory().load(pdfBytes)) {
assertEquals(1, result.getNumberOfPages());
}
}
}
@Test
@DisplayName("imageToPdf: JPEG -> single-page PDF (JPEGFactory path)")
void imageToPdf_shouldCreatePdfFromJpeg_UsingJpegFactory() throws Exception {
BufferedImage img = createImage(640, 360, Color.BLUE);
ByteArrayOutputStream jpgOut = new ByteArrayOutputStream();
ImageIO.write(img, "jpg", jpgOut);
MockMultipartFile file =
new MockMultipartFile("files", "photo.jpg", "image/jpeg", jpgOut.toByteArray());
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
.thenReturn(img);
mocked.when(
() ->
ImageProcessingUtils.convertColorType(
any(BufferedImage.class), anyString()))
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
byte[] pdfBytes =
PdfUtils.imageToPdf(
new MockMultipartFile[] {file}, "fillPage", false, "RGB", factory());
try (PDDocument result = factory().load(pdfBytes)) {
assertEquals(1, result.getNumberOfPages());
}
}
}
@Test
@DisplayName("addImageToDocument: fitDocumentToImage -> page size = image size")
void addImageToDocument_shouldUseImageSizeForPage_whenFitDocumentToImage() throws IOException {
try (PDDocument doc = new PDDocument()) {
BufferedImage img = createImage(300, 500, Color.GREEN);
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
PdfUtils.addImageToDocument(doc, ximg, "fitDocumentToImage", false);
assertEquals(1, doc.getNumberOfPages());
PDRectangle box = doc.getPage(0).getMediaBox();
assertEquals(300, (int) box.getWidth());
assertEquals(500, (int) box.getHeight());
}
}
@Test
@DisplayName("addImageToDocument: autoRotate rotates A4 for landscape image")
void addImageToDocument_shouldRotateA4_whenAutoRotateAndLandscape() throws IOException {
try (PDDocument doc = new PDDocument()) {
BufferedImage img = createImage(800, 400, Color.ORANGE); // Landscape
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
PdfUtils.addImageToDocument(doc, ximg, "maintainAspectRatio", true);
assertEquals(1, doc.getNumberOfPages());
PDRectangle box = doc.getPage(0).getMediaBox();
assertTrue(
box.getWidth() > box.getHeight(),
"A4 should be landscape when auto-rotate + landscape");
}
}
@Test
@DisplayName("addImageToDocument: fillPage runs without errors")
void addImageToDocument_fillPage_executes() throws IOException {
try (PDDocument doc = new PDDocument()) {
BufferedImage img = createImage(200, 200, Color.MAGENTA);
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
PdfUtils.addImageToDocument(doc, ximg, "fillPage", false);
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("overlayImage: everyPage=true overlays all pages")
void overlayImage_shouldOverlayAllPages_whenEveryPageTrue() throws IOException {
CustomPDFDocumentFactory factory = factory();
// Create PDF with 2 pages
byte[] basePdf;
try (PDDocument doc = factory.createNewDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
basePdf = baos.toByteArray();
}
// Create image bytes
BufferedImage img = createImage(50, 50, Color.BLACK);
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
ImageIO.write(img, "png", pngOut);
byte[] result = PdfUtils.overlayImage(factory, basePdf, pngOut.toByteArray(), 10, 10, true);
try (PDDocument out = factory.load(result)) {
assertEquals(2, out.getNumberOfPages(), "Page count remains identical");
}
}
/* Helper function: document with text on page1/page2 */
private static PDDocument createDocWithText(String p1, String p2) throws IOException {
PDDocument doc = new PDDocument();
PDPage page1 = new PDPage(PDRectangle.A4);
doc.addPage(page1);
try (PDPageContentStream cs =
new PDPageContentStream(doc, page1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText(p1);
cs.endText();
}
PDPage page2 = new PDPage(PDRectangle.A4);
doc.addPage(page2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, page2, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText(p2);
cs.endText();
}
return doc;
}
@Test
@DisplayName("containsTextInFile: pagesToCheck='all' finds text")
void containsTextInFile_allPages_true() throws IOException {
try (PDDocument doc = createDocWithText("alpha", "beta")) {
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "all"));
}
}
@Test
@DisplayName("containsTextInFile: single page '2' finds text")
void containsTextInFile_singlePage_two_true() throws IOException {
try (PDDocument doc = createDocWithText("alpha", "beta")) {
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "2"));
}
}
@Test
@DisplayName("containsTextInFile: range '1-1' finds text on page 1")
void containsTextInFile_range_oneToOne_true() throws IOException {
try (PDDocument doc = createDocWithText("findme", "other")) {
assertTrue(PdfUtils.containsTextInFile(doc, "findme", "1-1"));
}
}
@Test
@DisplayName("containsTextInFile: list '1,2' finds text (whitespace robust)")
void containsTextInFile_list_pages_true() throws IOException {
try (PDDocument doc = createDocWithText("foo", "bar")) {
assertTrue(PdfUtils.containsTextInFile(doc, "bar", " 1 , 2 "));
}
}
@Test
@DisplayName("containsTextInFile: text not present -> false")
void containsTextInFile_textNotPresent_false() throws IOException {
try (PDDocument doc = createDocWithText("xxx", "yyy")) {
assertFalse(PdfUtils.containsTextInFile(doc, "zzz", "all"));
}
}
@Test
@DisplayName("pageSize: different size returns false")
void pageSize_shouldReturnFalse_whenSizeDoesNotMatch() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
assertFalse(PdfUtils.pageSize(doc, "600x842"));
}
}
// ===================== New: convertFromPdf coverage =====================
@Test
@DisplayName("convertFromPdf: singleImage=true creates combined PNG file (readable)")
void convertFromPdf_singleImagePng_combinedReadable() throws Exception {
// Create two-page PDF
byte[] pdfBytes;
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
pdfBytes = baos.toByteArray();
}
byte[] imageBytes =
PdfUtils.convertFromPdf(
factory, pdfBytes, "png", ImageType.RGB, true, 72, "test.pdf", false);
// Should be readable as a single combined PNG image
BufferedImage img = ImageIO.read(new java.io.ByteArrayInputStream(imageBytes));
assertNotNull(img, "PNG should be readable");
assertTrue(img.getWidth() > 0 && img.getHeight() > 0, "Image dimensions > 0");
}
@Test
@DisplayName(
"convertFromPdf: singleImage=false returns ZIP with PNG entries (first image readable)")
void convertFromPdf_multiImagePng_firstReadable() throws Exception {
// Create two-page PDF
byte[] pdfBytes;
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
pdfBytes = baos.toByteArray();
}
// Act: singleImage=false -> ZIP with separate images
byte[] zipBytes =
PdfUtils.convertFromPdf(
factory, pdfBytes, "png", ImageType.RGB, false, 72, "test.pdf", false);
// Assert: open ZIP, read first entry as PNG
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry = zis.getNextEntry();
assertNotNull(entry, "ZIP should contain at least one entry");
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
zis.transferTo(imgOut);
BufferedImage first = ImageIO.read(new ByteArrayInputStream(imgOut.toByteArray()));
assertNotNull(first, "First PNG entry should be readable");
assertTrue(first.getWidth() > 0 && first.getHeight() > 0, "Image dimensions > 0");
}
}
@Test
@DisplayName("hasText: detects phrase on selected pages ('1', '2', 'all')")
void hasText_shouldDetectPhrase_onSelectedPages() throws Exception {
// Arrange: PDF with 2 pages and text
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
doc.addPage(p2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("alpha on page 1");
cs.endText();
}
try (PDPageContentStream cs =
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("beta on page 2");
cs.endText();
}
assertTrue(PdfUtils.hasText(doc, "1", "alpha"), "Page 1 should contain 'alpha'");
}
// For further checks, create new doc with identical content
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
doc.addPage(p2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("alpha on page 1");
cs.endText();
}
try (PDPageContentStream cs =
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("beta on page 2");
cs.endText();
}
assertTrue(PdfUtils.hasText(doc, "2", "beta"), "Page 2 should contain 'beta'");
}
// Third doc for 'all'
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
doc.addPage(p2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("gamma");
cs.endText();
}
assertTrue(PdfUtils.hasText(doc, "all", "gamma"), "'all' should find text on page 1");
}
}
@Test
@DisplayName("hasTextOnPage: true if page contains phrase, else false")
void hasTextOnPage_shouldReturnTrueOnlyForPagesWithPhrase() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
doc.addPage(p2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("needle");
cs.endText();
}
assertTrue(PdfUtils.hasTextOnPage(p1, "needle"));
assertTrue(!PdfUtils.hasTextOnPage(p2, "needle"));
}
}
@Test
@DisplayName("hasImages: detects images on selected pages and 'all'")
void hasImages_shouldDetectImages_onSelectedPages() throws Exception {
// Case 1: Page 1 without image (but resources set) -> false
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
p1.setResources(new PDResources());
p2.setResources(new PDResources());
doc.addPage(p1);
doc.addPage(p2);
// Image only on page 2
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
Graphics2D g = bi.createGraphics();
g.setColor(Color.GREEN);
g.fillRect(0, 0, 20, 20);
g.dispose();
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
cs.drawImage(ximg, 50, 700, 20, 20);
}
assertTrue(!PdfUtils.hasImages(doc, "1"), "Page 1 should have no image");
}
// Case 2: Page 2 with image -> true
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
p1.setResources(new PDResources());
p2.setResources(new PDResources());
doc.addPage(p1);
doc.addPage(p2);
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
Graphics2D g = bi.createGraphics();
g.setColor(Color.BLUE);
g.fillRect(0, 0, 20, 20);
g.dispose();
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
cs.drawImage(ximg, 50, 700, 20, 20);
}
assertTrue(PdfUtils.hasImages(doc, "2"), "Page 2 should have an image");
}
// Case 3: 'all' detects image
try (PDDocument doc = new PDDocument()) {
PDPage p = new PDPage(PDRectangle.A4);
p.setResources(new PDResources());
doc.addPage(p);
BufferedImage bi = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p, AppendMode.APPEND, true, true)) {
cs.drawImage(ximg, 20, 730, 10, 10);
}
assertTrue(PdfUtils.hasImages(doc, "all"), "'all' should detect the image");
}
}
@Test
@DisplayName("hasImagesOnPage: true if page contains an image, else false")
void hasImagesOnPage_shouldReturnTrueOnlyForPagesWithImage() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
p1.setResources(new PDResources());
p2.setResources(new PDResources());
doc.addPage(p1);
doc.addPage(p2);
BufferedImage bi = new BufferedImage(12, 12, BufferedImage.TYPE_INT_RGB);
Graphics2D g = bi.createGraphics();
g.setColor(Color.RED);
g.fillRect(0, 0, 12, 12);
g.dispose();
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.drawImage(ximg, 40, 720, 12, 12);
}
assertTrue(PdfUtils.hasImagesOnPage(p1));
assertTrue(!PdfUtils.hasImagesOnPage(p2));
}
}
@Test
@DisplayName("convertFromPdf: singleImage=true with JPG -> no alpha, white background")
void convertFromPdf_singleImageJpg_noAlphaWhiteBackground() throws Exception {
// small 1-page PDF
byte[] pdfBytes;
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
try (PDDocument doc = new PDDocument()) {
PDPage p = new PDPage(PDRectangle.A4);
doc.addPage(p);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
pdfBytes = baos.toByteArray();
}
byte[] jpgBytes =
PdfUtils.convertFromPdf(
factory, pdfBytes, "jpg", ImageType.RGB, true, 72, "sample.pdf", false);
BufferedImage img = ImageIO.read(new ByteArrayInputStream(jpgBytes));
assertNotNull(img, "JPG should be readable");
ColorModel cm = img.getColorModel();
assertFalse(cm.hasAlpha(), "JPG output should have no alpha channel");
// JPG background should be white (approximate check)
int rgb = img.getRGB(img.getWidth() / 2, img.getHeight() / 2) & 0x00FFFFFF;
assertEquals(0xFFFFFF, rgb, "Background pixel should be white");
}
}
@@ -1,86 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class ProcessExecutorTest {
private ProcessExecutor processExecutor;
@BeforeEach
public void setUp() {
// Initialize the ProcessExecutor instance
processExecutor = ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE);
}
@Test
public void testRunCommandWithOutputHandling() throws IOException, InterruptedException {
// Mock the command to execute
List<String> command = new ArrayList<>();
command.add("java");
command.add("-version");
// Execute the command
ProcessExecutor.ProcessExecutorResult result =
processExecutor.runCommandWithOutputHandling(command);
// Check the exit code and output messages
assertEquals(0, result.getRc());
assertNotNull(result.getMessages()); // Check if messages are not null
}
@Test
public void testRunCommandWithOutputHandling_Error() {
// Test with a command that will fail to execute (non-existent command)
List<String> command = new ArrayList<>();
command.add("nonexistent-command-that-does-not-exist");
// Execute the command and expect an IOException (command not found)
assertThrows(
IOException.class, () -> processExecutor.runCommandWithOutputHandling(command));
}
@Test
public void testRunCommandWithOutputHandling_PathTraversal() {
// Test that path traversal is blocked
List<String> command = new ArrayList<>();
command.add("../../../etc/passwd");
// Execute the command and expect an IllegalArgumentException
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> processExecutor.runCommandWithOutputHandling(command));
// Check the exception message
String errorMessage = thrown.getMessage();
assertTrue(
errorMessage.contains("path traversal"),
"Unexpected error message: " + errorMessage);
}
@Test
public void testRunCommandWithOutputHandling_NullByte() {
// Test that null bytes are blocked
List<String> command = new ArrayList<>();
command.add("test\0command");
// Execute the command and expect an IllegalArgumentException
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> processExecutor.runCommandWithOutputHandling(command));
// Check the exception message
String errorMessage = thrown.getMessage();
assertTrue(
errorMessage.contains("invalid characters"),
"Unexpected error message: " + errorMessage);
}
}
@@ -1,70 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
public class PropertyConfigsTest {
@Test
public void testGetBooleanValue_WithKeys() {
// Define keys and default value
List<String> keys = Arrays.asList("test.key1", "test.key2", "test.key3");
boolean defaultValue = false;
// Set property for one of the keys
System.setProperty("test.key2", "true");
// Call the method under test
boolean result = PropertyConfigs.getBooleanValue(keys, defaultValue);
// Verify the result
assertTrue(result);
}
@Test
public void testGetStringValue_WithKeys() {
// Define keys and default value
List<String> keys = Arrays.asList("test.key1", "test.key2", "test.key3");
String defaultValue = "default";
// Set property for one of the keys
System.setProperty("test.key2", "value");
// Call the method under test
String result = PropertyConfigs.getStringValue(keys, defaultValue);
// Verify the result
assertEquals("value", result);
}
@Test
public void testGetBooleanValue_WithKey() {
// Define key and default value
String key = "test.key";
boolean defaultValue = true;
// Call the method under test
boolean result = PropertyConfigs.getBooleanValue(key, defaultValue);
// Verify the result
assertTrue(result);
}
@Test
public void testGetStringValue_WithKey() {
// Define key and default value
String key = "test.key";
String defaultValue = "default";
// Call the method under test
String result = PropertyConfigs.getStringValue(key, defaultValue);
// Verify the result
assertEquals("default", result);
}
}
@@ -1,340 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
public class RequestUriUtilsTest {
@Test
void testIsStaticResource() {
// Test static resources without context path
assertTrue(
RequestUriUtils.isStaticResource("/css/styles.css"), "CSS files should be static");
assertTrue(RequestUriUtils.isStaticResource("/js/script.js"), "JS files should be static");
assertTrue(
RequestUriUtils.isStaticResource("/images/logo.png"),
"Image files should be static");
assertTrue(
RequestUriUtils.isStaticResource("/public/index.html"),
"Public files should be static");
assertTrue(
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
"PDF.js files should be static");
assertTrue(
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
"PDFium wasm should be static");
assertTrue(
RequestUriUtils.isStaticResource("/api/v1/info/status"),
"API status should be static");
assertTrue(
RequestUriUtils.isStaticResource("/some-path/icon.svg"),
"SVG files should be static");
assertTrue(RequestUriUtils.isStaticResource("/login"), "Login page should be static");
assertTrue(RequestUriUtils.isStaticResource("/error"), "Error page should be static");
// Test non-static resources
assertFalse(
RequestUriUtils.isStaticResource("/api/v1/users"),
"API users should not be static");
assertFalse(
RequestUriUtils.isStaticResource("/api/v1/orders"),
"API orders should not be static");
assertFalse(RequestUriUtils.isStaticResource("/"), "Root path should not be static");
assertFalse(
RequestUriUtils.isStaticResource("/register"),
"Register page should not be static");
assertFalse(
RequestUriUtils.isStaticResource("/api/v1/products"),
"API products should not be static");
}
@Test
void testIsFrontendRoute() {
assertTrue(
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
"React routes without extensions should be frontend routes");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
"API routes should not be frontend routes");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/register"),
"Register should not be treated as a frontend route");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
"Pipeline should not be treated as a frontend route");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/files/download"),
"Files path should not be treated as a frontend route");
}
@Test
void testIsStaticResourceWithContextPath() {
String contextPath = "/myapp";
// Test static resources with context path
assertTrue(
RequestUriUtils.isStaticResource(contextPath, contextPath + "/css/styles.css"),
"CSS with context path should be static");
assertTrue(
RequestUriUtils.isStaticResource(contextPath, contextPath + "/js/script.js"),
"JS with context path should be static");
assertTrue(
RequestUriUtils.isStaticResource(contextPath, contextPath + "/images/logo.png"),
"Images with context path should be static");
assertTrue(
RequestUriUtils.isStaticResource(contextPath, contextPath + "/login"),
"Login with context path should be static");
// Test non-static resources with context path
assertFalse(
RequestUriUtils.isStaticResource(contextPath, contextPath + "/api/v1/users"),
"API users with context path should not be static");
assertFalse(
RequestUriUtils.isStaticResource(contextPath, "/"),
"Root path with context path should not be static");
}
@ParameterizedTest
@ValueSource(
strings = {
"robots.txt",
"/favicon.ico",
"/icon.svg",
"/image.png",
"/locales/en/translation.toml",
"/site.webmanifest",
"/app/logo.svg",
"/downloads/document.png",
"/assets/brand.ico",
"/any/path/with/image.svg",
"/deep/nested/folder/icon.png",
"/pdfium/pdfium.wasm"
})
void testIsStaticResourceWithFileExtensions(String path) {
assertTrue(
RequestUriUtils.isStaticResource(path),
"Files with specific extensions should be static regardless of path");
}
@Test
void testIsTrackableResource() {
// Test non-trackable resources (returns false)
assertFalse(
RequestUriUtils.isTrackableResource("/js/script.js"),
"JS files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/v1/api-docs"),
"API docs should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("robots.txt"),
"robots.txt should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/images/logo.png"),
"Images should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/styles.css"),
"CSS files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/script.js.map"),
"Map files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/icon.svg"),
"SVG files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/popularity.txt"),
"Popularity file should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/script.js"),
"JS files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
"PDFium wasm should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/swagger/index.html"),
"Swagger files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/api/v1/info/status"),
"API info should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/site.webmanifest"),
"Webmanifest should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/fonts/font.woff"),
"Fonts should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/pdfjs/viewer.js"),
"PDF.js files should not be trackable");
// Test trackable resources (returns true)
assertTrue(RequestUriUtils.isTrackableResource("/login"), "Login page should be trackable");
assertTrue(
RequestUriUtils.isTrackableResource("/register"),
"Register page should be trackable");
assertTrue(
RequestUriUtils.isTrackableResource("/api/v1/users"),
"API users should be trackable");
assertTrue(RequestUriUtils.isTrackableResource("/"), "Root path should be trackable");
assertTrue(
RequestUriUtils.isTrackableResource("/some-other-path"),
"Other paths should be trackable");
}
@Test
void testIsTrackableResourceWithContextPath() {
String contextPath = "/myapp";
// Test with context path
assertFalse(
RequestUriUtils.isTrackableResource(contextPath, "/js/script.js"),
"JS files should not be trackable with context path");
assertTrue(
RequestUriUtils.isTrackableResource(contextPath, "/login"),
"Login page should be trackable with context path");
// Additional tests with context path
assertFalse(
RequestUriUtils.isTrackableResource(contextPath, "/fonts/custom.woff"),
"Font files should not be trackable with context path");
assertFalse(
RequestUriUtils.isTrackableResource(contextPath, "/images/header.png"),
"Images should not be trackable with context path");
assertFalse(
RequestUriUtils.isTrackableResource(contextPath, "/swagger/ui.html"),
"Swagger UI should not be trackable with context path");
assertTrue(
RequestUriUtils.isTrackableResource(contextPath, "/account/profile"),
"Account page should be trackable with context path");
assertTrue(
RequestUriUtils.isTrackableResource(contextPath, "/pdf/view"),
"PDF view page should be trackable with context path");
}
@ParameterizedTest
@ValueSource(
strings = {
"/js/util.js",
"/v1/api-docs/swagger.json",
"/robots.txt",
"/images/header/logo.png",
"/styles/theme.css",
"/build/app.js.map",
"/assets/icon.svg",
"/data/popularity.txt",
"/bundle.js",
"/api/swagger-ui.html",
"/api/v1/info/health",
"/site.webmanifest",
"/fonts/roboto.woff",
"/pdfjs/viewer.js",
"/pdfium/pdfium.wasm"
})
void testNonTrackableResources(String path) {
assertFalse(
RequestUriUtils.isTrackableResource(path),
"Resources matching patterns should not be trackable: " + path);
}
@ParameterizedTest
@ValueSource(
strings = {
"/",
"/home",
"/login",
"/register",
"/pdf/merge",
"/pdf/split",
"/api/v1/users/1",
"/api/v1/documents/process",
"/settings",
"/account/profile",
"/dashboard",
"/help",
"/about"
})
void testTrackableResources(String path) {
assertTrue(
RequestUriUtils.isTrackableResource(path),
"App routes should be trackable: " + path);
}
@Test
void testEdgeCases() {
// Test with empty strings
assertFalse(RequestUriUtils.isStaticResource("", ""), "Empty path should not be static");
assertTrue(RequestUriUtils.isTrackableResource("", ""), "Empty path should be trackable");
// Test with null-like behavior (would actually throw NPE in real code)
// These are not actual null tests but shows handling of odd cases
assertFalse(RequestUriUtils.isStaticResource("null"), "String 'null' should not be static");
// Test String "null" as a path
boolean isTrackable = RequestUriUtils.isTrackableResource("null");
assertTrue(isTrackable, "String 'null' should be trackable");
// Mixed case extensions test - note that Java's endsWith() is case-sensitive
// We'll check actual behavior and document it rather than asserting
// Always test the lowercase versions which should definitely work
assertTrue(
RequestUriUtils.isStaticResource("/logo.png"), "PNG (lowercase) should be static");
assertTrue(
RequestUriUtils.isStaticResource("/icon.svg"), "SVG (lowercase) should be static");
// Path with query parameters
assertFalse(
RequestUriUtils.isStaticResource("/api/users?page=1"),
"Path with query params should respect base path");
assertTrue(
RequestUriUtils.isStaticResource("/images/logo.png?v=123"),
"Static resource with query params should still be static");
// Paths with fragments
assertTrue(
RequestUriUtils.isStaticResource("/css/styles.css#section1"),
"CSS with fragment should be static");
// Multiple dots in filename
assertTrue(
RequestUriUtils.isStaticResource("/js/jquery.min.js"),
"JS with multiple dots should be static");
// Special characters in path
assertTrue(
RequestUriUtils.isStaticResource("/images/user's-photo.png"),
"Path with special chars should be handled correctly");
}
@Test
void testComplexPaths() {
// Test complex static resource paths
assertTrue(
RequestUriUtils.isStaticResource("/css/theme/dark/styles.css"),
"Nested CSS should be static");
assertTrue(
RequestUriUtils.isStaticResource("/fonts/open-sans/bold/font.woff"),
"Nested font should be static");
assertTrue(
RequestUriUtils.isStaticResource("/js/vendor/jquery/3.5.1/jquery.min.js"),
"Versioned JS should be static");
// Test complex paths with context
String contextPath = "/app";
assertTrue(
RequestUriUtils.isStaticResource(
contextPath, contextPath + "/css/theme/dark/styles.css"),
"Nested CSS with context should be static");
// Test boundary cases for isTrackableResource
assertFalse(
RequestUriUtils.isTrackableResource("/js-framework/components"),
"Path starting with js- should not be treated as JS resource");
assertFalse(
RequestUriUtils.isTrackableResource("/fonts-selection"),
"Path starting with fonts- should not be treated as font resource");
}
}
@@ -1,276 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.net.ServerSocket;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import jakarta.servlet.http.HttpServletRequest;
@ExtendWith(MockitoExtension.class)
class UrlUtilsTest {
@Mock private HttpServletRequest request;
@Test
void testGetOrigin() {
// Arrange
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("/myapp");
// Act
String origin = UrlUtils.getOrigin(request);
// Assert
assertEquals(
"http://localhost:8080/myapp", origin, "Origin URL should be correctly formatted");
}
@Test
void testGetOriginWithHttps() {
// Arrange
when(request.getScheme()).thenReturn("https");
when(request.getServerName()).thenReturn("example.com");
when(request.getServerPort()).thenReturn(443);
when(request.getContextPath()).thenReturn("");
// Act
String origin = UrlUtils.getOrigin(request);
// Assert
assertEquals(
"https://example.com:443",
origin,
"HTTPS origin URL should be correctly formatted");
}
@Test
void testGetOriginWithEmptyContextPath() {
// Arrange
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
// Act
String origin = UrlUtils.getOrigin(request);
// Assert
assertEquals(
"http://localhost:8080",
origin,
"Origin URL with empty context path should be correct");
}
@Test
void testGetOriginWithSpecialCharacters() {
// Arrange - Test with server name containing special characters
when(request.getScheme()).thenReturn("https");
when(request.getServerName()).thenReturn("internal-server.example-domain.com");
when(request.getServerPort()).thenReturn(8443);
when(request.getContextPath()).thenReturn("/app-v1.2");
// Act
String origin = UrlUtils.getOrigin(request);
// Assert
assertEquals(
"https://internal-server.example-domain.com:8443/app-v1.2",
origin,
"Origin URL with special characters should be correctly formatted");
}
@Test
void testGetOriginWithIPv4Address() {
// Arrange
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("192.168.1.100");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("/app");
// Act
String origin = UrlUtils.getOrigin(request);
// Assert
assertEquals(
"http://192.168.1.100:8080/app",
origin,
"Origin URL with IPv4 address should be correctly formatted");
}
@Test
void testGetOriginWithNonStandardPort() {
// Arrange
when(request.getScheme()).thenReturn("https");
when(request.getServerName()).thenReturn("example.org");
when(request.getServerPort()).thenReturn(8443);
when(request.getContextPath()).thenReturn("/api");
// Act
String origin = UrlUtils.getOrigin(request);
// Assert
assertEquals(
"https://example.org:8443/api",
origin,
"Origin URL with non-standard port should be correctly formatted");
}
@Test
void testIsPortAvailable() {
// We'll use a real server socket for this test
ServerSocket socket = null;
int port = 12345; // Choose a port unlikely to be in use
try {
// First check the port is available
boolean initialAvailability = UrlUtils.isPortAvailable(port);
// Then occupy the port
socket = new ServerSocket(port);
// Now check the port is no longer available
boolean afterSocketCreation = UrlUtils.isPortAvailable(port);
// Assert
assertTrue(initialAvailability, "Port should be available initially");
assertFalse(
afterSocketCreation, "Port should not be available after socket is created");
} catch (IOException e) {
// This might happen if the port is already in use by another process
// We'll just verify the behavior of isPortAvailable matches what we expect
assertFalse(
UrlUtils.isPortAvailable(port),
"Port should not be available if exception is thrown");
} finally {
if (socket != null && !socket.isClosed()) {
try {
socket.close();
} catch (IOException e) {
// Ignore cleanup exceptions
}
}
}
}
@Test
void testFindAvailablePort() {
// We'll create a socket on a port and ensure findAvailablePort returns a different port
ServerSocket socket = null;
int startPort = 12346; // Choose a port unlikely to be in use
try {
// Occupy the start port
socket = new ServerSocket(startPort);
// Find an available port
String availablePort = UrlUtils.findAvailablePort(startPort);
// Assert the returned port is not the occupied one
assertNotEquals(
String.valueOf(startPort),
availablePort,
"findAvailablePort should not return an occupied port");
// Verify the returned port is actually available
int portNumber = Integer.parseInt(availablePort);
// Close our test socket before checking the found port
socket.close();
socket = null;
// The port should now be available
assertTrue(
UrlUtils.isPortAvailable(portNumber),
"The port returned by findAvailablePort should be available");
} catch (IOException e) {
// If we can't create the socket, skip this assertion
} finally {
if (socket != null && !socket.isClosed()) {
try {
socket.close();
} catch (IOException e) {
// Ignore cleanup exceptions
}
}
}
}
@Test
void testFindAvailablePortWithAvailableStartPort() {
// Find an available port without occupying any
int startPort = 23456; // Choose a different unlikely-to-be-used port
// Make sure the port is available first
if (UrlUtils.isPortAvailable(startPort)) {
// Find an available port
String availablePort = UrlUtils.findAvailablePort(startPort);
// Assert the returned port is the start port since it's available
assertEquals(
String.valueOf(startPort),
availablePort,
"findAvailablePort should return the start port if it's available");
}
}
@Test
void testFindAvailablePortWithSequentialUsedPorts() {
// This test checks that findAvailablePort correctly skips multiple occupied ports
ServerSocket socket1 = null;
ServerSocket socket2 = null;
int startPort = 34567; // Another unlikely-to-be-used port
try {
// First verify the port is available
if (!UrlUtils.isPortAvailable(startPort)) {
return;
}
// Occupy two sequential ports
socket1 = new ServerSocket(startPort);
socket2 = new ServerSocket(startPort + 1);
// Find an available port starting from our occupied range
String availablePort = UrlUtils.findAvailablePort(startPort);
int foundPort = Integer.parseInt(availablePort);
// Should have skipped the two occupied ports
assertTrue(
foundPort >= startPort + 2,
"findAvailablePort should skip sequential occupied ports");
// Verify the found port is actually available
try (ServerSocket testSocket = new ServerSocket(foundPort)) {
assertTrue(testSocket.isBound(), "The found port should be bindable");
}
} catch (IOException e) {
// Skip test if we encounter IO exceptions
} finally {
// Clean up resources
try {
if (socket1 != null && !socket1.isClosed()) socket1.close();
if (socket2 != null && !socket2.isClosed()) socket2.close();
} catch (IOException e) {
// Ignore cleanup exceptions
}
}
}
@Test
void testIsPortAvailableWithPrivilegedPorts() {
// Skip tests for privileged ports as they typically require root access
// and results are environment-dependent
}
}
@@ -1,115 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
public class WebResponseUtilsTest {
@Test
public void testBoasToWebResponse() {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write("Sample PDF content".getBytes());
String docName = "sample.pdf";
ResponseEntity<byte[]> responseEntity =
WebResponseUtils.baosToWebResponse(baos, docName);
assertNotNull(responseEntity);
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
assertNotNull(responseEntity.getBody());
HttpHeaders headers = responseEntity.getHeaders();
assertNotNull(headers);
assertEquals(MediaType.APPLICATION_PDF, headers.getContentType());
assertNotNull(headers.getContentDisposition());
// assertEquals("attachment; filename=\"sample.pdf\"",
// headers.getContentDisposition().toString());
} catch (IOException e) {
fail("Exception thrown: " + e.getMessage());
}
}
@Test
public void testMultiPartFileToWebResponse() {
try {
byte[] fileContent = "Sample file content".getBytes();
MockMultipartFile file =
new MockMultipartFile(
"file", "sample.txt", MediaType.TEXT_PLAIN_VALUE, fileContent);
ResponseEntity<byte[]> responseEntity =
WebResponseUtils.multiPartFileToWebResponse(file);
assertNotNull(responseEntity);
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
assertNotNull(responseEntity.getBody());
HttpHeaders headers = responseEntity.getHeaders();
assertNotNull(headers);
assertEquals(MediaType.TEXT_PLAIN, headers.getContentType());
assertNotNull(headers.getContentDisposition());
} catch (IOException e) {
fail("Exception thrown: " + e.getMessage());
}
}
@Test
public void testBytesToWebResponse() {
try {
byte[] bytes = "Sample bytes".getBytes();
String docName = "sample.txt";
MediaType mediaType = MediaType.TEXT_PLAIN;
ResponseEntity<byte[]> responseEntity =
WebResponseUtils.bytesToWebResponse(bytes, docName, mediaType);
assertNotNull(responseEntity);
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
assertNotNull(responseEntity.getBody());
HttpHeaders headers = responseEntity.getHeaders();
assertNotNull(headers);
assertEquals(MediaType.TEXT_PLAIN, headers.getContentType());
assertNotNull(headers.getContentDisposition());
} catch (IOException e) {
fail("Exception thrown: " + e.getMessage());
}
}
@Test
public void testPdfDocToWebResponse() {
try (PDDocument document = new PDDocument()) {
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
String docName = "sample.pdf";
ResponseEntity<byte[]> responseEntity =
WebResponseUtils.pdfDocToWebResponse(document, docName);
assertNotNull(responseEntity);
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
assertNotNull(responseEntity.getBody());
HttpHeaders headers = responseEntity.getHeaders();
assertNotNull(headers);
assertEquals(MediaType.APPLICATION_PDF, headers.getContentType());
assertNotNull(headers.getContentDisposition());
} catch (IOException e) {
fail("Exception thrown: " + e.getMessage());
}
}
}
+3
View File
@@ -46,6 +46,7 @@ dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'org.eclipse.jetty.http2:jetty-http2-server'
implementation 'org.eclipse.jetty:jetty-alpn-java-server'
implementation ('org.telegram:telegrambots:6.9.7.1') {
// Grizzly server + Jersey JAX-RS stack: only used for webhook mode;
// Stirling-PDF uses long-polling mode so these are dead weight (~3 MB)
@@ -99,6 +100,8 @@ dependencies {
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
@@ -1,80 +0,0 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.service.PdfImageRemovalService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
/**
* Controller class for handling PDF image removal requests. Provides an endpoint to remove images
* from a PDF file to reduce its size.
*/
@GeneralApi
@RequiredArgsConstructor
public class PdfImageRemovalController {
// Service for removing images from PDFs
private final PdfImageRemovalService pdfImageRemovalService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
/**
* Endpoint to remove images from a PDF file.
*
* <p>This method processes the uploaded PDF file, removes all images, and returns the modified
* PDF file with a new name indicating that images were removed.
*
* @param file The PDF file with images to be removed.
* @return ResponseEntity containing the modified PDF file as byte array with appropriate
* content type and filename.
* @throws IOException If an error occurs while processing the PDF file.
*/
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-image-pdf")
@StandardPdfResponse
@Operation(
summary = "Remove images from file to reduce the file size.",
description =
"This endpoint remove images from file to reduce the file size.Input:PDF"
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile file) throws IOException {
// Load the PDF document with proper resource management
try (PDDocument document = pdfDocumentFactory.load(file)) {
// Remove images from the PDF document using the service
try (PDDocument modifiedDocument =
pdfImageRemovalService.removeImagesFromPdf(document)) {
// Create a ByteArrayOutputStream to hold the modified PDF data
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// Save the modified PDF document to the output stream
modifiedDocument.save(outputStream);
// Generate a new filename for the modified PDF
String mergedFileName =
GeneralUtils.generateFilename(
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
// Convert the byte array to a web response and return it
return WebResponseUtils.bytesToWebResponse(
outputStream.toByteArray(), mergedFileName);
}
}
}
}
@@ -150,28 +150,6 @@ public class RearrangePagesPDFController {
return newPageOrder;
}
/**
* Rearrange pages in a PDF file by merging odd and even pages. The first half of the pages will
* be the odd pages, and the second half will be the even pages as input. <br>
* This method is visible for testing purposes only.
*
* @param totalPages Total number of pages in the PDF file.
* @return List of page numbers in the new order. The first page is 0.
*/
List<Integer> oddEvenMerge(int totalPages) {
List<Integer> newPageOrderZeroBased = new ArrayList<>();
int numberOfOddPages = (totalPages + 1) / 2;
for (int oneBasedIndex = 1; oneBasedIndex < (numberOfOddPages + 1); oneBasedIndex++) {
newPageOrderZeroBased.add((oneBasedIndex - 1));
if (numberOfOddPages + oneBasedIndex <= totalPages) {
newPageOrderZeroBased.add((numberOfOddPages + oneBasedIndex - 1));
}
}
return newPageOrderZeroBased;
}
private List<Integer> duplicate(int totalPages, String pageOrder) {
List<Integer> newPageOrder = new ArrayList<>();
int duplicateCount;
@@ -220,7 +198,6 @@ public class RearrangePagesPDFController {
case BOOKLET_SORT -> bookletSort(totalPages);
case SIDE_STITCH_BOOKLET_SORT -> sideStitchBooklet(totalPages);
case ODD_EVEN_SPLIT -> oddEvenSplit(totalPages);
case ODD_EVEN_MERGE -> oddEvenMerge(totalPages);
case REMOVE_FIRST -> removeFirst(totalPages);
case REMOVE_LAST -> removeLast(totalPages);
case REMOVE_FIRST_AND_LAST -> removeFirstAndLast(totalPages);
@@ -81,7 +81,8 @@ public class ConvertEmlToPDF {
if (request.isDownloadHtml()) {
try {
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
String htmlContent =
EmlToPdf.convertEmlToHtml(fileBytes, request, customHtmlSanitizer);
log.info("Successfully converted email to HTML: {}", originalFilename);
return WebResponseUtils.bytesToWebResponse(
htmlContent.getBytes(StandardCharsets.UTF_8),
@@ -159,6 +159,24 @@ public class ConfigController {
"defaultHideUnavailableConversions",
applicationProperties.getUi().isDefaultHideUnavailableConversions());
// Hide disabled tools settings
configData.put(
"hideDisabledToolsGoogleDrive",
applicationProperties.getUi().getHideDisabledTools().isGoogleDrive());
configData.put(
"hideDisabledToolsMobileQRScanner",
applicationProperties.getUi().getHideDisabledTools().isMobileQRScanner());
// Google Drive backend settings (only if enabled)
ApplicationProperties.Premium.ProFeatures.GoogleDrive googleDrive =
applicationProperties.getPremium().getProFeatures().getGoogleDrive();
if (googleDrive.isEnabled()) {
configData.put("googleDriveEnabled", true);
configData.put("googleDriveClientId", googleDrive.getClientId());
configData.put("googleDriveApiKey", googleDrive.getApiKey());
configData.put("googleDriveAppId", googleDrive.getAppId());
}
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
@@ -212,6 +230,16 @@ public class ConfigController {
}
configData.put("isNewUser", isNewUser);
String username = null;
if (userService != null) {
try {
username = userService.getCurrentUsername();
} catch (Exception e) {
// Not authenticated or security disabled
}
}
configData.put("username", username);
// System settings
configData.put(
"enableAlphaFunctionality",
@@ -1,20 +1,14 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.*;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -43,7 +37,6 @@ import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ImageProcessingUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -65,203 +58,97 @@ public class ExtractImagesController {
+ " file. Users can specify the output image format. Input:PDF"
+ " Output:IMAGE/ZIP Type:SIMO")
public ResponseEntity<StreamingResponseBody> extractImages(
@ModelAttribute PDFExtractImagesRequest request)
throws IOException, InterruptedException, ExecutionException {
@ModelAttribute PDFExtractImagesRequest request) throws IOException {
MultipartFile file = request.getFileInput();
String format = request.getFormat();
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
String imageFormat = request.getFormat();
String filename = GeneralUtils.removeExtension(file.getOriginalFilename());
Set<byte[]> processedImages = new HashSet<>();
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
Set<Integer> processedImageHashes = new HashSet<>();
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zos =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()));
PDDocument document = pdfDocumentFactory.load(file)) {
TempFile zipFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile.getPath()));
PDDocument pdfDoc = pdfDocumentFactory.load(file)) {
// Set compression level
zos.setLevel(Deflater.BEST_COMPRESSION);
zipStream.setLevel(Deflater.BEST_COMPRESSION);
// Determine if multithreading should be used based on PDF size or number of pages
boolean useMultithreading = shouldUseMultithreading(file, document);
if (useMultithreading) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Set<Future<Void>> futures = new HashSet<>();
try {
int pageCount = document.getPages().getCount();
log.debug("Document reports {} pages", pageCount);
int consecutiveFailures = 0;
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
try {
PDPage page = document.getPage(pgNum);
consecutiveFailures = 0; // Reset on success
final int currentPageNum =
pgNum + 1; // Convert to 1-based page numbering
Future<Void> future =
executor.submit(
() -> {
try {
// Call the image extraction method for each
// page
extractImagesFromPage(
page,
format,
filename,
currentPageNum,
processedImages,
zos,
allowDuplicates);
} catch (Exception e) {
// Log the error and continue processing other
// pages
ExceptionUtils.logException(
"image extraction from page "
+ currentPageNum,
e);
}
return null; // Callable requires a return type
});
// Add the Future object to the list to track completion
futures.add(future);
} catch (Exception e) {
consecutiveFailures++;
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
if (consecutiveFailures >= 3) {
log.warn("Stopping page iteration after 3 consecutive failures");
break;
}
}
}
} catch (Exception e) {
ExceptionUtils.logException("page count determination", e);
throw e;
}
// Wait for all tasks to complete
for (Future<Void> future : futures) {
future.get();
}
// Close executor service
executor.shutdown();
} else {
// Single-threaded extraction
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
PDPage page = document.getPage(pgNum);
extractImagesFromPage(
page,
format,
filename,
pgNum + 1,
processedImages,
zos,
allowDuplicates);
}
int totalPages = pdfDoc.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage currentPage = pdfDoc.getPage(pageIndex);
extractAndAddImagesToZip(
currentPage, imageFormat, baseFilename, pageIndex + 1,
processedImageHashes, zipStream);
}
// document and zos closed by try-with-resources
} catch (Exception e) {
zipTempFile.close();
zipFile.close();
throw e;
}
return WebResponseUtils.zipFileToWebResponse(
zipTempFile, filename + "_extracted-images.zip");
zipFile, baseFilename + "_extracted-images.zip");
}
private boolean shouldUseMultithreading(MultipartFile file, PDDocument document) {
// Criteria: Use multithreading if file size > 10MB or number of pages > 20
long fileSizeInMB = file.getSize() / (1024 * 1024);
int numberOfPages = document.getPages().getCount();
return fileSizeInMB > 10 || numberOfPages > 20;
}
private void extractImagesFromPage(
private void extractAndAddImagesToZip(
PDPage page,
String format,
String filename,
int pageNum,
Set<byte[]> processedImages,
ZipOutputStream zos,
boolean allowDuplicates)
String imageFormat,
String baseFilename,
int pageNumber,
Set<Integer> seenImageHashes,
ZipOutputStream zipOutput)
throws IOException {
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
log.error("MD5 algorithm not available for extractImages hash.", e);
return;
}
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {
return;
}
int count = 1;
for (COSName name : page.getResources().getXObjectNames()) {
int imageCount = 1;
for (COSName resourceName : page.getResources().getXObjectNames()) {
if (!page.getResources().isImageXObject(resourceName)) {
continue;
}
try {
if (page.getResources().isImageXObject(name)) {
PDImageXObject image = (PDImageXObject) page.getResources().getXObject(name);
if (!allowDuplicates) {
byte[] data = ImageProcessingUtils.getImageData(image.getImage());
byte[] imageHash = md.digest(data);
synchronized (processedImages) {
if (processedImages.stream()
.anyMatch(hash -> Arrays.equals(hash, imageHash))) {
continue; // Skip already processed images
}
processedImages.add(imageHash);
}
}
PDImageXObject imageObject =
(PDImageXObject) page.getResources().getXObject(resourceName);
int imageHashCode = imageObject.hashCode();
RenderedImage renderedImage = image.getImage();
// Convert to standard RGB colorspace if needed
BufferedImage bufferedImage = convertToRGB(renderedImage, format);
// Encode image outside the lock to allow parallel encoding across threads
String imageName = filename + "_page_" + pageNum + "_" + count++ + "." + format;
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, format, imageBaos);
byte[] imageData = imageBaos.toByteArray();
// Write encoded bytes to zip under lock (ZipOutputStream requires
// serialization)
synchronized (zos) {
zos.putNextEntry(new ZipEntry(imageName));
zos.write(imageData);
zos.closeEntry();
}
if (seenImageHashes.contains(imageHashCode)) {
continue;
}
seenImageHashes.add(imageHashCode);
RenderedImage sourceImage = imageObject.getImage();
BufferedImage convertedImage = convertImageToFormat(sourceImage, imageFormat);
String imagePath =
baseFilename + "_page_" + pageNumber + "_" + imageCount++ + "."
+ imageFormat;
ByteArrayOutputStream imageBuffer = new ByteArrayOutputStream();
ImageIO.write(convertedImage, imageFormat, imageBuffer);
zipOutput.putNextEntry(new ZipEntry(imagePath));
zipOutput.write(imageBuffer.toByteArray());
zipOutput.closeEntry();
} catch (IOException e) {
ExceptionUtils.logException("image extraction", e);
ExceptionUtils.logException("image extraction failed", e);
throw ExceptionUtils.handlePdfException(e, "during image extraction");
}
}
}
private BufferedImage convertToRGB(RenderedImage renderedImage, String format) {
int width = renderedImage.getWidth();
int height = renderedImage.getHeight();
BufferedImage rgbImage;
private BufferedImage convertImageToFormat(RenderedImage source, String format) {
int width = source.getWidth();
int height = source.getHeight();
int imageType = BufferedImage.TYPE_INT_RGB;
if ("png".equalsIgnoreCase(format)) {
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
} else if ("jpeg".equalsIgnoreCase(format) || "jpg".equalsIgnoreCase(format)) {
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
} else if ("gif".equalsIgnoreCase(format)) {
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED);
} else {
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
imageType = BufferedImage.TYPE_INT_ARGB;
}
Graphics2D g = rgbImage.createGraphics();
g.drawImage((Image) renderedImage, 0, 0, null);
g.dispose();
return rgbImage;
BufferedImage result = new BufferedImage(width, height, imageType);
Graphics2D graphics = result.createGraphics();
graphics.drawImage((Image) source, 0, 0, null);
graphics.dispose();
return result;
}
}
@@ -36,7 +36,6 @@ import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PostHogService;
import stirling.software.common.util.FileMonitor;
import tools.jackson.databind.ObjectMapper;
@@ -50,7 +49,6 @@ public class PipelineDirectoryProcessor {
private final ObjectMapper objectMapper;
private final ApiDocService apiDocService;
private final PipelineProcessor processor;
private final FileMonitor fileMonitor;
private final PostHogService postHogService;
private final List<String> watchedFoldersDirs;
private final String finishedFoldersDir;
@@ -63,13 +61,11 @@ public class PipelineDirectoryProcessor {
ObjectMapper objectMapper,
ApiDocService apiDocService,
PipelineProcessor processor,
FileMonitor fileMonitor,
PostHogService postHogService,
RuntimePathConfig runtimePathConfig) {
this.objectMapper = objectMapper;
this.apiDocService = apiDocService;
this.processor = processor;
this.fileMonitor = fileMonitor;
this.postHogService = postHogService;
this.watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths();
this.finishedFoldersDir = runtimePathConfig.getPipelineFinishedFoldersPath();
@@ -274,18 +270,7 @@ public class PipelineDirectoryProcessor {
return isAllowed;
})
.map(Path::toAbsolutePath)
.filter(
path -> {
boolean isReady =
fileMonitor.isFileReadyForProcessing(path);
if (!isReady) {
log.info(
"File not ready for processing (locked/created"
+ " last 5s): {}",
path);
}
return isReady;
})
.filter(path -> true)
.map(Path::toFile)
.toArray(File[]::new);
log.info(
@@ -178,7 +178,8 @@ public class ReactRoutingController {
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
return
"""
<!doctype html>
<html>
<head>
@@ -237,7 +238,8 @@ public class ReactRoutingController {
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
return
"""
<!doctype html>
<html>
<head>
@@ -25,8 +25,9 @@ public class ApiEndpoint {
}
public boolean areParametersValid(Map<String, Object> providedParams) {
for (String requiredParam : parameters.keySet()) {
if (!providedParams.containsKey(requiredParam)) {
for (Map.Entry<String, JsonNode> entry : parameters.entrySet()) {
boolean isRequired = entry.getValue().path("required").asBoolean(false);
if (isRequired && !providedParams.containsKey(entry.getKey())) {
return false;
}
}
@@ -7,7 +7,6 @@ public enum SortTypes {
BOOKLET_SORT,
SIDE_STITCH_BOOKLET_SORT,
ODD_EVEN_SPLIT,
ODD_EVEN_MERGE,
REMOVE_FIRST,
REMOVE_LAST,
REMOVE_FIRST_AND_LAST,
@@ -9,10 +9,10 @@ import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
public class PDFExtractImagesRequest extends PDFWithImageFormatRequest {
@Schema(
description =
"Boolean to enable/disable the saving of duplicate images, true to enable"
+ " duplicates",
defaultValue = "false")
private Boolean allowDuplicates;
// @Schema(
// description =
// "Boolean to enable/disable the saving of duplicate images, true to enable"
// + " duplicates",
// defaultValue = "false")
// private Boolean allowDuplicates;
}
@@ -22,7 +22,6 @@ public class RearrangePagesRequest extends PDFWithPageNums {
+ "DUPLEX_SORT: Sorts pages as if all fronts were scanned then all backs in reverse (1, n, 2, n-1, ...). "
+ "BOOKLET_SORT: Arranges pages for booklet printing (last, first, second, second last, ...).\n"
+ "ODD_EVEN_SPLIT: Splits and arranges pages into odd and even numbered pages.\n"
+ "ODD_EVEN_MERGE: Merges pages and organises them alternately into odd and even pages.\n"
+ "REMOVE_FIRST: Removes the first page.\n"
+ "REMOVE_LAST: Removes the last page.\n"
+ "REMOVE_FIRST_AND_LAST: Removes both the first and the last pages.\n")
@@ -42,9 +42,10 @@ public class LanguageService {
languageCode -> {
Set<String> allowedLanguages =
new HashSet<>(applicationProperties.getUi().getLanguages());
// Empty list means all languages are allowed (no filtering)
// Non-empty list acts as a strict whitelist
return allowedLanguages.isEmpty()
|| allowedLanguages.contains(languageCode)
|| "en_GB".equals(languageCode);
|| allowedLanguages.contains(languageCode);
})
.collect(Collectors.toSet());
@@ -1,51 +0,0 @@
package stirling.software.SPDF.service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.springframework.stereotype.Service;
/** Service class responsible for removing image objects from a PDF document. */
@Service
public class PdfImageRemovalService {
/**
* Removes all image objects from the provided PDF document.
*
* <p>This method iterates over each page in the document and removes any image XObjects found
* in the page's resources.
*
* @param document The PDF document from which images will be removed.
* @return The modified PDF document with images removed.
* @throws IOException If an error occurs while processing the PDF document.
*/
public PDDocument removeImagesFromPdf(PDDocument document) throws IOException {
// Iterate over each page in the PDF document
for (PDPage page : document.getPages()) {
PDResources resources = page.getResources();
// Collect the XObject names to remove
List<COSName> namesToRemove = new ArrayList<>();
// Iterate over all XObject names in the page's resources
for (COSName name : resources.getXObjectNames()) {
// Check if the XObject is an image
if (resources.isImageXObject(name)) {
// Collect the name for removal
namesToRemove.add(name);
}
}
// Now, modify the resources by removing the collected names
for (COSName name : namesToRemove) {
resources.put(name, (PDXObject) null);
}
}
return document;
}
}
@@ -581,9 +581,10 @@ public class PdfJsonFallbackFontService {
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
return switch (script) {
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU, etc.)
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU,
// etc.)
case HAN -> FALLBACK_FONT_CJK_ID;
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
case HANGUL -> FALLBACK_FONT_KR_ID;
@@ -96,11 +96,19 @@ premium:
author: username
creator: Stirling-PDF
producer: Stirling-PDF
googleDrive:
enabled: false # Enable Google Drive file picker integration
clientId: "" # Google OAuth 2.0 client ID (obtain from Google Cloud Console)
apiKey: "" # Google API key for Google Picker API (obtain from Google Cloud Console)
appId: "" # Google Drive app ID
enterpriseFeatures:
audit:
enabled: true # Enable audit logging
level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
retentionDays: 90 # Number of days to retain audit logs
enabled: true # Enable audit logging for security and compliance tracking
level: 2 # Audit logging level: 0=OFF, 1=BASIC (compress/split/merge/etc and settings), 2=STANDARD (BASIC + user actions, excludes polling), 3=VERBOSE (everything including polling).
retentionDays: 90 # Number of days to retain audit logs (0 or negative = infinite retention)
captureFileHash: false # Capture SHA-256 hash of uploaded/processed files. Warning: adds 50-200ms per file depending on size. Only enabled independently of audit level.
capturePdfAuthor: false # Capture author metadata from PDF documents. Warning: requires PDF parsing which increases processing time. Only enabled independently of audit level.
captureOperationResults: false # Capture operation return values and responses in audit log. Warning: not recommended, significantly increases log volume and disk usage. Use only for debugging.
databaseNotifications:
backups:
successful: false # set to 'true' to enable email notifications for successful database backups
@@ -232,9 +240,12 @@ system:
ui:
appNameNavbar: "" # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
hideDisabledTools:
googleDrive: false # Hide Google Drive button when not enabled
mobileQRScanner: false # Hide mobile QR scanner button when not enabled
endpoints:
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
File diff suppressed because it is too large Load Diff
@@ -1,111 +0,0 @@
#searchBar {
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-container-low);
width: 100%;
font-size: 16px;
margin-bottom: 2rem;
padding: 0.75rem 3.5rem;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: 3rem;
outline-color: var(--md-sys-color-outline-variant);
}
#filtersContainer {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
gap: 10px;
}
.filter-button {
color: var(--md-sys-color-secondary);
user-select: none;
cursor: pointer;
transition: transform 0.3s;
transform-origin: center center;
}
.filter-button:hover {
transform: scale(1.08);
}
.search-icon {
position: absolute;
margin: 0.75rem 1rem;
border: 0.1rem solid transparent;
}
.favorite-icon {
display: none;
position: absolute;
top: 10px;
right: 10px;
color: var(--md-sys-color-secondary);
}
#tool-icon {
height: 100%;
}
#tool-text {
margin: 0.0rem 0 0 1.25rem;
}
.favorite-icon img {
filter: brightness(0) invert(var(--md-theme-filter-color));
}
.favorite-icon:hover .material-symbols-rounded {
transform: scale(1.2);
}
.favorite-icon .material-symbols-rounded.fill{
color: #f5c000;
}
.jumbotron {
padding: 3rem 3rem;
/* Reduce vertical padding */
}
.lookatme {
opacity: 1;
position: relative;
display: inline-block;
}
.lookatme::after {
color: #e33100;
text-shadow: 0 0 5px #e33100;
/* in the html, the data-lookatme-text attribute must */
/* contain the same text as the .lookatme element */
content: attr(data-lookatme-text);
padding: inherit;
position: absolute;
inset: 0 0 0 0;
z-index: 1;
/* 20 steps / 2 seconds = 10fps */
-webkit-animation: 2s infinite Pulse steps(20);
animation: 2s infinite Pulse steps(20);
}
.newfeature{
display: flex;
width:fit-content
}
.recent-features{
display: flex;
flex-direction: row;
max-width: 100%;
overflow: hidden;
justify-content: center;
}
.close-icon {
color: var(--favourite-remove) !important;
}
.add-icon {
color: var(--favourite-add) !important;
}
@@ -1,625 +0,0 @@
/* Base Container Styles */
.multi-tool-container {
max-width: 100vw;
margin: 0;
padding: 0 20px;
}
/* Form Elements */
label {
text-align: left;
display: block;
padding: 0rem 0.25rem;
font-size: 1.25rem;
}
.form-control {
border-radius: 16px !important;
padding: 0.75rem;
border: 1px solid var(--theme-color-outline-variant);
flex-grow: 5;
}
/* Action Bar Styles */
.mt-action-bar {
display: flex;
gap: 10px;
align-items: start;
border: none;
backdrop-filter: blur(2px);
top: 10px;
z-index: 11;
padding: 1.25rem;
border-radius: 2rem;
margin: 0px 25px;
justify-content: center;
}
.mt-action-bar > * {
padding-bottom: 0.5rem;
}
.mt-file-uploader {
width: 100%;
}
.mt-action-bar svg,
.mt-action-btn svg {
width: 20px;
height: 20px;
}
.mt-action-bar .mt-filename {
width: 100%;
display: flex;
gap: 10px;
}
/* Action Button Styles */
.mt-action-btn {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
border-radius: 2rem;
z-index: 12;
background-color: var(--md-sys-color-surface-container-low);
display: flex;
gap: 10px;
width: fit-content;
justify-content: center;
padding: 10px 20px;
transition: all 0.3s ease-in-out;
}
.mt-action-btn .btn {
width: 3.5rem;
height: 3.5rem;
border-radius: 20px;
padding: 0;
position: relative;
}
/* Card and Container Styles */
.bg-card {
background-color: var(--md-sys-color-surface-5);
border-radius: 3rem;
padding: 15px 0;
margin-left: 55px;
margin-right: 20px;
display: flex;
flex-direction: column;
align-items: stretch;
}
#pages-container-wrapper {
width: 100%;
display: flex;
justify-content: center;
padding: 0.75rem;
border-radius: 25px;
min-height: 275px;
margin: 0 0 20px 0;
}
#pages-container {
display: inline-flex;
flex-wrap: wrap;
gap: 20px;
justify-content: flex-start;
width: fit-content;
max-width: 100%;
}
/* Scrollbar Styles */
#pages-container-wrapper::-webkit-scrollbar {
width: 10px;
height: 10px;
}
#pages-container-wrapper::-webkit-scrollbar-track {
background: var(--scroll-bar-color);
}
#pages-container-wrapper::-webkit-scrollbar-thumb {
border-radius: 10px;
background: var(--scroll-bar-thumb);
}
#pages-container-wrapper::-webkit-scrollbar-thumb:hover {
background: var(--scroll-bar-thumb-hover);
}
/* Page Container Base Styles */
.page-container {
display: inline-block;
list-style-type: none;
width: 260px;
height: 260px;
line-height: 50px;
margin-top: 15px;
box-sizing: border-box;
text-align: center;
aspect-ratio: 1;
position: relative;
user-select: none;
transition: width 0.3s ease-in-out;
}
/* Responsive Page Container Sizes */
@media only screen and (max-width: 480px) {
.page-container {
width: calc(100vw - 90px);
height: calc(100vw - 90px);
max-width: 300px;
max-height: 300px;
margin: 5px auto;
}
#pages-container { gap: 10px; }
}
@media only screen and (min-width: 481px) and (max-width: 768px) {
.page-container {
width: calc((100vw - 120px - 12px) / 2);
height: calc((100vw - 120px - 12px) / 2);
max-width: 250px;
max-height: 250px;
margin: 6px;
}
#pages-container { gap: 12px; }
}
@media only screen and (min-width: 769px) and (max-width: 1199px) {
.page-container {
width: calc((100vw - 140px - 45px) / 3);
height: calc((100vw - 140px - 45px) / 3);
max-width: 220px;
max-height: 220px;
margin: 7px;
}
#pages-container { gap: 15px; }
}
@media only screen and (min-width: 1200px) and (max-width: 1280px) {
.page-container {
width: calc((100vw - 160px - 60px) / 4);
height: calc((100vw - 160px - 60px) / 4);
max-width: 200px;
max-height: 200px;
margin: 8px;
}
#pages-container { gap: 17px; }
}
@media only screen and (min-width: 1281px) {
.page-container {
width: calc((100vw - 180px - 80px) / 5);
height: calc((100vw - 180px - 80px) / 5);
max-width: 190px;
max-height: 190px;
margin: 10px;
}
#pages-container { gap: 20px; }
}
/* Split Page Styles */
.page-container.split-before {
border-left: 1px dashed var(--md-sys-color-on-surface);
padding-left: -1px;
}
.page-container.split-before:first-child {
border-left: none;
}
.page-container:first-child .pdf-actions_split-file-button {
display: none;
}
/* RTL Language Support */
.page-container:last-child:lang(ar),
.page-container:last-child:lang(he),
.page-container:last-child:lang(fa),
.page-container:last-child:lang(ur),
.page-container:last-child:lang(ckb),
.page-container:last-child:lang(ks),
.page-container:last-child:lang(kk),
.page-container:last-child:lang(uz),
.page-container:last-child:lang(ky),
.page-container:last-child:lang(bal),
.page-container:last-child:lang(dv),
.page-container:last-child:lang(ps),
.page-container:last-child:lang(sdg),
.page-container:last-child:lang(syr),
.page-container:last-child:lang(mzn),
.page-container:last-child:lang(tgl),
.page-container:last-child:lang(pnb),
.page-container:last-child:lang(ug),
.page-container:last-child:lang(nqo),
.page-container:last-child:lang(bqi) {
margin-left: auto !important;
margin-right: 0 !important;
}
/* Page Image Styles */
.page-container img {
max-width: calc(100% - 8px);
max-height: calc(100% - 8px);
display: block;
position: absolute;
left: 50%;
top: 50%;
translate: -50% -50%;
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.384);
border-radius: 4px;
transition: rotate 0.3s;
}
/* Page Number Styles */
.page-number {
position: absolute;
top: 5px;
left: 5px;
line-height: normal;
color: var(--md-sys-color-on-secondary);
background-color: rgba(162, 201, 255, 0.8);
padding: 6px 8px;
border-radius: 8px;
font-size: 16px;
z-index: 2;
font-weight: 450;
}
/* Tool Header and Button Styles */
.tool-header {
margin: 0.5rem 1rem 1rem;
}
#select-pages-button {
opacity: 0.5;
}
#add-pdf-button {
margin: 0 auto;
}
/* Selected Pages Container Styles */
.selected-pages-container {
background-color: var(--md-sys-color-surface);
border-radius: 16px;
padding: 15px;
width: 100%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
font-family: Arial, sans-serif;
}
.selected-pages-container h3 {
color: var(--md-sys-color-on-surface);
font-size: 1.2em;
margin-bottom: 10px;
}
.selected-pages-header {
margin-bottom: 15px;
}
.selected-pages-header h5 {
margin: 0 0 8px 0 !important;
font-size: 1.1rem;
font-weight: 600;
color: var(--md-sys-color-on-surface);
}
.selected-pages-header #csv-input {
width: 100%;
height: 2.5rem;
border-radius: 8px;
border: 1px solid var(--md-sys-color-outline);
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
padding: 0 12px;
font-size: 0.95rem;
}
.selected-pages-header #csv-input:focus {
outline: none;
border-color: var(--md-sys-color-primary);
box-shadow: 0 0 0 2px rgba(var(--md-sys-color-primary-rgb), 0.2);
}
/* Pages List Styles */
.pages-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 0;
list-style: none;
min-height: 0;
max-height: 10rem;
overflow: auto;
}
.page-item {
background-color: var(--md-sys-color-surface-container-low);
border-radius: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
color: var(--md-sys-color-on-surface);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 7rem;
height: 2.5rem;
}
.selected-page-number {
width: 4rem;
font-size: small;
}
.remove-btn {
cursor: pointer;
color: var(--md-sys-color-on-surface);
font-size: 1.2em;
}
.checkbox-container {
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
}
.checkbox-label {
font-size: medium;
}
/* Zoom Level Responsive Styles */
@media only screen and (min-width: 2000px) {
.mt-action-btn {
gap: 12px;
padding: 12px 24px;
border-radius: 2.5rem;
}
.mt-action-btn .btn {
width: 4rem;
height: 4rem;
border-radius: 22px;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 1.7rem;
}
}
@media only screen and (min-width: 2560px) {
.mt-action-btn {
gap: 15px;
padding: 15px 30px;
border-radius: 3rem;
}
.mt-action-btn .btn {
width: 5rem;
height: 5rem;
border-radius: 28px;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 2.1rem;
}
}
@media only screen and (min-width: 3840px) {
.mt-action-btn {
gap: 20px;
padding: 20px 40px;
border-radius: 4rem;
}
.mt-action-btn .btn {
width: 7rem;
height: 7rem;
border-radius: 40px;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 3rem;
}
}
@media only screen and (min-width: 7680px) {
.mt-action-btn {
gap: 40px;
padding: 40px 80px;
border-radius: 8rem;
}
.mt-action-btn .btn {
width: 14rem;
height: 14rem;
border-radius: 80px;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 6rem;
}
}
/* Zoom-responsive Sidebar Styles */
@media only screen and (max-width: 1280px) {
.container {
position: relative;
}
.mt-action-btn {
position: fixed !important;
left: 10px !important;
top: 80px !important;
bottom: 20px !important;
margin: 0 !important;
transform: none !important;
border-radius: 16px !important;
background-color: var(--md-sys-color-surface-container-low);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
height: calc(100vh - 100px) !important;
overflow-y: auto !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
z-index: 12;
width: 70px !important;
gap: 8px !important;
padding: 12px 8px !important;
box-sizing: border-box !important;
}
.mt-action-btn:has(.btn:nth-last-child(n+7)) {
justify-content: flex-start !important;
}
.mt-action-btn::-webkit-scrollbar {
display: none !important;
}
.mt-action-btn .btn {
width: 48px !important;
height: 48px !important;
border-radius: 12px !important;
padding: 0 !important;
margin: 0 !important;
flex-shrink: 0;
font-size: 16px !important;
box-sizing: border-box !important;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 20px !important;
line-height: 1 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
height: 100% !important;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24 !important;
}
.multi-tool-container {
margin-left: 0 !important;
max-width: 100% !important;
}
.mt-action-bar {
margin-left: 25px !important;
margin-right: 25px !important;
}
.mt-action-btn:not(:has(*:nth-child(6))) {
justify-content: center !important;
}
}
/* Mobile and High Zoom Responsive Styles */
@media only screen and (max-width: 960px) {
.mt-action-btn {
left: 8px !important;
top: 75px !important;
bottom: 15px !important;
height: calc(100vh - 90px) !important;
}
}
@media only screen and (max-width: 768px) {
.mt-action-btn {
left: 5px !important;
width: 60px !important;
top: calc(var(--navbar-height, 60px) + 10px) !important;
bottom: 10px !important;
height: calc(100vh - var(--navbar-height, 60px) - 20px) !important;
}
.mt-action-btn .btn {
width: 40px !important;
height: 40px !important;
box-sizing: border-box !important;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 16px !important;
}
#pages-container {
margin: 0 auto !important;
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
width: 100% !important;
}
.page-container {
width: calc(100vw - 55px) !important;
height: calc(100vw - 60px) !important;
max-width: 350px !important;
max-height: 350px !important;
margin: 5px auto !important;
}
}
@media only screen and (max-width: 480px) {
.mt-action-btn {
left: 2px !important;
top: calc(var(--navbar-height, 60px) + 5px) !important;
bottom: 5px !important;
height: calc(100vh - var(--navbar-height, 60px) - 10px) !important;
width: 50px !important;
gap: 6px !important;
padding: 10px 6px !important;
}
.mt-action-btn .btn {
width: 32px !important;
height: 32px !important;
border-radius: 10px !important;
box-sizing: border-box !important;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 14px !important;
}
}
@media only screen and (max-width: 384px) {
.mt-action-btn {
left: 2px !important;
top: calc(var(--navbar-height, 60px) + 5px) !important;
bottom: 5px !important;
height: calc(100vh - var(--navbar-height, 60px) - 10px) !important;
width: 40px !important;
gap: 4px !important;
padding: 8px 4px !important;
}
.mt-action-btn .btn {
width: 28px !important;
height: 28px !important;
border-radius: 8px !important;
box-sizing: border-box !important;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 12px !important;
}
.page-container {
width: calc(100vw - 55px) !important;
height: calc(100vw - 60px) !important;
max-width: 280px !important;
max-height: 280px !important;
margin: 3px auto !important;
}
.page-container img {
max-width: calc(100% - 4px) !important;
max-height: calc(100% - 4px) !important;
}
}
@@ -1,799 +0,0 @@
#navbarSearch {
top: 100%;
right: 0;
transition: all 0.3s;
max-height: 0;
overflow: hidden;
}
#navbarSearch.show {
height: auto;
/*dynamically changes height*/
}
#searchResults .dropdown-item {
display: flex;
align-items: center;
white-space: nowrap;
height: 50px;
/* Fixed height */
overflow: hidden;
/* Hide overflow */
}
#searchResults .icon {
margin-right: 10px;
}
#searchResults .icon-text {
display: inline;
overflow: hidden;
/* Hide overflow */
text-overflow: ellipsis;
/* Add ellipsis for long text */
}
#search-icon i {
font-size: 24px;
/* Adjust this to your desired size */
transition: color 0.3s;
}
#search-icon:hover i {
color: #666;
/* Adjust this to your hover color */
}
.search-input {
transition: border 0.3s, box-shadow 0.3s;
}
.search-input:focus {
border-color: #666;
/* Adjust this to your focus color */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
/* Adjust this to your desired shadow */
}
.main-icon {
width: 36px;
height: 36px;
vertical-align: middle;
transform: translateY(-2px);
}
/* Responsive navbar brand - prevent hamburger from wrapping */
.navbar-brand {
display: flex !important;
align-items: center;
gap: 8px;
min-width: 0; /* Allow shrinking */
}
.navbar-brand .icon-text {
font-size: 1.5rem;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Progressive text shrinking to prevent hamburger wrap */
@media only screen and (max-width: 480px) {
.navbar-brand .icon-text {
font-size: 1.3rem;
}
.main-icon {
width: 32px;
height: 32px;
}
}
@media only screen and (max-width: 420px) {
.navbar-brand .icon-text {
font-size: 1.1rem;
}
.main-icon {
width: 28px;
height: 28px;
}
}
@media only screen and (max-width: 380px) {
.navbar-brand .icon-text {
font-size: 1rem;
}
.main-icon {
width: 24px;
height: 24px;
}
}
@media only screen and (max-width: 340px) {
.navbar-brand .icon-text {
font-size: 0.9rem;
}
.main-icon {
width: 20px;
height: 20px;
}
}
/* Ultra-small screens - hide text, keep icon */
@media only screen and (max-width: 320px) {
.navbar-brand .icon-text {
display: none;
}
.main-icon {
width: 24px;
height: 24px;
}
}
.nav-icon {
vertical-align: middle;
font-size: 2rem !important;
padding: 0.25rem;
border-radius: 1rem;
}
.icon+.icon {
margin-left: -4px;
}
.icon-text {
margin-left: 8px;
margin-right: 4px;
display: inline-flex;
flex-direction: column;
justify-content: space-between;
vertical-align: middle;
}
.scalable-languages-container {
display: grid;
grid-template-columns: repeat(1, 1fr);
}
@media (min-width: 400px) {
#languageSelection.scalable-languages-container {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 600px) {
#languageSelection.scalable-languages-container {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 900px) {
#languageSelection.scalable-languages-container {
grid-template-columns: repeat(4, 1fr) !important;
}
}
.scalable-languages-container:not(:has(> :nth-child(4))) .lang-dropdown-item-wrapper:last-child {
border: 0px !important
}
html[dir="ltr"] #languageSelection .lang-dropdown-item-wrapper:last-child {
border-right: none !important;
}
#languageSelection .lang-dropdown-item-wrapper:last-child {
border: 0px !important;
}
@media (max-width: 600px) {
.scalable-languages-container {
grid-template-columns: repeat(2, 1fr);
}
.scalable-languages-container:not(:has(> :nth-child(2))) {
grid-template-columns: repeat(var(--count), 1fr) !important;
}
.scalable-languages-container .lang-dropdown-item-wrapper:nth-child(2n) {
border: 0px
}
}
@media (min-width: 601px) and (max-width: 900px) {
.scalable-languages-container {
grid-template-columns: repeat(3, 1fr);
}
.scalable-languages-container:not(:has(> :nth-child(3))) {
grid-template-columns: repeat(var(--count), 1fr) !important;
}
.scalable-languages-container .lang-dropdown-item-wrapper:nth-child(3n) {
border: 0px
}
}
@media (min-width: 901px) {
.scalable-languages-container {
grid-template-columns: repeat(4, 1fr);
}
.scalable-languages-container:not(:has(> :nth-child(4))) {
grid-template-columns: repeat(var(--count), 1fr) !important;
}
.scalable-languages-container .lang-dropdown-item-wrapper:nth-child(4n) {
border: 0px
}
}
.scalable-languages-container:has(> *:nth-child(1)) {
--count: 1;
}
.scalable-languages-container:has(> *:nth-child(2)) {
--count: 2;
}
.scalable-languages-container:has(> *:nth-child(3)) {
--count: 3;
}
html[dir="ltr"] .lang-dropdown-item-wrapper {
border-right: 2px solid var(--md-nav-color-on-separator);
}
html[dir="rtl"] .lang-dropdown-item-wrapper {
border-left: 2px solid var(--md-nav-color-on-separator);
}
.scroll-lock-y {
overflow-y: auto;
max-height: 30vh;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
/* Responsive adjustments */
@media (min-width: 1200px) {
.lang-dropdown-item-wrapper .dropdown-item {
min-width: 200px
}
.scroll-lock-y {
max-height: 80vh;
}
}
.dropdown-item .icon-text {
text-wrap: wrap;
word-break: break-word;
width: 80%;
font-size: large;
}
.close-icon {
color: var(--md-sys-color-secondary);
}
.close-icon:hover {
transform: scale(1.15);
}
span.icon-text::after {
content: attr(data-text);
content: attr(data-text) / "";
font-weight: 600;
height: 0;
visibility: hidden;
overflow: hidden;
user-select: none;
pointer-events: none;
}
.nav-item-separator {
position: relative;
margin: 0 4px;
/* Adjust the margin as needed */
}
.nav-item-separator::before {
content: "";
position: absolute;
left: 0;
top: 10%;
/* Adjust the top and bottom margins as needed */
bottom: 10%;
width: 1px;
background-color: #ccc;
/* Adjust the color as needed */
}
.navbar-icon {
width: 20px;
height: 20px;
transform: translateY(-2px);
}
.navbar-toggler {
color: var(--md-sys-color-on-surface-variant);
}
.nav-link {
display: flex;
align-items: center;
max-width: 98vw;
}
.chevron-icon {
margin-left: auto;
transition: transform 0.3s ease;
}
[aria-expanded="true"] > .chevron-icon {
transform: rotate(180deg);
}
.nav-item {
position: relative;
}
/* .tooltip-text {
visibility: hidden;
background-color: rgb(71 67 67 / 80%);
color: #fff;
text-align: center;
border-radius: 4px;
padding: 5px;
position: absolute;
z-index: 1;
top: 100%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
font-size: 12px;
white-space: nowrap;
margin-top: 5px;
}
.nav-item:hover .tooltip-text {
visibility: visible;
opacity: 1;
} */
.dropdown-menu.scrollable-y {
overflow-y: scroll;
max-height: 360px;
}
/* Dropdown Scrollbar*/
.scrollable-y {
overflow-y: scroll;
max-height: 190px;
overscroll-behavior: contain;
}
.scrollable-y::-webkit-scrollbar {
background: transparent;
width: 0.7rem;
}
.scrollable-y::-webkit-scrollbar-track {
background: transparent;
}
.scrollable-y::-webkit-scrollbar-thumb {
border-radius: 2rem;
background-color: var(--md-sys-color-surface-5);
border: 3px solid var(--md-sys-color-surface-5);
}
/* Mega Menu */
.dropdown-mega .dropdown-menu {
width: 100%;
left: 0 !important;
right: auto !important;
}
.dropdown-mega .title {
padding-bottom: 1rem;
margin: 0;
}
.dropdown-menu .list-group {
padding: 1rem;
}
.mega-content .dropdown-item:focus .nav-icon,
.mega-content .dropdown-item:hover .nav-icon,
.mega-content .dropdown-item.active .nav-icon {
background-color: transparent;
}
.dropdown-item:focus.sign,
.dropdown-item:hover.sign,
.dropdown-item.active.sign {
color: var(--md-nav-on-section-color-sign);
background-color: var(--md-nav-section-color-sign);
}
.dropdown-item:focus.organize,
.dropdown-item:hover.organize,
.dropdown-item.active.organize {
color: var(--md-nav-on-section-color-organize);
background-color: var(--md-nav-section-color-organize);
}
.dropdown-item:focus.convert,
.dropdown-item:hover.convert,
.dropdown-item.active.convert {
color: var(--md-nav-on-section-color-convert);
background-color: var(--md-nav-section-color-convert);
}
.dropdown-item:focus.convertto,
.dropdown-item:hover.convertto,
.dropdown-item.active.convertto {
color: var(--md-nav-on-section-color-convertto);
background-color: var(--md-nav-section-color-convertto);
}
.dropdown-item:focus.image,
.dropdown-item:hover.image,
.dropdown-item.active.image {
color: var(--md-nav-on-section-color-image);
background-color: var(--md-nav-section-color-image);
}
.dropdown-item:focus.word,
.dropdown-item:hover.word,
.dropdown-item.active.word {
color: var(--md-nav-on-section-color-word);
background-color: var(--md-nav-section-color-word);
}
.dropdown-item:focus.ppt,
.dropdown-item:hover.ppt,
.dropdown-item.active.ppt {
color: var(--md-nav-on-section-color-ppt);
background-color: var(--md-nav-section-color-ppt);
}
.dropdown-item:focus.security,
.dropdown-item:hover.security,
.dropdown-item.active.security {
color: var(--md-nav-on-section-color-security);
background-color: var(--md-nav-section-color-security);
}
.dropdown-item:focus.other,
.dropdown-item:hover.other,
.dropdown-item.active.other {
color: var(--md-nav-on-section-color-other);
background-color: var(--md-nav-section-color-other);
}
.dropdown-item:focus.advance,
.dropdown-item:hover.advance,
.dropdown-item.active.advance {
color: var(--md-nav-on-section-color-advance);
background-color: var(--md-nav-section-color-advance);
}
/* Dropdown min-width */
.dropdown-mw-28 {
min-width: 280px;
min-height: 50px;
}
.dropdown-mw-20 {
min-width: 200px;
}
/* Dropdown open on hover */
html[dir="ltr"] .dropdown-menu {
padding-top: 0.5rem;
top: auto;
left: auto;
right: 0;
}
html[dir="rtl"] .dropdown-menu {
padding-top: 0.5rem;
top: auto;
left: 0;
right: auto;
}
/* Bootstrap Popper positioning overrides removed - dropdowns now position naturally relative to their buttons */
.dropdown-menu-wrapper {
padding: 1.5rem 0;
border-radius: 1rem;
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface);
border: 1px solid var(--md-sys-color-surface-5);
box-shadow: var(--md-sys-elevation-2);
}
.dropdown-menu-tp {
color: transparent;
background-color: transparent;
border: none;
box-shadow: none;
}
.icon-hide {
display: inline-flex;
}
@media (max-width:1199.98px) {
.navbar-collapse .dropdown-menu {
width: 100%;
}
.navbar-collapse .dropdown-menu-wrapper {
width: 100%;
box-sizing: border-box;
}
.navbar-collapse .dropdown-mw-28 {
min-width: 0;
}
}
@media (min-width:1200px) {
/* This CSS-based hover is disabled because it conflicts with Bootstrap's JavaScript.
Hover functionality is now handled in navbar.js and search.js */
/*
.dropdown:hover .dropdown-menu {
display: block;
margin-top: 0;
}
*/
.icon-hide {
display: none;
}
.chevron-icon {
display: none !important;
}
}
@media (max-width: 1199.98px) {
.navbar-collapse .dropdown-menu {
width: 100vw !important;
max-width: 100vw !important;
left: 0 !important;
right: 0 !important;
transform: none !important;
transform-origin: none !important;
}
.navbar .navbar-expand-xl .dropdown-menu,
.navbar-expand .dropdown-menu {
transform: none !important;
transform-origin: none !important;
left: 0 !important;
right: 0 !important;
}
.navbar-collapse .dropdown-mega .dropdown-menu {
max-height: 60vh;
overflow-y: auto;
}
.navbar-collapse .dropdown-mega .dropdown-menu-wrapper {
border-radius: 0;
}
#favoritesDropdown,
#languageDropdown + .dropdown-menu .dropdown-menu-wrapper,
#searchDropdown + .dropdown-menu .dropdown-menu-wrapper {
padding: 1.5rem 1rem;
}
#favoritesDropdown, #languageSelection, #searchResults {
width: 100%;
box-sizing: border-box;
}
.navbar-collapse .dropdown-menu-wrapper {
width: 95vw;
box-sizing: border-box;
margin-left: 0 !important;
padding: 0 !important;
}
.navbar-collapse .dropdown-mw-28 {
min-width: 0;
}
.icon-hide {
display: inline-flex;
}
.navbar-collapse .dropdown-item {
margin-left: 0 !important;
}
.container {
margin-left: auto !important;
margin-right: auto !important;
padding-left: 4px !important;
}
#mainNavbarDropdownMenu {
transform: none !important;
transform-origin: none !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
margin-bottom: 0 !important;
}
.dropdown-menu,
.dropdown-menu-tp,
.dropdown-menu-tp.show {
transform: none !important;
transform-origin: none !important;
max-width: 95vw !important;
width: 100vw !important;
left: 0 !important;
right: 0 !important;
margin-bottom: 0 !important;
}
}
.go-pro-link {
position: relative;
padding: 0.5rem 1rem;
transition: all 0.3s ease;
z-index: 1;
display: inline-block;
width: auto;
}
.go-pro-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: bold;
color: #ffffff;
background-color: #007bff;
border-radius: 0.25rem;
text-transform: uppercase;
transition: all 0.3s ease;
}
.go-pro-link:hover .go-pro-badge {
background-color: #0056b3;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
#stacked {
gap: 1rem;
}
#stacked>.navbar-item {
margin: 0;
}
.features-container {
display: flex;
gap: 30px;
justify-content: center;
width: 100%;
position: relative;
}
.feature-group {
display: flex;
flex-direction: column;
min-width: 14rem;
max-width: 18rem;
flex: 1 1 min(14rem, 100%);
box-sizing: border-box;
}
@media (max-width: 768px) {
.feature-group {
min-width: 10rem;
}
}
.feature-rows {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 1rem;
padding: 0 1rem;
box-sizing: border-box;
}
.feature-rows.single-column {
justify-content: center;
/* Center-align a single column */
}
.feature-group-header {
display: flex;
justify-content: flex-start;
color: var(--md-sys-color-on-surface);
margin-top: 15px;
user-select: none;
cursor: pointer;
gap: 10px;
}
.nav-group-container {
padding: 10px;
}
.card-title.text-primary {
color: #000;
}
.home-card-icon {
width: 3rem;
height: 3rem;
transform: translateY(-5px);
}
.favorite-icon {
display: none;
position: absolute;
top: 10px;
right: 10px;
color: var(--md-sys-color-secondary);
}
.favorite-icon img {
filter: brightness(0) invert(var(--md-theme-filter-color));
}
.favorite-icon:hover .material-symbols-rounded {
transform: scale(1.2);
}
.favorite-icon .material-symbols-rounded.fill {
color: #f5c000;
}
@keyframes Pulse {
from {
opacity: 0;
}
50% {
opacity: 1;
}
to {
opacity: 0;
}
}
.update-notice {
animation: scale 1s infinite alternate;
}
@keyframes scale {
0% {
transform: scale(0.96);
}
100% {
transform: scale(1);
}
}
.hidden {
visibility: hidden;
}
@@ -1,117 +0,0 @@
.pdf-actions_button-container {
z-index: 4;
opacity: 0;
transition: opacity 0.1s linear;
position: absolute !important;
bottom: 0px;
left: 50%;
transform: translate(-50%, 0%);
}
.pdf-actions_container:hover .pdf-actions_button-container {
opacity: 1;
}
.pdf-actions_button-container>* {
padding: 0.25rem 0.5rem;
display: block;
}
.pdf-actions_button-container>*:focus {
box-shadow: none !important;
}
.pdf-actions_button-container .btn {
border-radius: 12px;
}
.pdf-actions_button-container> :first-child,
.pdf-actions_container:first-child>.pdf-actions_button-container> :first-child+* {
border-radius: 12px 0px 0px 12px;
}
.pdf-actions_container svg {
width: 16px;
height: 16px;
}
.pdf-actions_container:nth-child(1) .pdf-actions_move-left-button {
display: none;
}
.pdf-actions_container:last-child .pdf-actions_move-right-button {
display: none;
}
/* "insert pdf" buttons that appear on the right when hover */
.pdf-actions_insert-file-button-container {
display:flex;
flex-direction: column;
gap: 10px;
translate: 0 -50%;
height: 100%;
width: 100px;
padding-left: 30px;
padding-right:30px;
justify-content: center;
z-index: 3;
opacity: 0;
transition: opacity 0.4s;
}
.pdf-actions_insert-file-button-container.left {
left: -50px;
}
.pdf-actions_insert-file-button-container.right {
right: -50px;
}
html[dir="ltr"] .pdf-actions_insert-file-button-container.right {
display: none;
}
html[dir="rtl"] .pdf-actions_insert-file-button-container.left {
display: none;
}
html[dir="ltr"] .pdf-actions_container:last-child>.pdf-actions_insert-file-button-container.right {
display: flex;
}
html[dir="rtl"] .pdf-actions_container:last-child>.pdf-actions_insert-file-button-container.left {
display: flex;
}
.pdf-actions_insert-file-button-container.left button,
.pdf-actions_insert-file-button-container.right button {
padding: 0.45rem;
}
.pdf-actions_button-container button .material-symbols-rounded {
font-size: 1.25rem;
vertical-align: sub;
}
.pdf-actions_insert-file-button-container:hover {
opacity: 1;
transition: opacity 0.05s;
}
.pdf-actions_checkbox {
position: absolute;
top: 5px;
right: 3px;
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-5);
padding: 6px 8px;
border-radius: 8px;
font-size: 16px;
z-index: 10;
}
.hidden {
display: none;
}
@@ -1,22 +0,0 @@
.filename {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 10px;
}
.arrows {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
}
.arrows .btn {
margin: 0 3px;
}
.move-up span,
.move-down span {
font-weight: bold;
font-size: 1.2em;
}
@@ -1,973 +0,0 @@
/* Dark Mode Styles */
body,
select,
textarea {
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
}
.transition-theme {
transition: background 0.5s ease, color 0.5s ease, border 0.5s ease;
}
/*.global-buttons-container input:disabled::-webkit-input-placeholder { !* WebKit browsers *!*/
/* color: #98A0AB;*/
/*}*/
/*.global-buttons-container input:disabled:-moz-placeholder { !* Mozilla Firefox 4 to 18 *!*/
/* color: #98A0AB;*/
/*}*/
/*.global-buttons-container input:disabled::-moz-placeholder { !* Mozilla Firefox 19+ *!*/
/* color: #98A0AB;*/
/*}*/
/*.global-buttons-container input:disabled:-ms-input-placeholder { !* Internet Explorer 10+ *!*/
/* color: #98A0AB;*/
/*}*/
/* Scrollbar */
*::-webkit-scrollbar {
background: var(--md-sys-color-surface);
width: 1rem;
}
*::-webkit-scrollbar-track {
background: var(--md-sys-color-surface);
}
*::-webkit-scrollbar-thumb {
border-radius: 2rem;
background-color: var(--md-sys-color-surface-5);
border: 5px solid var(--md-sys-color-surface-5);
}
*::-webkit-scrollbar-corner {
background-color: var(--md-sys-color-surface);
}
/* Alerts */
.alert {
border-radius: 3rem;
}
/* Table */
td {
word-break: break-word;
}
.input-group-append {
margin: 0rem 0.5rem !important;
}
.card-header {
background-color: transparent;
border-bottom: none;
}
.bg-card {
background-color: var(--md-sys-color-surface-5);
border-radius: 3rem;
padding: 2.5rem;
max-width: 95vw;
margin-left: 2vw;
}
.card {
padding: 1.25rem;
border-radius: 2rem;
background-color: var(--md-sys-color-surface-5);
border: none;
}
/* Modal */
.modal-content {
background-color: var(--md-sys-color-surface);
border-radius: 2rem;
border: transparent;
}
.modal-header,
.modal-body,
.modal-footer {
background-color: var(--md-sys-color-surface-5);
border: none;
}
.modal-header {
border-radius: 2rem 2rem 0rem 0rem;
padding: 1.5rem 2rem 0.5rem;
}
.modal-body{
padding: 0.5rem 2rem;
}
.modal-footer {
border-radius: 0rem 0rem 2rem 2rem;
padding: 0.5rem 2rem 1.5rem;
}
/* Icon fill */
.material-symbols-rounded {
vertical-align: text-top;
}
/* Navbar Icon*/
.nav-icon {
color: var(--md-sys-color-surface);
}
.sign .nav-icon,
.sign.tool-header-icon {
color: var(--md-nav-on-section-color-sign);
background-color: var(--md-nav-section-color-sign);
}
.organize .nav-icon,
.organize.tool-header-icon {
color: var(--md-nav-on-section-color-organize);
background-color: var(--md-nav-section-color-organize);
}
.convert .nav-icon,
.convert.tool-header-icon {
color: var(--md-nav-on-section-color-convert);
background-color: var(--md-nav-section-color-convert);
}
.convertto .nav-icon,
.convertto.tool-header-icon {
color: var(--md-nav-on-section-color-convertto);
background-color: var(--md-nav-section-color-convertto);
}
.security .nav-icon,
.security.tool-header-icon {
color: var(--md-nav-on-section-color-security);
background-color: var(--md-nav-section-color-security);
}
.other .nav-icon,
.other.tool-header-icon {
color: var(--md-nav-on-section-color-other);
background-color: var(--md-nav-section-color-other);
}
.advance .nav-icon,
.advance.tool-header-icon {
color: var(--md-nav-on-section-color-advance);
background-color: var(--md-nav-section-color-advance);
}
.image .nav-icon,
.image.tool-header-icon {
color: var(--md-nav-on-section-color-image);
background-color: var(--md-nav-section-color-image);
}
.word .nav-icon,
.word.tool-header-icon {
color: var(--md-nav-on-section-color-word);
background-color: var(--md-nav-section-color-word);
}
.ppt .nav-icon,
.ppt.tool-header-icon {
color: var(--md-nav-on-section-color-ppt);
background-color: var(--md-nav-section-color-ppt);
}
/* Tool Page Header*/
.tool-header {
margin-bottom: 2rem;
}
.tool-header .tool-header-icon {
margin: 0px 1rem;
height: 4rem;
width: 4rem;
border-radius: 25px;
font-size: 3rem;
padding: 0.5rem;
vertical-align: middle;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: rgb(0 0 0 / 0%);
}
.tool-header .tool-header-text {
font-size: 2.5rem;
font-weight: 400;
vertical-align: middle;
}
/* Home Card Colors*/
.feature-card .nav-icon {
vertical-align: middle;
font-size: 2rem !important;
padding: 0.75rem;
border-radius: 0.9rem;
color: var(--md-sys-color-surface);
}
.feature-card .sign .nav-icon {
color: var(--md-nav-on-section-color-sign);
background-color: var(--md-nav-section-color-sign);
}
.feature-card .organize .nav-icon {
color: var(--md-nav-on-section-color-organize);
background-color: var(--md-nav-section-color-organize);
}
.feature-card .convert .nav-icon {
color: var(--md-nav-on-section-color-convert);
background-color: var(--md-nav-section-color-convert);
}
.feature-card .convertto .nav-icon {
color: var(--md-nav-on-section-color-convertto);
background-color: var(--md-nav-section-color-convertto);
}
.feature-card .security .nav-icon {
color: var(--md-nav-on-section-color-security);
background-color: var(--md-nav-section-color-security);
}
.feature-card .other .nav-icon {
color: var(--md-nav-on-section-color-other);
background-color: var(--md-nav-section-color-other);
}
.feature-card .advance .nav-icon {
color: var(--md-nav-on-section-color-advance);
background-color: var(--md-nav-section-color-advance);
}
.feature-card .image .nav-icon {
color: var(--md-nav-on-section-color-image);
background-color: var(--md-nav-section-color-image);
}
.feature-card .word .nav-icon {
color: var(--md-nav-on-section-color-word);
background-color: var(--md-nav-section-color-word);
}
.feature-card .ppt .nav-icon {
color: var(--md-nav-on-section-color-ppt);
background-color: var(--md-nav-section-color-ppt);
}
/* Buttons Components */
.btn {
border-radius: 1.25rem;
}
.btn-close {
width: auto;
height: auto;
color: var(--md-sys-color-on-surface);
background: transparent;
}
.btn-close:hover {
color: var(--md-sys-color-on-surface);
}
.modal-header .btn-close {
margin: 0;
}
/* Primary btn */
.btn-primary {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
box-shadow: none !important;
}
.btn-primary.disabled,
.btn-primary:disabled {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.btn-primary:hover {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
box-shadow: var(--md-sys-elevation-3) !important;
}
.btn-check:active+.btn-primary,
.btn-check:checked+.btn-primary,
.btn-primary.active,
.btn-primary:active,
.show>.btn-primary.dropdown-toggle {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
box-shadow: var(--md-sys-elevation-3) !important;
}
.btn-check:focus+.btn-primary,
.btn-primary:focus {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
box-shadow: var(--md-sys-elevation-3) !important;
}
/* Secondary btn */
.btn-secondary {
color: var(--md-sys-color-on-secondary);
background-color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
box-shadow: none !important;
}
.btn-secondary.disabled,
.btn-secondary:disabled {
color: var(--md-sys-color-on-secondary);
background-color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
}
.btn-secondary:hover {
color: var(--md-sys-color-on-secondary);
background-color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
box-shadow: var(--md-sys-elevation-3) !important;
}
.btn-check:active+.btn-secondary,
.btn-check:checked+.btn-secondary,
.btn-secondary.active,
.btn-secondary:active,
.show>.btn-secondary.dropdown-toggle {
color: var(--md-sys-color-on-secondary);
background-color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
box-shadow: var(--md-sys-elevation-3) !important;
}
.btn-check:focus+.btn-secondary,
.btn-secondary:focus {
color: var(--md-sys-color-on-secondary);
background-color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
box-shadow: var(--md-sys-elevation-3) !important;
}
/* Danger btn */
.btn-danger {
color: var(--md-sys-color-on-error);
background-color: var(--md-sys-color-error);
border-color: var(--md-sys-color-error);
box-shadow: none !important;
}
.btn-danger.disabled,
.btn-danger:disabled {
color: var(--md-sys-color-on-error);
background-color: var(--md-sys-color-error);
border-color: var(--md-sys-color-error);
}
.btn-danger:hover {
color: var(--md-sys-color-on-error);
background-color: var(--md-sys-color-error);
border-color: var(--md-sys-color-error);
box-shadow: var(--md-sys-elevation-3) !important;
}
.btn-check:active+.btn-danger,
.btn-check:checked+.btn-danger,
.btn-danger.active,
.btn-danger:active,
.show>.btn-danger.dropdown-toggle {
color: var(--md-sys-color-on-error);
background-color: var(--md-sys-color-error);
border-color: var(--md-sys-color-error);
box-shadow: var(--md-sys-elevation-3) !important;
}
.btn-check:focus+.btn-danger,
.btn-danger:focus {
color: var(--md-sys-color-on-error);
background-color: var(--md-sys-color-error);
border-color: var(--md-sys-color-error);
box-shadow: var(--md-sys-elevation-3) !important;
}
/* Info btn */
.btn-info {
color: var(--md-sys-color-on-tertiary);
background-color: var(--md-sys-color-tertiary);
border-color: var(--md-sys-color-tertiary);
box-shadow: none !important;
}
.btn-info .disabled,
.btn-info:disabled {
color: var(--md-sys-color-on-tertiary);
background-color: var(--md-sys-color-tertiary);
border-color: var(--md-sys-color-tertiary);
}
.btn-info:hover {
color: var(--md-sys-color-on-tertiary);
background-color: var(--md-sys-color-tertiary);
border-color: var(--md-sys-color-tertiary);
box-shadow: var(--md-sys-elevation-3) !important;
}
.btn-check:active+.btn-info,
.btn-check:checked+.btn-info,
.btn-info .active,
.btn-info:active,
.show>.btn-info.dropdown-toggle {
color: var(--md-sys-color-on-tertiary);
background-color: var(--md-sys-color-tertiary);
border-color: var(--md-sys-color-tertiary);
box-shadow: var(--md-sys-elevation-3) !important;
}
.btn-check:focus+.btn-info,
.btn-info:focus {
color: var(--md-sys-color-on-tertiary);
background-color: var(--md-sys-color-tertiary);
border-color: var(--md-sys-color-tertiary);
box-shadow: var(--md-sys-elevation-3) !important;
}
/* Info btn */
.btn-success {
box-shadow: none !important;
}
.btn-success:hover,
.btn-check:active+.btn-success,
.btn-check:checked+.btn-success,
.btn-success .active,
.btn-success:active,
.show>.btn-success.dropdown-toggle,
.btn-check:focus+.btn-success,
.btn-success:focus {
box-shadow: var(--md-sys-elevation-3) !important;
}
/* Warning btn */
.btn-warning {
box-shadow: none !important;
}
.btn-warning:hover,
.btn-check:active+.btn-warning,
.btn-check:checked+.btn-warning,
.btn-warning .active,
.btn-warning:active,
.show>.btn-warning.dropdown-toggle,
.btn-check:focus+.btn-warning,
.btn-warning:focus {
box-shadow: var(--md-sys-elevation-3) !important;
}
/* Outline Primary btn */
.btn-outline-primary {
color: var(--md-sys-color-primary);
background-color: transparent;
border-color: var(--md-sys-color-primary);
box-shadow: none !important;
}
.btn-outline-primary .disabled,
.btn-outline-primary:disabled {
color: var(--md-sys-color-primary);
background-color: transparent;
border-color: var(--md-sys-color-primary);
}
.btn-outline-primary:hover {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.btn-check:active+.btn-outline-primary,
.btn-check:checked+.btn-outline-primary,
.btn-outline-primary .active,
.btn-outline-primary:active,
.show>.btn-outline-primary.dropdown-toggle {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.btn-check:focus+.btn-outline-primary,
.btn-outline-primary:focus {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
/* Outline Secondary btn */
.btn-outline-secondary {
color: var(--md-sys-color-secondary);
background-color: transparent;
border-color: var(--md-sys-color-secondary);
box-shadow: none !important;
}
.btn-outline-secondary .disabled,
.btn-outline-secondary:disabled {
color: var(--md-sys-color-secondary);
background-color: transparent;
border-color: var(--md-sys-color-secondary);
}
.btn-outline-secondary:hover {
color: var(--md-sys-color-on-secondary);
background-color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
}
.btn-check:active+.btn-outline-secondary,
.btn-check:checked+.btn-outline-secondary,
.btn-outline-secondary .active,
.btn-outline-secondary:active,
.show>.btn-outline-secondary.dropdown-toggle {
color: var(--md-sys-color-on-secondary);
background-color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
}
.btn-check:focus+.btn-outline-secondary,
.btn-outline-secondary:focus {
color: var(--md-sys-color-on-secondary);
background-color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
}
/* Disabled btn */
.btn.disabled,
.btn:disabled,
fieldset:disabled .btn {
pointer-events: none;
opacity: 0.65;
}
/* Range Slider */
.form-range{
margin-top: 0.25rem;
}
.form-range:focus::-webkit-slider-thumb {
box-shadow: 0 0 0 1px var(--md-sys-color-surface), 0 0 0 .25rem var(--md-sys-color-primary)
}
.form-range:focus::-moz-range-thumb {
box-shadow: 0 0 0 1px var(--md-sys-color-surface), 0 0 0 .25rem var(--md-sys-color-primary)
}
.form-range::-webkit-slider-thumb {
background-color: var(--md-sys-color-primary);
}
.form-range::-webkit-slider-thumb:active {
background-color: var(--md-sys-color-primary)
}
.form-range::-webkit-slider-runnable-track {
background-color: var(--md-sys-color-on-primary)
}
.form-range::-moz-range-thumb {
background-color: var(--md-sys-color-primary);
}
/* checkbox */
.form-check {
margin-bottom: 1rem;
}
.form-check-label {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.form-check-input {
width: 1.5rem;
height: 1.5rem;
margin: 0;
background-color: var(--md-sys-color-surface);
border: 2px solid var(--md-sys-color-outline-variant);
}
.form-check-input:checked {
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-outline-variant);
border: none;
}
.form-check-input:focus {
border-color: var(--md-sys-color-outline-variant);
outline: 0;
box-shadow: 0 0 0 .25rem var(--md-sys-color-outline-variant);
}
.form-check-input:checked[type=checkbox] {
background-image: none;
}
.form-check input[type="checkbox"]:checked+span.material-symbols-rounded {
display: block;
}
.form-check span.material-symbols-rounded {
display: none;
color: var(--md-sys-color-surface);
position: absolute;
margin-left: -1.5rem;
margin-right: -1.5rem;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: rgb(0 0 0 / 0%);
}
.form-check {
min-height: 22px;
padding-left: 0;
}
.form-check > label {
padding-left: 29px !important;
min-height: 22px;
line-height: 22px;
display: inline-block;
position: relative;
vertical-align: top;
margin-bottom: 0;
font-weight: normal;
cursor: pointer;
padding-right: 29px !important;
}
.form-check > input:first-child {
position: absolute !important;
opacity: 0;
margin: 0;
background-color: var(--md-sys-state-hover-opacity);
border-radius: 50%;
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
-ms-appearance: none;
display: block;
width: 22px;
height: 22px;
outline: none;
transform: scale(1.65);
-ms-transform: scale(1.65);
transition: opacity .3s;
}
.form-check > input:first-child:hover {
opacity: 1;
transform: scale(1.65);
-ms-transform: scale(1.65);
}
.form-check > input:first-child:disabled {
cursor: default;
}
.form-check > input:first-child:disabled + label,
.form-check > input:first-child:disabled + input[type="hidden"] + label,
.form-check > input:first-child:disabled + label::before,
.form-check > input:first-child:disabled + input[type="hidden"] + label::before {
pointer-events: none;
cursor: default;
filter: alpha(opacity=65);
-webkit-box-shadow: none;
box-shadow: none;
opacity: .65;
}
.form-check > input:first-child + label::before,
.form-check > input:first-child + input[type="hidden"] + label::before {
content: "";
display: inline-block;
position: absolute;
width: 22px;
height: 22px;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: 3px;
margin-left: -29px;
box-sizing: border-box;
margin-right: -29px;
}
.form-check > input:first-child:checked + label::after,
.form-check > input:first-child:checked + input[type="hidden"] + label::after {
content: "";
display: inline-block;
position: absolute;
top: 0;
left: 0;
width: 7px;
height: 10px;
border: solid 2px;
border-left: none;
border-top: none;
transform: translate(7.75px, 4.5px) rotate(45deg);
-ms-transform: translate(7.75px, 4.5px) rotate(45deg);
box-sizing: border-box;
right: 0;
margin-right: 14px;
border-bottom-color: var(--md-sys-color-on-primary);
border-right-color: var(--md-sys-color-on-primary);
}
.form-check > input:first-child::-ms-check {
opacity: 0;
border-radius: 50%;
background-color: var(--md-sys-color-primary);
}
.form-check > input:first-child:active {
transform: scale(0);
-ms-transform: scale(0);
opacity: 1;
transition: opacity 0s, transform 0s;
}
.form-check > input[type="radio"]:first-child + label::before,
.form-check > input[type="radio"]:first-child + input[type="hidden"] + label::before {
border-radius: 50%;
}
.form-check > input[type="radio"]:first-child:checked + label::before,
.form-check > input[type="radio"]:first-child:checked + input[type="hidden"] + label::before {
background-color: transparent;
}
.form-check > input[type="radio"]:first-child:checked + label::after,
.form-check > input[type="radio"]:first-child:checked + input[type="hidden"] + label::after {
content: "";
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
border: none;
top: 6px;
left: 6px;
transform: none;
-ms-transform: none;
}
.form-check > input[type="checkbox"]:first-child:checked + label::after,
.form-check > input[type="checkbox"]:first-child:checked + input[type="hidden"] + label::after {
width: 8px;
height: 14px;
transform: translate(7px, 2px) rotate(45deg);
-ms-transform: translate(7px, 2px) rotate(45deg);
}
.form-check-inline {
display: inline-block;
}
.form-check-inline + .form-check-inline {
margin-left: .75rem;
margin-top: 6px;
}
.form-check > input:first-child:checked + label::before,
.form-check > input:first-child:checked + input[type="hidden"] + label::before {
background-color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
/* Forms */
textarea.form-control {
border-radius: 1.5rem !important;
}
.form-control,
.form-select,
.form-control:disabled,
.form-control[readonly] {
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-container-low);
border-color: var(--md-sys-color-outline-variant);
border-radius: 3rem !important;
}
.form-control:focus,
.form-select:focus {
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-container-lowest);
border-color: var(--md-sys-color-outline-variant);
outline: 0;
box-shadow: 0 0 0 0.25rem var(--md-sys-color-outline-variant);
}
.form-control-color {
padding: 0;
height: 2.4rem;
width: 2.4rem;
}
.form-control input[type="color"] {
opacity: 0;
height: 2.4rem;
width: 2.4rem;
box-sizing: border-box;
}
.form-control input[type="color"]:hover{
cursor: pointer;
}
/* Navbar Components */
.navbar-brand {
color: var(--md-sys-color-on-surface) !important;
}
.nav-link {
display: flex;
align-items: center;
transition: none !important;
padding: 0.5rem 1rem !important;
border: 1px transparent;
}
.navbar-nav li {
flex: 1;
}
.navbar-nav .nav-link {
color: var(--md-sys-color-on-surface-variant);
}
.navbar-nav .nav-link:focus,
.navbar-nav .nav-link:hover {
color: var(--md-sys-color-on-secondary-container);
background-color: var(--md-sys-color-surface-3);
border-radius: 3rem;
font-weight: 500;
font-variation-settings: var(--md-sys-icon-fill-1);
}
.navbar-nav .nav-link.active,
.navbar-nav .show>.nav-link {
color: var(--md-sys-color-on-secondary-container);
background-color: var(--md-sys-color-surface-5);
border-radius: 3rem;
font-weight: 500;
font-variation-settings: var(--md-sys-icon-fill-1);
}
.menu-title {
padding: 0 1rem;
}
.dropdown-menu {
margin: 0 1%;
padding: 1.5rem 0;
border-radius: 1rem;
min-width: 20rem;
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-surface-5);
box-shadow: var(--md-sys-elevation-2);
}
.dropdown-item {
color: var(--md-sys-color-on-surface);
padding: 0.25rem 1rem;
border-radius: 3rem;
}
.dropdown-item:focus,
.dropdown-item:hover {
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-5);
border-radius: 3rem;
font-variation-settings: var(--md-sys-icon-fill-1);
}
.dropdown-item.no-hover:hover,
.dropdown-item.no-hover:focus {
color: var(--md-sys-color-on-surface) !important;
background-color: transparent !important;
border-radius: 3rem !important;
font-variation-settings: initial !important;
}
.dropdown-item.active,
.dropdown-item:active {
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-5);
border-radius: 3rem;
font-weight: 500;
font-variation-settings: var(--md-sys-icon-fill-1);
}
/* list-group-item */
.list-group-item {
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-5);
border: 1px solid var(--md-sys-color-outline-variant);
}
.list-group-item:first-child {
border-radius: 1rem 1rem 0rem 0rem;
}
.list-group-item:last-child {
border-radius: 0rem 0rem 1rem 1rem;
}
.list-group-item:only-child {
border-radius: 1rem 1rem 1rem 1rem;
}
.list-group-item .btn {
padding: .375rem .5rem;
}
/*Alert */
.alert-container {
padding: 2rem 3rem;
border-radius: 3rem;
margin: 1rem 0rem 2rem;
}
.alert-header {
display: flex !important;
justify-content: space-between;
}
.alert-heading {
font-size: calc(1.275rem + .3vw);
}
.alert-dismissible .btn-close {
position: relative;
padding: 0;
}
.alert-danger {
color: var(--md-sys-color-on-error-container);
background-color: var(--md-sys-color-error-container);
border-color: transparent;
}
@@ -1,24 +0,0 @@
@font-face {
font-family: 'Material Symbols Rounded';
font-style: normal;
font-weight: 100 700;
src: url(../../fonts/google-symbol.woff2) format('woff2');
}
.material-symbols-rounded {
font-family: 'Material Symbols Rounded';
font-weight: 300;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
@@ -1,42 +0,0 @@
:where(html, .light-theme, .dark-theme),
.tokens,
:host {
/* Define surface colors based on primary color */
--md-sys-color-surface-1: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 100, 0.05) 5%);
--md-sys-color-surface-2: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.08) 5%);
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
/* Clear button disabled text color (default/light) */
--spdf-clear-disabled-text: var(--md-sys-color-primary);
/* Icon fill */
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
/* Hover Color */
--md-sys-state-hover-opacity: color-mix(in srgb, var(--md-sys-color-primary), rgba(0, 0, 0, 0) 80%);
/* Shadow */
--md-sys-color-shadow: #000000;
--md-elevation-shadow-color-rgb: 0, 0, 0;
--md-elevation-shadow-color: var(--md-elevation-shadow-color-rgb);
/* Shadow Elevation*/
--md-sys-elevation-0: 0px 0px 0px 0px rgb(var(--md-elevation-shadow-color), 0.2), 0px 0px 0px 0px rgb(var(--md-elevation-shadow-color), 0.14), 0px 0px 0px 0px rgb(var(--md-elevation-shadow-color), 0.12);
--md-sys-elevation-1: 0px 3px 1px -2px rgb(var(--md-elevation-shadow-color), 0.2), 0px 2px 2px 0px rgb(var(--md-elevation-shadow-color), 0.14), 0px 1px 5px 0px rgb(var(--md-elevation-shadow-color), 0.12);
--md-sys-elevation-2: 0px 2px 4px -1px rgb(var(--md-elevation-shadow-color), 0.2), 0px 4px 5px 0px rgb(var(--md-elevation-shadow-color), 0.14), 0px 1px 10px 0px rgb(var(--md-elevation-shadow-color), 0.12);
--md-sys-elevation-3: 0px 5px 5px -3px rgb(var(--md-elevation-shadow-color), 0.2), 0px 8px 10px 1px rgb(var(--md-elevation-shadow-color), 0.14), 0px 3px 14px 2px rgb(var(--md-elevation-shadow-color), 0.12);
--md-sys-elevation-4: 0px 5px 5px -3px rgb(var(--md-elevation-shadow-color) / 0.2), 0px 8px 10px 1px rgb(var(--md-elevation-shadow-color), 0.14), 0px 3px 14px 2px rgb(var(--md-elevation-shadow-color), 0.12);
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
}
/* Dark theme overrides */
.dark-theme {
/* In dark mode, use a neutral grey for disabled Clear button text */
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
}
.fill {
font-variation-settings: var(--md-sys-icon-fill-1);
}
.no-fill {
/* font-variation-settings: var(--md-sys-icon-fill-0); */
}
@@ -1,79 +0,0 @@
:root {
/* Colors */
--md-sys-color-primary: rgb(162 201 255);
--md-sys-color-surface-tint: rgb(162 201 255);
--md-sys-color-on-primary: rgb(0 49 92);
--md-sys-color-primary-container: rgb(0 118 208);
--md-sys-color-on-primary-container: rgb(255 255 255);
--md-sys-color-secondary: rgb(169 201 246);
--md-sys-color-on-secondary: rgb(12 49 87);
--md-sys-color-secondary-container: rgb(29 62 100);
--md-sys-color-on-secondary-container: rgb(180 210 255);
--md-sys-color-tertiary: rgb(193 194 248);
--md-sys-color-on-tertiary: rgb(42 44 88);
--md-sys-color-tertiary-container: rgb(110 112 161);
--md-sys-color-on-tertiary-container: rgb(255 255 255);
--md-sys-color-error: rgb(255 180 171);
--md-sys-color-on-error: rgb(105 0 5);
--md-sys-color-error-container: rgb(147 0 10);
--md-sys-color-on-error-container: rgb(255 218 214);
--md-sys-color-background: rgb(15 20 26);
--md-sys-color-on-background: rgb(223 226 235);
--md-sys-color-surface: rgb(15 20 26);
--md-sys-color-on-surface: rgb(223 226 235);
--md-sys-color-surface-variant: rgb(64 71 83);
--md-sys-color-on-surface-variant: rgb(192 199 213);
--md-sys-color-outline: rgb(138 145 158);
--md-sys-color-outline-variant: rgb(64 71 83);
--md-sys-color-shadow: rgb(0 0 0);
--md-sys-color-scrim: rgb(0 0 0);
--md-sys-color-inverse-surface: rgb(223 226 235);
--md-sys-color-inverse-on-surface: rgb(45 49 55);
--md-sys-color-inverse-primary: rgb(0 96 170);
--md-sys-color-primary-fixed: rgb(211 228 255);
--md-sys-color-on-primary-fixed: rgb(0 28 56);
--md-sys-color-primary-fixed-dim: rgb(162 201 255);
--md-sys-color-on-primary-fixed-variant: rgb(0 72 130);
--md-sys-color-secondary-fixed: rgb(211 228 255);
--md-sys-color-on-secondary-fixed: rgb(0 28 56);
--md-sys-color-secondary-fixed-dim: rgb(169 201 246);
--md-sys-color-on-secondary-fixed-variant: rgb(40 72 111);
--md-sys-color-tertiary-fixed: rgb(225 224 255);
--md-sys-color-on-tertiary-fixed: rgb(20 22 66);
--md-sys-color-tertiary-fixed-dim: rgb(193 194 248);
--md-sys-color-on-tertiary-fixed-variant: rgb(64 67 112);
--md-sys-color-surface-dim: rgb(15 20 26);
--md-sys-color-surface-bright: rgb(53 57 64);
--md-sys-color-surface-container-lowest: rgb(10 14 20);
--md-sys-color-surface-container-low: rgb(24 28 34);
--md-sys-color-surface-container: rgb(28 32 38);
--md-sys-color-surface-container-high: rgb(38 42 49);
--md-sys-color-surface-container-highest: rgb(49 53 60);
/* Tools Color */
--md-nav-section-color-opacity: 1;
--md-nav-on-section-color-opacity: 1;
--md-nav-section-color-sign: rgba(25, 101, 212, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-sign: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-organize: rgba(120, 130, 255, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-organize: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-convert: rgba(25, 177, 212, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-convert: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-convertto: rgba(104, 220, 149, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-convertto: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-security: rgba(255, 120, 146, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-security: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-other: rgba(72, 189, 84, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-other: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-advance: rgba(245, 84, 84, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-advance: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-image: rgba(212, 172, 25, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-image: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-word: rgba(61, 153, 245, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-word: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-ppt: rgba(255, 128, 0, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-ppt: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
--md-nav-color-on-separator: rgb(24 28 34);
--md-nav-background: rgb(15 20 26);
--favourite-add: #9ed18c;
--favourite-remove: palevioletred;
}
@@ -1,79 +0,0 @@
:root {
/* Colors */
--md-sys-color-primary: rgb(0 96 170);
--md-sys-color-surface-tint: rgb(0 96 170);
--md-sys-color-on-primary: rgb(255 255 255);
--md-sys-color-primary-container: rgb(80 163 255);
--md-sys-color-on-primary-container: rgb(0 20 43);
--md-sys-color-secondary: rgb(65 96 136);
--md-sys-color-on-secondary: rgb(255 255 255);
--md-sys-color-secondary-container: rgb(188 215 255);
--md-sys-color-on-secondary-container: rgb(32 65 103);
--md-sys-color-tertiary: rgb(88 90 138);
--md-sys-color-on-tertiary: rgb(255 255 255);
--md-sys-color-tertiary-container: rgb(151 153 205);
--md-sys-color-on-tertiary-container: rgb(7 9 55);
--md-sys-color-error: rgb(186 26 26);
--md-sys-color-on-error: rgb(255 255 255);
--md-sys-color-error-container: rgb(255 218 214);
--md-sys-color-on-error-container: rgb(65 0 2);
--md-sys-color-background: rgb(248 249 255);
--md-sys-color-on-background: rgb(24 28 34);
--md-sys-color-surface: rgb(237, 240, 245);
--md-sys-color-on-surface: rgb(0, 1, 1);
--md-sys-color-surface-variant: rgb(220 227 241);
--md-sys-color-on-surface-variant: rgb(64 71 83);
--md-sys-color-outline: rgb(112 119 132);
--md-sys-color-outline-variant: rgb(192 199 213);
--md-sys-color-shadow: rgb(0 0 0);
--md-sys-color-scrim: rgb(0 0 0);
--md-sys-color-inverse-surface: rgb(45 49 55);
--md-sys-color-inverse-on-surface: rgb(238 241 250);
--md-sys-color-inverse-primary: rgb(162 201 255);
--md-sys-color-primary-fixed: rgb(211 228 255);
--md-sys-color-on-primary-fixed: rgb(0 28 56);
--md-sys-color-primary-fixed-dim: rgb(162 201 255);
--md-sys-color-on-primary-fixed-variant: rgb(0 72 130);
--md-sys-color-secondary-fixed: rgb(211 228 255);
--md-sys-color-on-secondary-fixed: rgb(0 28 56);
--md-sys-color-secondary-fixed-dim: rgb(169 201 246);
--md-sys-color-on-secondary-fixed-variant: rgb(40 72 111);
--md-sys-color-tertiary-fixed: rgb(225 224 255);
--md-sys-color-on-tertiary-fixed: rgb(20 22 66);
--md-sys-color-tertiary-fixed-dim: rgb(193 194 248);
--md-sys-color-on-tertiary-fixed-variant: rgb(64 67 112);
--md-sys-color-surface-dim: rgb(215 218 227);
--md-sys-color-surface-bright: rgb(248 249 255);
--md-sys-color-surface-container-lowest: rgb(255 255 255);
--md-sys-color-surface-container-low: rgb(241 243 253);
--md-sys-color-surface-container: rgb(235 238 247);
--md-sys-color-surface-container-high: rgb(229 232 241);
--md-sys-color-surface-container-highest: rgb(223 226 235);
/* Tools Color */
--md-nav-section-color-opacity: 1;
--md-nav-on-section-color-opacity: 1;
--md-nav-section-color-sign: rgba(25, 101, 212, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-sign: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-organize: rgba(120, 130, 255, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-organize: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-convert: rgba(25, 177, 212, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-convert: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-convertto: rgba(104, 220, 149, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-convertto: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-security: rgba(255, 120, 146, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-security: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-other: rgba(72, 189, 84, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-other: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-advance: rgba(245, 84, 84, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-advance: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-image: rgba(212, 172, 25, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-image: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-word: rgba(61, 153, 245, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-word: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-section-color-ppt: rgba(255, 128, 0, var(--md-nav-section-color-opacity));
--md-nav-on-section-color-ppt: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
--md-nav-color-on-separator: rgb(174, 178, 179);
--md-nav-background: rgb(248 249 255);
--favourite-add: #25ab6c;
--favourite-remove: rgb(222, 94, 137);
}
@@ -1,96 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith({MockitoExtension.class})
class RearrangePagesPDFControllerTest {
@Mock private CustomPDFDocumentFactory mockPdfDocumentFactory;
private RearrangePagesPDFController sut;
@BeforeEach
void setUp() {
sut = new RearrangePagesPDFController(mockPdfDocumentFactory);
}
/** Tests the behavior of the oddEvenMerge method when there are no pages in the document. */
@Test
void oddEvenMerge_noPages() {
int totalNumberOfPages = 0;
List<Integer> newPageOrder = sut.oddEvenMerge(totalNumberOfPages);
assertNotNull(newPageOrder, "Returning null instead of page order list");
assertEquals(List.of(), newPageOrder, "Page order doesn't match");
}
/**
* Tests the behavior of the oddEvenMerge method when there are odd total pages in the document.
*/
@Test
void oddEvenMerge_oddTotalPageNumber() {
int totalNumberOfPages = 5;
List<Integer> newPageOrder = sut.oddEvenMerge(totalNumberOfPages);
assertNotNull(newPageOrder, "Returning null instead of page order list");
assertEquals(Arrays.asList(0, 3, 1, 4, 2), newPageOrder, "Page order doesn't match");
}
/**
* Tests the behavior of the oddEvenMerge method when there are even total pages in the
* document.
*/
@Test
void oddEvenMerge_evenTotalPageNumber() {
int totalNumberOfPages = 6;
List<Integer> newPageOrder = sut.oddEvenMerge(totalNumberOfPages);
assertNotNull(newPageOrder, "Returning null instead of page order list");
assertEquals(Arrays.asList(0, 3, 1, 4, 2, 5), newPageOrder, "Page order doesn't match");
}
/**
* Tests the behavior of the oddEvenMerge method with multiple test cases of multiple pages.
*
* @param totalNumberOfPages The total number of pages in the document.
* @param expectedPageOrder The expected order of the pages after rearranging.
*/
@ParameterizedTest
@CsvSource({
"1, '0'",
"2, '0,1'",
"3, '0,2,1'",
"4, '0,2,1,3'",
"5, '0,3,1,4,2'",
"6, '0,3,1,4,2,5'",
"10, '0,5,1,6,2,7,3,8,4,9'",
"50, '0,25,1,26,2,27,3,28,4,29,5,30,6,31,7,32,8,33,9,34,10,35,"
+ "11,36,12,37,13,38,14,39,15,40,16,41,17,42,18,43,19,44,20,45,21,46,"
+ "22,47,23,48,24,49'"
})
void oddEvenMerge_multi_test(int totalNumberOfPages, String expectedPageOrder) {
List<Integer> newPageOrder = sut.oddEvenMerge(totalNumberOfPages);
assertNotNull(newPageOrder, "Returning null instead of page order list");
assertEquals(
Arrays.stream(expectedPageOrder.split(",")).map(Integer::parseInt).toList(),
newPageOrder,
"Page order doesn't match");
}
}
@@ -18,6 +18,10 @@ class ApiEndpointTest {
private final ObjectMapper mapper = JsonMapper.builder().build();
private JsonNode postNodeWithParams(String description, String... names) {
return postNodeWithParams(description, true, names);
}
private JsonNode postNodeWithParams(String description, boolean required, String... names) {
ObjectNode post = mapper.createObjectNode();
post.put("description", description);
ArrayNode params = mapper.createArrayNode();
@@ -26,6 +30,7 @@ class ApiEndpointTest {
if (n != null) {
p.put("name", n);
}
p.put("required", required);
params.add(p);
}
post.set("parameters", params);
@@ -92,6 +97,75 @@ class ApiEndpointTest {
assertTrue(endpoint.areParametersValid(Map.of("", 42)));
}
@Test
void optional_parameters_can_be_omitted() {
JsonNode post = postNodeWithParams("desc", false, "fileOrder");
ApiEndpoint endpoint = new ApiEndpoint("merge", post);
assertTrue(
endpoint.areParametersValid(Map.of()),
"Should be valid when optional param is omitted");
}
@Test
void mixed_required_and_optional_validates_only_required() {
ObjectNode post = mapper.createObjectNode();
post.put("description", "merge pdfs");
ArrayNode params = mapper.createArrayNode();
ObjectNode required = mapper.createObjectNode();
required.put("name", "sortType");
required.put("required", true);
params.add(required);
ObjectNode optional = mapper.createObjectNode();
optional.put("name", "fileOrder");
optional.put("required", false);
params.add(optional);
post.set("parameters", params);
ApiEndpoint endpoint = new ApiEndpoint("/api/v1/general/merge-pdfs", post);
Map<String, Object> provided = new HashMap<>();
provided.put("sortType", "byFileName");
assertTrue(
endpoint.areParametersValid(provided),
"Should pass when required params present and optional omitted");
provided.put("fileOrder", "0,1,2");
assertTrue(
endpoint.areParametersValid(provided),
"Should also pass when optional param is provided");
}
@Test
void missing_required_param_with_optional_present_still_fails() {
ObjectNode post = mapper.createObjectNode();
post.put("description", "desc");
ArrayNode params = mapper.createArrayNode();
ObjectNode required = mapper.createObjectNode();
required.put("name", "file");
required.put("required", true);
params.add(required);
ObjectNode optional = mapper.createObjectNode();
optional.put("name", "fileOrder");
optional.put("required", false);
params.add(optional);
post.set("parameters", params);
ApiEndpoint endpoint = new ApiEndpoint("x", post);
Map<String, Object> provided = new HashMap<>();
provided.put("fileOrder", "0,1");
assertFalse(
endpoint.areParametersValid(provided),
"Should fail when required param is missing even if optional is present");
}
@Test
void toString_contains_name_and_parameter_names() {
JsonNode post = postNodeWithParams("desc", "file", "mode");
@@ -20,7 +20,6 @@ class SortTypesTest {
"BOOKLET_SORT",
"SIDE_STITCH_BOOKLET_SORT",
"ODD_EVEN_SPLIT",
"ODD_EVEN_MERGE",
"REMOVE_FIRST",
"REMOVE_LAST",
"REMOVE_FIRST_AND_LAST",
@@ -74,15 +74,37 @@ class ApiDocServiceTest {
@Test
void isValidOperationChecksRequiredParameters() throws Exception {
String json =
"{\"description\": \"desc\", \"parameters\": [{\"name\":\"param1\"}, {\"name\":\"param2\"}]}";
"{\"description\": \"desc\", \"parameters\": [{\"name\":\"param1\", \"required\": true}, {\"name\":\"param2\", \"required\": true}]}";
JsonNode postNode = mapper.readTree(json);
ApiEndpoint endpoint = new ApiEndpoint("/op", postNode);
setApiDocumentation(Map.of("/op", endpoint));
setApiDocsJsonRootNode();
// All required params provided - valid
assertTrue(apiDocService.isValidOperation("/op", Map.of("param1", "a", "param2", "b")));
// Missing required param2 - invalid
assertFalse(apiDocService.isValidOperation("/op", Map.of("param1", "a")));
// Missing required param1 - invalid
assertFalse(apiDocService.isValidOperation("/op", Map.of("param2", "b")));
}
@Test
void isValidOperationAllowsOptionalParameters() throws Exception {
String json =
"{\"description\": \"desc\", \"parameters\": [{\"name\":\"param1\", \"required\": false}, {\"name\":\"param2\", \"required\": false}]}";
JsonNode postNode = mapper.readTree(json);
ApiEndpoint endpoint = new ApiEndpoint("/op", postNode);
setApiDocumentation(Map.of("/op", endpoint));
setApiDocsJsonRootNode();
// All optional params provided - valid
assertTrue(apiDocService.isValidOperation("/op", Map.of("param1", "a", "param2", "b")));
// Only one optional param provided - valid
assertTrue(apiDocService.isValidOperation("/op", Map.of("param1", "a")));
// No optional params provided - valid
assertTrue(apiDocService.isValidOperation("/op", Map.of()));
}
@Test
@@ -93,7 +93,7 @@ class LanguageServiceBasicTest {
// Configure the test service
((LanguageServiceForTest) languageService).setMockResources(mockResources);
// Allow only specific languages (en_GB is always included)
// Allow only specific languages - strict whitelist
when(applicationProperties.getUi().getLanguages())
.thenReturn(Arrays.asList("en_US", "fr_FR"));
@@ -103,7 +103,9 @@ class LanguageServiceBasicTest {
// Verify filtering by restrictions
assertTrue(supportedLanguages.contains("en_US"), "Allowed language should be included");
assertTrue(supportedLanguages.contains("fr_FR"), "Allowed language should be included");
assertTrue(supportedLanguages.contains("en_GB"), "en_GB should always be included");
assertFalse(
supportedLanguages.contains("en_GB"),
"en_GB should NOT be included when not in whitelist");
assertFalse(supportedLanguages.contains("de_DE"), "Restricted language should be excluded");
}
@@ -69,25 +69,28 @@ class LanguageServiceTest {
// Setup
Set<String> expectedLanguages =
new HashSet<>(Arrays.asList("en_US", "fr_FR", "de_DE", "en_GB"));
Set<String> allowedLanguages = new HashSet<>(Arrays.asList("en_US", "fr_FR", "en_GB"));
Set<String> allowedLanguages = new HashSet<>(Arrays.asList("en_US", "fr_FR"));
// Mock the resource resolver response
Resource[] mockResources = createMockResources(expectedLanguages);
((LanguageServiceForTest) languageService).setMockResources(mockResources);
// Set language restrictions in properties
// Set language restrictions in properties - strict whitelist only
when(applicationProperties.getUi().getLanguages())
.thenReturn(Arrays.asList("en_US", "fr_FR")); // en_GB is always allowed
.thenReturn(Arrays.asList("en_US", "fr_FR"));
// Test
Set<String> supportedLanguages = languageService.getSupportedLanguages();
// Verify
assertEquals(
allowedLanguages,
supportedLanguages,
"Should return only allowed languages, plus en_GB which is always allowed");
assertTrue(supportedLanguages.contains("en_GB"), "en_GB should always be included");
allowedLanguages, supportedLanguages, "Should return only whitelisted languages");
assertFalse(
supportedLanguages.contains("en_GB"),
"en_GB should NOT be included when not in whitelist");
assertFalse(
supportedLanguages.contains("de_DE"),
"de_DE should NOT be included when not in whitelist");
}
@Test
@@ -1,146 +0,0 @@
package stirling.software.SPDF.service;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.util.*;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class PdfImageRemovalServiceTest {
private PdfImageRemovalService service;
@BeforeEach
void setUp() {
service = new PdfImageRemovalService();
}
// Helper method for matching COSName in verification
private static COSName eq(final COSName value) {
return Mockito.argThat(
new org.mockito.ArgumentMatcher<>() {
@Override
public boolean matches(COSName argument) {
if (argument == null && value == null) return true;
if (argument == null || value == null) return false;
return argument.getName().equals(value.getName());
}
@Override
public String toString() {
return "eq(" + (value != null ? value.getName() : "null") + ")";
}
});
}
@Test
void testRemoveImagesFromPdf_WithImages() throws IOException {
// Mock PDF document and its components
PDDocument document = mock(PDDocument.class);
PDPage page = mock(PDPage.class);
PDResources resources = mock(PDResources.class);
PDPageTree pageTree = mock(PDPageTree.class);
// Configure page tree to iterate over our single page
when(document.getPages()).thenReturn(pageTree);
Iterator<PDPage> pageIterator = Collections.singletonList(page).iterator();
when(pageTree.iterator()).thenReturn(pageIterator);
// Set up page resources
when(page.getResources()).thenReturn(resources);
// Set up image XObjects
COSName img1 = COSName.getPDFName("Im1");
COSName img2 = COSName.getPDFName("Im2");
COSName nonImg = COSName.getPDFName("NonImg");
List<COSName> xObjectNames = Arrays.asList(img1, img2, nonImg);
when(resources.getXObjectNames()).thenReturn(xObjectNames);
// Configure which are image XObjects
when(resources.isImageXObject(img1)).thenReturn(true);
when(resources.isImageXObject(img2)).thenReturn(true);
when(resources.isImageXObject(nonImg)).thenReturn(false);
// Execute the method
service.removeImagesFromPdf(document);
// Verify that images were removed
verify(resources, times(1)).put(eq(img1), Mockito.<PDXObject>isNull());
verify(resources, times(1)).put(eq(img2), Mockito.<PDXObject>isNull());
verify(resources, never()).put(eq(nonImg), Mockito.<PDXObject>isNull());
}
@Test
void testRemoveImagesFromPdf_NoImages() throws IOException {
// Mock PDF document and its components
PDDocument document = mock(PDDocument.class);
PDPage page = mock(PDPage.class);
PDResources resources = mock(PDResources.class);
PDPageTree pageTree = mock(PDPageTree.class);
// Configure page tree to iterate over our single page
when(document.getPages()).thenReturn(pageTree);
Iterator<PDPage> pageIterator = Collections.singletonList(page).iterator();
when(pageTree.iterator()).thenReturn(pageIterator);
// Set up page resources
when(page.getResources()).thenReturn(resources);
// Create empty list of XObject names
List<COSName> emptyList = new ArrayList<>();
when(resources.getXObjectNames()).thenReturn(emptyList);
// Execute the method
service.removeImagesFromPdf(document);
// Verify that no modifications were made
verify(resources, never()).put(any(COSName.class), any(PDXObject.class));
}
@Test
void testRemoveImagesFromPdf_MultiplePages() throws IOException {
// Mock PDF document and its components
PDDocument document = mock(PDDocument.class);
PDPage page1 = mock(PDPage.class);
PDPage page2 = mock(PDPage.class);
PDResources resources1 = mock(PDResources.class);
PDResources resources2 = mock(PDResources.class);
PDPageTree pageTree = mock(PDPageTree.class);
// Configure page tree to iterate over our two pages
when(document.getPages()).thenReturn(pageTree);
Iterator<PDPage> pageIterator = Arrays.asList(page1, page2).iterator();
when(pageTree.iterator()).thenReturn(pageIterator);
// Set up page resources
when(page1.getResources()).thenReturn(resources1);
when(page2.getResources()).thenReturn(resources2);
// Set up image XObjects for page 1
COSName img1 = COSName.getPDFName("Im1");
when(resources1.getXObjectNames()).thenReturn(Collections.singletonList(img1));
when(resources1.isImageXObject(img1)).thenReturn(true);
// Set up image XObjects for page 2
COSName img2 = COSName.getPDFName("Im2");
when(resources2.getXObjectNames()).thenReturn(Collections.singletonList(img2));
when(resources2.isImageXObject(img2)).thenReturn(true);
// Execute the method
service.removeImagesFromPdf(document);
// Verify that images were removed from both pages
verify(resources1, times(1)).put(eq(img1), Mockito.<PDXObject>isNull());
verify(resources2, times(1)).put(eq(img2), Mockito.<PDXObject>isNull());
}
}
-1
View File
@@ -58,7 +58,6 @@ dependencies {
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
api 'io.micrometer:micrometer-registry-prometheus'
implementation 'com.unboundid.product.scim2:scim2-sdk-client:5.0.0'
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
@@ -8,6 +8,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@@ -41,33 +42,67 @@ public class AuditAspect {
// Fast path: use unified check to determine if we should audit
// This avoids all data collection if auditing is disabled
if (!AuditUtils.shouldAudit(method, auditConfig)) {
if (!auditService.shouldAudit(method, auditConfig)) {
return joinPoint.proceed();
}
// EARLY CAPTURE: Try to get from MDC first (propagated from background threads)
// If not found, capture from SecurityContext on request thread
String capturedPrincipal = MDC.get("auditPrincipal");
if (capturedPrincipal == null) {
// Fallback: Capture from SecurityContext if running in request thread
capturedPrincipal = auditService.captureCurrentPrincipal();
}
String capturedOrigin = MDC.get("auditOrigin");
if (capturedOrigin == null) {
// Fallback: Capture from SecurityContext if running in request thread
capturedOrigin = auditService.captureCurrentOrigin();
}
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
String capturedIp = MDC.get("auditIp");
if (capturedIp == null) {
// Fallback: Try to extract from request if available
capturedIp = auditService.extractClientIp(req);
}
// Only create the map once we know we'll use it
Map<String, Object> auditData =
AuditUtils.createBaseAuditData(joinPoint, auditedAnnotation.level());
auditService.createBaseAuditData(joinPoint, auditedAnnotation.level());
// Add HTTP information if we're in a web context
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest req = attrs.getRequest();
String path = req.getRequestURI();
String httpMethod = req.getMethod();
AuditUtils.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
AuditUtils.addFileData(auditData, joinPoint, auditedAnnotation.level());
auditService.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
auditService.addFileData(auditData, joinPoint, auditedAnnotation.level());
// File operation details logged at DEBUG level for verification
if (auditData.containsKey("files") || auditData.containsKey("filename")) {
log.debug(
"@Audited method file operation - Principal: {}, Origin: {}, IP: {}, Method: {}, Path: {}, Files: {}",
capturedPrincipal,
capturedOrigin,
capturedIp,
httpMethod,
path,
auditData.getOrDefault("files", auditData.getOrDefault("filename", "N/A")));
}
if (auditData.containsKey("fileHash") || auditData.containsKey("hash")) {
log.debug(
"@Audited file hash captured - Hash: {}, Document: {}",
auditData.getOrDefault("fileHash", auditData.getOrDefault("hash", "N/A")),
auditData.getOrDefault("filename", "N/A"));
}
}
// Add arguments if requested and if at VERBOSE level, or if specifically requested
boolean includeArgs =
auditedAnnotation.includeArgs()
&& (auditedAnnotation.level() == AuditLevel.VERBOSE
|| auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
if (includeArgs) {
AuditUtils.addMethodArguments(auditData, joinPoint, AuditLevel.VERBOSE);
// Add method arguments if requested (captured at all audit levels for operational context)
if (auditedAnnotation.includeArgs()) {
auditService.addMethodArguments(auditData, joinPoint, auditedAnnotation.level());
}
// Record start time for latency calculation
@@ -80,15 +115,14 @@ public class AuditAspect {
// Add success status
auditData.put("status", "success");
// Add result if requested and if at VERBOSE level
// Add result only if requested in annotation AND operation result capture is enabled
boolean includeResult =
auditedAnnotation.includeResult()
&& (auditedAnnotation.level() == AuditLevel.VERBOSE
|| auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
&& auditService.shouldCaptureOperationResults();
if (includeResult && result != null) {
// Use safe string conversion with size limiting
auditData.put("result", AuditUtils.safeToString(result, 1000));
auditData.put("result", auditService.safeToString(result, 1000));
}
return result;
@@ -105,20 +139,19 @@ public class AuditAspect {
// methods
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
boolean isHttpRequest = attrs != null;
AuditUtils.addTimingData(
auditService.addTimingData(
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
// Resolve the event type based on annotation and context
String httpMethod = null;
String path = null;
if (attrs != null) {
HttpServletRequest req = attrs.getRequest();
httpMethod = req.getMethod();
path = req.getRequestURI();
}
AuditEventType eventType =
AuditUtils.resolveEventType(
auditService.resolveEventType(
method,
joinPoint.getTarget().getClass(),
path,
@@ -128,11 +161,23 @@ public class AuditAspect {
// Check if we should use string type instead
String typeString = auditedAnnotation.typeString();
if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) {
// Use the string type (for backward compatibility)
auditService.audit(typeString, auditData, auditedAnnotation.level());
// Use the string type with early-captured values
auditService.audit(
capturedPrincipal,
capturedOrigin,
capturedIp,
typeString,
auditData,
auditedAnnotation.level());
} else {
// Use the enum type (preferred)
auditService.audit(eventType, auditData, auditedAnnotation.level());
// Use the enum type with early-captured values
auditService.audit(
capturedPrincipal,
capturedOrigin,
capturedIp,
eventType,
auditData,
auditedAnnotation.level());
}
}
}
@@ -22,6 +22,9 @@ public enum AuditEventType {
// PDF operations - STANDARD level
PDF_PROCESS("PDF processing operation"),
// UI data requests - STANDARD level
UI_DATA("UI data request"),
// HTTP requests - STANDARD level
HTTP_REQUEST("HTTP request");
@@ -12,21 +12,26 @@ public enum AuditLevel {
OFF(0),
/**
* BASIC - Minimal audit logging (level 1) Includes: - Authentication events (login, logout,
* failed logins) - Password changes - User/role changes - System configuration changes
* BASIC - File modifications only (level 1) Tracks: PDF file operations like compress, split,
* merge, etc., and settings changes. Captures: Operation status (success/failure), method
* parameters, timing. Ideal for: Compliance tracking of file modifications with minimal log
* volume.
*/
BASIC(1),
/**
* STANDARD - Standard audit logging (level 2) Includes everything in BASIC plus: - All HTTP
* requests (basic info: URL, method, status) - File operations (upload, download, process) -
* PDF operations (view, edit, etc.) - User operations
* STANDARD - File operations and user actions (level 2) Tracks: Everything in BASIC plus user
* actions like login/logout, account changes, and general GET requests. Excludes continuous
* polling calls (e.g., auth/me, app-config, health, metrics endpoints). Ideal for: General
* audit trail with reasonable log volume for most deployments.
*/
STANDARD(2),
/**
* VERBOSE - Detailed audit logging (level 3) Includes everything in STANDARD plus: - Request
* headers and parameters - Method parameters - Operation results - Detailed timing information
* VERBOSE - Everything including polling (level 3) Tracks: Everything in STANDARD plus
* continuous polling calls and all GET requests. Captures: Detailed timing information. Note:
* Operation results (return values) are controlled by separate captureOperationResults flag.
* Warning: High log volume and performance impact.
*/
VERBOSE(3);
@@ -1,427 +0,0 @@
package stirling.software.proprietary.audit;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.config.AuditConfigurationProperties;
/**
* Shared utilities for audit aspects to ensure consistent behavior across different audit
* mechanisms.
*/
@Slf4j
public class AuditUtils {
/**
* Create a standard audit data map with common attributes based on the current audit level
*
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
* @return A map with standard audit data
*/
public static Map<String, Object> createBaseAuditData(
ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
Map<String, Object> data = new HashMap<>();
// Common data for all levels
data.put("timestamp", Instant.now().toString());
// Add principal if available
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getName() != null) {
data.put("principal", auth.getName());
} else {
data.put("principal", "system");
}
// Add class name and method name only at VERBOSE level
if (auditLevel.includes(AuditLevel.VERBOSE)) {
data.put("className", joinPoint.getTarget().getClass().getName());
data.put(
"methodName",
((MethodSignature) joinPoint.getSignature()).getMethod().getName());
}
return data;
}
/**
* Add HTTP-specific information to the audit data if available
*
* @param data The existing audit data map
* @param httpMethod The HTTP method (GET, POST, etc.)
* @param path The request path
* @param auditLevel The current audit level
*/
public static void addHttpData(
Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) {
if (httpMethod == null || path == null) {
return; // Skip if we don't have basic HTTP info
}
// BASIC level HTTP data
data.put("httpMethod", httpMethod);
data.put("path", path);
// Get request attributes safely
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
return; // No request context available
}
HttpServletRequest req = attrs.getRequest();
if (req == null) {
return; // No request available
}
// STANDARD level HTTP data
if (auditLevel.includes(AuditLevel.STANDARD)) {
data.put("clientIp", req.getRemoteAddr());
data.put(
"sessionId",
req.getSession(false) != null ? req.getSession(false).getId() : null);
data.put("requestId", MDC.get("requestId"));
// Form data for POST/PUT/PATCH
if (("POST".equalsIgnoreCase(httpMethod)
|| "PUT".equalsIgnoreCase(httpMethod)
|| "PATCH".equalsIgnoreCase(httpMethod))
&& req.getContentType() != null) {
String contentType = req.getContentType();
if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|| contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) {
Map<String, String[]> params = new HashMap<>(req.getParameterMap());
// Remove CSRF token from logged parameters
params.remove("_csrf");
if (!params.isEmpty()) {
data.put("formParams", params);
}
}
}
}
}
/**
* Add file information to the audit data if available
*
* @param data The existing audit data map
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
*/
public static void addFileData(
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
if (auditLevel.includes(AuditLevel.STANDARD)) {
List<MultipartFile> files =
Arrays.stream(joinPoint.getArgs())
.filter(a -> a instanceof MultipartFile)
.map(a -> (MultipartFile) a)
.collect(Collectors.toList());
if (!files.isEmpty()) {
List<Map<String, Object>> fileInfos =
files.stream()
.map(
f -> {
Map<String, Object> m = new HashMap<>();
m.put("name", f.getOriginalFilename());
m.put("size", f.getSize());
m.put("type", f.getContentType());
return m;
})
.collect(Collectors.toList());
data.put("files", fileInfos);
}
}
}
/**
* Add method arguments to the audit data
*
* @param data The existing audit data map
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
*/
public static void addMethodArguments(
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
if (auditLevel.includes(AuditLevel.VERBOSE)) {
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
String[] names = sig.getParameterNames();
Object[] vals = joinPoint.getArgs();
if (names != null && vals != null) {
IntStream.range(0, names.length)
.forEach(
i -> {
if (vals[i] != null) {
// Convert objects to safe string representation
data.put("arg_" + names[i], safeToString(vals[i], 500));
} else {
data.put("arg_" + names[i], null);
}
});
}
}
}
/**
* Safely convert an object to string with size limiting
*
* @param obj The object to convert
* @param maxLength Maximum length of the resulting string
* @return A safe string representation, truncated if needed
*/
public static String safeToString(Object obj, int maxLength) {
if (obj == null) {
return "null";
}
String result;
try {
// Handle common types directly to avoid toString() overhead
if (obj instanceof String) {
result = (String) obj;
} else if (obj instanceof Number || obj instanceof Boolean) {
result = obj.toString();
} else if (obj instanceof byte[]) {
result = "[binary data length=" + ((byte[]) obj).length + "]";
} else {
// For complex objects, use toString but handle exceptions
result = obj.toString();
}
// Truncate if necessary
if (result != null && result.length() > maxLength) {
return StringUtils.truncate(result, maxLength - 3) + "...";
}
return result;
} catch (Exception e) {
// If toString() fails, return the class name
return "[" + obj.getClass().getName() + " - toString() failed]";
}
}
/**
* Determine if a method should be audited based on config and annotation
*
* @param method The method to check
* @param auditConfig The audit configuration
* @return true if the method should be audited
*/
public static boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) {
// First check if audit is globally enabled - fast path
if (!auditConfig.isEnabled()) {
return false;
}
// Check for annotation override
Audited auditedAnnotation = method.getAnnotation(Audited.class);
AuditLevel requiredLevel =
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
// Check if the required level is enabled
return auditConfig.getAuditLevel().includes(requiredLevel);
}
/**
* Add timing and response status data to the audit record
*
* @param data The audit data to add to
* @param startTime The start time in milliseconds
* @param response The HTTP response (may be null for non-HTTP methods)
* @param level The current audit level
* @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call
*/
public static void addTimingData(
Map<String, Object> data,
long startTime,
HttpServletResponse response,
AuditLevel level,
boolean isHttpRequest) {
if (level.includes(AuditLevel.STANDARD)) {
// For HTTP requests, let ControllerAuditAspect handle timing separately
// For non-HTTP methods, add execution time here
if (!isHttpRequest) {
data.put("latencyMs", System.currentTimeMillis() - startTime);
}
// Add HTTP status code if available
if (response != null) {
try {
data.put("statusCode", response.getStatus());
} catch (Exception e) {
// Ignore - response might be in an inconsistent state
}
}
}
}
/**
* Resolve the event type to use for auditing, considering annotations and context
*
* @param method The method being audited
* @param controller The controller class
* @param path The request path (may be null for non-HTTP methods)
* @param httpMethod The HTTP method (may be null for non-HTTP methods)
* @param annotation The @Audited annotation (may be null)
* @return The resolved event type (never null)
*/
public static AuditEventType resolveEventType(
Method method,
Class<?> controller,
String path,
String httpMethod,
Audited annotation) {
// First check if we have an explicit annotation
if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) {
return annotation.type();
}
// For HTTP methods, infer based on controller and path
if (httpMethod != null && path != null) {
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
if (cls.contains("user")
|| cls.contains("auth")
|| pkg.contains("auth")
|| path.startsWith("/user")
|| path.startsWith("/login")) {
return AuditEventType.USER_PROFILE_UPDATE;
} else if (cls.contains("admin")
|| path.startsWith("/admin")
|| path.startsWith("/settings")) {
return AuditEventType.SETTINGS_CHANGED;
} else if (cls.contains("file")
|| path.startsWith("/file")
|| RegexPatternUtils.getInstance()
.getUploadDownloadPathPattern()
.matcher(path)
.matches()) {
return AuditEventType.FILE_OPERATION;
}
}
// Default for non-HTTP methods or when no specific match
return AuditEventType.PDF_PROCESS;
}
/**
* Determine the appropriate audit level to use
*
* @param method The method to check
* @param defaultLevel The default level to use if no annotation present
* @param auditConfig The audit configuration
* @return The audit level to use
*/
public static AuditLevel getEffectiveAuditLevel(
Method method, AuditLevel defaultLevel, AuditConfigurationProperties auditConfig) {
Audited auditedAnnotation = method.getAnnotation(Audited.class);
if (auditedAnnotation != null) {
// Method has @Audited - use its level
return auditedAnnotation.level();
}
// Use default level (typically from global config)
return defaultLevel;
}
/**
* Determine the appropriate audit event type to use
*
* @param method The method being audited
* @param controller The controller class
* @param path The request path
* @param httpMethod The HTTP method
* @return The determined audit event type
*/
public static AuditEventType determineAuditEventType(
Method method, Class<?> controller, String path, String httpMethod) {
// First check for explicit annotation
Audited auditedAnnotation = method.getAnnotation(Audited.class);
if (auditedAnnotation != null) {
return auditedAnnotation.type();
}
// Otherwise infer from controller and path
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
if (cls.contains("user")
|| cls.contains("auth")
|| pkg.contains("auth")
|| path.startsWith("/user")
|| path.startsWith("/login")) {
return AuditEventType.USER_PROFILE_UPDATE;
} else if (cls.contains("admin")
|| path.startsWith("/admin")
|| path.startsWith("/settings")) {
return AuditEventType.SETTINGS_CHANGED;
} else if (cls.contains("file")
|| path.startsWith("/file")
|| RegexPatternUtils.getInstance()
.getUploadDownloadPathPattern()
.matcher(path)
.matches()) {
return AuditEventType.FILE_OPERATION;
} else {
return AuditEventType.PDF_PROCESS;
}
}
/**
* Get the current HTTP request if available
*
* @return The current request or null if not in a request context
*/
public static HttpServletRequest getCurrentRequest() {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attrs != null ? attrs.getRequest() : null;
}
/**
* Check if a GET request is for a static resource
*
* @param request The HTTP request
* @return true if this is a static resource request
*/
public static boolean isStaticResourceRequest(HttpServletRequest request) {
return request != null
&& !RequestUriUtils.isTrackableResource(
request.getContextPath(), request.getRequestURI());
}
}
@@ -9,6 +9,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -37,7 +38,7 @@ import stirling.software.proprietary.service.AuditService;
@Slf4j
@RequiredArgsConstructor
@org.springframework.core.annotation.Order(
10) // Lower precedence (higher number) - executes after AutoJobAspect
0) // Highest precedence - runs BEFORE AutoJobAspect to populate MDC
public class ControllerAuditAspect {
private final AuditService auditService;
@@ -92,7 +93,7 @@ public class ControllerAuditAspect {
// Fast path: check if auditing is enabled before doing any work
// This avoids all data collection if auditing is disabled
if (!AuditUtils.shouldAudit(method, auditConfig)) {
if (!auditService.shouldAudit(method, auditConfig)) {
return joinPoint.proceed();
}
@@ -110,8 +111,14 @@ public class ControllerAuditAspect {
// Skip static GET resources
if ("GET".equals(httpMethod)) {
HttpServletRequest maybe = AuditUtils.getCurrentRequest();
if (maybe != null && AuditUtils.isStaticResourceRequest(maybe)) {
HttpServletRequest maybe = auditService.getCurrentRequest();
if (maybe != null && auditService.isStaticResourceRequest(maybe)) {
return joinPoint.proceed();
}
// Skip polling calls at STANDARD level (exclude from audit log noise)
if (maybe != null
&& auditService.isPollingCall(maybe)
&& auditConfig.getAuditLevel() == AuditLevel.STANDARD) {
return joinPoint.proceed();
}
}
@@ -121,75 +128,143 @@ public class ControllerAuditAspect {
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
long start = System.currentTimeMillis();
String previousPrincipal = MDC.get("auditPrincipal");
String previousOrigin = MDC.get("auditOrigin");
String previousIp = MDC.get("auditIp");
// Use AuditUtils to create the base audit data
Map<String, Object> data = AuditUtils.createBaseAuditData(joinPoint, level);
// Add HTTP-specific information
AuditUtils.addHttpData(data, httpMethod, path, level);
// Add file information if present
AuditUtils.addFileData(data, joinPoint, level);
// Add method arguments if at VERBOSE level
if (level.includes(AuditLevel.VERBOSE)) {
AuditUtils.addMethodArguments(data, joinPoint, level);
// EARLY CAPTURE: Capture from SecurityContext on request thread, store in MDC for async
// propagation
// MDC.put is necessary for background threads to inherit audit context
String capturedPrincipal = previousPrincipal;
if (capturedPrincipal == null) {
capturedPrincipal = auditService.captureCurrentPrincipal();
MDC.put("auditPrincipal", capturedPrincipal);
}
Object result = null;
String capturedOrigin = previousOrigin;
if (capturedOrigin == null) {
capturedOrigin = auditService.captureCurrentOrigin();
MDC.put("auditOrigin", capturedOrigin);
}
String capturedIp = previousIp;
if (capturedIp == null && req != null) {
capturedIp = auditService.extractClientIp(req);
if (capturedIp != null) {
MDC.put("auditIp", capturedIp);
}
}
try {
result = joinPoint.proceed();
data.put("outcome", "success");
} catch (Throwable ex) {
data.put("outcome", "failure");
data.put("errorType", ex.getClass().getSimpleName());
data.put("errorMessage", ex.getMessage());
throw ex;
} finally {
// Handle timing directly for HTTP requests
if (level.includes(AuditLevel.STANDARD)) {
data.put("latencyMs", System.currentTimeMillis() - start);
if (resp != null) data.put("statusCode", resp.getStatus());
}
// Call AuditUtils but with isHttpRequest=true to skip additional timing
AuditUtils.addTimingData(data, start, resp, level, true);
// Add result for VERBOSE level
if (level.includes(AuditLevel.VERBOSE) && result != null) {
// Use safe string conversion with size limiting
data.put("result", AuditUtils.safeToString(result, 1000));
}
// Resolve the event type using the unified method
AuditEventType eventType =
AuditUtils.resolveEventType(
method,
joinPoint.getTarget().getClass(),
path,
httpMethod,
auditedAnnotation);
// Check if we should use string type instead (for backward compatibility)
// Avoid duplicate events for controller methods explicitly annotated with @Audited.
// @Audited methods are audited by AuditAspect.
if (auditedAnnotation != null) {
String typeString = auditedAnnotation.typeString();
if (eventType == AuditEventType.HTTP_REQUEST
&& StringUtils.isNotEmpty(typeString)) {
auditService.audit(typeString, data, level);
return result;
return joinPoint.proceed();
}
long start = System.currentTimeMillis();
// Use auditService to create the base audit data
Map<String, Object> data = auditService.createBaseAuditData(joinPoint, level);
// Add HTTP-specific information
auditService.addHttpData(data, httpMethod, path, level);
// Add file information if present
auditService.addFileData(data, joinPoint, level);
// Add method arguments if at VERBOSE level
if (level.includes(AuditLevel.VERBOSE)) {
auditService.addMethodArguments(data, joinPoint, level);
}
Object result = null;
try {
result = joinPoint.proceed();
data.put("outcome", "success");
} catch (Throwable ex) {
data.put("outcome", "failure");
data.put("errorType", ex.getClass().getSimpleName());
data.put("errorMessage", ex.getMessage());
throw ex;
} finally {
// Handle timing directly for HTTP requests
if (level.includes(AuditLevel.STANDARD)) {
data.put("latencyMs", System.currentTimeMillis() - start);
if (resp != null) data.put("statusCode", resp.getStatus());
}
// Call auditService but with isHttpRequest=true to skip additional timing
auditService.addTimingData(data, start, resp, level, true);
// Resolve the event type using the unified method
AuditEventType eventType =
auditService.resolveEventType(
method,
joinPoint.getTarget().getClass(),
path,
httpMethod,
auditedAnnotation);
// Add result only if operation result capture is explicitly enabled
// Skip result for UI_DATA events to avoid storing large response bodies
if (auditService.shouldCaptureOperationResults()
&& result != null
&& eventType != AuditEventType.UI_DATA) {
// Use safe string conversion with size limiting
data.put("result", auditService.safeToString(result, 1000));
}
// Check if we should use string type instead (for backward compatibility)
if (auditedAnnotation != null) {
String typeString = auditedAnnotation.typeString();
if (eventType == AuditEventType.HTTP_REQUEST
&& StringUtils.isNotEmpty(typeString)) {
auditService.audit(
capturedPrincipal,
capturedOrigin,
capturedIp,
typeString,
data,
level);
} else {
// Use the enum type with early-captured values
auditService.audit(
capturedPrincipal,
capturedOrigin,
capturedIp,
eventType,
data,
level);
}
} else {
// Use the enum type with early-captured values
auditService.audit(
capturedPrincipal, capturedOrigin, capturedIp, eventType, data, level);
}
}
// Use the enum type
auditService.audit(eventType, data, level);
return result;
} finally {
restoreMdcValue("auditPrincipal", previousPrincipal);
restoreMdcValue("auditOrigin", previousOrigin);
restoreMdcValue("auditIp", previousIp);
}
return result;
}
// Using AuditUtils.determineAuditEventType instead
private String getRequestPath(Method method, String httpMethod) {
// Prefer actual request URI over annotation patterns (which may contain regex)
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest request = attrs.getRequest();
if (request != null) {
return request.getRequestURI();
}
}
// Fallback: reconstruct from annotations when not in web context
String base = "";
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
if (cm != null && cm.value().length > 0) base = cm.value()[0];
@@ -211,5 +286,13 @@ public class ControllerAuditAspect {
return base + mp;
}
private void restoreMdcValue(String key, String previousValue) {
if (previousValue != null) {
MDC.put(key, previousValue);
} else {
MDC.remove(key);
}
}
// Using AuditUtils.getCurrentRequest instead
}
@@ -23,6 +23,9 @@ public class AuditConfigurationProperties {
private final boolean enabled;
private final int level;
private final int retentionDays;
private final boolean captureFileHash;
private final boolean capturePdfAuthor;
private final boolean captureOperationResults;
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
@@ -37,11 +40,19 @@ public class AuditConfigurationProperties {
// Retention days (0 means infinite)
this.retentionDays = auditConfig.getRetentionDays();
// Metadata and detail capture flags
this.captureFileHash = auditConfig.isCaptureFileHash();
this.capturePdfAuthor = auditConfig.isCapturePdfAuthor();
this.captureOperationResults = auditConfig.isCaptureOperationResults();
log.debug(
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite)",
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite), fileHash={}, pdfAuthor={}, operationResults={}",
this.enabled,
this.level,
this.retentionDays);
this.retentionDays,
this.captureFileHash,
this.capturePdfAuthor,
this.captureOperationResults);
}
/**
@@ -1,5 +1,6 @@
package stirling.software.proprietary.controller.api;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -18,6 +19,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import lombok.RequiredArgsConstructor;
@@ -44,12 +46,13 @@ public class AuditRestController {
private final ObjectMapper objectMapper;
/**
* Get audit events with pagination and filters. Maps to frontend's getEvents() call.
* Get audit events with pagination and filters. Maps to frontend's getEvents() call. Supports
* both single values and multi-select arrays for eventType and username.
*
* @param page Page number (0-indexed)
* @param pageSize Number of items per page
* @param eventType Filter by event type
* @param username Filter by username (principal)
* @param eventType Filter by event type(s) - can be single value or array
* @param username Filter by username(s) - can be single value or array
* @param startDate Filter start date
* @param endDate Filter end date
* @return Paginated audit events response
@@ -58,8 +61,8 @@ public class AuditRestController {
public ResponseEntity<AuditEventsResponse> getAuditEvents(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
@RequestParam(value = "eventType", required = false) String eventType,
@RequestParam(value = "username", required = false) String username,
@RequestParam(value = "eventType", required = false) String[] eventTypes,
@RequestParam(value = "username", required = false) String[] usernames,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate,
@@ -70,33 +73,45 @@ public class AuditRestController {
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
Page<PersistentAuditEvent> events;
// Convert arrays to lists
List<String> eventTypeList =
(eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
List<String> usernameList =
(usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
Instant startInstant = null;
Instant endInstant = null;
if (startDate != null && endDate != null) {
startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
endInstant = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
}
// Apply filters based on provided parameters
if (eventType != null && username != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
if (eventTypeList != null
&& usernameList != null
&& startInstant != null
&& endInstant != null) {
events =
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
username, eventType, start, end, pageable);
} else if (eventType != null && username != null) {
events = auditRepository.findByPrincipalAndType(username, eventType, pageable);
} else if (eventType != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTypeAndTimestampBetween(eventType, start, end, pageable);
} else if (username != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
auditRepository.findByTypeInAndPrincipalInAndTimestampBetween(
eventTypeList, usernameList, startInstant, endInstant, pageable);
} else if (eventTypeList != null && usernameList != null) {
events =
auditRepository.findByPrincipalAndTimestampBetween(
username, start, end, pageable);
} else if (startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTimestampBetween(start, end, pageable);
} else if (eventType != null) {
events = auditRepository.findByType(eventType, pageable);
} else if (username != null) {
events = auditRepository.findByPrincipal(username, pageable);
auditRepository.findByTypeInAndPrincipalIn(
eventTypeList, usernameList, pageable);
} else if (eventTypeList != null && startInstant != null && endInstant != null) {
events =
auditRepository.findByTypeInAndTimestampBetween(
eventTypeList, startInstant, endInstant, pageable);
} else if (usernameList != null && startInstant != null && endInstant != null) {
events =
auditRepository.findByPrincipalInAndTimestampBetween(
usernameList, startInstant, endInstant, pageable);
} else if (startInstant != null && endInstant != null) {
events = auditRepository.findByTimestampBetween(startInstant, endInstant, pageable);
} else if (eventTypeList != null) {
events = auditRepository.findByTypeIn(eventTypeList, pageable);
} else if (usernameList != null) {
events = auditRepository.findByPrincipalIn(usernameList, pageable);
} else {
events = auditRepository.findAll(pageable);
}
@@ -258,11 +273,253 @@ public class AuditRestController {
}
/**
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call.
* Get audit statistics for KPI dashboard. Includes success rates, latency metrics, and top
* items.
*
* @param period Time period for statistics (day/week/month)
* @return Audit statistics data for dashboard KPI cards and enhanced charts
*/
@GetMapping("/audit-stats")
public ResponseEntity<AuditStatsData> getAuditStats(
@RequestParam(value = "period", defaultValue = "week") String period) {
// Calculate days based on period
int days;
switch (period.toLowerCase()) {
case "day":
days = 1;
break;
case "month":
days = 30;
break;
case "week":
default:
days = 7;
break;
}
// Get events from the specified period and previous period
Instant now = Instant.now();
Instant start = now.minus(java.time.Duration.ofDays(days));
Instant prevStart = start.minus(java.time.Duration.ofDays(days));
List<PersistentAuditEvent> currentEvents = auditRepository.findByTimestampAfter(start);
List<PersistentAuditEvent> prevEvents =
auditRepository.findAllByTimestampBetweenForExport(prevStart, start);
// Compute metrics for current period
AuditMetrics currentMetrics = computeMetrics(currentEvents);
AuditMetrics prevMetrics = computeMetrics(prevEvents);
// Get hourly distribution using DB aggregation
List<Object[]> hourlyData = auditRepository.histogramByHourBetween(start, now);
Map<String, Long> hourlyDistribution = new TreeMap<>();
for (int h = 0; h < 24; h++) {
hourlyDistribution.put(String.format("%02d", h), 0L);
}
for (Object[] row : hourlyData) {
int hour = ((Number) row[0]).intValue();
long count = ((Number) row[1]).longValue();
hourlyDistribution.put(String.format("%02d", hour), count);
}
return ResponseEntity.ok(
AuditStatsData.builder()
.totalEvents(currentMetrics.totalEvents)
.prevTotalEvents(prevMetrics.totalEvents)
.uniqueUsers(currentMetrics.uniqueUsers)
.prevUniqueUsers(prevMetrics.uniqueUsers)
.successRate(currentMetrics.successRate)
.prevSuccessRate(prevMetrics.successRate)
.avgLatencyMs(currentMetrics.avgLatencyMs)
.prevAvgLatencyMs(prevMetrics.avgLatencyMs)
.errorCount(currentMetrics.errorCount)
.topEventType(currentMetrics.topEventType)
.topUser(currentMetrics.topUser)
.eventsByType(currentMetrics.eventsByType)
.eventsByUser(currentMetrics.eventsByUser)
.topTools(currentMetrics.topTools)
.hourlyDistribution(hourlyDistribution)
.build());
}
/** Compute metrics from a list of audit events. */
private AuditMetrics computeMetrics(List<PersistentAuditEvent> events) {
if (events.isEmpty()) {
return AuditMetrics.builder().build();
}
// Count by type
Map<String, Long> eventsByType =
events.stream()
.collect(
Collectors.groupingBy(
PersistentAuditEvent::getType, Collectors.counting()));
// Count by principal (user)
Map<String, Long> eventsByUser =
events.stream()
.collect(
Collectors.groupingBy(
PersistentAuditEvent::getPrincipal, Collectors.counting()));
// Parse JSON data once for success rate, latency, tool extraction, and error counting
long successCount = 0;
long failureCount = 0;
long errorCount = 0;
long totalLatencyMs = 0;
long latencyCount = 0;
Map<String, Long> topTools = new HashMap<>();
for (PersistentAuditEvent event : events) {
if (event.getData() != null) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> data = objectMapper.readValue(event.getData(), Map.class);
// Track success/failure (safe type conversion)
// Check both "status" (current) and "outcome" (legacy) for compatibility
Object statusObj = data.get("status");
if (statusObj == null) {
statusObj = data.get("outcome");
}
String status = null;
if (statusObj instanceof String) {
status = (String) statusObj;
} else if (statusObj != null) {
status = String.valueOf(statusObj);
}
if ("success".equals(status)) {
successCount++;
} else if ("failure".equals(status)) {
failureCount++;
errorCount++;
} else {
// Check statusCode for error counting (when status is not explicit failure)
Object statusCode = data.get("statusCode");
if (statusCode != null) {
try {
int statusCodeVal;
if (statusCode instanceof Number) {
statusCodeVal = ((Number) statusCode).intValue();
} else if (statusCode instanceof String) {
statusCodeVal = Integer.parseInt((String) statusCode);
} else {
statusCodeVal = 0;
}
if (statusCodeVal >= 400) {
errorCount++;
}
} catch (NumberFormatException e) {
log.trace("Failed to parse statusCode value: {}", statusCode);
}
}
}
// Track latency (safe conversion to handle strings/numbers)
Object latency = data.get("latencyMs");
if (latency != null) {
try {
long latencyVal;
if (latency instanceof Number) {
latencyVal = ((Number) latency).longValue();
} else if (latency instanceof String) {
latencyVal = Long.parseLong((String) latency);
} else {
latencyVal = 0;
}
totalLatencyMs += latencyVal;
latencyCount++;
} catch (NumberFormatException e) {
log.trace("Failed to parse latency value: {}", latency);
}
}
// Extract tool from path (safe type conversion)
Object pathObj = data.get("path");
String path = null;
if (pathObj instanceof String) {
path = (String) pathObj;
} else if (pathObj != null) {
path = String.valueOf(pathObj);
}
if (path != null && !path.isEmpty()) {
String[] parts = path.split("/");
if (parts.length > 0) {
String tool = parts[parts.length - 1];
if (!tool.isEmpty()) {
topTools.put(tool, topTools.getOrDefault(tool, 0L) + 1);
}
}
}
} catch (JacksonException e) {
log.trace("Failed to parse audit event data: {}", event.getData());
}
}
}
// Calculate success rate
double successRate = 0;
long totalWithOutcome = successCount + failureCount;
if (totalWithOutcome > 0) {
successRate = (successCount * 100.0) / totalWithOutcome;
}
// Calculate average latency
double avgLatencyMs = 0;
if (latencyCount > 0) {
avgLatencyMs = totalLatencyMs / (double) latencyCount;
}
// Get top event type
String topEventType =
eventsByType.entrySet().stream()
.max((e1, e2) -> Long.compare(e1.getValue(), e2.getValue()))
.map(Map.Entry::getKey)
.orElse("");
// Get top user
String topUser =
eventsByUser.entrySet().stream()
.max((e1, e2) -> Long.compare(e1.getValue(), e2.getValue()))
.map(Map.Entry::getKey)
.orElse("");
// Sort and limit top tools to 10
Map<String, Long> topToolsSorted =
topTools.entrySet().stream()
.sorted((e1, e2) -> Long.compare(e2.getValue(), e1.getValue()))
.limit(10)
.collect(
Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(e1, e2) -> e1,
LinkedHashMap::new));
return AuditMetrics.builder()
.totalEvents(events.size())
.uniqueUsers((int) eventsByUser.size())
.successRate(successRate)
.avgLatencyMs(avgLatencyMs)
.errorCount(errorCount)
.topEventType(topEventType)
.topUser(topUser)
.eventsByType(eventsByType)
.eventsByUser(eventsByUser)
.topTools(topToolsSorted)
.build();
}
/**
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call. Supports both
* single values and multi-select arrays for eventType and username.
*
* @param format Export format (csv or json)
* @param eventType Filter by event type
* @param username Filter by username
* @param fields Comma-separated list of fields to include (e.g.,
* "date,username,tool,documentName,author,fileHash")
* @param eventTypes Filter by event type(s) - can be single value or array
* @param usernames Filter by username(s) - can be single value or array
* @param startDate Filter start date
* @param endDate Filter end date
* @return File download response
@@ -270,8 +527,9 @@ public class AuditRestController {
@GetMapping("/audit-export")
public ResponseEntity<byte[]> exportAuditData(
@RequestParam(value = "format", defaultValue = "csv") String format,
@RequestParam(value = "eventType", required = false) String eventType,
@RequestParam(value = "username", required = false) String username,
@RequestParam(value = "fields", required = false) String fields,
@RequestParam(value = "eventType", required = false) String[] eventTypes,
@RequestParam(value = "username", required = false) String[] usernames,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate,
@@ -282,34 +540,44 @@ public class AuditRestController {
// Get data with same filtering as getAuditEvents
List<PersistentAuditEvent> events;
if (eventType != null && username != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
// Convert arrays to lists
List<String> eventTypeList =
(eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
List<String> usernameList =
(usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
Instant startInstant = null;
Instant endInstant = null;
if (startDate != null && endDate != null) {
startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
endInstant = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
}
if (eventTypeList != null
&& usernameList != null
&& startInstant != null
&& endInstant != null) {
events =
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
username, eventType, start, end);
} else if (eventType != null && username != null) {
events = auditRepository.findAllByPrincipalAndTypeForExport(username, eventType);
} else if (eventType != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
auditRepository.findByTypeInAndPrincipalInAndTimestampBetweenForExport(
eventTypeList, usernameList, startInstant, endInstant);
} else if (eventTypeList != null && usernameList != null) {
events =
auditRepository.findAllByTypeAndTimestampBetweenForExport(
eventType, start, end);
} else if (username != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
auditRepository.findByTypeInAndPrincipalInForExport(
eventTypeList, usernameList);
} else if (eventTypeList != null && startInstant != null && endInstant != null) {
events =
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
username, start, end);
} else if (startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
} else if (eventType != null) {
events = auditRepository.findByTypeForExport(eventType);
} else if (username != null) {
events = auditRepository.findAllByPrincipalForExport(username);
auditRepository.findByTypeInAndTimestampBetweenForExport(
eventTypeList, startInstant, endInstant);
} else if (usernameList != null && startInstant != null && endInstant != null) {
events =
auditRepository.findByPrincipalInAndTimestampBetweenForExport(
usernameList, startInstant, endInstant);
} else if (startInstant != null && endInstant != null) {
events = auditRepository.findAllByTimestampBetweenForExport(startInstant, endInstant);
} else if (eventTypeList != null) {
events = auditRepository.findByTypeInForExport(eventTypeList);
} else if (usernameList != null) {
events = auditRepository.findByPrincipalInForExport(usernameList);
} else {
events = auditRepository.findAll();
}
@@ -318,7 +586,7 @@ public class AuditRestController {
if ("json".equalsIgnoreCase(format)) {
return exportAsJson(events);
} else {
return exportAsCsv(events);
return exportAsCsv(events, fields);
}
}
@@ -338,17 +606,89 @@ public class AuditRestController {
}
}
// Extract IP address (check both clientIp and __ipAddress for async/audited events)
String ipAddress = "";
Object ipObj = details.get("clientIp");
if (ipObj != null) {
ipAddress = String.valueOf(ipObj);
} else {
ipObj = details.get("__ipAddress");
if (ipObj != null) {
ipAddress = String.valueOf(ipObj);
}
}
return AuditEventDto.builder()
.id(String.valueOf(event.getId()))
.timestamp(event.getTimestamp().toString())
.eventType(event.getType())
.username(event.getPrincipal())
.ipAddress((String) details.getOrDefault("ipAddress", "")) // Extract if available
.ipAddress(ipAddress)
.details(details)
.build();
}
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events) {
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events, String fields) {
// Parse selected fields (comma-separated:
// date,username,tool,documentName,author,fileHash,ipAddress,etc)
Set<String> selectedFields = new HashSet<>();
if (fields != null && !fields.trim().isEmpty()) {
String[] fieldArray = fields.split(",");
for (String field : fieldArray) {
selectedFields.add(field.trim().toLowerCase());
}
}
// If no fields specified, use default technical export
if (selectedFields.isEmpty()) {
return exportAsDefaultCsv(events);
}
StringBuilder csv = new StringBuilder();
// Build header based on selected fields
List<String> headerOrder = new ArrayList<>();
if (selectedFields.contains("date")) headerOrder.add("date");
if (selectedFields.contains("username")) headerOrder.add("username");
if (selectedFields.contains("ipaddress")) headerOrder.add("ipaddress");
if (selectedFields.contains("tool")) headerOrder.add("tool");
if (selectedFields.contains("documentname")) headerOrder.add("documentname");
if (selectedFields.contains("outcome")) headerOrder.add("outcome");
if (selectedFields.contains("author")) headerOrder.add("author");
if (selectedFields.contains("filehash")) headerOrder.add("filehash");
if (selectedFields.contains("operationresults")) headerOrder.add("operationresults");
if (selectedFields.contains("eventtype")) headerOrder.add("eventtype");
// Write header
for (int i = 0; i < headerOrder.size(); i++) {
csv.append(capitalizeHeader(headerOrder.get(i)));
if (i < headerOrder.size() - 1) csv.append(",");
}
csv.append("\n");
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
// Write data rows
for (PersistentAuditEvent event : events) {
Map<String, String> rowData = extractEventData(event, formatter);
for (int i = 0; i < headerOrder.size(); i++) {
csv.append(escapeCSV(rowData.getOrDefault(headerOrder.get(i), "")));
if (i < headerOrder.size() - 1) csv.append(",");
}
csv.append("\n");
}
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
headers.setContentDispositionFormData(
"attachment", "audit_export_" + System.currentTimeMillis() + ".csv");
return ResponseEntity.ok().headers(headers).body(csvBytes);
}
private ResponseEntity<byte[]> exportAsDefaultCsv(List<PersistentAuditEvent> events) {
StringBuilder csv = new StringBuilder();
csv.append("ID,Principal,Type,Timestamp,Data\n");
@@ -362,15 +702,102 @@ public class AuditRestController {
csv.append(escapeCSV(event.getData())).append("\n");
}
byte[] csvBytes = csv.toString().getBytes();
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
headers.setContentDispositionFormData("attachment", "audit_export.csv");
return ResponseEntity.ok().headers(headers).body(csvBytes);
}
private Map<String, String> extractEventData(
PersistentAuditEvent event, DateTimeFormatter formatter) {
Map<String, String> data = new HashMap<>();
data.put("date", formatter.format(event.getTimestamp()));
data.put("username", event.getPrincipal());
data.put("eventtype", event.getType());
data.put("ipaddress", "");
data.put("tool", "");
data.put("documentname", "");
data.put("outcome", "");
data.put("author", "");
data.put("filehash", "");
data.put("operationresults", "");
if (event.getData() != null) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> eventData = objectMapper.readValue(event.getData(), Map.class);
// Extract IP address (check both clientIp and __ipAddress)
String ipAddress = "";
if (eventData.containsKey("clientIp")) {
ipAddress = String.valueOf(eventData.getOrDefault("clientIp", ""));
} else if (eventData.containsKey("__ipAddress")) {
ipAddress = String.valueOf(eventData.getOrDefault("__ipAddress", ""));
}
if (!ipAddress.isEmpty()) {
data.put("ipaddress", ipAddress);
}
// Extract outcome (success/failure), supporting legacy "status" key
if (eventData.containsKey("outcome")) {
data.put("outcome", String.valueOf(eventData.getOrDefault("outcome", "")));
} else if (eventData.containsKey("status")) {
data.put("outcome", String.valueOf(eventData.getOrDefault("status", "")));
}
// Extract operation result if present
if (eventData.containsKey("result")) {
data.put(
"operationresults",
String.valueOf(eventData.getOrDefault("result", "")));
}
// Extract tool from path
if (eventData.containsKey("path")) {
String path = (String) eventData.get("path");
if (path != null) {
String[] parts = path.split("/");
data.put("tool", parts.length > 0 ? parts[parts.length - 1] : "");
}
}
// Extract file information
@SuppressWarnings("unchecked")
List<Map<String, Object>> files =
(List<Map<String, Object>>) eventData.get("files");
if (files != null && !files.isEmpty()) {
Map<String, Object> firstFile = files.get(0);
data.put("documentname", String.valueOf(firstFile.getOrDefault("name", "")));
data.put("author", String.valueOf(firstFile.getOrDefault("pdfAuthor", "")));
data.put("filehash", String.valueOf(firstFile.getOrDefault("fileHash", "")));
}
} catch (Exception e) {
log.trace("Failed to parse audit event data: {}", event.getData());
}
}
return data;
}
private String capitalizeHeader(String field) {
return switch (field.toLowerCase()) {
case "date" -> "Date";
case "username" -> "Username";
case "ipaddress" -> "IP Address";
case "tool" -> "Tool";
case "documentname" -> "Document Name";
case "outcome" -> "Outcome";
case "author" -> "Author";
case "filehash" -> "File Hash";
case "operationresults" -> "Operation Results";
case "eventtype" -> "Event Type";
default -> field;
};
}
private ResponseEntity<byte[]> exportAsJson(List<PersistentAuditEvent> events) {
try {
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
@@ -431,4 +858,60 @@ public class AuditRestController {
private List<String> labels;
private List<Integer> values;
}
@lombok.Data
@lombok.Builder
public static class AuditStatsData {
private long totalEvents;
private long prevTotalEvents;
private int uniqueUsers;
private int prevUniqueUsers;
private double successRate;
private double prevSuccessRate;
private double avgLatencyMs;
private double prevAvgLatencyMs;
private long errorCount;
private String topEventType;
private String topUser;
private Map<String, Long> eventsByType;
private Map<String, Long> eventsByUser;
private Map<String, Long> topTools;
private Map<String, Long> hourlyDistribution;
}
@lombok.Data
@lombok.Builder
public static class AuditMetrics {
private long totalEvents;
private int uniqueUsers;
private double successRate;
private double avgLatencyMs;
private long errorCount;
private String topEventType;
private String topUser;
private Map<String, Long> eventsByType;
private Map<String, Long> eventsByUser;
private Map<String, Long> topTools;
}
/**
* Clear all audit data from the database. This is an irreversible operation. Requires ADMIN
* role.
*
* @return Success response
*/
@PostMapping("/audit-clear-all")
public ResponseEntity<?> clearAllAuditData() {
try {
// Delete all audit events
auditRepository.deleteAll();
log.warn("All audit data has been cleared by admin user");
return ResponseEntity.ok()
.body(Map.of("message", "All audit data has been cleared successfully"));
} catch (Exception e) {
log.error("Error clearing audit data", e);
return ResponseEntity.internalServerError()
.body("Failed to clear audit data: " + e.getMessage());
}
}
}
@@ -127,6 +127,13 @@ public class ProprietaryUIDataController {
data.setRetentionDays(auditConfig.getRetentionDays());
data.setAuditLevels(AuditLevel.values());
data.setAuditEventTypes(AuditEventType.values());
// Metadata capture settings (independent flags)
data.setCaptureFileHash(auditConfig.isCaptureFileHash());
data.setCapturePdfAuthor(auditConfig.isCapturePdfAuthor());
data.setCaptureOperationResults(auditConfig.isCaptureOperationResults());
// pdfMetadataEnabled: true if any metadata flag is enabled (file hash or PDF author)
data.setPdfMetadataEnabled(
auditConfig.isCaptureFileHash() || auditConfig.isCapturePdfAuthor());
return ResponseEntity.ok(data);
}
@@ -560,6 +567,10 @@ public class ProprietaryUIDataController {
private int retentionDays;
private AuditLevel[] auditLevels;
private AuditEventType[] auditEventTypes;
private boolean pdfMetadataEnabled;
private boolean captureFileHash;
private boolean capturePdfAuthor;
private boolean captureOperationResults;
}
@Data
@@ -1,5 +1,7 @@
package stirling.software.proprietary.controller.api;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@@ -32,34 +34,32 @@ public class UsageRestController {
private final ObjectMapper objectMapper;
/**
* Get endpoint statistics derived from audit events. This endpoint analyzes HTTP_REQUEST audit
* events to generate usage statistics.
* Get endpoint statistics derived from audit events. This endpoint analyzes audit events
* filtered by type to generate usage statistics.
*
* @param limit Optional limit on number of endpoints to return
* @param dataType Type of data to include: "all" (default), "api" (API endpoints excluding
* auth), or "ui" (non-API endpoints)
* @param dataType Type of data to include: "all" (default), "api" (operational endpoints), or
* "ui" (UI data endpoints)
* @param days Lookback window in days (default 30, clamped to 1-365)
* @return Endpoint statistics response
*/
@GetMapping("/usage-endpoint-statistics")
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
@RequestParam(value = "limit", required = false) Integer limit,
@RequestParam(value = "dataType", defaultValue = "all") String dataType) {
@RequestParam(value = "dataType", defaultValue = "all") String dataType,
@RequestParam(value = "days", defaultValue = "30") Integer days) {
// Get all HTTP_REQUEST audit events
List<PersistentAuditEvent> httpEvents =
auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name());
int lookbackDays = Math.max(1, Math.min(days, 365));
// Get audit events filtered by type
List<PersistentAuditEvent> events = getEventsByDataType(dataType, lookbackDays);
// Count visits per endpoint
Map<String, Long> endpointCounts = new HashMap<>();
for (PersistentAuditEvent event : httpEvents) {
for (PersistentAuditEvent event : events) {
String endpoint = extractEndpointFromAuditData(event.getData());
if (endpoint != null) {
// Apply data type filter
if (!shouldIncludeEndpoint(endpoint, dataType)) {
continue;
}
endpointCounts.merge(endpoint, 1L, Long::sum);
}
}
@@ -168,52 +168,28 @@ public class UsageRestController {
}
/**
* Determine if an endpoint should be included based on the data type filter.
* Get audit events filtered by data type. UI = UI_DATA events. API = everything except UI_DATA.
*
* @param endpoint The endpoint path to check
* @param dataType The filter type: "all", "api", or "ui"
* @return true if the endpoint should be included, false otherwise
* @param dataType "all", "api" (not UI_DATA), or "ui" (UI_DATA only)
* @param days lookback window in days
* @return List of audit events matching the data type filter
*/
private boolean shouldIncludeEndpoint(String endpoint, String dataType) {
private List<PersistentAuditEvent> getEventsByDataType(String dataType, int days) {
Instant start = Instant.now().minus(Duration.ofDays(days));
if ("all".equalsIgnoreCase(dataType)) {
return true;
}
boolean isApiEndpoint = isApiEndpoint(endpoint);
if ("api".equalsIgnoreCase(dataType)) {
return isApiEndpoint;
return auditRepository.findByTimestampAfter(start);
} else if ("ui".equalsIgnoreCase(dataType)) {
return !isApiEndpoint;
// UI data endpoints only
return auditRepository.findByTypeAndTimestampAfterForExport(
AuditEventType.UI_DATA.name(), start);
} else if ("api".equalsIgnoreCase(dataType)) {
// API = everything except UI_DATA (queried at DB level, not filtered in-memory)
return auditRepository.findAllExceptTypeAndTimestampAfterForExport(
AuditEventType.UI_DATA.name(), start);
}
// Default to including all if unrecognized type
return true;
}
/**
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
* /api/v1/auth/* paths.
*
* @param endpoint The endpoint path to check
* @return true if this is an API endpoint (excluding auth endpoints), false otherwise
*/
private boolean isApiEndpoint(String endpoint) {
if (endpoint == null) {
return false;
}
// Check if it starts with /api/v1/
if (!endpoint.startsWith("/api/v1/")) {
return false;
}
// Exclude auth endpoints
if (endpoint.startsWith("/api/v1/auth/")) {
return false;
}
return true;
return new ArrayList<>();
}
// DTOs for response formatting
@@ -68,6 +68,10 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type")
List<PersistentAuditEvent> findByTypeForExport(@Param("type") String type);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp > :startDate")
List<PersistentAuditEvent> findByTypeAndTimestampAfterForExport(
@Param("type") String type, @Param("startDate") Instant startDate);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findAllByTimestampBetweenForExport(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
@@ -171,4 +175,88 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
Optional<PersistentAuditEvent> findTopByPrincipalOrderByTimestampDesc(String principal);
Optional<PersistentAuditEvent> findTopByTypeOrderByTimestampDesc(String type);
// Multi-value queries for filtering by multiple types and/or principals
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types")
Page<PersistentAuditEvent> findByTypeIn(@Param("types") List<String> types, Pageable pageable);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals")
Page<PersistentAuditEvent> findByPrincipalIn(
@Param("principals") List<String> principals, Pageable pageable);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByTypeInAndTimestampBetween(
@Param("types") List<String> types,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByPrincipalInAndTimestampBetween(
@Param("principals") List<String> principals,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals")
Page<PersistentAuditEvent> findByTypeInAndPrincipalIn(
@Param("types") List<String> types,
@Param("principals") List<String> principals,
Pageable pageable);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetween(
@Param("types") List<String> types,
@Param("principals") List<String> principals,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate,
Pageable pageable);
// Export versions (non-paged)
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types")
List<PersistentAuditEvent> findByTypeInForExport(@Param("types") List<String> types);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals")
List<PersistentAuditEvent> findByPrincipalInForExport(
@Param("principals") List<String> principals);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findByTypeInAndTimestampBetweenForExport(
@Param("types") List<String> types,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findByPrincipalInAndTimestampBetweenForExport(
@Param("principals") List<String> principals,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals")
List<PersistentAuditEvent> findByTypeInAndPrincipalInForExport(
@Param("types") List<String> types, @Param("principals") List<String> principals);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetweenForExport(
@Param("types") List<String> types,
@Param("principals") List<String> principals,
@Param("startDate") Instant startDate,
@Param("endDate") Instant endDate);
// Query events excluding a specific type (used for analytics where we want to exclude UI_DATA)
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType")
List<PersistentAuditEvent> findAllExceptTypeForExport(@Param("excludeType") String excludeType);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate")
List<PersistentAuditEvent> findAllExceptTypeAndTimestampAfterForExport(
@Param("excludeType") String excludeType, @Param("startDate") Instant startDate);
}
@@ -1,6 +1,7 @@
package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -28,6 +29,8 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
@@ -136,6 +139,34 @@ public class SecurityConfiguration {
return new BCryptPasswordEncoder();
}
/**
* Configures HttpFirewall to allow non-ASCII characters in header values. This fixes issues
* with reverse proxies (like Authelia) that may set headers with non-ASCII characters (e.g.,
* "Remote-User: Dvořák").
*
* <p>By default, StrictHttpFirewall rejects header values containing non-ASCII characters. This
* configuration allows valid UTF-8 encoded characters while maintaining security.
*
* @return Configured HttpFirewall that allows non-ASCII characters in headers
*/
@Bean
public HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
// Allow non-ASCII characters but continue to reject control characters such as newlines.
// Pattern adapted from Spring Security's StrictHttpFirewall documentation.
Pattern allowedChars = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*");
firewall.setAllowedHeaderValues(
headerValue ->
headerValue != null && allowedChars.matcher(headerValue).matches());
// Apply the same rules to parameter values for consistency.
firewall.setAllowedParameterValues(
parameterValue ->
parameterValue != null && allowedChars.matcher(parameterValue).matches());
return firewall;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> configuredOrigins = null;
@@ -172,7 +172,7 @@ public class AdminSettingsController {
.body(Map.of("error", "No settings provided to update"));
}
int updatedCount = 0;
// Validate all settings first before applying any changes
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
@@ -192,15 +192,18 @@ public class AdminSettingsController {
return ResponseEntity.badRequest()
.body(Map.of("error", HtmlUtils.htmlEscape(validationError)));
}
}
// Apply all updates in a single transaction (load once, update all, save once)
// This ensures nested settings like oauth2.client.* don't lose sibling values
GeneralUtils.updateSettingsTransactional(settings);
// Track all as pending changes
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
log.info("Admin updating setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
// Track this as a pending change (convert null to empty string for
// ConcurrentHashMap)
pendingChanges.put(key, value != null ? value : "");
updatedCount++;
}
return ResponseEntity.ok(
@@ -209,7 +212,7 @@ public class AdminSettingsController {
String.format(
"Successfully updated %d setting(s). Changes will take effect on"
+ " application restart.",
updatedCount)));
settings.size())));
} catch (IOException e) {
log.error("Failed to save settings to file: {}", e.getMessage(), e);
@@ -488,14 +488,14 @@ public class DatabaseService implements DatabaseServiceInterface {
private void executeDatabaseScript(Path scriptPath) {
if (isH2Database()) {
// Validate SQL content BEFORE execution to prevent injection attacks
validateSqlContent(scriptPath);
if (!verifyBackup(scriptPath)) {
log.error("Backup verification failed for: {}", scriptPath);
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
}
// Validate SQL content before execution to prevent injection attacks
validateSqlContent(scriptPath);
String query = "RUNSCRIPT from ?;";
try (Connection conn = dataSource.getConnection();
@@ -161,7 +161,7 @@ public class EmailService {
String subject = "Welcome to Stirling PDF";
String body =
"""
"""
<html><body style="margin: 0; padding: 0;">
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
@@ -220,7 +220,7 @@ public class EmailService {
String subject = "You've been invited to Stirling PDF";
String body =
"""
"""
<html><body style="margin: 0; padding: 0;">
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
@@ -269,7 +269,8 @@ public class EmailService {
String passwordSection =
newPassword == null
? ""
: """
:
"""
<div style=\"background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;\">
<p style=\"margin: 0;\"><strong>Temporary Password:</strong> %s</p>
</div>
@@ -277,7 +278,7 @@ public class EmailService {
.formatted(newPassword);
String body =
"""
"""
<html><body style=\"margin: 0; padding: 0;\">
<div style=\"font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;\">
<div style=\"max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;\">
@@ -16,6 +16,7 @@ import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.slf4j.MDC;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -522,19 +523,35 @@ public class UserService implements UserServiceInterface {
@Override
public String getCurrentUsername() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// Try SecurityContext first (normal request context)
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
Object principal = auth.getPrincipal();
if (principal instanceof UserDetails detailsUser) {
return detailsUser.getUsername();
} else if (principal instanceof User domainUser) {
return domainUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
return oAuth2User.getAttribute(oAuth2.getUseAsUsername());
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
return saml2User.name();
} else if (principal instanceof String stringUser) {
return stringUser;
if (principal instanceof UserDetails detailsUser) {
return detailsUser.getUsername();
} else if (principal instanceof User domainUser) {
return domainUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
return oAuth2User.getAttribute(oAuth2.getUseAsUsername());
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
return saml2User.name();
} else if (principal instanceof String stringUser) {
return stringUser;
}
}
} catch (Exception e) {
log.trace("Error retrieving username from SecurityContext, falling back to MDC", e);
}
// Fallback to MDC for async contexts (e.g., when called from async job threads)
// ControllerAuditAspect captures principal in MDC and AutoJobAspect propagates it
String mdcPrincipal = MDC.get("auditPrincipal");
if (mdcPrincipal != null && !mdcPrincipal.isEmpty()) {
return mdcPrincipal;
}
return null;
}
@@ -1,23 +1,55 @@
package stirling.software.proprietary.service;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.actuate.audit.AuditEvent;
import org.springframework.boot.actuate.audit.AuditEventRepository;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.service.JwtServiceInterface;
/**
* Service for creating manual audit events throughout the application. This provides easy access to
* audit functionality in any component.
* Service for audit event creation, data collection, and persistence. Combines persistence logic
* with data collection utilities for comprehensive audit trail management.
*/
@Slf4j
@Service
@@ -26,16 +58,24 @@ public class AuditService {
private final AuditEventRepository repository;
private final AuditConfigurationProperties auditConfig;
private final boolean runningEE;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JwtServiceInterface jwtService;
public AuditService(
AuditEventRepository repository,
AuditConfigurationProperties auditConfig,
@Qualifier("runningEE") boolean runningEE) {
@Qualifier("runningEE") boolean runningEE,
CustomPDFDocumentFactory pdfDocumentFactory,
JwtServiceInterface jwtService) {
this.repository = repository;
this.auditConfig = auditConfig;
this.runningEE = runningEE;
this.pdfDocumentFactory = pdfDocumentFactory;
this.jwtService = jwtService;
}
// ========== PERSISTENCE METHODS ==========
/**
* Record an audit event for the current authenticated user with a specific audit level using
* the standardized AuditEventType enum
@@ -53,7 +93,12 @@ public class AuditService {
}
String principal = getCurrentUsername();
repository.add(new AuditEvent(principal, type.name(), data));
// Add origin to the data map (captured here before async execution)
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
enrichedData.put("__origin", determineOrigin());
repository.add(new AuditEvent(principal, type.name(), enrichedData));
}
/**
@@ -115,7 +160,12 @@ public class AuditService {
}
String principal = getCurrentUsername();
repository.add(new AuditEvent(principal, type, data));
// Add origin to the data map (captured here before async execution)
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
enrichedData.put("__origin", determineOrigin());
repository.add(new AuditEvent(principal, type, enrichedData));
}
/**
@@ -161,9 +211,758 @@ public class AuditService {
audit(principal, type, data, AuditLevel.STANDARD);
}
/**
* Record an audit event with pre-captured principal, origin, and IP (for use by audit aspects).
*/
public void audit(
String principal,
String origin,
String ipAddress,
AuditEventType type,
Map<String, Object> data,
AuditLevel level) {
if (!auditConfig.isEnabled()
|| !auditConfig.getAuditLevel().includes(level)
|| !runningEE) {
return;
}
// Add origin and IP to the data map (already captured before async)
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
enrichedData.put("__origin", origin);
if (ipAddress != null) {
enrichedData.put("__ipAddress", ipAddress);
}
repository.add(new AuditEvent(principal, type.name(), enrichedData));
}
/**
* Record an audit event with pre-captured principal, origin, and IP using string type (for
* backward compatibility).
*/
public void audit(
String principal,
String origin,
String ipAddress,
String type,
Map<String, Object> data,
AuditLevel level) {
if (!auditConfig.isEnabled()
|| !auditConfig.getAuditLevel().includes(level)
|| !runningEE) {
return;
}
// Add origin and IP to the data map (already captured before async)
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
enrichedData.put("__origin", origin);
if (ipAddress != null) {
enrichedData.put("__ipAddress", ipAddress);
}
repository.add(new AuditEvent(principal, type, enrichedData));
}
// ========== DATA COLLECTION METHODS ==========
/**
* Create a standard audit data map with common attributes based on the current audit level
*
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
* @return A map with standard audit data
*/
public Map<String, Object> createBaseAuditData(
ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
Map<String, Object> data = new HashMap<>();
// Common data for all levels
data.put("timestamp", Instant.now().toString());
// Add principal: prefer MDC (captured on request thread) over SecurityContext
// This ensures consistency in async contexts where SecurityContext may not be available
String principal = MDC.get("auditPrincipal");
if (principal == null) {
// Fallback: capture from SecurityContext if running in request thread
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
principal = (auth != null && auth.getName() != null) ? auth.getName() : "system";
}
data.put("principal", principal);
// Add class name and method name only at VERBOSE level
if (auditLevel.includes(AuditLevel.VERBOSE)) {
data.put("className", joinPoint.getTarget().getClass().getName());
data.put(
"methodName",
((MethodSignature) joinPoint.getSignature()).getMethod().getName());
}
return data;
}
/**
* Add HTTP-specific information to the audit data if available
*
* @param data The existing audit data map
* @param httpMethod The HTTP method (GET, POST, etc.)
* @param path The request path
* @param auditLevel The current audit level
*/
public void addHttpData(
Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) {
if (httpMethod == null || path == null) {
return; // Skip if we don't have basic HTTP info
}
// BASIC level HTTP data
data.put("httpMethod", httpMethod);
data.put("path", path);
// Get request attributes safely
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
return; // No request context available
}
HttpServletRequest req = attrs.getRequest();
if (req == null) {
return; // No request available
}
// STANDARD level HTTP data
if (auditLevel.includes(AuditLevel.STANDARD)) {
// Use extracted client IP (supports X-Forwarded-For, X-Real-IP behind proxies)
data.put("clientIp", extractClientIp(req));
data.put(
"sessionId",
req.getSession(false) != null ? req.getSession(false).getId() : null);
data.put("requestId", MDC.get("requestId"));
// Form data for POST/PUT/PATCH
if (("POST".equalsIgnoreCase(httpMethod)
|| "PUT".equalsIgnoreCase(httpMethod)
|| "PATCH".equalsIgnoreCase(httpMethod))
&& req.getContentType() != null) {
String contentType = req.getContentType();
if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|| contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) {
Map<String, String[]> params = new HashMap<>(req.getParameterMap());
// Remove CSRF token from logged parameters
params.remove("_csrf");
if (!params.isEmpty()) {
data.put("formParams", params);
}
}
}
}
}
/**
* Add file information to the audit data if available
*
* @param data The existing audit data map
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
*/
public void addFileData(
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
if (auditLevel.includes(AuditLevel.STANDARD)) {
List<MultipartFile> files = new ArrayList<>();
// Extract files from multiple sources:
for (Object arg : joinPoint.getArgs()) {
// 1. Direct MultipartFile arguments
if (arg instanceof MultipartFile) {
files.add((MultipartFile) arg);
}
// 2. MultipartFile[] arrays (ConvertToPdfRequest, HandleDataRequest, etc.)
else if (arg instanceof MultipartFile[]) {
files.addAll(Arrays.asList((MultipartFile[]) arg));
}
// 3. PDFFile-based objects (request wrappers like OptimizePdfRequest,
// MergePdfsRequest)
else if (arg instanceof PDFFile) {
MultipartFile fileInput = ((PDFFile) arg).getFileInput();
if (fileInput != null) {
files.add(fileInput);
}
}
}
if (!files.isEmpty()) {
List<Map<String, Object>> fileInfos =
files.stream()
.map(
f -> {
Map<String, Object> m = new HashMap<>();
m.put("name", f.getOriginalFilename());
m.put("size", f.getSize());
m.put("type", f.getContentType());
// Add file metadata if enabled (independent of audit
// level)
if (auditConfig.isCaptureFileHash()
|| auditConfig.isCapturePdfAuthor()) {
addFileMetadata(m, f);
}
return m;
})
.collect(Collectors.toList());
data.put("files", fileInfos);
}
}
}
/**
* Extract SHA-256 hash and PDF author metadata for a file
*
* @param fileData The file data map to add metadata to
* @param file The MultipartFile to extract metadata from
*/
private void addFileMetadata(Map<String, Object> fileData, MultipartFile file) {
// Extract SHA-256 hash if enabled (using streaming to avoid loading entire file into
// memory)
if (auditConfig.isCaptureFileHash()) {
try (InputStream is = file.getInputStream()) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
DigestInputStream dis = new DigestInputStream(is, digest);
byte[] buffer = new byte[8192];
while (dis.read(buffer) != -1) {
// Just read through the stream to compute digest
}
byte[] hashBytes = digest.digest();
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
hexString.append(String.format("%02x", b));
}
fileData.put("fileHash", hexString.toString());
} catch (Exception e) {
log.debug(
"Could not calculate file hash for {}: {}",
file.getOriginalFilename(),
e.getMessage());
}
}
// Extract PDF author if file is a PDF and enabled
if (auditConfig.isCapturePdfAuthor()
&& "application/pdf".equalsIgnoreCase(file.getContentType())) {
try (InputStream is = file.getInputStream();
PDDocument doc = pdfDocumentFactory.load(is, true)) {
PDDocumentInformation info = doc.getDocumentInformation();
if (info != null && info.getAuthor() != null) {
fileData.put("pdfAuthor", info.getAuthor());
}
} catch (Exception e) {
log.debug(
"Could not extract PDF author from {}: {}",
file.getOriginalFilename(),
e.getMessage());
}
}
}
/**
* Add method arguments to the audit data
*
* @param data The existing audit data map
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
*/
public void addMethodArguments(
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
if (auditLevel.includes(AuditLevel.VERBOSE)) {
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
String[] names = sig.getParameterNames();
Object[] vals = joinPoint.getArgs();
if (names != null && vals != null) {
IntStream.range(0, names.length)
.forEach(
i -> {
if (vals[i] != null) {
// Convert objects to safe string representation
data.put("arg_" + names[i], safeToString(vals[i], 500));
} else {
data.put("arg_" + names[i], null);
}
});
}
}
}
/**
* Safely convert an object to string with size limiting
*
* @param obj The object to convert
* @param maxLength Maximum length of the resulting string
* @return A safe string representation, truncated if needed
*/
public String safeToString(Object obj, int maxLength) {
if (obj == null) {
return "null";
}
String result;
try {
// Handle common types directly to avoid toString() overhead
if (obj instanceof String) {
result = (String) obj;
} else if (obj instanceof Number || obj instanceof Boolean) {
result = obj.toString();
} else if (obj instanceof byte[]) {
result = "[binary data length=" + ((byte[]) obj).length + "]";
} else {
// For complex objects, use toString but handle exceptions
result = obj.toString();
}
// Truncate if necessary
if (result != null && result.length() > maxLength) {
return StringUtils.truncate(result, maxLength - 3) + "...";
}
return result;
} catch (Exception e) {
// If toString() fails, return the class name
return "[" + obj.getClass().getName() + " - toString() failed]";
}
}
/**
* Determine if a method should be audited based on config and annotation
*
* @param method The method to check
* @param auditConfig The audit configuration
* @return true if the method should be audited
*/
public boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) {
// First check if we're running Enterprise Edition and audit is enabled - fast path
if (!runningEE || !auditConfig.isEnabled()) {
return false;
}
// Check for annotation override
Audited auditedAnnotation = method.getAnnotation(Audited.class);
AuditLevel requiredLevel =
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
// Check if the required level is enabled
return auditConfig.getAuditLevel().includes(requiredLevel);
}
/**
* Add timing and response status data to the audit record
*
* @param data The audit data to add to
* @param startTime The start time in milliseconds
* @param response The HTTP response (may be null for non-HTTP methods)
* @param level The current audit level
* @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call
*/
public void addTimingData(
Map<String, Object> data,
long startTime,
HttpServletResponse response,
AuditLevel level,
boolean isHttpRequest) {
if (level.includes(AuditLevel.STANDARD)) {
// For HTTP requests, let ControllerAuditAspect handle timing separately
// For non-HTTP methods, add execution time here
if (!isHttpRequest) {
data.put("latencyMs", System.currentTimeMillis() - startTime);
}
// Add HTTP status code if available
if (response != null) {
try {
data.put("statusCode", response.getStatus());
} catch (Exception e) {
// Ignore - response might be in an inconsistent state
}
}
}
}
/**
* Resolve the event type to use for auditing, considering annotations and context
*
* @param method The method being audited
* @param controller The controller class
* @param path The request path (may be null for non-HTTP methods)
* @param httpMethod The HTTP method (may be null for non-HTTP methods)
* @param annotation The @Audited annotation (may be null)
* @return The resolved event type (never null)
*/
public AuditEventType resolveEventType(
Method method,
Class<?> controller,
String path,
String httpMethod,
Audited annotation) {
// First check if we have an explicit annotation
if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) {
return annotation.type();
}
// For HTTP methods, infer based on controller and path
if (httpMethod != null && path != null) {
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
if ("GET".equals(httpMethod)) {
// Categorize GET requests as UI_DATA (UI data fetches)
// API endpoints use POST/PUT/DELETE, or are specific operational endpoints
if (isUiDataEndpoint(path)) {
return AuditEventType.UI_DATA;
}
return AuditEventType.HTTP_REQUEST;
}
if (cls.contains("user")
|| cls.contains("auth")
|| pkg.contains("auth")
|| path.startsWith("/user")
|| path.startsWith("/login")) {
return AuditEventType.USER_PROFILE_UPDATE;
} else if (cls.contains("admin")
|| path.startsWith("/admin")
|| path.startsWith("/settings")) {
return AuditEventType.SETTINGS_CHANGED;
} else if (cls.contains("file")
|| path.startsWith("/file")
|| RegexPatternUtils.getInstance()
.getUploadDownloadPathPattern()
.matcher(path)
.matches()) {
return AuditEventType.FILE_OPERATION;
}
}
// Default for non-HTTP methods or when no specific match
return AuditEventType.PDF_PROCESS;
}
/**
* Determine the appropriate audit level to use
*
* @param method The method to check
* @param defaultLevel The default level to use if no annotation present
* @param auditConfig The audit configuration
* @return The audit level to use
*/
public AuditLevel getEffectiveAuditLevel(
Method method, AuditLevel defaultLevel, AuditConfigurationProperties auditConfig) {
Audited auditedAnnotation = method.getAnnotation(Audited.class);
if (auditedAnnotation != null) {
// Method has @Audited - use its level
return auditedAnnotation.level();
}
// Use default level (typically from global config)
return defaultLevel;
}
/**
* Determine the appropriate audit event type to use
*
* @param method The method being audited
* @param controller The controller class
* @param path The request path
* @param httpMethod The HTTP method
* @return The determined audit event type
*/
public AuditEventType determineAuditEventType(
Method method, Class<?> controller, String path, String httpMethod) {
// First check for explicit annotation
Audited auditedAnnotation = method.getAnnotation(Audited.class);
if (auditedAnnotation != null) {
return auditedAnnotation.type();
}
// Otherwise infer from controller and path
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
if (cls.contains("user")
|| cls.contains("auth")
|| pkg.contains("auth")
|| path.startsWith("/user")
|| path.startsWith("/login")) {
return AuditEventType.USER_PROFILE_UPDATE;
} else if (cls.contains("admin")
|| path.startsWith("/admin")
|| path.startsWith("/settings")) {
return AuditEventType.SETTINGS_CHANGED;
} else if (cls.contains("file")
|| path.startsWith("/file")
|| RegexPatternUtils.getInstance()
.getUploadDownloadPathPattern()
.matcher(path)
.matches()) {
return AuditEventType.FILE_OPERATION;
} else {
return AuditEventType.PDF_PROCESS;
}
}
/**
* Get the current HTTP request if available
*
* @return The current request or null if not in a request context
*/
public HttpServletRequest getCurrentRequest() {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attrs != null ? attrs.getRequest() : null;
}
/**
* Check if a GET request is for a static resource
*
* @param request The HTTP request
* @return true if this is a static resource request
*/
public boolean isStaticResourceRequest(HttpServletRequest request) {
return request != null
&& !RequestUriUtils.isTrackableResource(
request.getContextPath(), request.getRequestURI());
}
/**
* Check if a GET request is a continuous polling call that should be excluded from STANDARD
* level
*
* @param request The HTTP request
* @return true if this is a polling/continuous call that should be excluded from STANDARD level
*/
public boolean isPollingCall(HttpServletRequest request) {
if (request == null) {
return false;
}
String path = request.getRequestURI();
String method = request.getMethod();
// Only filter GET requests
if (!"GET".equalsIgnoreCase(method)) {
return false;
}
// Polling endpoints excluded from STANDARD level auditing.
// Use exact/prefix matching to avoid accidental exclusions on unrelated paths.
return path.equals("/api/v1/auth/me")
|| path.equals("/api/v1/app-config")
|| path.equals("/api/v1/footer-info")
|| path.equals("/api/v1/admin/license-info")
|| path.equals("/api/v1/endpoints-availability")
|| path.equals("/health")
|| path.startsWith("/health/")
|| path.equals("/metrics")
|| path.startsWith("/metrics/")
|| path.equals("/actuator/health")
|| path.startsWith("/actuator/health/")
|| path.equals("/actuator/metrics")
|| path.startsWith("/actuator/metrics/");
}
// ========== HELPER METHODS ==========
/**
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
* /api/v1/auth/*, /api/v1/ui-data/*, /api/v1/proprietary/ui-data/*, /api/v1/config/*, and
* /api/v1/admin/license-info. Everything else is considered "UI".
*
* @param endpoint The endpoint path to check
* @return true if this is an API endpoint, false if it's a UI endpoint
*/
private boolean isUiDataEndpoint(String endpoint) {
if (endpoint == null) {
return false;
}
// UI data endpoints include auth, settings, config, user/team management, and UI data
// fetches
return endpoint.startsWith("/api/v1/auth/")
|| endpoint.startsWith("/api/v1/ui-data/")
|| endpoint.startsWith("/api/v1/proprietary/ui-data/")
|| endpoint.startsWith("/api/v1/config/")
|| endpoint.startsWith("/api/v1/admin/settings/")
|| endpoint.startsWith("/api/v1/user/")
|| endpoint.startsWith("/api/v1/users/")
|| endpoint.equals("/api/v1/admin/license-info")
|| endpoint.equals("/login");
}
/**
* Check if operation results (return values) should be captured
*
* @return true if captureOperationResults is enabled in config
*/
public boolean shouldCaptureOperationResults() {
return auditConfig.isCaptureOperationResults();
}
/** Get the current authenticated username or "system" if none */
private String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getName() != null && !"anonymousUser".equals(auth.getName())) {
return auth.getName();
}
// Refresh endpoint runs without normal JWT authentication so SecurityContext may be
// anonymous.
// In that case, derive principal from a signature-verified refresh token for attribution.
HttpServletRequest req = getCurrentRequest();
String tokenSubject = extractRefreshTokenSubject(req);
if (StringUtils.isNotBlank(tokenSubject)) {
return tokenSubject;
}
return (auth != null && auth.getName() != null) ? auth.getName() : "system";
}
/**
* Captures the current principal for later use (avoids SecurityContext issues in async/thread
* changes). Public so audit aspects can capture early before thread context changes.
*/
public String captureCurrentPrincipal() {
String principal = getCurrentUsername();
return principal;
}
/**
* Captures the current origin for later use (avoids SecurityContext issues in async/thread
* changes). Public so audit aspects can capture early before thread context changes.
*/
public String captureCurrentOrigin() {
String origin = determineOrigin();
return origin;
}
/**
* Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth).
* IMPORTANT: This must be called in the request thread before async execution.
*
* @return "API", "WEB", or "SYSTEM"
*/
private String determineOrigin() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// Check if authenticated via API key
if (auth instanceof ApiKeyAuthenticationToken) {
return "API";
}
// Check if authenticated via JWT (web user)
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) {
return "WEB";
}
// Refresh endpoint may still be anonymous in SecurityContext; infer origin from a
// verified JWT if present.
HttpServletRequest req = getCurrentRequest();
String refreshOrigin = extractRefreshTokenOrigin(req);
if (refreshOrigin != null) {
return refreshOrigin;
}
// System or unauthenticated
return "SYSTEM";
} catch (Exception e) {
log.debug("Could not determine origin for audit event", e);
return "SYSTEM";
}
}
private String extractRefreshTokenSubject(HttpServletRequest request) {
if (!isRefreshEndpoint(request)) {
return null;
}
String token = jwtService.extractToken(request);
if (StringUtils.isBlank(token)) {
return null;
}
try {
String subject = jwtService.extractUsernameAllowExpired(token);
return StringUtils.isNotBlank(subject) ? subject : null;
} catch (Exception e) {
log.debug("Could not extract refresh token subject for audit attribution", e);
return null;
}
}
private String extractRefreshTokenOrigin(HttpServletRequest request) {
if (!isRefreshEndpoint(request)) {
return null;
}
String token = jwtService.extractToken(request);
if (StringUtils.isBlank(token)) {
return null;
}
try {
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
Object authType = claims.get("authType");
if (authType != null && "API".equalsIgnoreCase(String.valueOf(authType))) {
return "API";
}
// Any verified non-API JWT refresh request is web/SSO user traffic.
return "WEB";
} catch (Exception e) {
log.debug("Could not extract refresh token origin for audit attribution", e);
return null;
}
}
private boolean isRefreshEndpoint(HttpServletRequest request) {
if (request == null) {
return false;
}
String path = request.getRequestURI();
return StringUtils.isNotBlank(path) && path.endsWith("/api/v1/auth/refresh");
}
/**
* Extract client IP address from the request, preferring X-Forwarded-For header for proxy/load
* balancer support. IMPORTANT: Must be called in the request thread before async execution to
* preserve the IP.
*
* @param request The HTTP request
* @return The client IP address, or null if not available
*/
public String extractClientIp(HttpServletRequest request) {
if (request == null) {
return null;
}
// Try X-Forwarded-For first (set by proxies/load balancers)
String forwardedFor = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotBlank(forwardedFor)) {
// X-Forwarded-For can contain multiple IPs, take the first one (original client)
String[] ips = forwardedFor.split(",");
return ips[0].trim();
}
// Try X-Real-IP (used by some proxies)
String realIp = request.getHeader("X-Real-IP");
if (StringUtils.isNotBlank(realIp)) {
return realIp;
}
// Fall back to remote address
return request.getRemoteAddr();
}
}
@@ -16,7 +16,7 @@ public final class SecretMasker {
private static final Pattern SENSITIVE =
RegexPatternUtils.getInstance()
.getPattern(
"(?i)(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)");
"(?i)\\b(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
private SecretMasker() {}
+5 -6
View File
@@ -28,7 +28,7 @@ ext {
springSecuritySamlVersion = "7.0.2"
openSamlVersion = "5.2.1"
commonmarkVersion = "0.27.1"
googleJavaFormatVersion = "1.34.1"
googleJavaFormatVersion = "1.35.0"
logback = "1.5.32"
junitPlatformVersion = "1.12.2"
modernJavaVersion = 21
@@ -78,7 +78,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.5.3'
version = '2.7.3'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
@@ -177,9 +177,8 @@ subprojects {
exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on'
exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on'
exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on'
// google-java-format 1.34+ requires Guava 33.x (ImmutableSortedMapFauxverideShim);
// force it here so Spotless's FeatureClassLoader resolves the correct version.
resolutionStrategy.force 'com.google.guava:guava:33.4.8-jre'
// Force a compatible Guava version for spotless
resolutionStrategy.force 'com.google.guava:guava:33.0.0-jre'
// Security CVE fixes - hardcoded resolution strategy to ensure safe versions
// Primary fixes via explicit dependencies in app/core/build.gradle:
@@ -205,7 +204,7 @@ subprojects {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.github.pixee:java-security-toolkit:1.2.2'
implementation 'io.github.pixee:java-security-toolkit:1.2.3'
//tmp for security bumps
implementation "ch.qos.logback:logback-core:$logback"
-24
View File
@@ -1,24 +0,0 @@
# Codex Contribution Guidelines for Stirling-PDF
This file provides high-level instructions for Codex when modifying any files within this repository. Follow these rules to ensure changes remain consistent with the existing project structure.
## 1. Code Style and Formatting
- Respect the `.editorconfig` settings located in the repository root. Java files use 4 spaces; HTML, JS, and Python generally use 2 spaces. Lines should end with `LF`.
- Format Java code with `./gradlew spotlessApply` before committing.
- Review `DeveloperGuide.md` for project structure and design details before making significant changes.
## 2. Testing
- Run `./gradlew build` before committing changes to ensure the project compiles.
- If the build cannot complete due to environment restrictions, DO NOT COMMIT THE CHANGE
## 3. Commits
- Keep commits focused. Group related changes together and provide concise commit messages.
- Ensure the working tree is clean (`git status`) before concluding your work.
## 4. Pull Requests
- Summarize what was changed and why. Include build results from `./gradlew build` in the PR description.
- Note that the code was generated with the assistance of AI.
## 5. Translations
- Only modify `messages_en_GB.properties` when adding or updating translations.
+5 -5
View File
@@ -25,11 +25,11 @@ COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
ENV JAVA_TOOL_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
ENV JAVA_TOOL_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0

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