Compare commits

..
Author SHA1 Message Date
EthanHealy01 8256fe6214 Hold an upload's policy back until its unlock prompt is answered
An encrypted upload opens the unlock modal and dispatches its policy in the
same tick, so the two race. When the user wins - which is likely, the run
takes seconds - the policy fails on a document they have already replaced with
a decrypted version. That bills for a run guaranteed to fail and leaves a row
about a file id no longer in the workbench, so it cannot be opened, retried or
tidied away.

The workbench publishes the ids whose prompt is still open, and the auto-run
skips them. Answering releases the file either way: unlocking dispatches on
the decrypted version, and skipping dispatches on the encrypted one, so a
document nobody unlocks still records the failure the bell offers Decrypt and
retry on.

A module store rather than context, matching refreshNotificationsNow: the
reader is a policy hook in another layer that needs the answer inside an
effect rather than as a render input.
2026-09-01 02:08:36 +01:00
EthanHealy01 a7be366e9e Merge remote-tracking branch 'origin/main' into feature/policy-decrypt-retry
# Conflicts:
#	frontend/editor/src/core/components/notifications/NotificationBell.tsx
2026-08-31 22:48:40 +01:00
EthanHealy01 50f7bb50a2 Show only failures about a document in the bell
Every open failure reached the panel, including the ones naming no file, where
the only thing it could offer was a line saying there was nothing to open. The
review surface is where those belong, and it still lists them unfiltered.

Filtered on the file the row names rather than the kind's declared scope: an
editor-reported tool failure is RUN-scoped but does name the document it ran
on, so scoping would have dropped exactly the failures worth showing.
2026-08-27 15:38:23 +01:00
EthanHealy01 ea6e32753f Report every failure a run produces, not just a total one
Three gaps meant a failure could happen and nothing would ever say so.

A batch that lost one input to a bad PDF reached the success path, because
processFiles only rethrows when EVERY input fails. The per-input errors were
discarded, so the report and the retry stash never ran: one bad file in twenty
was silently swallowed. processFiles now returns the failed inputs with their
errors, and both paths report through one helper so they cannot drift.

A locked document never escalated to the server classifier. The local pass
throws on an encrypted file and writes no verdict, and shouldDispatchToAi read
an absent verdict as "not yet" rather than "never" - so no server run was
dispatched, nothing recorded the failure, and the bell stayed empty.

A recurrence could not reopen an incident closed as FILE_REMOVED. A library
re-adds a file under the same id, so every later failure folded into the closed
row and left the queue for good. RESOLVED and FILE_REMOVED now both reopen;
DISMISSED still stands, being a reviewer's decision rather than a claim about
the document.

Also: appliedCategoriesFor no longer counts the browser-local pass as the
policy having run, which would have suppressed the same escalation; a retry
opens the failed tool in the viewer, the only view that scopes a tool to one
document; and View in processor navigates in place now that the workbench
survives the trip.
2026-08-27 14:40:16 +01:00
EthanHealy01 bbea3256fd Merge remote-tracking branch 'origin/main' into feature/policy-decrypt-retry 2026-08-26 19:38:42 +01:00
EthanHealy01 8441193631 Merge remote-tracking branch 'origin/main' into feature/policy-decrypt-retry 2026-08-26 19:08:50 +01:00
EthanHealy01 1aaf9c53cb reduce comments length 2026-08-26 14:40:05 +01:00
EthanHealy01 9471f60fc3 Merge branch 'main' into feature/policy-decrypt-retry 2026-08-25 16:33:35 +01:00
EthanHealy01 dc1757cd48 style: run oxfmt over the pre-review fixes
Main swapped Prettier for Oxfmt while this branch was in flight, and the
review fixes were written to the old formatter's taste. No code change.
2026-08-25 15:42:22 +01:00
EthanHealy01 ce8068ae3b review: fix what the pre-review pass surfaced
Functional fixes:
- A batch run's success no longer resolves rows for files it silently
  failed: the continuation's tool arm now requires the row's file to
  have produced an output, the same proof the policy arm already asked
  for. Being an input of a successful run proves nothing on its own.
- The action registry's useMemo takes aiEnabled as a dependency, so a
  decrypt-and-retry no longer rejoins the upload chain computed with the
  pre-load value and drops the AI policies.
- The withheld reason can no longer come from an action this build has
  never heard of: promoteActions takes the build's knowledge as its own
  predicate, restoring the parent PR's reviewed rule.
- A single-file endpoint's retry sends only the row's own document, not
  the whole stashed batch in one request the server would silently
  truncate to its first file; a ZIP answer is unpacked rather than
  adopted as one PDF; and the stash records which endpoint shape it was.
- The stash also records the failure's error code, and both consumers
  refuse a stash another kind's failure wrote: the server keys incidents
  on kind as well as file, the stash never did, so the newest failure
  could hand its operation to an older row.
- Custom-processor tools are not stashed: a generic re-submission would
  bypass their endpoint-specific request building, and the PR already
  claims they get no retry. Builds without notifications stash nothing
  at all, for the same reason they mount no bell.
- A row with no runnable action keeps its error log: the overflow menu
  no longer hides behind the primary button.

Housekeeping:
- The retry service reports failure reasons and the component layer
  words them, so its copy is translated like everything else's.
- The inline more-options icon becomes LocalIcon's own.
- localFilePresence.ts deleted: notificationRetry.ts is its successor
  and the merge had left both alive.
- Stale comments corrected (RESOLVED is set now; the clipboard fallback;
  the one-source-today anchor) and a duplicated TODO dropped.
2026-08-25 14:08:26 +01:00
EthanHealy01 2f3c116c2a Merge remote-tracking branch 'origin/main' into feature/policy-decrypt-retry
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java
#	app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java
#	app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationService.java
#	app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationSource.java
#	app/proprietary/src/test/java/stirling/software/proprietary/failure/CheckConstrainedEnumsTest.java
#	app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java
#	app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java
#	app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java
#	app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java
#	app/proprietary/src/test/java/stirling/software/proprietary/failure/NotificationProjectionTest.java
#	frontend/editor/public/locales/en-US/translation.toml
#	frontend/editor/src/core/components/notifications/NotificationBell.css
#	frontend/editor/src/core/components/notifications/NotificationBell.test.tsx
#	frontend/editor/src/core/components/notifications/NotificationBell.tsx
#	frontend/editor/src/core/components/notifications/NotificationItem.tsx
#	frontend/editor/src/core/components/notifications/notificationActions.ts
#	frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
#	frontend/editor/src/core/hooks/useNotifications.test.ts
#	frontend/editor/src/core/hooks/useNotifications.ts
#	frontend/editor/src/core/services/notifications.ts
#	frontend/editor/src/proprietary/components/notifications/notificationActions.test.tsx
#	frontend/editor/src/proprietary/components/notifications/notificationActions.ts
2026-08-25 12:48:55 +01:00
EthanHealy01 d79297e25f Merge remote-tracking branch 'origin/feature/failure-notifications' into feature/policy-decrypt-retry
# Conflicts:
#	frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
2026-08-24 17:52:07 +01:00
EthanHealy01 f44172e077 Merge branch 'main' into feature/failure-notifications 2026-08-24 16:08:36 +01:00
EthanHealy01 5ef11df1fc Merge remote-tracking branch 'origin/feature/failure-notifications' into feature/policy-decrypt-retry
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
#	frontend/editor/src/core/components/notifications/NotificationBell.test.tsx
#	frontend/editor/src/core/components/notifications/NotificationBell.tsx
2026-08-24 15:26:49 +01:00
EthanHealy01 cc68ebc920 review: address the smaller comments from the first pass
- The bell mounts nothing in a build with no notifications API, rather
  than polling a nonexistent endpoint forever to show nothing: core
  answers no through useNotificationsAvailable, a build that ships the
  routes overrides it to say so.
- NotificationItem moves to its own file, taking noteFor and the action
  button with it.
- BellIcon moves to the shared UI set.
- Time-anchored javadoc on FailureActionId restated as invariants, the
  notification read limits get the comments they deserved, and the bell's
  heavier comments trimmed to what the code cannot say. The workbench bar
  comment stops describing behaviour that lives in Workbench.
2026-08-24 14:38:43 +01:00
EthanHealy01 184bfb20da fix(failure): carry the unlock modal's resolution through too
The upload modal removes the password with its own direct call rather
than through useToolOperation, so an unlock performed there never asked
whether it was an open failure's fix: the row stayed open and the policy
unrun, while the same unlock through the tool carried on. The modal is
the remove-password tool by another door, so it now reports its success
to the same continuation, output paired to the file it unlocked.

Also pins the boundary the continuation must keep: a success that is
not the row's declared resolution re-runs nothing, however real the
success - compress on a document whose failure wants an unlock leaves
the row exactly as it was.
2026-08-24 10:19:43 +01:00
EthanHealy01 7a772982ae Merge branch 'main' into feature/failure-notifications 2026-08-24 10:09:23 +01:00
EthanHealy01 2f0a5757ff feat(failure): carry a resolution through when the user performs it by hand
The bell's Decrypt and retry is unlock, re-run, resolve - but the unlock
is just the remove-password tool, and a user who reaches it through the
tool has asked for the same thing. Until now that path left the row open
and the policy unrun: tool outputs are deliberately outside the upload
auto-run, so nothing ever picked the fix up.

A successful tool run now asks whether it WAS an open failure's fix.
For an attended policy failure whose kind names this tool as its
resolution, the output goes back through the same chain resume the bell
uses - original reference so a repeat folds, run recorded so its output
is delivered - and the row is reported resolved only once the run is
tracked. A tool failure resolves when the operation that failed succeeds
on the same document, since that IS its retry.

The server's offer stays the authority: a row whose resolution was
withheld, a colleague's row, and an unattended row are left alone
however many tools run. An output that cannot be paired to the failed
input (independent artifacts, several at once) leaves the row open
rather than re-running a policy on a guessed document. Runs that could
not resolve anything cost no read: the gate is answered from the
resolution table and the local retry stash.

Declared as an extension seam (core stub, proprietary implementation),
and a future resolution is one entry in RESOLUTION_TOOLS - matching,
offer checks, chaining and resolve reporting are generic.
2026-08-24 01:05:20 +01:00
EthanHealy01 af08162805 Merge remote-tracking branch 'origin/feature/failure-notifications' into feature/policy-decrypt-retry
# Conflicts:
#	frontend/editor/src/core/hooks/useNotifications.test.ts
2026-08-24 00:50:09 +01:00
EthanHealy01 83671b73c0 fix(failure): stop a refresh joining a read that predates the write it reports
refreshNotificationsNow exists so the person who just caused a failure
does not wait out the poll interval to hear about it. But load() joins
any read already in flight, and a read that started before the report
landed answers without the new row - so the refresh silently reported
stale news and the badge waited for the next poll anyway.

An explicit refresh now chains one fresh read behind whatever is in
flight instead of joining it. Concurrent refreshes share the chained
read, the poll itself still joins as before, and the chain checks for
subscribers so a bell unmounting mid-read does not strand a read
nobody is watching.
2026-08-24 00:48:50 +01:00
EthanHealy01 26ba197f38 Merge remote-tracking branch 'origin/feature/failure-notifications' into feature/policy-decrypt-retry 2026-08-23 23:47:45 +01:00
EthanHealy01 a7faa3d632 Merge remote-tracking branch 'origin/main' into feature/failure-notifications
# Conflicts:
#	frontend/editor/src/core/components/shared/WorkbenchBar.tsx
2026-08-23 19:47:31 +01:00
EthanHealy01 04d84f67ac Merge branch 'main' into feature/failure-notifications 2026-08-21 14:55:01 +01:00
EthanHealy01 a1349168ae Merge branch 'main' into feature/failure-notifications 2026-08-21 12:50:25 +01:00
EthanHealy01 c163ec5ff0 Make the bell read like a bell, and stop the unlock duplicating a file
Three things from this morning's demo:

The row now carries the kind's own sentence rather than the server's raw
message, the empty panel says the reader is caught up, and the log moves
to a Copy log entry in the overflow menu. Reading the stack trace is the
processor's job, not the bell's.

View in processor opens a new tab when the reader has a loaded workbench
to lose, so a failure mid-upload does not cost them every file they had
open. From the processor, or from an empty workbench, it navigates as it
did.

Decrypt and retry versions the encrypted original in place when the file
is merely closed in the sidebar. The parent stub was read only from the
workbench, so a closed file was adopted as a new one: the user ended up
holding a decrypted copy and the locked original side by side.
2026-08-20 14:41:23 +01:00
EthanHealy01 3abc7588df Merge remote-tracking branch 'origin/main' into feature/failure-notifications
# Conflicts:
#	frontend/editor/src/core/components/layout/Workbench.tsx
2026-08-20 12:09:27 +01:00
EthanHealy01 106eb464ed Merge remote-tracking branch 'origin/feature/failure-notifications' into feature/policy-decrypt-retry
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureKind.java
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventView.java
#	app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java
#	app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationService.java
#	app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationSource.java
#	app/proprietary/src/test/java/stirling/software/proprietary/failure/FailureKindTest.java
#	app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java
#	frontend/editor/src/core/components/notifications/NotificationBell.test.tsx
#	frontend/editor/src/core/components/notifications/NotificationBell.tsx
#	frontend/editor/src/core/components/notifications/notificationActions.ts
#	frontend/editor/src/core/hooks/useNotifications.test.ts
#	frontend/editor/src/core/hooks/useNotifications.ts
#	frontend/editor/src/core/services/localFilePresence.ts
#	frontend/editor/src/core/services/notifications.ts
#	frontend/editor/src/proprietary/components/notifications/notificationActions.ts
2026-08-20 03:04:03 +01:00
EthanHealy01 6539b98c64 Fix decrypt-and-retry, and give the bell a hierarchy to read
Decrypt and retry unlocked the document but left the user holding two
copies of it, and the retry restarted the upload chain from the front
rather than rejoining it where it stopped. The unlock now versions the
original in place via consumeFiles, and the run resumes at the first
policy that has not already been applied.

Read state moves from the id of the newest notification to a watermark
on the time the list is ordered by. Resolving the newest row used to
leave the marker pointing at nothing, which read every remaining row as
unread again.

Each row now shows two buttons and an overflow menu instead of a line of
near-equal ones, and the failure message sits in its own box with copy
and expand as corner glyphs.
2026-08-20 02:44:19 +01:00
EthanHealy01 a75a959b22 Revert the accidentally committed wt-perms worktree
A sibling worktree created inside the repo and not yet in
.git/info/exclude, so a git add -A caught it as a gitlink. Excluded
locally now, the way wt1 through wt6 already are.
2026-08-19 15:31:07 +01:00
EthanHealy01 fc967bb04b docs: cut the comments back to what a reader will still need
Review found the comment load indefensible, worst on backend code at 43%
of added lines. Every comment this branch added or touched is now at most
two lines, and the ones that only restated the code, narrated a decision
already visible in the diff, or justified the PR to its reviewer are
gone. What survives is the reasoning a reader cannot recover from the
code: why markFilesRemoved keys on the absence of a source, why the
divider boundary is frozen as an id, why the contexts are read raw, why
"/" is not a destination.

Two were not merely long but wrong, describing a retry this PR no longer
has: the usePolicyAutoRun gate cited notificationActions.adopt, and its
sibling cited "Decrypt and retry".

857 comment lines to 446 across the branch, backend code 273 to 121.
2026-08-19 15:30:18 +01:00
EthanHealy01 1410c4d848 Merge remote-tracking branch 'origin/main' into feature/policy-decrypt-retry
# Conflicts:
#	frontend/editor/public/locales/en-US/translation.toml
2026-08-19 01:10:58 +01:00
EthanHealy01 7d1bbfecfb Merge remote-tracking branch 'origin/main' into feature/failure-notifications
# Conflicts:
#	frontend/editor/public/locales/en-US/translation.toml
2026-08-19 00:54:53 +01:00
EthanHealy01 eab3cd178a feat(failure): resolve a failed run by retrying or decrypting and retrying
All resolution of a recorded failure lives here, on top of the bell that
surfaces it:

- The retry stash: what a failed tool run would need to run again (endpoint,
  parameters, file ids), kept client-side with password-shaped fields stripped
  at any depth and a depth-bounded walk that fails closed.
- Editor retry and decrypt-and-retry: re-open the failed tool, or unlock a
  password-protected document and re-run it in place.
- Server policy retry: an attended policy failure is re-run with the password
  the user supplies, exactly once per click.

The action slot model arrives with them. Slots rank an action as the row's
resolution, its runner-up, or overflow, which is only a question worth asking
once a kind has a resolution to rank: the bell alone offers viewing and
dismissal, in declaration order. promoteActions turns those slots into the
row's primary and secondary buttons and reports what it withheld.

A build without this PR still shows every failure; the server declares RETRY
and DECRYPT_AND_RETRY there and a client with no handler for them skips them.
2026-08-18 16:16:19 +01:00
EthanHealy01 69aceb8da7 Merge branch 'main' into feature/failure-notifications 2026-08-18 15:31:19 +01:00
EthanHealy01 2f60201acd Merge branch 'main' into feature/failure-notifications 2026-08-18 13:35:42 +01:00
EthanHealy01 6e21c27078 fix(failure): stop closing a user's failures when they close a file
WHY THEY VANISHED. removeFiles serves two jobs the caller separates only
by deleteFromStorage: deleting a document, and taking one out of the
workbench. It reported every one of them as a delete, and the server
closes each incident about a deleted document as FILE_REMOVED. Closing a
tab, unchecking a file in the file manager or swapping which files are
open therefore cleared the user's own notifications as they worked. Only
a real delete reports now, which is what FILE_REMOVED already claimed to
mean.

They also now read as two sections. The panel divides new from earlier on
the boundary it froze when the user opened it, so the divider does not
collapse the instant opening marks everything read, and one arriving on a
poll lands above it rather than shifting a stale index onto the wrong
row. Dividers only where there is something on both sides: a lone
"Earlier" over the whole list says nothing the empty badge has not.

The badge was already the count since the last one seen, so it is
unchanged. isUnread goes with the per-row marker it existed for.

DividerWithText moves to core, since the bell mounts in every build and
core cannot reach into proprietary. Every import of it already went
through @app, so nothing else changes.
2026-08-18 10:07:29 +01:00
EthanHealy01 fd1938bdc4 fix(failure): open the document from a notification instead of reloading
View file selected the document and then pushed "/". In a build that
ships the processor, "/" is the role-based router, so the reader was
sent through a redirect that reads as the app reloading and lands them
wherever their role says. The selection did nothing either: an id the
workbench is not holding shows nothing, and the view stays as it was.

It now opens the way the file sidebar does. The stub is added if the
workbench does not already hold it, made the active file, and the viewer
brought to the front, with no navigation at all when the bell is already
in the editor. Only the processor shell, which has no workbench to open
into, still hands the document over, and it goes to EDITOR_BASENAME
rather than the role router.

NavigationActionsContext is exported for the same reason
FileActionsContext already is: the bell mounts in both shells and has to
ask whether these actions exist rather than assume it.
2026-08-18 02:43:28 +01:00
EthanHealy01 e320d2366d fix(failure): stop the copy promising a retry this PR does not have
Four strings still offered to re-run something. Both server-sent reasons
only ever attach to an OWNER offer, which is now View file alone, and
both client notes appear on a row whose only action is opening the
document. So each one now says what is actually unavailable: opening it.

The unattended reason said re-running from a folder or bucket "needs
work that has not landed", which described a feature rather than the
row: it is disabled because nobody's browser holds a source-fed file.
2026-08-18 02:00:59 +01:00
EthanHealy01 74a5fd701d test(e2e): stub the notifications endpoint in the backend-free suite
The bell polls GET /api/v1/notifications from every shell it mounts in,
starting on load, so every stubbed route acquired a failed request. The
hook swallows it and shows an empty bell, but the browser still logs the
request itself, and console-clean.spec counts that: seven routes failed
on "Failed to load resource: 500" pointing at the poll.

Stubbed empty, for the same reason and in the same place as the policy
reconcile above it.
2026-08-18 01:55:29 +01:00
EthanHealy01 8fbe752893 fix(failure): rank a row's actions the same way for every kind
The two kinds listed the surviving actions in opposite orders, so the
solid button flipped between rows: "View in processor" led an
unrecognised failure, "View file" led a password-protected one. Fallout
from cutting the retries, which had been declared first on both and hid
the disagreement.

Declaration order is display order and the first usable offer renders as
the row's primary, so the document now leads wherever it is offered.
FailureKindTest asserts every kind against one shared ranking rather
than its own list, so a kind added later cannot reintroduce the flip.
2026-08-18 01:35:57 +01:00
EthanHealy01 39234fa8dc refactor(failure): cut the resolution machinery that ships with a later PR
This PR is the bell: users open their own document, reviewers open the
run. Resolutions (retry, decrypt-and-retry) live in their own branch and
land with the code that runs them, so everything that existed only to
serve them goes:

- RETRY and DECRYPT_AND_RETRY leave the action vocabulary and every
  kind's offers; nothing wired them on any client
- POST /file-run-events/{id}/resolved and its notification mirror go,
  along with service.resolve(): the only caller was a frontend function
  nothing called
- the notification API is read-only until something posts back, so the
  act route, its dispatch plumbing and NotificationSource.parse go too
- wasCancelled loses the export a nonexistent "retry stash" justified

The bell also stops rendering server-run dispositions, per the intended
shape: a reviewer gets at most View file and View in processor, a user
View file alone, and deciding a failure's fate stays on the review
surface. NotificationService now projects only client-run offers, so
the payload cannot offer a button the panel would refuse to draw.

notificationRetry.ts is renamed localFilePresence.ts: it never retried
anything, it answers whether the document is still in this browser.
2026-08-17 17:53:15 +01:00
EthanHealy01 ed8f33deae Merge branch 'main' into feature/failure-notifications
Review Flow PRs 2 and 3 were squash-merged, so their commits are not
ancestors of main and this branch's merge base was stuck before both.
That made the PR diff re-claim 19 files it did not change.

Every conflict resolved in favour of this branch, whose side supersedes
what main carries from the squashed PRs:

- markFilesRemoved scopes on the absence of a source rather than
  origin = TOOL, so a user's own attended policy failures close when
  they delete the document (FileRunEventRepository, its in-memory
  mirror, and FileRunEventStoreDbTest)
- FailureKind offers audience-scoped actions via getOfferedActions
  instead of a flat getActions list, so INPUT_PASSWORD_PROTECTED no
  longer offers ACKNOWLEDGE
- FileRunEventService.requireVisible is main's inline read-scope check
  extracted verbatim
- FailureActionException.statusOf replaces the controller's private
  statusFor and adds ACTION_NOT_DISPATCHABLE

Main's own unrelated changes in the conflicted files are kept, notably
the portal-surface class on failure rows from #7497.
2026-08-17 16:12:20 +01:00
EthanHealy01 27333f3bd1 fix(failure): close a user's own policy failures when they delete the document
Deleting a document from the editor closed only the incidents the editor had
reported itself. A policy run the same upload triggered was recorded by the
processor, so its failures stayed in the queue about a document that no longer
exists anywhere, and the only way to clear them was to dismiss each one by hand.

The guard was keyed on origin, which is not the question. The question is which
of the two id spaces the row's fileId is in: an editor report and an attended
policy run both carry the id the client minted for its own document, so the
client that holds it can say it is gone; a source-fed run carries a one-way hash
of a path or key that was never on any device, so no client can name it. That is
the absence of a source, not the origin, and it is the same rule the frontend
already uses to decide whether a row is resolvable locally.

Losing the origin clause does not widen anything: the actor and team clauses
still hold. It matters most where the actor clause cannot help, which is a
login-disabled deployment where both sides are null and every unattended row
would otherwise match; that case is now pinned by its own test.

Both tests run against the real database, because the scoping lives entirely in
the JPQL and the in-memory fake implements the same rules by hand -- it would
agree with a wrong query.
2026-08-14 00:42:15 +01:00
EthanHealy01 46b4905abe Merge remote-tracking branch 'origin/feature/failure-read-scope' into feature/failure-notifications
# Conflicts:
#	frontend/editor/public/locales/en-US/translation.toml
#	frontend/editor/src/core/components/layout/Workbench.tsx
#	frontend/editor/src/portal/components/AppShell.tsx
2026-08-14 00:36:46 +01:00
EthanHealy01 beaedba27f Merge remote-tracking branch 'origin/feature/editor-originated-failure-reporting' into feature/failure-read-scope 2026-08-14 00:16:39 +01:00
EthanHealy01 cb5c64ef81 Merge remote-tracking branch 'origin/feature/editor-originated-failure-reporting' into feature/editor-originated-failure-reporting 2026-08-14 00:15:04 +01:00
EthanHealy01 94805d90ee Merge branch 'feature/editor-originated-failure-reporting' into feature/failure-read-scope 2026-08-14 00:11:00 +01:00
EthanHealy01 e6f26a2601 Merge remote-tracking branch 'origin/main' into feature/editor-originated-failure-reporting 2026-08-14 00:10:50 +01:00
EthanHealy01 ae906e7626 feat(failure): surface recorded failures in a notification bell
A bell in the editor and the processor lists the failures the reader is
allowed to see, each with the actions they can actually take: open the
document, view the run in the processor, dismiss. Resolution (retry,
decrypt-and-retry) is a separate PR; the server declares those actions here and
a build with no client handler for them skips them.

Actions carry an audience (owner / team reviewer / anyone) and the server
derives the reader's ownership of each row, so an admin reviewing someone
else's failure is not offered a document their browser does not hold. Order is
the kind's declaration order, first leading.

An attended policy run now carries the client's own document reference, which
is what lets a repeat fold onto one incident, lets deleting the file clear its
failure, and lets the owner open the document from the row.

The bell also re-reads as soon as a failure this user caused is recorded,
rather than leaving them to wait out a poll interval for news of their own
upload. Other people's failures still arrive on the poll.
2026-08-13 22:29:23 +01:00
EthanHealy01 59c98da29e fix(failure): record who triggered a failed run, not who owns the policy
A failure's actor was read from the MDC audit principal, which carries the
BILLING identity: for a stored policy that is always its owner. So every
attended failure was filed under someone who may never have touched the
document, and an unattended sweep's failure looked attended.

This matters here because this PR narrows a member's reads to the rows they
are the actor on. With the wrong actor, the member who caused a failure and
holds the document reads nothing at all, while the policy owner is handed
incidents from runs they did not trigger.

The triggering user is now captured on the request thread and carried on the
run, separate from the billing principal and the output owner. Null for a
trigger-fired sweep, which is what keeps an unattended failure ownerless
rather than the owner's problem.

PolicyFailureAttributionTest runs the real engine, recorder, store and service
together, because the two sides used to assert independently: the engine's
test matched the actor with any(), which is how this went unnoticed.
2026-08-13 22:18:21 +01:00
EthanHealy01 cbad8c6ded Merge branch 'main' into feature/editor-originated-failure-reporting 2026-08-13 16:47:18 +01:00
EthanHealy01 36e53263b4 Merge branch 'feature/editor-originated-failure-reporting' into feature/failure-read-scope
Picks up the request cap and the suppressed error toast from the base.

One conflict, in the /reports @Operation description: the base still said "a
leader reviews it", which this branch makes untrue, while the base added the
400 rejection. Resolved to state both accurately.

Two compile fixes the merge needed but did not flag, because git took the
base's new tests and this branch's surrounding file without either side
conflicting: the new cap tests call store.list with the four-argument
signature this branch widened to five, and they use assertThatThrownBy, which
this branch's import block does not have.
2026-08-13 01:57:47 +01:00
EthanHealy01 b18a9cdbb1 fix(failure): refuse an oversized editor report rather than record it
One POST /file-run-events/reports could name any number of files, and the
endpoint sits outside the leader gate on purpose, so any authenticated user
could mint an unbounded number of permanent incidents in a single request and
flood a leader's queue. TOOL dedup keys carry the actor, so distinct ids never
fold into one row.

Bounded at 200 files per report and refused with 400 above it, before the first
write, so a rejected report leaves nothing behind. Refused rather than trimmed:
showing a reviewer part of a set with nothing saying the rest existed is the
failure mode the cap inside the service already caused once.

/removed-files stays uncapped deliberately. It creates nothing, only closing
rows the caller already owns, and refusing one would be the harmful direction:
the editor says it once and never retries, so those incidents would sit in the
queue asking for attention about files that no longer exist.

The editor swallowed its own report failures wholesale, so the new 400 would
have vanished. It now logs a rejected report, while still never throwing into
the tool's error path, and asks the global handler not to toast a background
call the user never made.
2026-08-13 01:38:13 +01:00
EthanHealy01 944d1b4480 Merge branch 'main' into feature/editor-originated-failure-reporting
One conflict, in PolicyEngine's call to submitForPrincipal: main moved asset
resolution ahead of the async hop and passes the resolved inputs, while this
branch added sourceId. Keeping only one side would either stop stored
certificates and watermark images binding, or lose the source attribution
this branch exists to add, so the resolution passes main's `resolved` through
this branch's parameter order.
2026-08-13 01:03:30 +01:00
EthanHealy01 2155fc3bf0 test(failure): exercise the actor clause against real SQL
FileRunEventStoreDbTest exists because the in-memory repository reimplements
the team filter in Java and would agree with a query that had lost it. The
new actor clause is what makes a member read only their own rows, and it had
no such coverage: every call in this file passed a null actor. Verified by
deleting the clause, which now fails exactly this test.
2026-08-13 00:53:50 +01:00
EthanHealy01 2e4ddf3c40 feat(failure): let everyone read the failures they caused
Reading or triaging a recorded failure was leader-only, so a member could
report a failure and then never see it again. That makes telling them
about one pointless, and it is their own error about their own file.

The endpoints no longer decide who may do what. A leader still reads and
closes the whole team's failures; everyone else reads and closes the ones
they caused. The decision lives in the service, so the read and the
triage cannot drift apart, and the kinds registry stops pretending to be
someone's data: it is copy a member needs to render what they can see.

Refusals stay refusals. A caller whose team cannot be resolved reads
nothing, and a member whose name cannot be resolved reads nothing either,
because dropping the actor filter would silently widen them to the whole
team. A colleague's incident answers 404 rather than 403, so nobody can
discover that one exists by trying to close it.

Two paths deliberately do not follow the read scope. forgetFiles narrows
to the caller's own rows however senior they are, since file ids are
minted by each client and a leader reading with a null actor would match
every unattributed row in the team. Recording takes the caller's team
rather than their read scope, so a reporter who cannot be named still
files under the team the failure happened in instead of the unteamed
bucket every team shares.
2026-08-13 00:16:19 +01:00
EthanHealy01 baef6d1629 fix(failure): bind the request body before asserting on it
oxlint's no-unsafe-optional-chaining: the optional chain can yield
undefined, and the cast dereferenced it straight away. Matches how the
other assertions in this file already read the body.
2026-08-10 23:49:57 +01:00
EthanHealy01 5ded2e6639 Merge branch 'main' into feature/editor-originated-failure-reporting
PR 1 was squash-merged, so main carries its content under a commit this
branch has never seen. A plain merge therefore reports every file PR 1
added as add/add. Resolved to main's copy of that work plus this branch's
own three commits, which is what the two sides actually say.
2026-08-10 22:39:18 +01:00
EthanHealy01 bb1ed1592c feat(failure): add a dev-only Dismiss all to the failures inspector
Empties the queue so a test run starts from nothing, rather than clicking
through every row. Sits with the other inspector buttons, so it is dropped
from a build along with the rest of the panel.

Only touches rows the server offered DISMISS on and marked usable, so it
cannot try to close something already closed. Sequential rather than
concurrent: dismissing is cheap, and one at a time keeps a refusal obvious.
2026-08-06 16:17:17 +01:00
EthanHealy01 803e3e1573 feat(failure): close incidents when their file is deleted from the editor
The queue means "needs attention", and a document that no longer exists needs
none. Deleting a file in the editor now closes its open incidents: they leave
the queue, the rows stay for audit.

- new terminal FILE_REMOVED status, distinct from DISMISSED (a reviewer's
  decision) and RESOLVED (which reopens on recurrence; this cannot recur)
- POST /file-run-events/removed-files, called fire-and-forget from
  FileContext.removeFiles so a server that cannot be told never blocks a delete
- scoped in SQL to the caller's own open editor rows: file ids come from the
  client, so team scoping alone would let one caller close a colleague's
  incidents by naming ids, and processor rows are excluded outright

Best-effort by nature: a cleared cache or another device never sends this, so
rows can still be left open. That is retention's job, not this.

Also fixes a folding bug the new database test caught. UNKNOWN is RUN scoped
and an editor report has no run, so every unclassified editor failure in a
team collapsed into a single incident: one actor credited for everyone's, and
the wrong person offered the row. A RUN-scoped failure with no run now falls
back to the document, and an editor incident is keyed by the person who hit
it. Processor dedup keys are unchanged.
2026-08-06 16:17:17 +01:00
EthanHealy01 a00262b8b3 feat(failure): report editor-originated failures into the same queue
A failure a user hits in the editor now lands in the same durable queue as
one from a folder, bucket or webhook. The editor calls tools directly, so
nothing server-side sees these unless the client reports them.

- POST /api/v1/file-run-events/reports, outside the leader gate so a member
  can file their own; team and actor come from the session, never the request
- one incident per named file, so each document stays separately actionable
- source attribution (sourceId) threaded through PolicyRunner -> PolicyRun ->
  recorder, so an unattended failure says which folder or bucket fed it
- the default list is the open queue, so dismissing a row clears it from view
  instead of leaving a list that can never be emptied
- UNKNOWN offers only Dismiss: with nothing to fix, "seen it" and "clear it"
  are the same decision
- unsupported-format and other client-side refusals are reported too, since
  they are the same class of problem as the processor rejecting a file type;
  only user cancellations are dropped

No document identity leaves the browser: file ids only, names refused by the
request type and scrubbed from the message on both sides. Nothing here stores
the failed document, so no action touches one.

Also gitignores policy-webhook-spool, the runtime spool dir, which was the
only one of its siblings not ignored.
2026-08-06 16:16:00 +01:00
EthanHealy01 024899f3f6 revert(failure): store failure messages verbatim
Per review: these are the user's own errors about their own files, and hiding
parts of a message makes a row harder to act on without making it meaningfully
safer. A name is also already kept elsewhere, so redacting only here bought
consistency with nothing.

Drops the redaction pattern, the shared utility and their tests, and stops
suppressing the description for kinds we recognise. What remains of the privacy
contract is the part that never depended on pattern-matching: no name column,
and a dedup key built only from opaque identifiers.
2026-08-06 16:03:16 +01:00
EthanHealy01 b24ab7892f refactor(common): make filename redaction a shared utility
It was private to RecordFailure, which is the wrong home for a rule that
belongs wherever a document name might be written down. policy_processed_files
already stores the full path in plaintext, and that is the next caller.

Moves the pattern to common as FilenameRedaction.attemptRedaction, named for
what it is rather than what it guarantees, with the cases moved alongside it.
RecordFailurePrivacyTest keeps one check that a stored message goes through it
at all, which is the part that belongs to the failure package.

Also stops storing the downstream description for a kind we recognise: we
already have its own copy, so the raw text only adds somewhere for a name to
hide. Unrecognised failures keep theirs, since it is the only thing telling a
reviewer what went wrong.
2026-08-06 15:41:16 +01:00
EthanHealy01 49baf647d5 docs(failure): trim the redaction comment, and name the real fix
26 lines of javadoc for one field was too much. The detail of what is and is
not caught belongs in RecordFailurePrivacyTest, which asserts it rather than
claims it, so the comment now points there.

Keeps the TODO, reframed around James's point: we own the producer, so the
durable fix is to store the parsed Problem Details fields instead of the
stringified exception, rather than reading our own structured data back out
of prose.
2026-08-06 15:13:05 +01:00
EthanHealy01 bfaed76f70 docs(failure): mark the redaction gap as a known limitation
Agreed with Anthony that storing a name is not a concern today and proper
detection comes later, so the shape-matching stays and the gap is written down
rather than engineered around.

Adds the TODO on both the pattern and the test that pins the gap, naming the
durable fix: stop forwarding a downstream tool's message verbatim, keep our own
wording plus the error code, and only pass through a body whose kind we
classify and whose text we therefore know carries no name.

Widens the cases the test covers to quoted names, Windows paths, ampersands and
an uppercase extension, and makes the partial case assert what actually
survives instead of only what does not.
2026-08-06 13:32:53 +01:00
EthanHealy01 1bf3154a7f fix(failure): redact the shapes a document name actually takes
Raised in review, twice. The pattern only handled a single hyphenated ASCII
token with one extension, which is not what documents are called. Everything
below leaked in full or in part: report (final).pdf, severance-agreement.pdf.gz,
termination.tar.gz, 履歴書.pdf, отчёт-зарплата.pdf, payslip%20march.pdf.

It now matches by shape over unicode letters and digits, allows the punctuation
names carry (brackets, %, &, apostrophes) and takes up to three stacked
extensions. An all-digit extension still is not one, so v2.14.2 survives, and
the lookarounds still keep it off dotted identifiers so a stack trace does.

Spaces are crossed only when the name is delimited by a quote, bracket or path
separator. An undelimited spaced name is indistinguishable from the sentence
around it, so "Failed on Q3 Layoff List.pdf" loses the name but keeps its
leading words rather than collapsing the whole message to "<file>". That limit
is now stated in the javadoc instead of the old wording, and pinned by a test.
2026-08-06 12:07:27 +01:00
EthanHealy01 e83abbea53 Merge remote-tracking branch 'origin/main' into feature/file-run-info-transit-and-notifications
# Conflicts:
#	frontend/editor/src/portal/queries/keys.ts
2026-08-05 18:22:48 +01:00
EthanHealy01 bd452de1fd refactor(failure): make origin the run type, not the place it started
The values mixed two questions. EDITOR and API answer "where was this started",
PROCESSOR answers "what ran it", and they overlap: a policy kicked off from the
editor is both, and was recorded as PROCESSOR, which reads as unattended next to
a row that names the person who hit it.

Origin is now the run type alone: TOOL for a single tool call with no policy
around it, POLICY for anything the policy engine ran however it was triggered.
Where it came from is already answered by the fields beside it — actor names the
person for an attended run, sourceId names the folder, bucket or webhook for an
unattended one.

API is dropped, having been an answer to the other question. PIPELINE is
declared without a producer: the watched-folder pipeline that predates policies
records no failures at all, and instrumenting it is its own piece of work.
2026-08-05 17:42:04 +01:00
EthanHealy01 a44c173321 fix(failure): only stop polling on a refusal that cannot change
The guard treated any error as permanent, so one dropped connection or a
restarting server switched the list's refresh off for the rest of the session,
with nothing to turn it back on.

Only 403 and 404 stop it now: a build without the failure registry has no such
route, and a member who is not a team leader may not read the queue. Everything
else is transient and polling rides it out.

Also formats the file, which is what failed frontend-validation.
2026-08-05 16:57:24 +01:00
EthanHealy01 ac2ec76414 fix(failure): fold repeats on the document, and keep the view out of builds
Three review findings.

Folding did not work. Both kinds ended up keyed on the run id — a FILE-scoped
kind fell back to the run because nothing populated file_id, and UNKNOWN is
run-scoped — and every sweep starts a new run. So a file that failed on every
sweep opened a new incident every sweep, while several files failing in one
run collapsed into a single row.

Input sources now pass their own reference for the document, hashed via
IdentityHasher (a folder identity is a path, and a path is a filename), and it
is carried ResolvedInput -> PolicyRun -> recorder -> file_id. Incidents key on
the document, so a repeat folds and distinct files stay distinct. This also
fills in the column that was always NULL.

The failures view is no longer mounted outside dev. Only its debug panel was
gated before, so the section itself rendered in a build; the endpoints stay
live and leader-gated, but the surface is unfinished. Vite folds the guard, so
neither the view nor its fetch ships.

The list polls every 30s, paused while the tab is hidden and stopped once the
route answers 404 or 403. Failures arrive from background sweeps, so without
it the list silently went stale.
2026-08-05 16:32:40 +01:00
EthanHealy01 4477b7a975 fix(failure): drop the unlock label from the password-protected kind
"I'll unlock this" promised something no action performs. Acknowledge only
moves the row's status; nothing collects a password and nothing retries,
because the failed document is never kept server-side.

The kind now offers the generic Acknowledge alongside "Skip this file".
Unlocking can be offered once there is a document to unlock.
2026-08-05 01:10:44 +01:00
EthanHealy01 a5ed7352ea fix(failure): make incident folding and status transitions concurrency-safe
Addresses review feedback on the failure-reporting PR.

Folding a repeat into an existing incident was a read-modify-save of a
detached entity, so a dismiss landing between the read and the save was
reverted to NEW and concurrent folds lost occurrence counts. Both steps are
now guarded UPDATE statements against the row's current values. A row that
vanished between the dedup read and the fold is re-inserted rather than
surfacing as an error the recorder swallows.

Status transitions are guarded in the database too, so two racing closes
resolve to one winner, and the pre-read that only classified the refusal is
gone. AcknowledgeAction no longer hand-rolls that logic.

Other fixes:
- kindId filters in the query, before the limit, so a filtered page is no
  longer empty while matching rows exist
- saveAndFlush on insert, so the duplicate-key violation lands in the catch
  rather than at a later commit
- truncate() caps at 2000 including the ellipsis and never splits a
  surrogate pair
- byErrorCode indexes once instead of scanning; a code claimed twice fails
  the boot instead of resolving by declaration order
- facet enums promoted to top level, matching the other persisted enums
- status query parameter bound by Spring's converter
- redaction javadoc states plainly that it is best-effort

Tests: FileRunEventStoreDbTest runs the real JPQL on a real database, since
every existing test used the in-memory fake and the queries were unverified.
Four tests that could not fail are fixed or deleted, and the fake now honours
Pageable, which is what made the limit tests meaningless.
2026-08-05 00:22:18 +01:00
EthanHealy01 ba905e1416 Merge branch 'main' into feature/file-run-info-transit-and-notifications 2026-08-04 12:51:49 +01:00
EthanHealy01 bc87bf9af8 test: drop the permit-all chain in favour of excluding Spring Security
The HTTP integration test built its own SecurityFilterChain with CSRF
disabled, which CodeQL flagged (java/spring-disabled-csrf-protection).
The chain only existed so the auto-configured one would not answer 401
before the handler ran, so excluding the security auto-configurations
removes both the 401 and the need to disable anything.
2026-08-03 18:19:12 +01:00
EthanHealy01 5793c3d7f3 feat(processor): record policy-run failures as durable, actionable events
Adds a team-scoped record of why a policy run failed, surfaced in the portal
with the triage actions each failure kind allows.

A failure kind registry (FailureKind) describes what can go wrong as data: a
stable id, i18n keys, an English fallback, and four facets (stage, severity,
remedy, scope). A classifier maps a thrown failure onto a kind by reading the
errorCode out of the Problem Details body a tool returns, so classification
keys off structured codes rather than exception message matching. Anything
unrecognised becomes UNKNOWN, which means every failed run gets a durable
record from day one.

Actions are declared by a kind but implemented in FailureAction beans resolved
by id, the same idiom already used for InputSource and PolicyOutputSink. A
kind cannot be sent an action it does not declare, so an incoherent pairing is
unreachable rather than merely unrendered.

The record holds no document name or content: fileId is an opaque reference,
and detail is stripped of anything path- or filename-shaped on the way in.
PolicyExecutor's type-mismatch message now reports the extension instead of
the filename, since that message becomes the stored detail.

Reads and triage are leader-only, gated the same way policy editing is, and
every read and write is scoped to the caller's own team from the authenticated
principal.
2026-08-03 16:57:52 +01:00
289 changed files with 6300 additions and 2382 deletions
+1 -1
View File
@@ -42,7 +42,7 @@
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.35.0",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
// (DE) Aktiviert Kommentare im Java-Format.
// (EN) Enables comments in Java formatting.
@@ -174,8 +174,7 @@ public class EndpointConfiguration {
&& disabledGroups.contains(group)
&& entry.getValue().contains(endpoint)) {
log.debug(
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no"
+ " alternatives)",
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
original,
group);
return false;
@@ -334,8 +333,7 @@ public class EndpointConfiguration {
String.join(", ", functionallyDisabledEndpoints));
} else if (!disabledToolGroups.isEmpty()) {
log.info(
"No endpoints disabled despite missing tools - fallback implementations"
+ " available");
"No endpoints disabled despite missing tools - fallback implementations available");
}
}
@@ -85,8 +85,7 @@ public class AutoJobAspect {
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution:"
+ " {}",
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
// Rethrow RuntimeException as-is to preserve exception type
@@ -166,8 +165,8 @@ public class AutoJobAspect {
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution"
+ " (attempt {}/{}): {}",
"AutoJobAspect caught exception during job execution (attempt"
+ " {}/{}): {}",
currentAttempt,
maxRetries,
ex.getMessage(),
@@ -184,8 +183,7 @@ public class AutoJobAspect {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in"
+ " TaskManager",
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API
// access
@@ -43,9 +43,9 @@ public class ClusterConfig {
} else if ("inprocess".equalsIgnoreCase(backplane)) {
// enabled+inprocess only coordinates the local JVM; cross-node lookups will 410.
log.warn(
"cluster.enabled=true with backplane=inprocess - only the local JVM is"
+ " coordinated. Cross-node lookups and the file proxy will fail. Use"
+ " backplane=valkey for real multi-node deployments.");
"cluster.enabled=true with backplane=inprocess - only the local"
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
+ " Use backplane=valkey for real multi-node deployments.");
} else {
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
@@ -230,14 +230,12 @@ public class RuntimePathConfig {
// Check if one path is a parent of the other
if (path1.startsWith(path2)) {
log.warn(
"Watched folder path '{}' is nested inside '{}' - this may cause"
+ " duplicate processing",
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
path1,
path2);
} else if (path2.startsWith(path1)) {
log.warn(
"Watched folder path '{}' is nested inside '{}' - this may cause"
+ " duplicate processing",
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
path2,
path1);
}
@@ -255,24 +253,21 @@ public class RuntimePathConfig {
// Check if watched folder is same as finished folder
if (watchedPath.equals(finishedPath)) {
log.error(
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' -"
+ " this will cause processing loops!",
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' - this will cause processing loops!",
watchedPath,
finishedPath);
}
// Check if watched folder contains finished folder
else if (finishedPath.startsWith(watchedPath)) {
log.warn(
"Finished folder '{}' is nested inside watched folder '{}' - this may"
+ " cause issues",
"Finished folder '{}' is nested inside watched folder '{}' - this may cause issues",
finishedPath,
watchedPath);
}
// Check if finished folder contains watched folder
else if (watchedPath.startsWith(finishedPath)) {
log.error(
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' -"
+ " this will cause processing loops!",
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' - this will cause processing loops!",
watchedPath,
finishedPath);
}
@@ -300,17 +295,15 @@ public class RuntimePathConfig {
// Warn if manual endpoint count doesn't match sessionLimit
if (configured.size() != sessionLimit) {
log.warn(
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit"
+ " ({}). Concurrency will be limited by endpoint count, not"
+ " sessionLimit.",
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit ({}). "
+ "Concurrency will be limited by endpoint count, not sessionLimit.",
configured.size(),
sessionLimit);
}
return configured;
}
log.warn(
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to"
+ " 127.0.0.1:2003.");
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to 127.0.0.1:2003.");
return Collections.singletonList(
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
}
@@ -144,8 +144,7 @@ public class ApplicationProperties {
sizeInMB);
} else {
log.warn(
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999),"
+ " ignoring",
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
sizeInMB);
}
} catch (NumberFormatException e) {
@@ -24,8 +24,7 @@ public class PDFFile {
@Schema(
description =
"File ID for server-side files (can be used instead of fileInput if job was"
+ " previously done on file in async mode)")
"File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
private String fileId;
@AssertTrue(message = "Either fileInput or fileId must be provided")
@@ -209,8 +209,7 @@ public class ResourceMonitor {
return (double) m.invoke(osMXBean);
} catch (Exception e2) {
log.trace(
"Could not get CPU load through reflection, assuming moderate load"
+ " (0.5)");
"Could not get CPU load through reflection, assuming moderate load (0.5)");
return 0.5;
}
}
@@ -167,8 +167,7 @@ public class TempFileCleanupService {
|| unregisteredDeletedCount > 0
|| directoriesDeletedCount > 0) {
log.info(
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered"
+ " files, {} directories",
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories",
registeredDeletedCount,
unregisteredDeletedCount,
directoriesDeletedCount);
@@ -253,8 +252,7 @@ public class TempFileCleanupService {
dirDeletedCount.incrementAndGet();
if (log.isDebugEnabled()) {
log.debug(
"Deleted temp file during {} cleanup:"
+ " {}",
"Deleted temp file during {} cleanup: {}",
phase,
path);
}
@@ -41,8 +41,7 @@ public class AttachmentUtils {
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
log.info(
"Set PDF PageMode to UseAttachments to automatically show attachments"
+ " pane");
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
}
} catch (Exception e) {
log.error("Failed to set catalog viewer preferences for attachments", e);
@@ -342,26 +342,26 @@ public class EmlProcessingUtils {
private String getFallbackStyles() {
return """
/* Minimal fallback - main CSS resource failed to load */
body {
font-family: var(--font-family, Helvetica, sans-serif);
font-size: var(--font-size, 12px);
line-height: var(--line-height, 1.4);
color: var(--text-color, #202124);
margin: 0;
padding: 20px;
word-wrap: break-word;
}
.email-container { max-width: 100%; }
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
.email-meta { font-size: 12px; color: #666; }
.email-body { line-height: 1.6; }
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
img { max-width: 100%; height: auto; }
""";
/* Minimal fallback - main CSS resource failed to load */
body {
font-family: var(--font-family, Helvetica, sans-serif);
font-size: var(--font-size, 12px);
line-height: var(--line-height, 1.4);
color: var(--text-color, #202124);
margin: 0;
padding: 20px;
word-wrap: break-word;
}
.email-container { max-width: 100%; }
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
.email-meta { font-size: 12px; color: #666; }
.email-body { line-height: 1.6; }
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
img { max-width: 100%; height: auto; }
""";
}
private void appendAttachmentsSection(
@@ -290,8 +290,7 @@ public class ExceptionUtils {
// Additional safety check: warn about very large images (> 1GB estimated)
if (estimatedBytes > 1024L * 1024 * 1024) {
log.warn(
"Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This"
+ " may cause memory issues.",
"Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This may cause memory issues.",
pageNumber,
widthInPixels,
heightInPixels,
@@ -395,8 +394,7 @@ public class ExceptionUtils {
message = getMessage(contextKey, defaultMsg, context);
} else {
message =
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
+ " feature first to fix the file before proceeding with this operation.";
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.";
}
return new PdfCorruptedException(message, cause, ErrorCode.PDF_CORRUPTED.getCode());
@@ -1121,25 +1119,19 @@ public class ExceptionUtils {
PDF_CORRUPTED(
"E001",
"error.pdfCorrupted",
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
+ " feature first to fix the file before proceeding with this operation."),
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation."),
PDF_MULTIPLE_CORRUPTED(
"E002",
"error.multiplePdfCorrupted",
"One or more PDF files appear to be corrupted or damaged. Please try using the"
+ " 'Repair PDF' feature on each file first before attempting to merge them."),
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them."),
PDF_ENCRYPTION(
"E003",
"error.pdfEncryption",
"The PDF appears to have corrupted encryption data. This can happen when the PDF"
+ " was created with incompatible encryption methods. Please try using the"
+ " 'Repair PDF' feature first, or contact the document creator for a new"
+ " copy."),
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy."),
PDF_PASSWORD(
"E004",
"error.pdfPassword",
"The PDF Document is passworded and either the password was not provided or was"
+ " incorrect"),
"The PDF Document is passworded and either the password was not provided or was incorrect"),
PDF_NO_PAGES("E005", "error.pdfNoPages", "PDF file contains no pages"),
PDF_NOT_PDF("E006", "error.notPdfFile", "File must be in PDF format"),
@@ -1147,25 +1139,20 @@ public class ExceptionUtils {
CBR_INVALID_FORMAT(
"E010",
"error.cbrInvalidFormat",
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an"
+ " unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR"
+ " archive."),
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR archive."),
CBR_NO_IMAGES(
"E012",
"error.cbrNoImages",
"No valid images found in the CBR file. The archive may be empty, or all images may"
+ " be corrupted or in unsupported formats."),
"No valid images found in the CBR file. The archive may be empty, or all images may be corrupted or in unsupported formats."),
CBR_NOT_CBR("E014", "error.notCbrFile", "File must be a CBR or RAR archive"),
CBZ_INVALID_FORMAT(
"E015",
"error.cbzInvalidFormat",
"Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not"
+ " be a valid ZIP archive."),
"Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not be a valid ZIP archive."),
CBZ_NO_IMAGES(
"E016",
"error.cbzNoImages",
"No valid images found in the CBZ file. The archive may be empty, or all images may"
+ " be corrupted or in unsupported formats."),
"No valid images found in the CBZ file. The archive may be empty, or all images may be corrupted or in unsupported formats."),
CBZ_NOT_CBZ("E018", "error.notCbzFile", "File must be a CBZ or ZIP archive"),
// EML errors
@@ -1218,8 +1205,7 @@ public class ExceptionUtils {
FFMPEG_REQUIRED(
"E063",
"error.ffmpegRequired",
"FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is"
+ " available on the system PATH."),
"FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is available on the system PATH."),
// Validation errors
INVALID_ARGUMENT("E070", "error.invalidArgument", "Invalid argument ''{0}'': {1}"),
@@ -1235,10 +1221,7 @@ public class ExceptionUtils {
OUT_OF_MEMORY_DPI(
"E081",
"error.outOfMemoryDpi",
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI."
+ " This can occur when the resulting image exceeds Java's array/memory limits"
+ " (e.g., NegativeArraySizeException). Please use a lower DPI value"
+ " (recommended: 150 or less) or process the document in smaller chunks.");
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI. This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks.");
private final String code;
private final String messageKey;
@@ -456,8 +456,7 @@ public class FormUtils {
|| !Float.isFinite(finalW)
|| !Float.isFinite(finalH)) {
log.warn(
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={},"
+ " h={}",
"Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}",
field.getFullyQualifiedName(),
pageIndex,
finalX,
@@ -392,9 +392,9 @@ public class PdfUtils {
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigFor300Dpi",
"PDF page {0} is too large to render at 300 DPI. The resulting"
+ " image would exceed Java's maximum array size. Please use a"
+ " lower DPI value for PDF-to-image conversion.",
"PDF page {0} is too large to render at 300 DPI. The resulting image"
+ " would exceed Java's maximum array size. Please use a lower DPI"
+ " value for PDF-to-image conversion.",
pageIndex + 1);
}
throw e;
@@ -253,8 +253,7 @@ public class ProcessExecutor {
}
} catch (InterruptedIOException e) {
log.warn(
"Error reader thread was interrupted due to"
+ " timeout.");
"Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
@@ -279,8 +278,7 @@ public class ProcessExecutor {
}
} catch (InterruptedIOException e) {
log.warn(
"Error reader thread was interrupted due to"
+ " timeout.");
"Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
@@ -237,7 +237,6 @@ class ApplicationPropertiesLogicTest {
assertTrue(
oauth2.isValid(oneBlank, "scopes"),
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn"
+ " Element leer/blank ist");
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
}
}
@@ -130,8 +130,7 @@ class PdfMarkdownConverterTest {
if (similarity < THRESHOLD) {
fail(
String.format(
"Markdown output differs from golden file '%s' by %.1f%% (threshold"
+ " %.0f%%):%n%s",
"Markdown output differs from golden file '%s' by %.1f%% (threshold %.0f%%):%n%s",
mdName,
(1.0 - similarity) * 100,
(1.0 - THRESHOLD) * 100,
@@ -60,10 +60,10 @@ class CustomHtmlSanitizerTest {
new String[] {"<p>", "<strong>", "<em>"}),
Arguments.of(
"<p>Text with <b>bold</b>, <i>italic</i>, <u>underline</u>,"
+ " <em>emphasis</em>, <strong>strong</strong>,"
+ " <strike>strikethrough</strike>, <s>strike</s>,"
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
+ " <em>emphasis</em>, <strong>strong</strong>,"
+ " <strike>strikethrough</strike>, <s>strike</s>,"
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
new String[] {
"<b>bold</b>",
"<i>italic</i>",
@@ -271,8 +271,8 @@ class CustomHtmlSanitizerTest {
// Arrange
String htmlWithObjects =
"<p>Safe content</p><object data=\"data.swf\""
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
+ " type=\"application/x-shockwave-flash\">";
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
+ " type=\"application/x-shockwave-flash\">";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithObjects);
@@ -309,11 +309,11 @@ class CustomHtmlSanitizerTest {
// Arrange
String complexHtml =
"<div class=\"container\"> <h1 style=\"color: blue;\">Welcome</h1> <p>This is a"
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
+ " image\"> <script>alert('XSS');</script> <iframe"
+ " src=\"https://evil.com\"></iframe></div>";
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
+ " image\"> <script>alert('XSS');</script> <iframe"
+ " src=\"https://evil.com\"></iframe></div>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(complexHtml);
@@ -120,10 +120,10 @@ class EmlToPdfTest {
void parseHtmlEmailWithStyling() throws IOException {
String htmlBody =
"<html><head><style>.header{color:blue;font-weight:bold;}"
+ ".content{margin:10px;}.footer{font-size:12px;}</style></head><body><div"
+ " class=\"header\">Important Notice</div><div class=\"content\">This is"
+ " <strong>HTML content</strong> with styling.</div><div"
+ " class=\"footer\">Best regards</div></body></html>";
+ ".content{margin:10px;}.footer{font-size:12px;}</style></head>"
+ "<body><div class=\"header\">Important Notice</div>"
+ "<div class=\"content\">This is <strong>HTML content</strong> with styling.</div>"
+ "<div class=\"footer\">Best regards</div></body></html>";
String emlContent =
createHtmlEmail(
@@ -286,13 +286,11 @@ class EmlToPdfTest {
@DisplayName("Should handle complex nested HTML structures")
void handleComplexNestedHtml() throws IOException {
String complexHtml =
"<html><head><title>Complex Email</title></head><body><div"
+ " class=\"container\"><header><h1>Email"
+ " Header</h1></header><main><section><p>Paragraph with <a"
+ " href=\"https://example.com\">link</a></p><ul><li>List item"
+ " 1</li><li>List item 2 with"
+ " <em>emphasis</em></li></ul><table><tr><td>Cell 1</td><td>Cell"
+ " 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>"
"<html><head><title>Complex Email</title></head><body>"
+ "<div class=\"container\"><header><h1>Email Header</h1></header><main><section>"
+ "<p>Paragraph with <a href=\"https://example.com\">link</a></p><ul>"
+ "<li>List item 1</li><li>List item 2 with <em>emphasis</em></li></ul><table>"
+ "<tr><td>Cell 1</td><td>Cell 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>"
+ "</table></section></main></div></body></html>";
String emlContent =
@@ -348,8 +346,7 @@ class EmlToPdfTest {
This line breaks header format
Content-Type: text/plain
Body content\
""";
Body content""";
byte[] emlBytes = malformedEml.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
@@ -784,13 +781,7 @@ class EmlToPdfTest {
String from, String to, String subject, String body, String charset) {
return String.format(
Locale.ROOT,
"From: %s\n"
+ "To: %s\n"
+ "Subject: %s\n"
+ "Date: %s\n"
+ "Content-Type: text/plain; charset=%s\n"
+ "Content-Transfer-Encoding: 8bit\n\n"
+ "%s",
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s",
from,
to,
subject,
@@ -802,11 +793,7 @@ class EmlToPdfTest {
private String createEmailWithCustomHeaders() {
return String.format(
Locale.ROOT,
"From: sender@example.com\n"
+ "Date: %s\n"
+ "Content-Type: text/plain; charset=UTF-8\n"
+ "Content-Transfer-Encoding: 8bit\n\n"
+ "%s",
"From: sender@example.com\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
getTimestamp(),
"This is an email body with some headers missing.");
}
@@ -814,13 +801,7 @@ class EmlToPdfTest {
private String createHtmlEmail(String from, String to, String subject, String htmlBody) {
return String.format(
Locale.ROOT,
"From: %s\n"
+ "To: %s\n"
+ "Subject: %s\n"
+ "Date: %s\n"
+ "Content-Type: text/html; charset=UTF-8\n"
+ "Content-Transfer-Encoding: 8bit\n\n"
+ "%s",
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
from,
to,
subject,
@@ -842,27 +823,26 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
%s
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
%s
--%s--\
""",
--%s--""",
from,
to,
subject,
@@ -883,27 +863,26 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
%s
--%s
Content-Type: message/rfc822; name="%s"
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
--%s
Content-Type: message/rfc822; name="%s"
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
%s
--%s--\
""",
--%s--""",
"outer@example.com",
"outer_recipient@example.com",
"Fwd: Inner Email Subject",
@@ -923,27 +902,26 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
%s
%s
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
%s
%s
--%s--\
""",
--%s--""",
"sender@example.com",
"receiver@example.com",
"Multipart/Alternative Test",
@@ -959,14 +937,7 @@ class EmlToPdfTest {
private String createQuotedPrintableEmail() {
return String.format(
Locale.ROOT,
"From: %s\n"
+ "To: %s\n"
+ "Subject: %s\n"
+ "Date: %s\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: text/plain; charset=UTF-8\n"
+ "Content-Transfer-Encoding: quoted-printable\n\n"
+ "%s",
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s",
"sender@example.com",
"recipient@example.com",
"Quoted-Printable Test",
@@ -979,14 +950,7 @@ class EmlToPdfTest {
Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8));
return String.format(
Locale.ROOT,
"From: %s\n"
+ "To: %s\n"
+ "Subject: %s\n"
+ "Date: %s\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: text/plain; charset=UTF-8\n"
+ "Content-Transfer-Encoding: base64\n\n"
+ "%s",
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s",
"sender@example.com",
"recipient@example.com",
"Base64 Test",
@@ -999,28 +963,27 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/related; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/related; boundary="%s"
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
%s
--%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
--%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
%s
%s
--%s--\
""",
--%s--""",
"sender@example.com",
"receiver@example.com",
"Inline Image Test",
@@ -1045,40 +1008,39 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: multipart/related; boundary="related-%s"
--%s
Content-Type: multipart/related; boundary="related-%s"
--related-%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
--related-%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
%s
--related-%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
--related-%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
%s
%s
--related-%s--
--related-%s--
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
%s
--%s--\
""",
--%s--""",
"sender@example.com",
"receiver@example.com",
"Mixed Attachments Test",
@@ -31,22 +31,21 @@ class OfficeDocumentSanitizerTest {
private static final String INTERNAL_TARGET = "media/image1.png";
private static final String DOCX_RELS =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
+ " Id=\"rId1\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
+ EXTERNAL_URL
+ "\" TargetMode=\"External\"/><Relationship Id=\"rId2\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ "\" TargetMode=\"External\"/>"
+ "<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
+ INTERNAL_TARGET
+ "\"/>"
+ "</Relationships>";
private static final String DOCX_DOCUMENT =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><w:document"
+ " xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
+ "<w:body><w:p/></w:body></w:document>";
private static final String ODF_CONTENT_EXTERNAL =
@@ -58,8 +57,8 @@ class OfficeDocumentSanitizerTest {
+ "<office:body><office:text>"
+ "<draw:frame><draw:image xlink:href=\""
+ EXTERNAL_URL
+ "\" xlink:type=\"simple\"/></draw:frame><draw:frame><draw:image"
+ " xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
+ "\" xlink:type=\"simple\"/></draw:frame>"
+ "<draw:frame><draw:image xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
+ "</office:text></office:body></office:document-content>";
private SsrfProtectionService ssrfProtectionService;
@@ -114,11 +113,10 @@ class OfficeDocumentSanitizerTest {
@Test
void sanitize_pptxExternalImageRelStripped() throws IOException {
String pptxRels =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
+ " Id=\"rId1\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
+ EXTERNAL_URL
+ "\" TargetMode=\"External\"/>"
+ "</Relationships>";
@@ -137,11 +135,10 @@ class OfficeDocumentSanitizerTest {
@Test
void sanitize_xlsxExternalImageRelStripped() throws IOException {
String xlsxRels =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
+ " Id=\"rId1\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
+ EXTERNAL_URL
+ "\" TargetMode=\"External\"/>"
+ "</Relationships>";
@@ -165,7 +162,7 @@ class OfficeDocumentSanitizerTest {
entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8));
String manifestXml =
"<?xml version=\"1.0\"?><manifest:manifest"
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
entries.put("META-INF/manifest.xml", manifestXml.getBytes(StandardCharsets.UTF_8));
byte[] odt = zip(entries);
@@ -297,11 +294,11 @@ class OfficeDocumentSanitizerTest {
@Test
void sanitize_internalLinksKeptWhenNoExternalPresent() throws IOException {
String internalOnlyRels =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
+ " Id=\"rId1\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\"media/image1.png\"/></Relationships>";
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\"media/image1.png\"/>"
+ "</Relationships>";
Map<String, byte[]> entries = new LinkedHashMap<>();
entries.put(
"word/_rels/document.xml.rels", internalOnlyRels.getBytes(StandardCharsets.UTF_8));
@@ -249,8 +249,7 @@ class ProcessExecutorGapTest {
@Test
@DisplayName(
"injects --host/--port after the executable, defaults omit host-location and"
+ " protocol")
"injects --host/--port after the executable, defaults omit host-location and protocol")
void injectsHostAndPortWithDefaults() throws Exception {
List<String> command = List.of("unoconvert", "in.docx", "out.pdf");
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
@@ -38,8 +38,7 @@ class SvgSanitizerTest {
@Test
void testSanitize_removesScriptElement() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle"
+ " r=\"10\"/></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle r=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("script"));
@@ -49,8 +48,7 @@ class SvgSanitizerTest {
@Test
void testSanitize_removesEventHandler() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\""
+ " onclick=\"alert('xss')\"/></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\" onclick=\"alert('xss')\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("onclick"));
@@ -59,8 +57,7 @@ class SvgSanitizerTest {
@Test
void testSanitize_removesJavascriptUrl() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a"
+ " href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("javascript"));
@@ -89,8 +86,7 @@ class SvgSanitizerTest {
@Test
void testSanitize_removesForeignObject() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect"
+ " width=\"10\" height=\"10\"/></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.toLowerCase().contains("foreignobject"));
@@ -117,8 +113,8 @@ class SvgSanitizerTest {
void testSanitize_removesRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"../../assets/image.png\""
+ " width=\"10\" height=\"10\"/></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"../../assets/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped");
@@ -36,8 +36,7 @@ public class ReplaceAndInvertColorFactory {
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw new IllegalStateException(
"CMYK color space conversion requires Ghostscript, which is not available on"
+ " this system");
"CMYK color space conversion requires Ghostscript, which is not available on this system");
}
return switch (replaceAndInvertOption) {
@@ -74,8 +74,7 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
private ApiResponse create400Response() {
return new ApiResponse()
.description(
"Bad request - Invalid input parameters, unsupported format, or corrupted"
+ " file")
"Bad request - Invalid input parameters, unsupported format, or corrupted file")
.content(
new Content()
.addMediaType(
@@ -84,14 +83,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
.schema(
createErrorSchema(
400,
"Invalid input parameters or"
+ " corrupted file",
"Invalid input parameters or corrupted file",
"/api/v1/example/endpoint"))
.example(
createErrorExample(
400,
"Invalid input parameters or"
+ " corrupted file",
"Invalid input parameters or corrupted file",
"/api/v1/example/endpoint"))));
}
@@ -106,14 +103,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
.schema(
createErrorSchema(
413,
"File size exceeds maximum allowed"
+ " limit",
"File size exceeds maximum allowed limit",
"/api/v1/example/endpoint"))
.example(
createErrorExample(
413,
"File size exceeds maximum allowed"
+ " limit",
"File size exceeds maximum allowed limit",
"/api/v1/example/endpoint"))));
}
@@ -128,14 +123,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
.schema(
createErrorSchema(
422,
"File is valid but cannot be"
+ " processed",
"File is valid but cannot be processed",
"/api/v1/example/endpoint"))
.example(
createErrorExample(
422,
"File is valid but cannot be"
+ " processed",
"File is valid but cannot be processed",
"/api/v1/example/endpoint"))));
}
@@ -150,14 +143,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
.schema(
createErrorSchema(
500,
"Unexpected error during"
+ " processing",
"Unexpected error during processing",
"/api/v1/example/endpoint"))
.example(
createErrorExample(
500,
"Unexpected error during"
+ " processing",
"Unexpected error during processing",
"/api/v1/example/endpoint"))));
}
@@ -51,8 +51,7 @@ public class LocaleConfiguration implements WebMvcConfigurer {
defaultLocale = tempLocale;
} else {
System.err.println(
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back"
+ " to default en-US.");
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-US.");
}
}
}
@@ -46,12 +46,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - Processing API")
.description(
"APIs for converting, editing, securing, and"
+ " analysing PDF documents. Use these"
+ " endpoints to automate common PDF tasks"
+ " (like split, merge, convert, OCR) and"
+ " plug them into your own apps and"
+ " backend jobs."));
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
})
.build();
}
@@ -84,9 +79,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - Management API")
.description(
"Endpoints for authentication, user management,"
+ " invitations, audit logging, and system"
+ " configuration."));
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
})
.build();
}
@@ -109,8 +102,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - System API")
.description(
"System information, UI metadata, job status,"
+ " and file management endpoints."));
"System information, UI metadata, job status, and file management endpoints."));
})
.build();
}
@@ -45,8 +45,7 @@ public class TauriProcessMonitor {
startMonitoring();
} else {
logger.warn(
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring"
+ " disabled.");
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring disabled.");
}
}
@@ -75,8 +74,7 @@ public class TauriProcessMonitor {
try {
if (!isProcessAlive(parentProcessId)) {
logger.warn(
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful"
+ " shutdown...",
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful shutdown...",
parentProcessId);
initiateGracefulShutdown();
}
@@ -120,8 +118,7 @@ public class TauriProcessMonitor {
} else {
// Fallback to system exit
logger.warn(
"Unable to shutdown Spring context gracefully, using"
+ " System.exit");
"Unable to shutdown Spring context gracefully, using System.exit");
System.exit(0);
}
} catch (Exception e) {
@@ -29,8 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
type = "string",
format = "binary",
description =
"CSV file containing extracted table"
+ " data")),
"CSV file containing extracted table data")),
@Content(
mediaType = "application/zip",
schema =
@@ -38,9 +37,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
type = "string",
format = "binary",
description =
"ZIP archive containing multiple CSV files"
+ " when multiple tables are"
+ " extracted"))
"ZIP archive containing multiple CSV files when multiple tables are extracted"))
}),
@ApiResponse(
responseCode = "400",
@@ -51,8 +51,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "422",
description =
"Unprocessable entity - PDF is valid but cannot be analyzed for"
+ " filtering",
"Unprocessable entity - PDF is valid but cannot be analyzed for filtering",
content =
@Content(
mediaType = "application/json",
@@ -28,8 +28,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@Schema(
type = "object",
description =
"JSON object containing the requested"
+ " data or analysis results"))),
"JSON object containing the requested data or analysis results"))),
@ApiResponse(
responseCode = "400",
description = "Invalid PDF file or request parameters",
@@ -21,8 +21,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "200",
description =
"Files processed successfully. Returns single file or ZIP archive"
+ " containing multiple files.",
"Files processed successfully. Returns single file or ZIP archive containing multiple files.",
content = {
@Content(
mediaType = "application/pdf",
@@ -38,8 +37,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
type = "string",
format = "binary",
description =
"ZIP archive containing multiple output"
+ " files")),
"ZIP archive containing multiple output files")),
@Content(
mediaType = "image/png",
schema =
@@ -30,13 +30,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
type = "string",
format = "binary",
description =
"Microsoft PowerPoint presentation"
+ " (PPTX)"))),
"Microsoft PowerPoint presentation (PPTX)"))),
@ApiResponse(
responseCode = "400",
description =
"Bad request - Invalid input parameters, unsupported format, or"
+ " corrupted PDF",
"Bad request - Invalid input parameters, unsupported format, or corrupted PDF",
content =
@Content(
mediaType = "application/json",
@@ -51,8 +49,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "422",
description =
"Unprocessable entity - PDF is valid but cannot be converted to"
+ " PowerPoint format",
"Unprocessable entity - PDF is valid but cannot be converted to PowerPoint format",
content =
@Content(
mediaType = "application/json",
@@ -41,8 +41,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "400",
description =
"Bad request - Invalid input parameters, unsupported format, or"
+ " corrupted PDF",
"Bad request - Invalid input parameters, unsupported format, or corrupted PDF",
content =
@Content(
mediaType = "application/json",
@@ -57,8 +56,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "422",
description =
"Unprocessable entity - PDF is valid but cannot be converted to Word"
+ " format",
"Unprocessable entity - PDF is valid but cannot be converted to Word format",
content =
@Content(
mediaType = "application/json",
@@ -39,18 +39,18 @@ public class AdditionalLanguageJsController {
// Generiere die `getDetailedLanguageCode`-Funktion
writer.println(
"""
function getDetailedLanguageCode() {
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
for (let lang of userLanguages) {
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
if (matchedLang) {
return matchedLang;
function getDetailedLanguageCode() {
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
for (let lang of userLanguages) {
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
if (matchedLang) {
return matchedLang;
}
}
// Fallback
return "en_US";
}
}
// Fallback
return "en_US";
}
""");
""");
writer.flush();
}
@@ -54,9 +54,8 @@ public class BookletImpositionController {
summary = "Create a booklet with proper page imposition",
description =
"This operation combines page reordering for booklet printing with multi-page"
+ " layout. It rearranges pages in the correct order for booklet printing"
+ " and places multiple pages on each sheet for proper folding and"
+ " binding.")
+ " layout. It rearranges pages in the correct order for booklet printing and"
+ " places multiple pages on each sheet for proper folding and binding.")
public ResponseEntity<Resource> createBookletImposition(
@ModelAttribute BookletImpositionRequest request) throws IOException {
@@ -74,8 +73,7 @@ public class BookletImpositionController {
// Validate pages per sheet for booklet - only 2-up landscape is proper booklet
if (pagesPerSheet != 2) {
throw new IllegalArgumentException(
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up"
+ " feature.");
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
}
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
@@ -150,8 +150,7 @@ public class CropController {
|| request.getWidth() == null
|| request.getHeight() == null) {
throw new IllegalArgumentException(
"Crop coordinates (x, y, width, height) are required when auto-crop is not"
+ " enabled");
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
}
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
@@ -90,14 +90,13 @@ public class EditTextController {
summary = "Edit text in a PDF via find and replace",
description =
"Applies an ordered list of find/replace operations to the text in a PDF and"
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
+ " updating a company name throughout a document), and copy editing where"
+ " the AI agent has identified specific replacements. Matching is"
+ " performed against the joined text of each page, so find strings can"
+ " span multiple visual runs (titles split per word, kerning-broken"
+ " phrases). Cross-element matches are written as a single replacement run"
+ " anchored at the leftmost matched position; centered or tracked text may"
+ " shift left when its content changes.")
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
+ " updating a company name throughout a document), and copy editing where the AI"
+ " agent has identified specific replacements. Matching is performed against the"
+ " joined text of each page, so find strings can span multiple visual runs"
+ " (titles split per word, kerning-broken phrases). Cross-element matches are"
+ " written as a single replacement run anchored at the leftmost matched position;"
+ " centered or tracked text may shift left when its content changes.")
public ResponseEntity<Resource> editText(@ModelAttribute EditTextRequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -246,8 +246,8 @@ public class MergeController {
summary = "Merge multiple PDF files into one",
description =
"This endpoint merges multiple PDF files into a single PDF file. The merged"
+ " file will contain all pages from the input files in the order they were"
+ " provided.")
+ " file will contain all pages from the input files in the order they were"
+ " provided.")
public ResponseEntity<Resource> mergePdfs(
@ModelAttribute MergePdfsRequest request,
@RequestParam(value = "fileOrder", required = false) String fileOrder)
@@ -220,9 +220,8 @@ public class MultiPageLayoutController {
"error.invalidFormat",
"Invalid {0} format: {1}",
"margin/layout configuration",
"Invalid margin or layout configuration: resulting cell size is"
+ " non-positive. Please reduce outer margins or adjust"
+ " rows/columns.");
"Invalid margin or layout configuration: resulting cell size is non-positive. "
+ "Please reduce outer margins or adjust rows/columns.");
}
float innerWidth = cellWidth - 2 * innerMargin;
@@ -57,8 +57,8 @@ public class PosterPdfController {
summary = "Split large PDF pages into smaller printable chunks",
description =
"This endpoint splits large or oddly-sized PDF pages into smaller chunks"
+ " suitable for printing on standard paper sizes (e.g., A4, Letter)."
+ " Divides each page into a grid of smaller pages using Apache PDFBox.")
+ " suitable for printing on standard paper sizes (e.g., A4, Letter). Divides each"
+ " page into a grid of smaller pages using Apache PDFBox.")
public ResponseEntity<Resource> posterPdf(@ModelAttribute PosterPdfRequest request)
throws Exception {
@@ -214,8 +214,7 @@ public class PosterPdfController {
}
log.trace(
"Created output page for grid cell [{},{}] of page {}:"
+ " cropX={}, cropY={}, translate=({}, {})",
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
row,
actualCol,
pageIndex,
@@ -241,8 +241,8 @@ public class RearrangePagesPDFController {
summary = "Rearrange pages in a PDF file",
description =
"This endpoint rearranges pages in a given PDF file based on the specified page"
+ " order or custom mode. Users can provide a page order as a"
+ " comma-separated list of page numbers or page ranges, or a custom mode.")
+ " order or custom mode. Users can provide a page order as a comma-separated list"
+ " of page numbers or page ranges, or a custom mode.")
public ResponseEntity<Resource> rearrangePages(@ModelAttribute RearrangePagesRequest request)
throws IOException {
MultipartFile pdfFile = request.getFileInput();
@@ -60,8 +60,8 @@ public class SplitPDFController {
summary = "Split a PDF file into separate documents",
description =
"This endpoint splits a given PDF file into separate documents based on the"
+ " specified page numbers or ranges. Users can specify pages using"
+ " individual numbers, ranges, or 'all' for every page.")
+ " specified page numbers or ranges. Users can specify pages using individual"
+ " numbers, ranges, or 'all' for every page.")
public ResponseEntity<Resource> splitPdf(@ModelAttribute SplitPagesRequest request)
throws IOException {
@@ -62,8 +62,8 @@ public class SplitPdfBySectionsController {
summary = "Split PDF pages into smaller sections",
description =
"Split each page of a PDF into smaller sections based on the user's choice"
+ " which page to split, and how to split ( halves, thirds, quarters,"
+ " etc.), both vertically and horizontally.")
+ " which page to split, and how to split ( halves, thirds, quarters, etc.), both"
+ " vertically and horizontally.")
public ResponseEntity<Resource> splitPdf(
@Valid @ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
MultipartFile file = request.getFileInput();
@@ -60,9 +60,9 @@ public class SplitPdfBySizeController {
summary = "Auto split PDF pages into separate documents based on size or count",
description =
"split PDF into multiple paged documents based on size/count, ie if 20 pages"
+ " and split into 5, it does 5 documents each 4 pages\r\n"
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
+ " (rounded so that it accepts 1.9MB but not 2.1MB)")
+ " and split into 5, it does 5 documents each 4 pages\r\n if 10MB and each page"
+ " is 1MB and you enter 2MB then 5 docs each 2MB (rounded so that it accepts"
+ " 1.9MB but not 2.1MB)")
public ResponseEntity<Resource> autoSplitPdf(
@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
@@ -46,8 +46,8 @@ public class ToSinglePageController {
summary = "Convert a multi-page PDF into a single long page PDF",
description =
"This endpoint converts a multi-page PDF document into a single paged PDF"
+ " document. The width of the single page will be same as the input's"
+ " width, but the height will be the sum of all the pages' heights.")
+ " document. The width of the single page will be same as the input's width, but"
+ " the height will be the sum of all the pages' heights.")
public ResponseEntity<Resource> pdfToSinglePage(@ModelAttribute PDFFile request)
throws IOException {
@@ -56,9 +56,9 @@ public class ConvertEmlToPDF {
summary = "Convert EML/MSG to PDF",
description =
"This endpoint converts EML (email) and MSG (Outlook) files to PDF format with"
+ " extensive customization options. Features include font settings, image"
+ " constraints, display modes, attachment handling, and HTML debug output."
+ " or MSG file, or HTML file.")
+ " extensive customization options. Features include font settings, image"
+ " constraints, display modes, attachment handling, and HTML debug output. or MSG"
+ " file, or HTML file.")
public ResponseEntity<Resource> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput();
@@ -48,8 +48,7 @@ public class ConvertHtmlToPDF {
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description =
"This endpoint takes an HTML or ZIP file input and converts it to a PDF"
+ " format.")
"This endpoint takes an HTML or ZIP file input and converts it to a PDF format.")
public ResponseEntity<Resource> HtmlToPdf(@ModelAttribute HTMLToPdfRequest request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
@@ -95,8 +95,8 @@ public class ConvertImgPDFController {
summary = "Convert PDF to image(s)",
description =
"This endpoint converts a PDF file to image(s) with the specified image format,"
+ " color type, and DPI. Users can choose to get a single image or multiple"
+ " images.")
+ " color type, and DPI. Users can choose to get a single image or multiple"
+ " images.")
public ResponseEntity<?> convertToImage(@ModelAttribute ConvertToImageRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
@@ -97,8 +97,8 @@ public class ConvertPDFToEpubController {
if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) {
throw new IllegalStateException(
"Calibre support is disabled. Enable the Calibre group or install Calibre to"
+ " use this feature.");
"Calibre support is disabled. Enable the Calibre group or install Calibre to use"
+ " this feature.");
}
MultipartFile inputFile = request.getFileInput();
@@ -453,32 +453,32 @@ public class ConvertPDFToPDFA {
String pdfaDefContent =
String.format(
"""
%% This is a sample prefix file for creating a PDF/A document.
%% Feel free to modify entries marked with "Customize".
%% This is a sample prefix file for creating a PDF/A document.
%% Feel free to modify entries marked with "Customize".
%% Define entries in the document Info dictionary.
[/Title (%s)
/DOCINFO pdfmark
%% Define entries in the document Info dictionary.
[/Title (%s)
/DOCINFO pdfmark
%% Define an ICC profile.
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
[{icc_PDFA} <<
/N 3
>> /PUT pdfmark
[{icc_PDFA} (%s) (r) file /PUT pdfmark
%% Define an ICC profile.
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
[{icc_PDFA} <<
/N 3
>> /PUT pdfmark
[{icc_PDFA} (%s) (r) file /PUT pdfmark
%% Define the output intent dictionary.
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
[{OutputIntent_PDFA} <<
/Type /OutputIntent
/S /GTS_PDFA1
/DestOutputProfile {icc_PDFA}
/OutputConditionIdentifier (sRGB IEC61966-2.1)
/Info (sRGB IEC61966-2.1)
/RegistryName (http://www.color.org)
>> /PUT pdfmark
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
""",
%% Define the output intent dictionary.
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
[{OutputIntent_PDFA} <<
/Type /OutputIntent
/S /GTS_PDFA1
/DestOutputProfile {icc_PDFA}
/OutputConditionIdentifier (sRGB IEC61966-2.1)
/Info (sRGB IEC61966-2.1)
/RegistryName (http://www.color.org)
>> /PUT pdfmark
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
""",
title, rgbProfilePath);
Files.writeString(pdfaDefFile, pdfaDefContent);
@@ -598,9 +598,8 @@ public class ConvertPDFToPDFA {
summary = "Convert a PDF to a PDF/A or PDF/X",
description =
"This endpoint converts a PDF file to a PDF/A or PDF/X file using Ghostscript"
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format"
+ " designed for long-term archiving, while PDF/X is optimized for print"
+ " production.")
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format designed for"
+ " long-term archiving, while PDF/X is optimized for print production.")
public ResponseEntity<Resource> pdfToPdfA(@ModelAttribute PdfToPdfARequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -662,8 +661,7 @@ public class ConvertPDFToPDFA {
if (!isGhostscriptAvailable()) {
log.error("Ghostscript is required for PDF/X conversion");
throw new IOException(
"Ghostscript is required for PDF/X conversion but is not available on the"
+ " system");
"Ghostscript is required for PDF/X conversion but is not available on the system");
}
log.info("Using Ghostscript for PDF/X conversion to {}", profile.getDisplayName());
@@ -745,8 +743,7 @@ public class ConvertPDFToPDFA {
if (fontNameStr.contains("+") || fontNameStr.contains("Subset")) {
descDict.removeItem(COSName.CHAR_SET);
log.debug(
"Removed potentially invalid CharSet from subsetted Type1"
+ " font: {}",
"Removed potentially invalid CharSet from subsetted Type1 font: {}",
fontNameStr);
} else if (!hasFontFile && fontEmbedded) {
// Font is embedded but we can't verify CharSet, remove it
@@ -764,8 +761,7 @@ public class ConvertPDFToPDFA {
if (!glyphSet.isEmpty()) {
descDict.setString(COSName.CHAR_SET, glyphSet);
log.debug(
"Added missing CharSet for Type1 font {} with {}"
+ " glyphs",
"Added missing CharSet for Type1 font {} with {} glyphs",
fontNameStr,
countGlyphs(glyphSet));
}
@@ -1939,8 +1935,7 @@ public class ConvertPDFToPDFA {
return WebResponseUtils.pdfFileToWebResponse(tempOut, outputFilename);
} catch (IOException | InterruptedException e) {
log.warn(
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice"
+ " method",
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice method",
e);
}
} else {
@@ -2541,8 +2536,7 @@ public class ConvertPDFToPDFA {
return converted;
} catch (IOException | InterruptedException e) {
log.warn(
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice"
+ " method",
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice method",
e);
}
} else {
@@ -62,8 +62,7 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Convert PDF to Text Editor Format",
description =
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the"
+ " text editor tool.")
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool.")
public ResponseEntity<Resource> convertPdfToJson(
@ModelAttribute PDFFile request,
@RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight)
@@ -105,8 +104,7 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Convert Text Editor Format to PDF",
description =
"Rebuilds a PDF from the editable JSON structure generated by the text editor"
+ " tool.")
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool.")
public ResponseEntity<Resource> convertJsonToPdf(@ModelAttribute GeneralFile request)
throws Exception {
MultipartFile jsonFile = request.getFileInput();
@@ -139,9 +137,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Extract PDF metadata for text editor lazy loading",
description =
"Extracts document metadata, fonts, and page dimensions for the text editor"
+ " tool. Caches the document for subsequent page requests. Returns a"
+ " server-generated jobId scoped to the authenticated user.")
"Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for"
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
+ " authenticated user.")
public ResponseEntity<Resource> extractPdfMetadata(@ModelAttribute PDFFile request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -183,10 +181,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Apply incremental edits from text editor to a cached PDF",
description =
"Applies edits for the specified pages of a cached PDF and returns an updated"
+ " PDF. Requires the PDF to have been previously cached via the text"
+ " editor metadata endpoint. The jobId must be obtained from the metadata"
+ " extraction endpoint.")
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
+ " Requires the PDF to have been previously cached via the text editor metadata endpoint."
+ " The jobId must be obtained from the metadata extraction endpoint.")
public ResponseEntity<Resource> exportPartialPdf(
@PathVariable String jobId,
@RequestBody PdfJsonDocument document,
@@ -227,9 +224,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Extract single page from cached PDF for text editor",
description =
"Retrieves a single page's content from a previously cached PDF document for"
+ " the text editor tool. Requires prior call to /pdf/text-editor/metadata."
+ " The jobId must belong to the authenticated user.")
"Retrieves a single page's content from a previously cached PDF document for the text editor tool."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Resource> extractSinglePage(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
@@ -256,9 +253,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Extract fonts used by a single cached page for text editor",
description =
"Retrieves the font payloads used by a single page from a previously cached PDF"
+ " document. Requires prior call to /pdf/text-editor/metadata. The jobId"
+ " must belong to the authenticated user.")
"Retrieves the font payloads used by a single page from a previously cached PDF document."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Resource> extractPageFonts(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
@@ -288,9 +285,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Clear cached PDF document for text editor",
description =
"Manually clears a cached PDF document used by the text editor to free up"
+ " server resources. Called automatically after 30 minutes. The jobId must"
+ " belong to the authenticated user.")
"Manually clears a cached PDF document used by the text editor to free up server resources."
+ " Called automatically after 30 minutes. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
validateJobAccess(jobId);
@@ -68,11 +68,10 @@ public class ConvertSvgToPDF {
summary = "Convert SVG to PDF",
description =
"This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF"
+ " format. Each SVG is converted to a separate PDF file. The conversion"
+ " preserves vector graphics for crisp output at any resolution - no"
+ " rasterization occurs. SVG dimensions (width/height) determine the PDF"
+ " page size; defaults to A4 if not specified. SVG content is sanitized to"
+ " prevent XSS attacks.")
+ " format. Each SVG is converted to a separate PDF file. The conversion preserves"
+ " vector graphics for crisp output at any resolution - no rasterization occurs."
+ " SVG dimensions (width/height) determine the PDF page size; defaults to A4 if"
+ " not specified. SVG content is sanitized to prevent XSS attacks.")
public ResponseEntity<Resource> convertSvgToPdf(@ModelAttribute SvgToPdfRequest request) {
MultipartFile[] inputFiles = request.getFileInput();
@@ -221,8 +221,7 @@ public class PdfVectorExportController {
if (result.getRc() != 0) {
log.error(
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command:"
+ " {}",
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command: {}",
outputFormat.toUpperCase(),
result.getRc(),
result.getMessages(),
@@ -262,8 +261,7 @@ public class PdfVectorExportController {
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
if (criticalError != null) {
log.error(
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command:"
+ " {}",
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command: {}",
criticalError.getMessage(),
String.join(" ", command));
throw criticalError;
@@ -271,8 +269,7 @@ public class PdfVectorExportController {
if (result.getRc() != 0) {
log.error(
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}."
+ " Command: {}",
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}. Command: {}",
result.getRc(),
result.getMessages(),
String.join(" ", command));
@@ -295,8 +295,7 @@ public class FormFillController {
@Operation(
summary = "Extract form fields as XLSX",
description =
"Returns an Excel (XLSX) file containing all form field names and their current"
+ " values")
"Returns an Excel (XLSX) file containing all form field names and their current values")
public ResponseEntity<byte[]> extractXlsx(
@Parameter(
description = "The input PDF file",
@@ -428,8 +427,8 @@ public class FormFillController {
@Parameter(
description =
"Return a ZIP holding the updated PDF plus the field list it"
+ " produced, instead of the bare PDF. Saves re-uploading"
+ " the result just to read its fields back.")
+ " produced, instead of the bare PDF. Saves re-uploading"
+ " the result just to read its fields back.")
@RequestParam(value = "includeFields", defaultValue = "false")
boolean includeFields)
throws IOException {
@@ -79,10 +79,9 @@ public class AddCommentsController {
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
description =
"Attaches PDF Text (sticky-note) annotations to the document. Each CommentSpec"
+ " can either supply absolute coordinates or an `anchorText` hint; when"
+ " provided, the tool locates the first matching line on the target page"
+ " and anchors the icon there (falling back to the coordinates if no"
+ " match).")
+ " can either supply absolute coordinates or an `anchorText` hint; when provided,"
+ " the tool locates the first matching line on the target page and anchors the"
+ " icon there (falling back to the coordinates if no match).")
public ResponseEntity<Resource> addComments(@ModelAttribute AddCommentsRequest request)
throws IOException {
@@ -149,8 +149,7 @@ public class AttachmentController {
@Operation(
summary = "Extract attachments from PDF",
description =
"This endpoint extracts all embedded attachments from a PDF into a ZIP"
+ " archive.")
"This endpoint extracts all embedded attachments from a PDF into a ZIP archive.")
public ResponseEntity<Resource> extractAttachments(
@ModelAttribute ExtractAttachmentsRequest request) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
@@ -282,8 +282,8 @@ public class AutoSplitPdfController {
summary = "Auto split PDF pages into separate documents",
description =
"This endpoint accepts a PDF file, scans each page for a specific QR code, and"
+ " splits the document at the QR code boundaries. The output is a zip file"
+ " containing each separate PDF document.")
+ " splits the document at the QR code boundaries. The output is a zip file"
+ " containing each separate PDF document.")
public ResponseEntity<Resource> autoSplitPdf(@ModelAttribute AutoSplitPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
@@ -94,7 +94,7 @@ public class BlankPageController {
summary = "Remove blank pages from a PDF file",
description =
"This endpoint removes blank pages from a given PDF file. Users can specify the"
+ " threshold and white percentage to tune the detection of blank pages.")
+ " threshold and white percentage to tune the detection of blank pages.")
public ResponseEntity<Resource> removeBlankPages(
@ModelAttribute RemoveBlankPagesRequest request)
throws IOException, InterruptedException {
@@ -68,8 +68,8 @@ public class ExtractImageScansController {
summary = "Extract image scans from an input file",
description =
"This endpoint extracts image scans from a given file based on certain"
+ " parameters. Users can specify angle threshold, tolerance, minimum area,"
+ " minimum contour area, and border size.")
+ " parameters. Users can specify angle threshold, tolerance, minimum area,"
+ " minimum contour area, and border size.")
public ResponseEntity<Resource> extractImageScans(
@ModelAttribute ExtractImageScansRequest request)
throws IOException, InterruptedException {
@@ -45,8 +45,8 @@ import stirling.software.common.service.MobileScannerService.FileMetadata;
@Tag(
name = "Mobile Scanner",
description =
"Endpoints for mobile-to-desktop file transfer via QR code scanning. Files are"
+ " temporarily stored and automatically cleaned up after 10 minutes.")
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
@Hidden
@Slf4j
public class MobileScannerController {
@@ -271,8 +271,7 @@ public class MobileScannerController {
@Operation(
summary = "Download a specific file",
description =
"Download a file that was uploaded to a session. File is automatically deleted"
+ " after download.")
"Download a file that was uploaded to a session. File is automatically deleted after download.")
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
@ApiResponse(responseCode = "404", description = "File or session not found")
@@ -104,9 +104,9 @@ public class OCRController {
summary = "Process a PDF file with OCR",
description =
"This endpoint processes a PDF file using OCR (Optical Character Recognition)."
+ " Users can specify languages, sidecar, deskew, clean, cleanFinal,"
+ " ocrType, ocrRenderType, and removeImagesAfter options. Uses OCRmyPDF if"
+ " available, falls back to Tesseract.")
+ " Users can specify languages, sidecar, deskew, clean, cleanFinal, ocrType,"
+ " ocrRenderType, and removeImagesAfter options. Uses OCRmyPDF if available,"
+ " falls back to Tesseract.")
public ResponseEntity<Resource> processPdfWithOCR(
@ModelAttribute ProcessPdfWithOcrRequest request)
throws IOException, InterruptedException {
@@ -442,8 +442,7 @@ public class OCRController {
// Verify the OCR'd PDF was created
if (!pageOutputPath.exists()) {
log.warn(
"Tesseract did not create expected output file: {}. Page may be"
+ " blank or unreadable.",
"Tesseract did not create expected output file: {}. Page may be blank or unreadable.",
pageOutputPath.getAbsolutePath());
// Save original page without OCR as fallback
try (PDDocument pageDoc = new PDDocument()) {
@@ -50,10 +50,9 @@ public class OverlayImageController {
summary = "Overlay image onto a PDF file",
description =
"This endpoint overlays an image onto a PDF file at the specified coordinates."
+ " Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG)."
+ " SVG files are rendered as vector graphics for crisp output at any"
+ " resolution. The image can be overlaid on every page of the PDF if"
+ " specified.")
+ " Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG). SVG"
+ " files are rendered as vector graphics for crisp output at any resolution. The"
+ " image can be overlaid on every page of the PDF if specified.")
public ResponseEntity<Resource> overlayImage(@ModelAttribute OverlayImageRequest request) {
MultipartFile pdfFile = request.getFileInput();
MultipartFile imageFile = request.getImageFile();
@@ -59,9 +59,8 @@ public class RepairController {
summary = "Repair a PDF file",
description =
"This endpoint repairs a given PDF file by running Ghostscript (primary), qpdf"
+ " (fallback), or PDFBox (if no external tools available). The PDF is"
+ " first saved to a temporary location, repaired, read back, and then"
+ " returned as a response.")
+ " (fallback), or PDFBox (if no external tools available). The PDF is first saved"
+ " to a temporary location, repaired, read back, and then returned as a response.")
public ResponseEntity<Resource> repairPdf(@ModelAttribute PDFFile file)
throws IOException, InterruptedException {
MultipartFile inputFile = file.getFileInput();
@@ -43,8 +43,8 @@ public class ReplaceAndInvertColorController {
summary = "Replace-Invert Color PDF",
description =
"This endpoint accepts a PDF file and provides options to invert all colors,"
+ " replace text and background colors, or convert to CMYK color space for"
+ " printing.")
+ " replace text and background colors, or convert to CMYK color space for"
+ " printing.")
public ResponseEntity<Resource> replaceAndInvertColor(
@ModelAttribute ReplaceAndInvertColorRequest request) throws IOException {
@@ -98,8 +98,7 @@ public class StampController {
summary = "Add stamp to a PDF file",
description =
"This endpoint adds a stamp to a given PDF file. Users can specify the stamp"
+ " type (text or image), rotation, opacity, width spacer, and height"
+ " spacer.")
+ " type (text or image), rotation, opacity, width spacer, and height spacer.")
public ResponseEntity<Resource> addStamp(@ModelAttribute AddStampRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
@@ -58,9 +58,8 @@ public class PipelineController {
@Operation(
summary = "Execute automated PDF processing pipeline",
description =
"This endpoint processes multiple PDF files through a configurable pipeline of"
+ " operations. Users provide files and a JSON configuration defining the"
+ " sequence of operations to perform.")
"This endpoint processes multiple PDF files through a configurable pipeline of operations. "
+ "Users provide files and a JSON configuration defining the sequence of operations to perform.")
public ResponseEntity<Resource> handleData(@ModelAttribute HandleDataRequest request)
throws DatabindException, JacksonException {
MultipartFile[] files = request.getFileInput();
@@ -177,8 +177,8 @@ public class CertSignController {
summary = "Sign PDF with a Digital Certificate",
description =
"This endpoint accepts a PDF file, a digital certificate and related"
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file.")
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file.")
public ResponseEntity<Resource> signPDFWithCert(
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
throws Exception {
@@ -99,8 +99,8 @@ public class RedactController {
summary = "Redacts areas and pages in a PDF document",
description =
"This endpoint redacts content from a PDF file based on manually specified"
+ " areas. Users can specify areas to redact and optionally convert the PDF"
+ " to an image.")
+ " areas. Users can specify areas to redact and optionally convert the PDF to an"
+ " image.")
public ResponseEntity<Resource> redactPDF(@ModelAttribute ManualRedactPdfRequest request)
throws IOException {
@@ -146,8 +146,8 @@ public class RedactController {
operationId = "redactPdfAuto",
description =
"This endpoint automatically redacts text from a PDF file based on specified"
+ " patterns. Users can provide text patterns to redact, with options for"
+ " regex and whole word matching.")
+ " patterns. Users can provide text patterns to redact, with options for regex"
+ " and whole word matching.")
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
if (request.getFileInput() == null || request.getFileInput().isEmpty()) {
log.error("File input is null or empty");
@@ -299,7 +299,7 @@ public class RedactController {
summary = "Execute a unified redaction plan on a PDF",
description =
"Unified redaction endpoint that accepts exact strings, regex patterns, and"
+ " page numbers in a single request. Supports execution strategy hints.")
+ " page numbers in a single request. Supports execution strategy hints.")
public ResponseEntity<Resource> executeRedaction(@ModelAttribute RedactExecuteRequest request)
throws IOException {
@@ -73,8 +73,7 @@ class RedactExecuteService {
boolean hasTextOps = !textValues.isEmpty() || !regexPatterns.isEmpty();
log.info(
"[redact/execute] strategy={} textValues={} regexPatterns={} wipePages={} ranges={}"
+ " imageBoxes={} imagePages={}",
"[redact/execute] strategy={} textValues={} regexPatterns={} wipePages={} ranges={} imageBoxes={} imagePages={}",
style.getStrategy(),
textValues.size(),
regexPatterns.size(),
@@ -108,8 +107,7 @@ class RedactExecuteService {
needsOverlayOnly = applyTextRemoval(document, request);
} else if (overlayOnly) {
log.info(
"[redact/execute] overlay-only mode requested — skipping content-stream"
+ " rewriting");
"[redact/execute] overlay-only mode requested — skipping content-stream rewriting");
}
// Reload fresh document on fallback so we overlay onto clean content.
@@ -460,8 +458,7 @@ class RedactExecuteService {
}
if (end == null) {
log.warn(
"[redact/execute] no end anchor after start at (page={}, col={}, y={})"
+ " — skipping",
"[redact/execute] no end anchor after start at (page={}, col={}, y={}) — skipping",
start.page + 1,
start.col,
start.y);
@@ -129,8 +129,7 @@ class TextRedactionService {
result != null ? result.totalMatches() : -1);
if (result == null) {
log.warn(
"JPDFium PdfRedactor.redact returned null result, falling back to box-only"
+ " redaction mode");
"JPDFium PdfRedactor.redact returned null result, falling back to box-only redaction mode");
return true;
}
@@ -154,8 +153,7 @@ class TextRedactionService {
return false;
} catch (Exception e) {
log.warn(
"JPDFium native text replacement failed, falling back to box-only redaction"
+ " mode: {}",
"JPDFium native text replacement failed, falling back to box-only redaction mode: {}",
e.getMessage());
return true;
} finally {
@@ -91,8 +91,8 @@ public class TimestampController {
summary = "Add RFC 3161 document timestamp to a PDF",
description =
"Contacts a trusted Time Stamp Authority (TSA) server and embeds an RFC 3161"
+ " document timestamp into the PDF. Only a SHA-256 hash of the document is"
+ " sent to the TSA - the PDF itself never leaves the server.")
+ " document timestamp into the PDF. Only a SHA-256 hash of the document is sent"
+ " to the TSA - the PDF itself never leaves the server.")
public ResponseEntity<Resource> timestampPdf(@ModelAttribute TimestampPdfRequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -38,8 +38,8 @@ public class VerifyPDFController {
summary = "Verify PDF Standards Compliance",
description =
"Validates PDF files against the standards declared in their metadata."
+ " Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards"
+ " from the document's XMP metadata and validates compliance.")
+ " Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards from the"
+ " document's XMP metadata and validates compliance.")
@AutoJobPostMapping(
value = "/verify-pdf",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
@@ -81,8 +81,8 @@ public class WatermarkController {
summary = "Add watermark to a PDF file",
description =
"This endpoint adds a watermark to a given PDF file. Users can specify the"
+ " watermark type (text or image), rotation, opacity, width spacer, and"
+ " height spacer.")
+ " watermark type (text or image), rotation, opacity, width spacer, and height"
+ " spacer.")
public ResponseEntity<Resource> addWatermark(@Valid @ModelAttribute AddWatermarkRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
@@ -58,8 +58,7 @@ public class MetricsController {
@Operation(
summary = "Application health check",
description =
"This endpoint returns the health status of the application and its version"
+ " number. Mirrors /api/v1/info/status.")
"This endpoint returns the health status of the application and its version number. Mirrors /api/v1/info/status.")
public ResponseEntity<?> getHealth() {
return getApplicationStatus();
}
@@ -92,8 +91,7 @@ public class MetricsController {
@Operation(
summary = "GET request count",
description =
"This endpoint returns the total count of GET requests for a specific endpoint"
+ " or all endpoints.")
"This endpoint returns the total count of GET requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getPageLoads(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
@@ -112,8 +110,7 @@ public class MetricsController {
@Operation(
summary = "Unique users count for GET requests",
description =
"This endpoint returns the count of unique users for GET requests for a"
+ " specific endpoint or all endpoints.")
"This endpoint returns the count of unique users for GET requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getUniquePageLoads(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
@@ -148,8 +145,7 @@ public class MetricsController {
@Operation(
summary = "Unique users count for GET requests for all endpoints",
description =
"This endpoint returns the count of unique users for GET requests for each"
+ " endpoint.")
"This endpoint returns the count of unique users for GET requests for each endpoint.")
public ResponseEntity<?> getAllUniqueEndpointLoads() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
@@ -166,8 +162,7 @@ public class MetricsController {
@Operation(
summary = "POST request count",
description =
"This endpoint returns the total count of POST requests for a specific endpoint"
+ " or all endpoints.")
"This endpoint returns the total count of POST requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getTotalRequests(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
@@ -186,8 +181,7 @@ public class MetricsController {
@Operation(
summary = "Unique users count for POST requests",
description =
"This endpoint returns the count of unique users for POST requests for a"
+ " specific endpoint or all endpoints.")
"This endpoint returns the count of unique users for POST requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getUniqueTotalRequests(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
@@ -222,8 +216,7 @@ public class MetricsController {
@Operation(
summary = "Unique users count for POST requests for all endpoints",
description =
"This endpoint returns the count of unique users for POST requests for each"
+ " endpoint.")
"This endpoint returns the count of unique users for POST requests for each endpoint.")
public ResponseEntity<?> getAllUniquePostRequests() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
@@ -404,8 +397,7 @@ public class MetricsController {
if (wauService.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(
"WAU tracking is only available when security is disabled (no-login"
+ " mode)");
"WAU tracking is only available when security is disabled (no-login mode)");
}
WeeklyActiveUsersService service = wauService.get();
@@ -138,8 +138,7 @@ public class ReactRoutingController {
this.useExternalIndexHtml = false;
this.loggedMissingIndex = true;
log.warn(
"index.html not found in classpath or custom path; using lightweight fallback"
+ " page");
"index.html not found in classpath or custom path; using lightweight fallback page");
}
private String processIndexHtml() {
@@ -372,51 +371,51 @@ public class ReactRoutingController {
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Stirling PDF</title>
<script>
// Minimal handler for SSO callback when index.html is missing (desktop fallback)
(function() {
const baseUrl = '%s';
window.STIRLING_PDF_API_BASE_URL = baseUrl;
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const serverUrl = %s;
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Stirling PDF</title>
<script>
// Minimal handler for SSO callback when index.html is missing (desktop fallback)
(function() {
const baseUrl = '%s';
window.STIRLING_PDF_API_BASE_URL = baseUrl;
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const serverUrl = %s;
if (token) {
// Extract nonce from URL to send back to desktop app for validation
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
if (token) {
// Extract nonce from URL to send back to desktop app for validation
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
console.log('[Fallback Auth] Token received, sending to desktop app via deep link');
console.log('[Fallback Auth] Token received, sending to desktop app via deep link');
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
return;
} catch (_) {
// ignore deep link errors
}
}
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
return;
} catch (_) {
// ignore deep link errors
}
}
// No redirect to avoid loops when index.html is missing
})();
</script>
</head>
<body>
<p>Stirling PDF is running.</p>
</body>
</html>
"""
// No redirect to avoid loops when index.html is missing
})();
</script>
</head>
<body>
<p>Stirling PDF is running.</p>
</body>
</html>
"""
.formatted(escapedBaseUrlHtml, escapedBaseUrlJs, serverUrl);
}
@@ -431,238 +430,238 @@ public class ReactRoutingController {
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Authentication Complete</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
text-align: center;
padding: 50px 20px;
background: #f5f5f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: #ffffff;
border-radius: 12px;
padding: 40px;
max-width: 420px;
width: 100%%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
color: #1a1a1a;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
color: #2e7d32;
}
.icon.error {
color: #d32f2f;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
color: #1a1a1a;
}
p {
color: #666;
line-height: 1.6;
font-size: 15px;
}
.error-details {
background: #ffebee;
border: 1px solid #ffcdd2;
padding: 16px;
border-radius: 8px;
margin-top: 20px;
font-size: 14px;
color: #c62828;
word-break: break-word;
text-align: left;
line-height: 1.5;
display: none;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e0e0e0;
}
.container {
background: #2d2d2d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: #374151;
color: #e5e7eb;
}
.icon {
color: #66bb6a;
}
.icon.error {
color: #ef5350;
}
h1 {
color: #f5f5f5;
}
p {
color: #b0b0b0;
}
.error-details {
background: #3d2020;
border: 1px solid #5d3030;
color: #ef9a9a;
}
}
@media (max-width: 480px) {
body {
padding: 20px 16px;
}
.container {
padding: 32px 24px;
}
h1 {
font-size: 20px;
}
.icon {
font-size: 40px;
}
}
</style>
<script>
(function() {
const run = () => {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const errorCode = searchParams.get('errorOAuth')
|| searchParams.get('error')
|| hashParams.get('error')
|| searchParams.get('error_description')
|| hashParams.get('error_description');
const serverUrl = %s;
const iconEl = document.getElementById('auth-icon');
const titleEl = document.getElementById('auth-title');
const messageEl = document.getElementById('auth-message');
const detailsEl = document.getElementById('auth-error-details');
const sendDeepLink = (type, value, key) => {
try {
const encodedValue = encodeURIComponent(value || '');
const encodedServer = encodeURIComponent(serverUrl);
const hashKey = key || 'access_token';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#${hashKey}=${encodedValue}&type=${type}`;
window.location.href = deepLink;
} catch (_) {
// ignore deep link errors
}
};
const showError = (message, details) => {
if (iconEl) {
iconEl.textContent = '✗';
iconEl.classList.add('error');
}
if (titleEl) {
titleEl.textContent = 'Authentication failed';
}
if (messageEl) {
messageEl.textContent = message;
}
if (detailsEl && details) {
detailsEl.textContent = details;
detailsEl.style.display = 'block';
}
};
if (token) {
// Extract nonce from URL to send back to desktop app for validation
// (System browser doesn't have access to desktop app's sessionStorage)
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
console.log('[Auth Callback] Token received, sending to desktop app via deep link');
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
setTimeout(() => {
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
} catch (err) {
console.error('[Auth Callback] Failed to trigger deep link:', err);
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Authentication Complete</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
}, 200);
return;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
text-align: center;
padding: 50px 20px;
background: #f5f5f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
if (errorCode) {
const isCancelled = errorCode === 'access_denied';
sendDeepLink('sso-error', errorCode, 'error');
showError(
isCancelled
? 'Authentication was cancelled. You can close this window and return to the app.'
: 'Authentication was not successful. You can close this window and return to the app.',
errorCode
);
return;
}
.container {
background: #ffffff;
border-radius: 12px;
padding: 40px;
max-width: 420px;
width: 100%%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
color: #1a1a1a;
}
showError(
'Authentication did not complete. You can close this window and try again.',
'missing_token'
);
};
.icon {
font-size: 48px;
margin-bottom: 16px;
color: #2e7d32;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
</script>
</head>
<body>
<div class="container">
<div class="icon" id="auth-icon">&#10003;</div>
<h1 id="auth-title">Authentication complete</h1>
<p id="auth-message">You can close this window and return to Stirling PDF.</p>
<div class="error-details" id="auth-error-details"></div>
</div>
</body>
</html>
"""
.icon.error {
color: #d32f2f;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
color: #1a1a1a;
}
p {
color: #666;
line-height: 1.6;
font-size: 15px;
}
.error-details {
background: #ffebee;
border: 1px solid #ffcdd2;
padding: 16px;
border-radius: 8px;
margin-top: 20px;
font-size: 14px;
color: #c62828;
word-break: break-word;
text-align: left;
line-height: 1.5;
display: none;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e0e0e0;
}
.container {
background: #2d2d2d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: #374151;
color: #e5e7eb;
}
.icon {
color: #66bb6a;
}
.icon.error {
color: #ef5350;
}
h1 {
color: #f5f5f5;
}
p {
color: #b0b0b0;
}
.error-details {
background: #3d2020;
border: 1px solid #5d3030;
color: #ef9a9a;
}
}
@media (max-width: 480px) {
body {
padding: 20px 16px;
}
.container {
padding: 32px 24px;
}
h1 {
font-size: 20px;
}
.icon {
font-size: 40px;
}
}
</style>
<script>
(function() {
const run = () => {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const errorCode = searchParams.get('errorOAuth')
|| searchParams.get('error')
|| hashParams.get('error')
|| searchParams.get('error_description')
|| hashParams.get('error_description');
const serverUrl = %s;
const iconEl = document.getElementById('auth-icon');
const titleEl = document.getElementById('auth-title');
const messageEl = document.getElementById('auth-message');
const detailsEl = document.getElementById('auth-error-details');
const sendDeepLink = (type, value, key) => {
try {
const encodedValue = encodeURIComponent(value || '');
const encodedServer = encodeURIComponent(serverUrl);
const hashKey = key || 'access_token';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#${hashKey}=${encodedValue}&type=${type}`;
window.location.href = deepLink;
} catch (_) {
// ignore deep link errors
}
};
const showError = (message, details) => {
if (iconEl) {
iconEl.textContent = '✗';
iconEl.classList.add('error');
}
if (titleEl) {
titleEl.textContent = 'Authentication failed';
}
if (messageEl) {
messageEl.textContent = message;
}
if (detailsEl && details) {
detailsEl.textContent = details;
detailsEl.style.display = 'block';
}
};
if (token) {
// Extract nonce from URL to send back to desktop app for validation
// (System browser doesn't have access to desktop app's sessionStorage)
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
console.log('[Auth Callback] Token received, sending to desktop app via deep link');
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
setTimeout(() => {
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
} catch (err) {
console.error('[Auth Callback] Failed to trigger deep link:', err);
}
}, 200);
return;
}
if (errorCode) {
const isCancelled = errorCode === 'access_denied';
sendDeepLink('sso-error', errorCode, 'error');
showError(
isCancelled
? 'Authentication was cancelled. You can close this window and return to the app.'
: 'Authentication was not successful. You can close this window and return to the app.',
errorCode
);
return;
}
showError(
'Authentication did not complete. You can close this window and try again.',
'missing_token'
);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
</script>
</head>
<body>
<div class="container">
<div class="icon" id="auth-icon">&#10003;</div>
<h1 id="auth-title">Authentication complete</h1>
<p id="auth-message">You can close this window and return to Stirling PDF.</p>
<div class="error-details" id="auth-error-details"></div>
</div>
</body>
</html>
"""
.formatted(escapedBaseUrlHtml, serverUrl);
}
}
@@ -755,8 +755,7 @@ public class GlobalExceptionHandler {
getLocalizedMessage(
"error.methodNotAllowed.detail",
String.format(
"HTTP method '%s' is not supported for this endpoint. Supported"
+ " methods: %s",
"HTTP method '%s' is not supported for this endpoint. Supported methods: %s",
ex.getMethod(), String.join(", ", ex.getSupportedMethods())),
ex.getMethod(),
String.join(", ", ex.getSupportedMethods()));
@@ -880,15 +879,13 @@ public class GlobalExceptionHandler {
errorMap.put("status", 406);
errorMap.put(
"detail",
"The requested resource could not be returned in an acceptable format. Error"
+ " responses are returned as JSON.");
"The requested resource could not be returned in an acceptable format. Error responses are returned as JSON.");
errorMap.put("instance", request.getRequestURI());
errorMap.put("timestamp", Instant.now().toString());
errorMap.put(
"hints",
java.util.Arrays.asList(
"Error responses are always returned as application/json or"
+ " application/problem+json",
"Error responses are always returned as application/json or application/problem+json",
"Set Accept header to include application/json for proper error handling"));
String errorJson = mapper.writeValueAsString(errorMap);
@@ -1253,8 +1250,7 @@ public class GlobalExceptionHandler {
String message =
getLocalizedMessage(
"error.tempFileNotFound.detail",
"The temporary file was not found. This may indicate a processing error"
+ " or cleanup issue. Please try again.");
"The temporary file was not found. This may indicate a processing error or cleanup issue. Please try again.");
String title =
getLocalizedMessage("error.tempFileNotFound.title", "Temporary File Not Found");
@@ -1266,8 +1262,7 @@ public class GlobalExceptionHandler {
problemDetail.setProperty("errorCode", "E999");
problemDetail.setProperty(
"hint.1",
"This error usually occurs when temporary files are cleaned up before"
+ " processing completes.");
"This error usually occurs when temporary files are cleaned up before processing completes.");
problemDetail.setProperty("hint.2", "Try submitting your request again.");
return new ResponseEntity<>(problemDetail, HttpStatus.INTERNAL_SERVER_ERROR);
}
@@ -15,9 +15,7 @@ public class EditTableOfContentsRequest extends PDFFile {
description = "Bookmark structure in JSON format",
type = "string",
example =
"[{\\\"title\\\":\\\"Chapter"
+ " 1\\\",\\\"pageNumber\\\":1,\\\"children\\\":[{\\\"title\\\":\\\"Section"
+ " 1.1\\\",\\\"pageNumber\\\":2}]}]")
"[{\\\"title\\\":\\\"Chapter 1\\\",\\\"pageNumber\\\":1,\\\"children\\\":[{\\\"title\\\":\\\"Section 1.1\\\",\\\"pageNumber\\\":2}]}]")
private String bookmarkData;
@Schema(
@@ -20,9 +20,9 @@ public class PDFWithPageNums extends PDFFile {
@Schema(
description =
"The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions"
+ " in the format 'an+b' where 'a' is the multiplier of the page number"
+ " 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')",
"The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the"
+ " format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a"
+ " constant (e.g., '2n+1', '3n', '6n-5')",
defaultValue = "all",
requiredMode = RequiredMode.REQUIRED)
private String pageNumbers;
@@ -24,8 +24,7 @@ public class SplitPdfBySectionsRequest extends PDFFile {
implementation = SplitTypes.class,
description =
"Modes for page split. Valid values are:\n"
+ "SPLIT_ALL_EXCEPT_FIRST_AND_LAST: Splits all except the first and the"
+ " last pages.\n"
+ "SPLIT_ALL_EXCEPT_FIRST_AND_LAST: Splits all except the first and the last pages.\n"
+ "SPLIT_ALL_EXCEPT_FIRST: Splits all except the first page.\n"
+ "SPLIT_ALL_EXCEPT_LAST: Splits all except the last page.\n"
+ "SPLIT_ALL: Splits all pages.\n"
@@ -17,8 +17,8 @@ public class ConvertEbookToPdfRequest {
+ " TXT, DOCX)",
contentMediaType =
"application/epub+zip, application/x-mobipocket-ebook, application/x-azw3,"
+ " text/xml, text/plain,"
+ " application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ " text/xml, text/plain,"
+ " application/vnd.openxmlformats-officedocument.wordprocessingml.document",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile fileInput;
@@ -13,17 +13,16 @@ public class SvgToPdfRequest {
@Schema(
description =
"The SVG file(s) to be converted to PDF. SVGs are scalable and have inherent"
+ " dimensions - the conversion uses these dimensions to determine the PDF"
+ " page size. If dimensions are not specified in the SVG, A4 size is"
+ " used.",
"The SVG file(s) to be converted to PDF. "
+ "SVGs are scalable and have inherent dimensions - the conversion uses these dimensions "
+ "to determine the PDF page size. If dimensions are not specified in the SVG, A4 size is used.",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile[] fileInput;
@Schema(
description =
"Whether to combine all SVG files into a single PDF (each SVG as a separate"
+ " page) or create separate PDF files for each SVG.",
"Whether to combine all SVG files into a single PDF (each SVG as a separate page) "
+ "or create separate PDF files for each SVG.",
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean combineIntoSinglePdf;
@@ -13,8 +13,7 @@ public class BookletImpositionRequest extends PDFFile {
@Schema(
description =
"The number of pages per side for booklet printing (always 2 for proper"
+ " booklet).",
"The number of pages per side for booklet printing (always 2 for proper booklet).",
type = "number",
defaultValue = "2",
requiredMode = Schema.RequiredMode.REQUIRED,
@@ -28,8 +28,7 @@ public class MergeMultiplePagesRequest extends PDFFile {
@Schema(
description =
"The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while"
+ " BY_COLUMNS fills pages column by column.",
"The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while BY_COLUMNS fills pages column by column.",
type = "string",
defaultValue = "BY_ROWS",
allowableValues = {"BY_ROWS", "BY_COLUMNS"})
@@ -37,8 +36,7 @@ public class MergeMultiplePagesRequest extends PDFFile {
@Schema(
description =
"The direction in which pages are arranged on the sheet: LTR (left-to-right) or"
+ " RTL (right-to-left).",
"The direction in which pages are arranged on the sheet: LTR (left-to-right) or RTL (right-to-left).",
type = "string",
defaultValue = "LTR",
allowableValues = {"LTR", "RTL"})
@@ -35,17 +35,14 @@ public class MergePdfsRequest extends MultiplePDFFiles {
@Schema(
description =
"Flag indicating whether to generate a table of contents for the merged PDF. If"
+ " true, a table of contents will be created using the input filenames as"
+ " chapter names.",
"Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean generateToc = false;
@Schema(
description =
"JSON array of client-provided IDs for each uploaded file (same order as"
+ " fileInput)",
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String clientFileIds;
}
@@ -23,8 +23,8 @@ public class OverlayPdfsRequest extends PDFFile {
@Schema(
description =
"The mode of overlaying: 'SequentialOverlay' for sequential application,"
+ " 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay'"
+ " for fixed repetition based on provided counts",
+ " 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay'"
+ " for fixed repetition based on provided counts",
allowableValues = {"SequentialOverlay", "InterleavedOverlay", "FixedRepeatOverlay"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String overlayMode;
@@ -32,8 +32,8 @@ public class OverlayPdfsRequest extends PDFFile {
@Schema(
description =
"An array of integers specifying the number of times each corresponding overlay"
+ " file should be applied in the 'FixedRepeatOverlay' mode. This should"
+ " match the length of the overlayFiles array.",
+ " file should be applied in the 'FixedRepeatOverlay' mode. This should"
+ " match the length of the overlayFiles array.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private int[] counts;
@@ -16,16 +16,14 @@ public class RearrangePagesRequest extends PDFWithPageNums {
implementation = SortTypes.class,
description =
"The custom mode for page rearrangement. Valid values are:\n"
+ "CUSTOM: Uses order defined in PageNums DUPLICATE: Duplicate pages n"
+ " times (if Page order defined as 4, then duplicates each page 4"
+ " times)REVERSE_ORDER: Reverses the order of all pages.\n"
+ "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"
+ "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")
+ "CUSTOM: Uses order defined in PageNums "
+ "DUPLICATE: Duplicate pages n times (if Page order defined as 4, then duplicates each page 4 times)"
+ "REVERSE_ORDER: Reverses the order of all pages.\n"
+ "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"
+ "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")
private String customMode;
}
@@ -13,8 +13,7 @@ public class RotatePDFRequest extends PDFFile {
@Schema(
description =
"The clockwise angle by which to rotate all pages in the PDF file. Must be a"
+ " multiple of 90.",
"The clockwise angle by which to rotate all pages in the PDF file. Must be a multiple of 90.",
type = "integer",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"0", "90", "180", "270"})
@@ -13,8 +13,7 @@ public class SplitPdfBySizeOrCountRequest extends PDFFile {
@Schema(
description =
"Determines the type of split: 0 for size, 1 for page count, 2 for document"
+ " count",
"Determines the type of split: 0 for size, 1 for page count, 2 for document count",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "0")
private int splitType;
@@ -22,8 +22,8 @@ public class AddCommentsRequest extends PDFFile {
@Schema(
description =
"JSON array of comment specs. Each element has: {pageIndex, x, y, width,"
+ " height, text, author?, subject?}. Coordinates are PDF user-space with"
+ " origin at the page's bottom-left.",
+ " height, text, author?, subject?}. Coordinates are PDF user-space with"
+ " origin at the page's bottom-left.",
example =
"[{\"pageIndex\":0,\"x\":72,\"y\":720,\"width\":20,\"height\":20,"
+ "\"text\":\"Check this paragraph\",\"author\":\"Reviewer\","
@@ -41,8 +41,7 @@ public class AddPageNumbersRequest extends PDFWithPageNums {
@Schema(
description =
"Zero-padding width for page numbers (Bates Stamping). Set to 0 to disable"
+ " padding",
"Zero-padding width for page numbers (Bates Stamping). Set to 0 to disable padding",
minimum = "0",
defaultValue = "0",
requiredMode = RequiredMode.NOT_REQUIRED)
@@ -51,9 +51,9 @@ public class AddStampRequest extends PDFWithPageNums {
@Schema(
description =
"Position for stamp placement based on a 1-9 grid (1: bottom-left, 2:"
+ " bottom-center, 3: bottom-right, 4: middle-left, 5: middle-center, 6:"
+ " middle-right, 7: top-left, 8: top-center, 9: top-right)",
"Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center,"
+ " 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right,"
+ " 7: top-left, 8: top-center, 9: top-right)",
allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"},
defaultValue = "8",
requiredMode = Schema.RequiredMode.REQUIRED)
@@ -13,8 +13,7 @@ public class AutoSplitPdfRequest extends PDFFile {
@Schema(
description =
"Flag indicating if the duplex mode is active, where the page after the divider"
+ " also gets removed.",
"Flag indicating if the duplex mode is active, where the page after the divider also gets removed.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private Boolean duplexMode;
@@ -13,8 +13,7 @@ public class ExtractHeaderRequest extends PDFFile {
@Schema(
description =
"Flag indicating whether to use the first text as a fallback if no suitable"
+ " title is found. Defaults to false.",
"Flag indicating whether to use the first text as a fallback if no suitable title is found. Defaults to false.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private Boolean useFirstTextAsFallback;
@@ -48,8 +48,7 @@ public class OptimizePdfRequest extends PDFFile {
@Schema(
description =
"Whether to convert images to high-contrast line art using ImageMagick. Default"
+ " is false.",
"Whether to convert images to high-contrast line art using ImageMagick. Default is false.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private Boolean lineArt = false;
@@ -15,9 +15,9 @@ public class OverlayImageRequest extends PDFFile {
@Schema(
description =
"The image file to be overlaid onto the PDF. Supports raster formats (PNG,"
+ " JPEG, etc.) and vector format (SVG). SVG files are rendered as vector"
+ " graphics for crisp output at any resolution.",
"The image file to be overlaid onto the PDF. "
+ "Supports raster formats (PNG, JPEG, etc.) and vector format (SVG). "
+ "SVG files are rendered as vector graphics for crisp output at any resolution.",
requiredMode = Schema.RequiredMode.REQUIRED,
format = "binary")
private MultipartFile imageFile;
@@ -27,8 +27,7 @@ public class ReplaceAndInvertColorRequest extends PDFFile {
@Schema(
description =
"If HIGH_CONTRAST_COLOR option selected, then pick the default color option for"
+ " text and background.",
"If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background.",
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "WHITE_TEXT_ON_BLACK",
allowableValues = {
@@ -16,24 +16,22 @@ public class RedactExecuteRequest extends PDFFile {
@Schema(
description =
"Exact strings to find and black out. One entry per phrase to redact. Best for"
+ " known names, identifiers, and specific text found in the document.")
"Exact strings to find and black out. One entry per phrase to redact."
+ " Best for known names, identifiers, and specific text found in the document.")
private List<String> textValues = new ArrayList<>();
@Schema(
description =
"Regex patterns to match and redact. Each match anywhere in the document is"
+ " blacked out. Uses Java/PCRE regex syntax. Well-suited for strings that"
+ " follow known patterns, like phone numbers, email addresses, national ID"
+ " numbers, or dates (which can appear with different separators, optional"
+ " country codes, etc.). For fixed known strings such as names, use"
+ " textValues instead.")
"Regex patterns to match and redact. Each match anywhere in the document is blacked out."
+ " Uses Java/PCRE regex syntax. Well-suited for strings that follow known patterns, like"
+ " phone numbers, email addresses, national ID numbers, or"
+ " dates (which can appear with different separators, optional country codes,"
+ " etc.). For fixed known strings such as names, use textValues instead.")
private List<String> regexPatterns = new ArrayList<>();
@Schema(
description =
"1-indexed page numbers to wipe entirely (all content removed from those"
+ " pages).")
"1-indexed page numbers to wipe entirely (all content removed from those pages).")
private List<Integer> wipePages = new ArrayList<>();
@Schema(
@@ -46,15 +44,12 @@ public class RedactExecuteRequest extends PDFFile {
@Schema(
description =
"Rectangular areas to black out, each defined by a page number and bounding box"
+ " coordinates.")
"Rectangular areas to black out, each defined by a page number and bounding box coordinates.")
private List<ImageBox> imageBoxes = new ArrayList<>();
@Schema(
description =
"1-indexed page numbers to redact all detected images from. Pass an empty list"
+ " to redact images from every page. Omit or pass null to skip image"
+ " redaction entirely.")
"1-indexed page numbers to redact all detected images from. Pass an empty list to redact images from every page. Omit or pass null to skip image redaction entirely.")
private List<Integer> redactImagePages;
@Schema(description = "Redaction style options")
@@ -64,17 +59,17 @@ public class RedactExecuteRequest extends PDFFile {
@Schema(
description =
"A short, distinctive phrase (515 words) that marks where"
+ " redaction begins (inclusive). Must appear verbatim in"
+ " the document — e.g. a section heading or a unique"
+ " sentence fragment.",
+ " redaction begins (inclusive). Must appear verbatim in"
+ " the document — e.g. a section heading or a unique"
+ " sentence fragment.",
requiredMode = Schema.RequiredMode.REQUIRED,
minLength = 1)
String startString,
@Schema(
description =
"A short, distinctive phrase (515 words) that marks where"
+ " redaction ends (inclusive). Must appear verbatim in the"
+ " document. Shorter phrases match more reliably.",
+ " redaction ends (inclusive). Must appear verbatim in the"
+ " document. Shorter phrases match more reliably.",
requiredMode = Schema.RequiredMode.REQUIRED,
minLength = 1)
String endString) {
@@ -90,26 +85,22 @@ public class RedactExecuteRequest extends PDFFile {
int pageIndex,
@Schema(
description =
"Left x coordinate of the redaction rectangle in PDF user-space"
+ " points.",
"Left x coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float x1,
@Schema(
description =
"Top y coordinate of the redaction rectangle in PDF user-space"
+ " points.",
"Top y coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float y1,
@Schema(
description =
"Right x coordinate of the redaction rectangle in PDF"
+ " user-space points.",
"Right x coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float x2,
@Schema(
description =
"Bottom y coordinate of the redaction rectangle in PDF"
+ " user-space points.",
"Bottom y coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float y2) {}
@@ -173,8 +173,7 @@ public class AttachmentService implements AttachmentServiceInterface {
Optional<byte[]> attachmentData = readAttachmentData(embeddedFile);
if (attachmentData.isEmpty()) {
log.warn(
"Skipping attachment '{}' because it exceeds the size limit of {}"
+ " bytes",
"Skipping attachment '{}' because it exceeds the size limit of {} bytes",
sanitizedFilename,
maxAttachmentSizeBytes);
continue;
@@ -429,8 +429,7 @@ public class CertificateValidationService {
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem")) {
if (certStream == null) {
log.debug(
"Bundled Mozilla CA certificate file not found in resources — using"
+ " Java system trust store only");
"Bundled Mozilla CA certificate file not found in resources — using Java system trust store only");
return;
}
@@ -455,8 +454,7 @@ public class CertificateValidationService {
}
log.info(
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA"
+ " certs)",
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
loadedCount,
skippedCount);
}
@@ -485,8 +483,7 @@ public class CertificateValidationService {
ca);
} else {
log.warn(
"Server certificate is neither self-signed nor a CA; not adding as"
+ " trust anchor");
"Server certificate is neither self-signed nor a CA; not adding as trust anchor");
}
}
} catch (Exception e) {
@@ -166,8 +166,7 @@ public class HardwareKeyStoreService {
candidates.put(
"OpenSC",
List.of(
"C:\\Program Files\\OpenSC"
+ " Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
"C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
candidates.put(
"YubiKey (ykcs11)",
List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll"));

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