Compare commits

..
Author SHA1 Message Date
ConnorYoh d4147637c8 feat(checkout): let buyers choose Server capacity (#7782)
Adds the capacity step to self-hosted checkout. the field is ignored
until [#325
](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/325) lands.
<img width="1262" height="487" alt="image"
src="https://github.com/user-attachments/assets/c22d0b15-103a-491a-bd98-df772e63c71b"
/>

## What it does

A capacity stage between billing period and payment, for the **Server
tier only**. Enterprise is priced per seat and Free has nothing to size,
so both go straight to payment exactly as before.

The stepper counts **servers**, because that is the unit we sell. Every
figure beside it is stated in **users**, because that is the unit an
admin measures. The line item does the translation, so a buyer picks "3
servers" and reads "300 users" without converting anything themselves.

`server_quantity` now rides `createCheckoutSession` through to the edge
function. Before this the base line item was always `quantity: 1`, and
the only way to buy more capacity was Stripe's own portal after the
fact.

## Two guards on the stepper

**It cannot go below current usage.** An installation running 240 users
cannot buy fewer than three servers. Reducing capacity is a renewal
conversation, not something checkout should do by stranding accounts
that already exist.

**At five servers, or a thousand users, it offers an enterprise quote**
beside the purchase. Deliberately an option and not a gate — self-serve
checkout still completes. `onContactSales` is optional, so the door only
appears where a caller wires it up.

## Review notes

- **`USERS_PER_SERVER = 100` is a frontend constant** in
`utils/capacity.ts`. The authoritative value lives on `pricing_policy`
and is resolved server-side at licence-issue time, but before a purchase
there is no licence to read it from and the packaging RPC is not
anon-callable. The comment says so. A follow-up could serve it from
`stripe-price-lookup`, which the plan page already calls; I kept it out
of scope so this PR stays inside one repo.
- The `SELF_SERVE_MAX_SERVERS` bound here is cosmetic. `create-checkout`
clamps server-side against the policy, so a crafted request cannot
exceed it regardless of what the stepper allows.
- No copy sweep needed — `plan.features.usersIncluded` already reads
"100 users included" on main.

## Testing

- 5 unit tests on the capacity arithmetic and the enterprise-door
threshold.
- 5 Storybook stories: single server, multiple servers monthly,
constrained by current users, below current usage (blocked), and the
enterprise door.
2026-09-03 14:35:21 +00:00
ConnorYoh 76047297f9 Encryption at rest: operator surface (P3A) (#7501)
# Encryption at rest: operator surface (P3A)

Encryption at rest has shipped twice and has never been visible in the
product. P1 (#7155) built the
crypto, P2 (#7173) built the admin API. Today an operator enables it by
editing YAML, revokes a key
with curl, and has no screen anywhere telling them the feature exists or
whether their files are
actually encrypted.

This PR puts the P2 API behind a UI. No crypto changes, no schema
changes.

## Scope

In: the Storage tab re-enabled with encryption as its content, key
actions, migration control,
rotation status, the encrypted badge on documents, and honest licence
messaging.

Note on placement: #7497 removed the Infrastructure tabs backed by
mock-only
`/v1/infrastructure/*` data, including the old Storage tab, leaving
`storage` as a disabled
placeholder. This PR re-enables it, because it now reads the real
`/api/v1/admin/storage-encryption` surface, which is the criterion #7497
used. The mock usage,
quota, provider and retention UI that used to live there is not
restored.

Out: per-source encryption (P3B, needs a schema change), KMS and BYOK
backends (P4), FIPS builds
(P4).

## User stories

### Knowing where you stand

**US-1. See whether encryption is actually on**

As an administrator, I want one screen that tells me whether stored
files are being encrypted, so
that I can answer "are we encrypted at rest?" without reading config
files or logs.

- [x] The Storage tab under Infrastructure is enabled again, with
encryption as its content
- [x] Shows write state from `writeEnabled`: new uploads are encrypted,
or they are not
- [x] Shows `encryptedFiles` and `plaintextFiles` counts, so partial
coverage is obvious
- [x] Distinguishes `writeEnabled: false, active: true` (decrypt-only,
the state after turning the
flag off) from `writeEnabled: false, active: false` (feature never used)
- [x] Empty state when no keys exist yet: explains that a key is created
on the first upload, not at
      startup
- [x] When storage is disabled the API returns 403 "Storage is disabled"
before touching the
database. The panel shows that as an explanatory state, not an error
toast
- [x] When the key registry cannot be read the API returns 503. The
panel says the registry is
unavailable and suggests checking the database, rather than showing zero
keys

**US-2. Verify my key backup matches the running system**

As an administrator, I want to compare the live master key against my
backup without exposing key
material, so that I can prove my disaster recovery actually works before
I need it.

- [x] Displays `masterKeyFingerprint` (SHA-256 prefix, 16 hex
characters) and `masterKeyVersion`
- [x] Copy-to-clipboard on the fingerprint
- [x] Explains in one line what the fingerprint is for: it matches the
fingerprint logged at startup,
      so an operator can check their archived key is the one in use
- [x] Never renders anything that could be key material, and the field
is absent when the key
      machinery has not been materialised

**US-3. Be told when my licence gives me no audit trail**

As a compliance reviewer on a Pro licence, I want the product to tell me
that encryption events are
not being recorded, so that I do not report an audit trail to my auditor
that does not exist.

- [x] On a non-Enterprise licence the panel carries a persistent notice:
encryption is active, audit
      events are not recorded, this requires Enterprise
- [x] The notice sits next to any claim about auditing, not in a
separate help page
- [x] On Enterprise the notice is absent
- [x] Wording matches the startup warning and
`devGuide/STORAGE_ENCRYPTION_AT_REST.md`, so the log,
      the docs and the UI agree

Note: encryption is gated at Pro but `AuditService` only records on
Enterprise. Today this is
visible only in a startup log line. If we would rather move audit down
to Pro, that is a licence
decision and this story changes to "remove the notice".

**US-4. Be told my master key was generated for me**

As an administrator of a single-node install, I want to know that the
system created an encryption
key on my behalf, so that I do not discover an unbacked-up secret after
losing it.

- [x] When the key came from the auto-generated `file-encryption.key`
file rather than explicit
config, the panel says so and states the consequence: lose this file and
encrypted files
      cannot be recovered
- [x] Links to the backup section of the runbook
- [x] Requires a small `/status` addition to report key provenance
(config, environment, or generated
      file). Included in this PR

### Acting on it

**US-5. Revoke a scope's stored content**

As an administrator responding to an incident, I want to revoke access
to one team's stored files,
so that their content cannot be read while I investigate.

- [x] Each key row has a Revoke action, calling `POST
/keys/{keyId}/disable`
- [x] The confirmation states what revocation actually does, in plain
words:
  - reads of existing files under this key start failing with 403
  - the team can still upload new files, which get a fresh key
  - it is reversible, and no key material is destroyed
  - on a cluster, other nodes take up to 60 seconds to catch up
- [x] The row shows DISABLED, plus `statusChangedBy` and
`statusChangedAt`
- [x] The 60 second cluster note is only shown when clustering is on

Note: the "still uploads" and "60 seconds" points are not padding. Both
are real behaviour that
surprised us during review, and an admin who believes revoke means
"frozen instantly" will be wrong.

**US-6. Restore access, and understand what came back**

As an administrator, I want to undo a revocation and see exactly what
state the key returned to, so
that I am not misled into thinking a key is wrapping new files when it
is not.

- [x] Enable action on DISABLED keys only, calling `POST
/keys/{keyId}/enable`
- [x] The response status is what the row shows: ACTIVE if the scope had
no other active key,
      RETIRED if one was minted while it was revoked
- [x] When the result is RETIRED, the UI explains it: this key decrypts
its existing files again,
      and a newer key is wrapping new uploads
- [x] Enable is not offered on ACTIVE or RETIRED keys, matching the
API's 409
- [x] A key that no longer exists returns 404 and surfaces as a clear
message, not a crash

**US-7. Encrypt the files I already had**

As an administrator who has just enabled encryption, I want to encrypt
the existing plaintext
backlog and watch it happen, so that "encrypted at rest" is true of my
whole estate rather than only
new uploads.

- [x] Start action calls `POST /migrate`, disabled when `plaintextFiles`
is zero
- [x] Progress from `GET /migrate/status`: state, total, processed,
skipped, failed, started time
- [x] All four terminal states render distinctly: IDLE, RUNNING,
COMPLETED, FAILED
- [x] FAILED is a real, explained state, not a stalled spinner. The run
now ends FAILED when the
write flag is turned off mid-run, and the message says so and what to do
- [x] Skipped is explained on hover: a user replaced the file
mid-migration, so the job left their
      copy alone
- [x] 409 when a run is already going, or when encryption is off,
surfaces as a message rather than a
      failure
- [x] Progress is in-memory, so a backend restart resets it to IDLE. The
UI says that rather than
      appearing to lose the run
- [ ] Adds a cancel endpoint and page-size and pause knobs to the API,
since exposing a long job in a
      UI without a stop button is not defensible

**US-8. Finish a key rotation without sealing my files**

As an administrator rotating the master key, I want to see how many key
rows are still on the old
key, so that I do not remove the outgoing key while rows still depend on
it.

- [x] Shows the current `masterKeyVersion` and the number of key rows
below it
- [x] Re-wrap action calls `POST /master/rotate` and reports the
`rewrapped` count
- [x] States plainly that key material never travels over HTTP: the new
key is a config and restart
      operation, and this button only performs the re-wrap step
- [x] Warns while any row is behind: removing the previous key now would
make those files
      unreadable
- [x] Links to the rotation runbook

### Everyone else

**US-9. See which of my files are encrypted** — moved to #7550

The badge component was built here but never wired to a surface, and no
documents endpoint returns
`encryptionKeyId`, so it could not render in the product. It moved to
its own draft PR rather than
shipping as unreachable code behind a screenshot.

**US-10. Understand why a file will not open**

As a user whose team's key has been revoked, I want a clear message
rather than a generic error, so
that I raise the right request with the right person.

- [ ] A 403 from a revoked key renders as "access to this file has been
revoked" wording, not a
      generic failure toast
- [ ] Distinguished from a permissions 403
- [ ] Applies to both My Files and workflow document reads, which P2
made consistent

## Definition of done

- [x] New API client module under `portal/api/`, following the
conventions in `sources.ts`
- [x] All copy is i18n keys in the editor `en-US` catalogue, which is
the source of truth
- [x] Storybook stories for every state named above, including the
empty, 403, 503 and FAILED states
- [x] a11y baseline recorded with the full task, so a partial scan does
not delete other entries
- [x] Light and dark themes both checked
- [x] No raw `<button>` elements, per the lint rule
- [ ] `/status` paginates its key list, since this UI is the consumer
that makes an unbounded list
      matter

## Not covered in this PR

The five unticked boxes above, all tracked elsewhere. Everything else
was checked against the code,
not assumed.

**Split into follow-up PRs:**

- **#7550** — the encrypted badge on documents (US-9). Needs a documents
endpoint to return
  `encryptionKeyId` before it can render anywhere
- **#7551** — the revoked-key message (US-10), the migration cancel
endpoint with page-size and
  pause knobs, and pagination on the `/status` key list

**Also tracked:** #7549, to publish the operator docs. The two runbook
links in this PR point at
`devGuide/STORAGE_ENCRYPTION_AT_REST.md` until those pages exist; both
are constants in
`storageEncryption.ts`, so swapping them is a one-file change.

## Backend changes riding along

Two fields added to `/status`. Nothing else in the backend changed:

| Change | Why |
|---|---|
| `masterKeySource` (config, environment, generated) | US-4 cannot be
built without it. The value already existed inside
`FileEncryptionMasterKey.resolveKey` and was simply never returned |
| `provider` (local, database, s3) | The panel names the storage
backend, and warns that object-store downloads stream through the app
once anything is encrypted |
| `pendingRotationRows` | The rotation warning needs a whole-table
count, which the returned key list cannot give |

Two items from the original plan are still not done and are called out
below: key-list pagination,
and the migration cancel endpoint and knobs.

## Screenshots

Replace each placeholder with the matching image.

| | |
|---|---|
| Encryption off, nothing encrypted yet | <img width="2560"
height="1800" alt="01-panel-encryption-off"
src="https://github.com/user-attachments/assets/36ae3c9a-2c54-48b1-8837-c7c68e157961"
/> |
| Encryption on, healthy, key table | <img width="2560" height="2036"
alt="02-panel-active"
src="https://github.com/user-attachments/assets/949d930c-7e13-409d-b960-49d44e49fc21"
/> |
| Pro licence audit notice | <img width="2560" height="2226"
alt="03-licence-notice"
src="https://github.com/user-attachments/assets/f5e1b39e-c221-4e42-87c1-0f45f8ecd2a3"
/> |
| Revoke confirmation | <img width="2560" height="2036"
alt="04-revoke-confirm"
src="https://github.com/user-attachments/assets/0c86a40c-3bf1-4a0d-aa19-c28691986e2f"
/> |
| Revoked key row | <img width="2560" height="1912" alt="05-key-revoked"
src="https://github.com/user-attachments/assets/2333bc0d-968f-4472-b253-7877fe1d41a2"
/> |
| Re-enabled as RETIRED, with explanation |<img width="2560"
height="1912" alt="06-key-retired"
src="https://github.com/user-attachments/assets/a4e84165-020b-4377-8506-49f4312783e4"
/> |
| Migration running | <img width="2560" height="2222"
alt="07-migration-running"
src="https://github.com/user-attachments/assets/6ca07208-ad58-441d-85ba-e5617f36bb29"
/> |
| Migration failed after the flag was turned off | <img width="2560"
height="2380" alt="08-migration-failed"
src="https://github.com/user-attachments/assets/35a54549-1b92-4c17-9fad-4790bda8c24b"
/> |
| Rotation with rows still on the old key | <img width="2560"
height="2232" alt="09-rotation-pending"
src="https://github.com/user-attachments/assets/991e1811-3103-45d1-8bd6-a83b6eb9291b"
/> |
| Storage disabled state | <img width="2560" height="1800"
alt="11-storage-disabled"
src="https://github.com/user-attachments/assets/c52c5087-2a4a-4189-a3c1-22e69016fc2e"
/> |
| Dark theme, active panel | <img width="2560" height="2036"
alt="12-panel-active-dark"
src="https://github.com/user-attachments/assets/7d6c3ff5-7824-4ce6-92c7-d8af82d52c10"
/> |

Extras captured while iterating, not required in the body: registry
unavailable
<img width="2560" height="1800" alt="13-registry-unavailable"
src="https://github.com/user-attachments/assets/4c8df8ce-5659-41f0-9484-010cc9f74c2d"
/>

(`13`), generated master key (`14`)
<img width="2560" height="2226" alt="14-generated-master-key"
src="https://github.com/user-attachments/assets/3ff765ed-a24f-48fa-b9b8-d92de3cc2af0"
/>
, dark migration (`15`)
<img width="2560" height="2222" alt="15-panel-active-dark-migration"
src="https://github.com/user-attachments/assets/7a2ae906-8c23-404b-b778-b40ea74c3058"
/>
.

## Test plan

What is actually covered:

- Storybook covers every state named above, driven by fixtures rather
than a live backend:
encryption off, active, Pro notice, revoke dialog, revoked row,
restore-as-RETIRED, migration
running, migration FAILED, rotation pending, storage disabled (403),
registry unreadable (503),
  and generated master key
- `Infrastructure.test.tsx` covers the tab wiring: Storage is enabled,
reachable by click and by
  `?tab=storage`, and disabled tabs stay inert
- `FileEncryptionMasterKeySourceTest` covers key provenance, including
that the wire names match the
  values the UI switches on
- The shared `Tooltip` has a play-function story asserting it opens on
keyboard focus and wires
  `aria-describedby`
- a11y scanned clean across both themes, light and dark, and the shared
baseline is unchanged
- Typecheck across all nine build variants, oxlint, stylelint and a
production build

Not covered, and worth a reviewer's attention:

- No component tests for the encryption UI itself. The migration state
machine and the
enable-returns-RETIRED path are exercised by stories, which render them
but assert almost nothing.
  This is the biggest remaining gap in the PR
- No manual pass against a live backend with encryption on, a seeded
plaintext backlog and a
  revoked key. Every state in this PR was driven by fixtures

## Notes for reviewers

The revoke confirmation copy is deliberately blunt about two things that
are easy to get wrong:
revoking does not stop the team storing new files, and on a cluster it
takes up to a minute. Both
were found during the P2 review. If the copy reads as over-explaining,
that is the intent.
2026-09-03 13:50:20 +00:00
James Brunton 235d8fde37 Remove dead policies code (#7783)
# Description of Changes
In #7681, we redefined what Policies were and merged the Policies page
into the Pipelines page. This left over a lot of now dead/redundant
Policies code which should be removed because the Pipelines code already
covers it all. This PR does not remove _all_ of the Policies code
because some of it is still being used in the UI now, but as in-editor
pipelines and as pipeline templates. That code needs to be renamed to
get it to the correct naming, but this PR is big enough as it is to just
delete all the dead code, so that'll come in another PR.
2026-09-03 11:18:09 +00:00
ConnorYoh 83dd31ef4f Do not treat "have not asked yet" as "not linked" (#7779)
Fixes the report that a **linked** self-hosted instance is still shown
the connect prompt, the sidebar "Link Stirling account" button and the
feature gates.

## Why it happens

`LinkState` is `unlinked | linked-free | linked-subscribed`. There is no
"unknown", so `LinkProvider` opens at `"unlinked"` and a separate status
call corrects it afterwards.

`useConnectGate` then does:

```ts
const loading = query.isPending;                       // app-config only
const gated  = available && !link?.isLinked && !devBypass;
```

`loading` covers the **app-config** query and nothing else, so the
moment app-config lands the gate treats a linked instance as unlinked
until the status arrives. The prompt fires and the gates close.

**The version users actually hit is the permanent one.**
`useAccountLink.refresh` swallows a failed status call:

```ts
} catch {
  setStatus({ linked: false, name: null });   // "could not ask" becomes "not linked"
}
```

A 401 once the admin session lapses, a 5xx, a dropped connection — and
nothing retries. The instance reads as unlinked for the rest of the
session, which is why it does not clear on its own.

## The fix

The context now carries whether the status has actually been read back.
Anything that *blocks* waits for it; anything that merely reports state
does not.

- Gate holds open until the answer is in, and **stays** open if the call
never succeeds. A gate that cannot read its own precondition should not
be the thing standing in the way, and the real enforcement for these
features belongs on the server regardless.
- Sidebar button hides until the status is known, so a linked instance
no longer flashes a "link account" button.
- Only a successful read marks the status known. Marking it in the
`catch` too would put us straight back to reading "could not ask" as
"not linked".

The `statusKnown` prop defaults to `true`, so a pinned state in a story
or test is still believed as given. Only the app passes `false`, because
only the app has to go and ask.

## Tests

`connectGateStatus.test.tsx` drives the real provider stack and pins
apart the three answers that were being conflated:

| Status | Gate |
|---|---|
| still in flight | open |
| linked | open |
| call failed | open |
| genuinely unlinked | **gated** |
2026-09-03 11:15:18 +00:00
ConnorYoh ff9b6d7093 feat(licensing): enforce the user cap and surface capacity (#7492)
Groundwork for capping the Server plan at 100 users per server.
Self-hosted half; the commerce half is
[Stirling-PDF-SaaS#feat/server-plan-capacity](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/tree/feat/server-plan-capacity).

**No behaviour changes for any licence in the wild.** Every licence
today carries `metadata.users = 0`, and nothing here alters what that
means.

## The licensing rule needs no change

`calculateMaxAllowedUsers()` already returns `licenseMaxUsers` when
positive and unlimited when zero, keyed on the value rather than the
tier. That is exactly the behaviour we want once licences carry a real
number, and it means an instance running a six-month-old build enforces
a capped licence correctly. Worth knowing before review, because earlier
drafts of this work invented a metadata version flag to distinguish
capped from legacy licences; it turned out to be doing no work and is
not here.

## What did need work

**Two ways past the limit.** Invite links were checked when the link was
*generated*, not when it was *redeemed* — a link minted while slots were
free could still create the user over the cap. Redemption now re-checks
and returns `409` naming the limit. `saveUserCore` gained a final guard
so a seventh creation path cannot be added without one.

**The guard needs an exemption.** `INTERNAL_API_USER` is excluded from
`getTotalUsersCount()`, so charging its *creation* against the limit
would strand an installation already at its cap — it could not create
the account it needs to function. `SaveUserRequest` carries an explicit
`bypassUserLimit` flag, set only by the three `InitialSecuritySetup`
bootstrap paths.

**Cycle break.** `UserService` reaches `UserLicenseSettingsService`
through an `ObjectProvider`, because that service already injects
`UserService` to count users. Same pattern it uses for
`LicenseKeyChecker`.

## Capacity fields for the UI

`server_quantity` and `user_block_size` are read from licence metadata
at **all three** verification paths (certificate, JWT policy, live API)
through one helper so they cannot drift, persisted so they survive the
weekly refresh, and exposed on `/license-info` and the admin payload.
Both are presentation only — `maxUsers` stays the enforced limit — so
the UI can say "2 servers, 100 users each" rather than a bare 200.

The admin payload also gains `pendingInvites`. Disabled accounts and
unredeemed invites both hold a slot, so the UI can show what is
consuming capacity and offer a way to reclaim it rather than making
payment the only exit.

## Testing

Four new tests cover the refusal, the bootstrap bypass, and invite
redemption at the cap.

## Review notes

- The `bypassUserLimit` flag is the thing most worth a second opinion.
It is deliberately explicit rather than inferred from the role, so the
exemption is visible in review, but it is a flag anyone could set.
- Race safety is **not** here. Making check-then-insert atomic means
giving `saveUserCore` a transaction boundary, and it currently ends in
`databaseService.exportDatabase()` — file I/O you do not want inside a
transaction. Its only symptom today is landing on 101 instead of 100, so
it is deferred to its own PR.
2026-09-03 10:06:28 +00:00
Anthony Stirling d43a3dfe90 Warn about small context windows on self-hosted AI providers (#7776)
Replaces #7770, which put this in `engine/.env` where an admin
configuring AI through the settings UI would never see it.

Shown only when the provider is Ollama or Custom (OpenAI-compatible),
directly under the existing SSRF warning on the base URL field - the
point where someone is actually pointing the engine at their own
endpoint.

Ollama defaults `OLLAMA_CONTEXT_LENGTH` to 4096 and divides it further
between parallel request slots, leaving roughly 2,000 tokens of usable
prompt. Measured against a local qwen3:8b: prompts up to ~2,685 tokens
arrive intact, anything larger is clipped to exactly 2,050, and the
overflow is discarded from the **start** of the prompt - which is where
the planner puts the user's request. The model then selects tools
without having seen what was asked. Nothing errors and nothing logs.

The engine cannot set this itself: it is an Ollama server option with no
equivalent on the OpenAI-compatible endpoint the engine speaks. So the
useful thing it can do is tell the person configuring it, at the moment
they configure it.

String added to en-GB and en-US only; other locales pick it up through
the usual translation flow and fall back to English until then.
2026-09-03 09:42:08 +00:00
Anthony Stirling 84ac22a7de Generate the OpenAPI spec on a free port (#7777)
`generateOpenApiDocs` forks a Spring Boot instance to harvest the spec,
but the scrape URL was hardcoded to `http://localhost:8080` - the app's
own port. If any Stirling instance is already listening there (a dev
server, a container, a WSL relay) the plugin reads **that** build
instead of the fork, and `task tool-models` then generates the Python
and TypeScript models from whatever happens to be running.

It fails silently and looks like success. I hit it while regenerating
models after editing Java annotations: the build passed, the compiled
class contained the new text, and the spec contained none of it.
Fetching the URL by hand settled it:

```
paths live8080: 276   ours: 276
identical parsed content: True
```

The generated `SwaggerDoc.json` was the running instance's document, not
the working tree's.

Now on a random free port, passed to both the fork and the scrape URL so
they cannot disagree. Override with `-PopenApiPort=NNNN` to use a
specific port.
2026-09-03 09:41:55 +00:00
Anthony Stirling 70cdf2429f Put the user request last in the planner prompt (#7773)
Two changes to the `pdf_edit` selection prompt.

**The user's request moves to the end.** When a prompt exceeds the
model's context, Ollama keeps the tail and discards the head. The
request was the second line, so it was the first thing thrown away.
Ending with it makes the most important line the most likely to survive.

Reproduced on a local qwen3:8b before the change: five completely
different requests - convert to Word, merge two files, run OCR, add a
password, add a watermark - run sequentially at temperature 0 through
the production prompt shape all returned `COMPRESS_PDF`, and every one
reported `prompt_tokens: 2050` against a much larger prompt. The model
was not choosing badly; it was answering a question it had never been
shown.

This is defensive rather than a fix on its own - the real repair is
giving the model a context window large enough, and a prompt small
enough, to avoid truncation entirely. But the ordering is free and
correct regardless of context size.

**The duplicated unavailable-operations list is removed from the user
prompt.** It was rendered twice, once in the system prompt and again in
the user prompt. It is also counterproductive: it spends tokens teaching
the model names it must not use, and `handle()` already rejects a plan
referencing an unavailable operation in Python afterwards. The
system-prompt copy stays, so the model can still explain that an
operation exists but is not available here.

The existing test that asserted the list appears in the user prompt now
asserts it appears in the system prompt and not the user prompt.
2026-09-03 09:41:45 +00:00
ConnorYoh cbc8af1951 feat(desktop): custom in-app window title bar on Windows (#7781)
<img width="1750" height="1223" alt="image"
src="https://github.com/user-attachments/assets/c115a30c-be62-4eaa-8de3-26a93a605919"
/>

## Custom windows top bar
* Doesn't work on mac
* doesn't effect web 
* little effort been put into mobile view
2026-09-02 21:31:45 +00:00
James BruntonandEthanHealy01 aca0e40c37 Combine Policies and Pipelines pages (#7681)
# Description of Changes
Combine the Policies and Pipelines pages into one, so we have the new
concept of Policies as Pipelines that always run which the user cannot
disable. What used to be Policies are now referred to as Templates, and
they allow you to create a new Pipeline more easily with the simple UI.

There's followup work to be done here to improve the template UIs
because they've not been touched in a long time, but I've considered
that beyond the scope of this merge. The only real changes I've made to
them in this PR is that they have a toggle for whether they're policies,
they now have a "Customise" button to kick you into the full Pipeline
editor, and I've removed the source selection. Previously, they
supported selecting as many sources as you liked, but that feature never
worked and is incompatible with the backend as it stands now, which only
allows for one source. Because of that, I've made it so that they can
only run in editor unless you open them in the custom pipeline editor,
where you can switch out which source it will use.

There's also another bit of followup to rename and remove all the
previous Policies code. Now that they've been combined into one, we
don't need a lot of the Policies code anymore, but also there's about
300 files in the frontend referencing policies in text/comments which
need to be updated to say pipelines. This is way more work than is
reasonable to do in this PR so I'll just do it in a new PR.

## Limitations
This PR is about the merging of the old Policies and Pipelines and I'm
considering enforcing the new definition of a Policy where it's only
modifiable by admins beyond the scope of this PR.

<img width="756" height="395" alt="image"
src="https://github.com/user-attachments/assets/d31be5ce-f1c9-46b3-8e8d-866e63f89a81"
/>

<img width="1507" height="793" alt="image"
src="https://github.com/user-attachments/assets/9ba8875f-8be5-4881-91cf-40e0bc1076dc"
/>

<img width="1508" height="787" alt="image"
src="https://github.com/user-attachments/assets/3e1da77b-a0c0-4262-aad3-16650098db81"
/>

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-09-02 16:08:15 +00:00
Reece Browne 1b2a3118a6 Disk-mounted folders on desktop and improved folder management (#7502)
Description of Changes

Adds folder kinds so the file manager can work with real directories on
disk.

Desktop
- New folder is now a menu with two options: "Add local folder" and "New
folder on the server".
- Add local folder opens the native picker and mounts a directory. Files
are listed straight from disk, nothing is copied in.
- Subfolders show inside a mount and open like any folder. New folder
inside a mount creates a real directory on disk.
- Moving, dropping or uploading files into a mount writes them to the
directory. The app copy is only removed after the write succeeds. Name
clashes get a " (2)" suffix.
- Mounted files get thumbnails.
- Adding the same directory twice just returns the existing mount.
- Removing a mount never touches the disk.
- The server option is disabled in local mode with a sign in message.

Web + desktop
- Uploading or dropping files while inside a folder puts them in that
folder instead of Local.
- Files can be dragged onto folders in the grid and the tree to move
them.
- Folders show an origin badge (cloud or local).
- The Local view now means files that are not in any folder.

Follow ups for a future pr
- Mount listing cap: large directories currently show the 500 most
recent files with no notice. Will be removed as part of the
virtualisation/performance PR.
- Folders within folders need to be supported
- Symlinks in mounts: currently not listed. Behaviour to be decided
alongside the wider folder work.
2026-09-02 14:18:57 +00:00
ConnorYoh 3056e5ff44 Reset the PAYG free grant each billing period (#7709)
Needs the schema half: Stirling-Tools/Stirling-PDF-SaaS#327

## Current state

The PAYG free allowance is a one-time lifetime pool.
`pricing_policy.free_tier_units` is copied into
`payg_team_extensions.free_units_remaining` once, at team creation (V14
trigger, updated in V19), and the charge pipeline decrements it until it
reaches zero. Nothing ever puts it back.

## Problem

The product promises a monthly allowance the billing model does not
grant.

- The account-link connect dialog advertises "500 free per month". That
has **merged to main** (#7415), so the claim is live and unhonoured
until this lands.
- The wallet meter already read "Process 500 PDFs free, then $X/PDF",
which reads as an allowance-then-meter model.
- `SignupRequiredBootstrap`'s own doc comment described a "free
500-op/month allowance" while its copy said only "500 free operations".

Three separate comments asserted the opposite in code
(`billing/types.ts`, `WalletSnapshotResponse`, `TeamBillingContext`), so
the two halves of the repo disagreed about what a customer is owed.

## Solution

The grant now recurs each billing period, **for every team**. Paying
does not cost you the allowance: a subscribed team draws its grant first
each period and meters only the excess, which is what the meter's copy
always described. That also matches how the grant already worked at
charge time, where it reduced metered units regardless of subscription.

### The reset is lazy, with no scheduler

`payg_team_extensions` gains `free_units_period_start`: the period
`free_units_remaining` was last written for.

- A stamp older than the current period start, or absent as on every
existing row, means the reset is owed.
`TeamBillingService.remainingForPeriod` projects it to a full grant, so
the entitlement gate and the wallet both show it the instant the period
turns.
- `JobChargeService.consumeFreeGrant` persists it on the next charge,
under the pessimistic row lock that already makes the per-job free/paid
split exact.

One rule, both callers, so display and enforcement cannot drift onto
separate schedules. A team that runs nothing for a month has nothing to
write, and no job is needed to hand out the grant.

### One period definition

"Per period" is `TeamBillingContext.periodStart`: the Stripe
subscription's current period when subscribed, the calendar month
otherwise. It was already the only period notion in the system, so the
grant joined it rather than inventing its own:

- `InstanceEntitlement.periodCapUnits` is enforced over the same window.
- `localUsageService.currentPeriodUnsynced` already buckets a linked
instance's local usage by the `periodStart` it reads from the same
snapshot, and resets its counters on that boundary.

For an un-subscribed team, the only kind the grant gates, that window is
the calendar month, which is what the copy promises.

The period rule stays in Java by choice, not necessity: SQL could reach
the Stripe period through the sync engine, but restating the rule there
would give it a second home to drift from. Hence a nullable column and
no backfill in the migration — NULL already means "stale", so every
existing team reads as owed the current period's grant.

### Refunds

A refund landing after the period turned would have stacked last
period's units on top of the fresh grant.
`JobChargeService.restoreFreeGrant` now clamps the restore to one
period's grant, taking the same row lock, and the bulk-increment
`restoreFreeUnits` query is gone. Removing it also removed a `@Query`
string that no test would have parsed before application startup.

### Copy and comments

Every comment and user-facing string that asserted the lifetime model is
corrected. The strings that changed (code defaults and `en-US` TOML
updated together):

| Key | Now reads |
| --- | --- |
| `portal.billing.walletMeter.title` / `titleWithRate` | "500 free
credits every month, then $X per PDF" |
| `portal.billing.walletMeter.capSuffix` / `barAria` | "of 500 free
credits left this month" / "Free credits remaining" |
| `payg.free.hero.capSuffix` | "of 500 free PDFs left this month" |
| `plan.freeLimit.message` | "...this month. ... It resets next month,
or keep the momentum going now..." |
| `payg.signupRequired.body` | "500 free operations a month" |

Main rewrote these keys to "500 free credits to start" while this branch
was open. The merge keeps main's credits vocabulary and drops "to
start", which asserts the one-time grant this branch removes and which
main's own connect dialog already contradicts.

Also fixed in passing: `testing/compose/payg/saas-seed.sql` still
inserted `free_tier_units_per_cycle`, the pre-V19 column name, so that
INSERT had been failing since the rename.

## How to test

Backend:

```bash
STIRLING_FLAVOR=saas ./gradlew :saas:test spotlessCheck
```

Frontend:

```bash
task frontend:typecheck && task frontend:lint && task frontend:format:check
```

New coverage, 10 tests:

- `TeamBillingServiceMoreTest` — a past-period stamp reads as a fresh
grant, a current stamp reads the stored balance, an unstamped row reads
as a fresh grant, the grant follows the Stripe window rather than the
calendar month, plus the `remainingForPeriod` rule itself including a
future stamp and null/negative balances.
- `JobChargeServiceTest` — the first charge of a new period resets and
re-stamps, an unstamped row resets, a zero-grant policy still advances
the stamp, and a refund crossing a period boundary does not exceed the
grant.

Manually, against a team whose grant is spent: set
`free_units_period_start` back a month (or leave it NULL) and the
wallet, the sidebar meter and the entitlement gate should all show a
full grant before any job runs. The first billable job should then draw
from it and write the reset.

Three tests fail on a local Windows run and pass in CI, on files this
branch does not touch: `workbenchSession.test.ts`,
`notificationActions.test.tsx`, and `:proprietary`
`FolderIdentitiesTest.identityAgreesAcrossASymlinkedAliasOfTheDirectory`.
Nothing to do here — noted so a local run does not look like a
regression.

## Merge order

The migration is additive, and Hibernate `ddl-auto=update` will add the
column in a dev environment, so either order works locally. Beyond that
the schema goes first: Stirling-Tools/Stirling-PDF-SaaS#327 targets `v3`
(staging), so it needs to reach an environment before this lands there.
2026-09-02 13:57:56 +00:00
Anthony Stirling 798ba57f0b Reply to chat in the user's UI language (#7766)
# Description of Changes

Pass a user browser lang ID to engine

<img width="1400" height="900" alt="image"
src="https://github.com/user-attachments/assets/7e8fc5c2-8881-4a74-b718-7f5cd350d457"
/>



---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-09-02 12:35:22 +00:00
Anthony Stirling 42bdce155c Fix mobile scanner upload flow and fit it to one screen (#7684)
file mobile phone scanner UI issues when on http and scaling UI issues
Ensuring that smaller screens dont cut off UI elements 
better handling of batch photos

<img width="2104" height="8800" alt="montage_mobile-scanner"
src="https://github.com/user-attachments/assets/b4dd114b-c54d-4101-8700-7307dbb0eee9"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-09-02 09:13:31 +00:00
ConnorYoh 2cf355c5cd feat(editor): move admin settings onto TanStack Query (#7437)
# Description of Changes

## The problem

`useAdminSettings` backs all 18 admin config sections. Each section
fetched its own copy of its settings block, held it in hand-rolled
loading/saving state, and refetched manually after every save.

Three consequences:

- **Duplicate fetching.** Four AI tabs all read the `aiEngine` block.
Nothing was shared, so each open refetched it.
- **Duplicated wiring.** All 18 sections carried the same effect to
trigger the fetch, each one depending on a `fetchSettings` callback that
would have refetched on every render had it ever become unstable.
- **Console noise.** The hook made 11 `console.*` calls, four of them
`JSON.stringify(settings, null, 2)` on **every fetch and every save** —
admin configuration serialised into the console of every admin session.

Every save also ended with a hand-written `await fetchSettings()`.
Forget it in a new section and its pending badges silently go stale.

## The fix

The hook uses TanStack Query, keyed on `sectionName`, so sections
reading the same block share one fetch and one cache entry.

The fetch gate moved into the hook. Sections used to write:

```ts
const { settings, fetchSettings } = useAdminSettings({ sectionName: "legal" });

useEffect(() => {
  if (loginEnabled) fetchSettings();
}, [loginEnabled, fetchSettings]);
```

and now write:

```ts
const { settings } = useAdminSettings({
  sectionName: "legal",
  enabled: loginEnabled,
});
```

Saving is a mutation that invalidates the section on success, so the
refetch is structural rather than something each section remembers.

The delta computation and the save transformer are unchanged — that is
domain logic, not fetching. `settings` is still an editable draft seeded
from the server response, so forms behave exactly as before.

## Why it is better

Measured against the previous implementation across identical scenarios.
`commits` counts committed renders.

| Scenario | Before | After |
|---|---|---|
| Open one section | 2 commits, 1 request | 2 commits, 1 request |
| Browse the four AI tabs | 8 commits, 4 requests | **5 commits, 1
request** |
| Edit and save | 4 commits, 2 requests | 4 commits, 2 requests |

Committed renders are equal or better everywhere; browsing the AI tabs
costs a quarter of the requests.

The diff reads +449 / −303, but that includes a test file for a hook
that had no tests:

| | Added | Removed | Net |
|---|---|---|---|
| Production code (21 files) | 154 | 303 | **−149** |
| Tests (1 file) | 295 | 0 | +295 |

The 18 section files account for −133 of that: each drops an effect, a
destructure and usually an import, and gains one `enabled:` line. The
hook itself goes from 234 to 180 lines. `console.*` calls go from 11 to
0.

## Caching

Settings inherit the client's 30s stale window rather than refetching on
every mount, which is where the request saving comes from.

Nothing inside a cached block is server-observed — the only live reads
in these sections, `/api/v1/ai/health` and the tessdata language list,
are separate calls outside this query. A block therefore only changes
when another admin writes it.

Two things bound the staleness:

- Sections already held a single snapshot for as long as the modal
stayed open, with no refetch on focus. 30s is shorter than that window,
not longer.
- `computeDelta` only emits fields whose draft differs from the baseline
it was seeded from, so a stale baseline cannot produce a collateral
write. The only race is two admins editing the same field, which is
unchanged. Saving invalidates, so acting refreshes to current values.

The blocks where a stale read would matter most — `security`, `premium`,
`database` — are set once at deployment and effectively never edited
concurrently. The block with the most cache reuse, `aiEngine`, is the
least consequential.

**Convention:** config blocks cache; observed state does not. A section
that displays live server state inside its settings block should
override `staleTime` locally.

## Testing

14 tests, covering the shared fetch, cache reuse across tab reopens, key
separation between blocks, the `enabled` gate, delta-only saves, the
empty-delta short circuit, post-save invalidation, pending-value
display, and draft reseeding.

Each was checked by breaking the implementation and confirming the suite
fails: per-consumer query keys, sending the whole draft instead of the
delta, dropping the post-save invalidate, reporting loaded while
disabled, skipping the empty-delta short circuit, and reverting the
stale window to zero.

`task frontend:check` green. Two unrelated tests fail on this branch —
`workbenchSession.test.ts` and `notificationActions.test.tsx` — and fail
identically on `main`.

## Follow-ups

The sections that fetch through services rather than this hook — Teams,
TeamDetails, People, roughly 2,600 lines — are unchanged. Between them
they share two reads (`getTeams` and `getUsers`, both used by all three)
and carry ten distinct write operations, with no test coverage today.

---

## Primer: mutations

`useQuery` is for reads. It caches, dedupes, and re-renders when data
arrives. `useMutation` is for writes, where none of that applies — a
write happens once, when the user asks.

```ts
const save = useMutation({
  mutationFn: (body) => putAdminSection("legal", body),
  onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});

save.mutate(body);            // fire and forget
await save.mutateAsync(body); // or await it
save.isPending;               // disable the button
save.error;                   // show the failure
```

`isPending` and `error` replace the `useState` flag and
`try/catch/finally` you would otherwise write around every save.

After a write the cache holds stale data. Two ways to fix it:

| | What it does | Use when |
|---|---|---|
| `invalidateQueries` | Marks the data stale so it refetches | The
server may transform, queue or reject part of what you sent |
| `setQueryData` | Writes your value into the cache, no request | The
response tells you exactly what the server now holds |

**Invalidate by default. Use `setQueryData` only when the response is
authoritative.**

This hook has to invalidate: the server can queue a settings change
rather than applying it, returning it in a `_pending` block that the
form renders as a badge. Writing the local draft into the cache would
show a queued change as applied.

Most mutations are not like that. A "rename a team" write, where the
response is the new team, is a `setQueryData` case.

One gotcha: `mutate` does not throw, `mutateAsync` does. An awaited
`mutateAsync` without a `try/catch` is an unhandled rejection.
2026-09-01 21:58:50 +00:00
Anthony Stirling c57a2a45de Add v2 client-side PDF text editor (#6500)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-09-01 20:55:59 +01:00
ConnorYoh d30faf246b fix(billing): the paid tier is Team, and it is not unlimited users (#7730)
Copy only. No behaviour, no lookup keys, no licence semantics, no
backend.

## Current state

Every surface that sells the paid self-hosted tier offers **"unlimited
seats"** for **"$99/server/mo"**, and the portal's free plan badges
**"Unlimited users"** and **"SSO included"** as free-tier facts.

## Problem

Both claims are now enforceably false.
[#7492](https://github.com/Stirling-Tools/Stirling-PDF/pull/7492) makes
the licence carry a real user cap, and
[Stirling-PDF-SaaS#325](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/325)
sells capacity in blocks of 100 users. An admin reading "unlimited
seats" and then hitting a 409 at the invite screen is the worst version
of this.

The demo has already dropped both claims; ours were the last ones
standing.

## Solution

| Surface | Was | Now |
|---|---|---|
| Onboarding licence slide | "Stirling Server plan, **unlimited seats**
… $99/server/mo" | "Stirling Team plan, **100 users** … $99/mo" |
| Plan comparison table | `unlimitedUsers` = "Unlimited users" |
`usersIncluded` = "100 users included" |
| Plan card highlights | "Unlimited users" | "100 users included" |
| Static plan section | `name: "Server"`, `maxUsers: "Unlimited users"`
| `plan.team.name`, `plan.team.maxUsers` |
| Upgrade banner | "Upgrade to Server Plan" / "unlimited users" |
"Upgrade to the Team plan" / "100 users, SSO" |
| Portal free plan | "Editor" + "SSO included" + "Unlimited users" |
"Editor" + "Every PDF tool" + "Web, desktop & self-hosted" |

The i18n keys are **renamed** (`unlimitedUsers` to `usersIncluded`)
rather than just revalued, so the key name cannot outlive the claim.

Also drops "per server" from `plan.licenseWarning` — we price a block of
100 users and count the provisioned roster, never nodes. And deletes the
orphaned `[settings.planBilling.tier]` block: zero source references,
and it described a retired model (50 credits/mo free, 500 included plus
overage billing).

## Deliberately unchanged

**"Processor" stays the name of the product surface.** The demo names
each plan for its price tier (Editor = $0, Team = $99/mo, Credits = 1¢
each) while keeping Processor as the surface a plan unlocks. Renaming
the surface here would conflate the two, so the plan-name split is left
for the explicit plan catalogue. The free plan also gains no "500 free
credits monthly" badge yet: that is true in the demo but not in our
backend, which still grants a one-time lifetime pool.

## How to test

Self-hosted, as an admin over the free user limit: Settings → Plan
should offer the Team plan at "100 users included", and the onboarding
licence slide should no longer promise unlimited seats. On the portal
billing page, the free plan should read "Free" with no SSO or
unlimited-users badge.

Green locally: 4/4 i18n audits (missing, unused, structure,
translation), 876 tests across 110 files, oxlint, prettier, and all four
typecheck variants (core, proprietary, saas, portal).
2026-09-01 19:09:57 +00:00
EthanHealy01 f6661a8f87 Failure action slots, resolve transition, and the bell that renders them (Review Flow PR 5a) (#7761)
Review Flow PR 5a — the first half of #7479, which stays open for
reference until both halves land. This PR is the ranking and the
bookkeeping; #7762 adds the retry handlers. Merging both reproduces
#7479's diff byte-for-byte.

## What's added

**The action slot model (backend).** `FailureActionSlot` ranks each of a
kind's offers as its `RESOLUTION`, `SECONDARY` or `OVERFLOW`.
`FailureKind` now declares placement per offer — the password-protected
kind names `DECRYPT_AND_RETRY` as its resolution, `UNKNOWN` leads with a
plain `RETRY` — and `FailureActionId` gains those two ids. The
declarations are data; their client handlers arrive in the follow-up, so
this build withholds them with a reason rather than rendering unwired
buttons (the same forward-compatibility #7478 relied on).

**A resolve transition.** `POST /api/v1/notifications/{id}/resolved`
lets a client report a failure fixed. `NotificationSource.parse` turns a
qualified notification id back into the source that owns it, and
`FileRunEventService` folds the resolution into the incident rather than
deleting it.

**`viewerReviewsTeam` on the list response.** A member sees only rows
whose document this browser holds — they can neither open nor fix
anything else — while a team reviewer keeps every row.

**The bell renders the ranking** (`promoteActions`): one primary button,
at most one secondary, the rest in an overflow menu beside **Copy log**.
The row's body is the kind's own sentence; the raw failure message moves
into the menu.

**Read state is a timestamp, not a row id.** `readThroughAt` replaces
`lastSeenId`: when a resolved or dismissed row leaves the list, the rows
below it stay read instead of re-lighting the badge.

## How to test

Needs a proprietary or SaaS build with login enabled (`task dev:all`,
sign in).

1. **Create a failure.** Add a password-protected PDF to the editor and
choose **Skip for now**; the upload's policy run fails on it.
2. **Open the bell.** The row reads the kind's sentence, not a stack
trace. Its primary button is **View file** — the server offers Decrypt
and retry as the resolution, but this build withholds it (handler lands
in the follow-up), so the best renderable offer is promoted instead.
3. **Open the row's ⋯ menu.** View in processor and Dismiss sit there,
along with **Copy log**, which copies the raw message.
4. **Check the read marker survives a departure.** With two failures,
open the bell (badge clears), dismiss the newer row, and refresh: the
badge stays dark. On main, the marker held the departed row's id and the
older row re-read as unread.
5. **Member visibility.** As a plain member, a failure recorded from
another browser does not appear in the bell; as a team reviewer it does.
6. **Resolve endpoint.** `POST
/api/v1/notifications/failure-{eventId}/resolved` as the owner removes
the row on the next poll; `NotificationResolveTest` pins refusal for a
non-owner, an unknown id, and a foreign prefix.

## Migration

None.
2026-09-01 13:25:19 +00:00
Anthony StirlingandJames Brunton ceeec53df4 Let a pipeline run on the editor, on upload or export (#7581)
Redesigns the policies system so that the backend has an understanding
of policies running over the Editor. The Editor is not set up as a
source for the backend because the backend can't actively get files from
it, they come in via the frontend sending them to the backend, so
instead pipelines have a specific editor key in them to encode whether
the pipeline is triggered on file upload/export in the editor.

Also make a big effort in the frontend code towards genericising policy
running. Previously, there was specific support in the main policy
executor for each policy that it had to run, which was not going to be
appropriate long-term, especially when users can run any pipeline in the
editor. There's more work needed here for me to really be happy with it
but this PR is plenty large on its own and moves it in the right
direction.

All of the above was required to allow arbitrary user pipelines to run
in the editor. This PR makes it so that the user can select Editor as a
source in the pipeline creator, along with whether it should run on
upload or export.

<img width="1437" height="506" alt="image"
src="https://github.com/user-attachments/assets/b2d176a1-185c-480b-9916-abdd1447d8e1"
/>

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2026-09-01 13:12:06 +00:00
ConnorYoh 4ef2e3811c ci(preview): give PR previews the Stirling account config they need to link (#7728)
Add CI steps to enable PR deploy servers to link to prod saas. This will
allow pr testing of payment flows, usage of real credits etc
2026-09-01 12:35:57 +00:00
ConnorYoh 31d52d4c32 Connect flow for self-hosted account linking, and the triggers that drive it (#7415)
Replaces the bare account-link login box with a guided Connect flow, and
wires up the triggers that actually put it in front of someone.
## Top bar 
<img width="1580" height="422" alt="image"
src="https://github.com/user-attachments/assets/719e12fc-121a-4caa-bc72-124c5167b011"
/>

## The modal

Three steps on the portal's own `FlowModal` + `StepModalHeader`, the
shells procurement and prepay already wear:

1. **What you unlock** — six benefits as a plain list.
<img width="817" height="503" alt="image"
src="https://github.com/user-attachments/assets/4644ddd2-6181-44e1-9be9-7a961972195d"
/>

2. **Sign in** — the existing `SupabaseLoginForm`, reseated.
<img width="880" height="930" alt="image"
src="https://github.com/user-attachments/assets/fc66cbbb-9f98-40a4-9daa-4f2447713f39"
/>

3. **Connected** — confirms, then deep links into Users, Pipelines and
Policies.
<img width="876" height="752" alt="image"
src="https://github.com/user-attachments/assets/28358e4d-a44f-4118-a8ae-8275984ebd00"
/>


Re-auth stays a single step with no pitch and no success screen.

## The triggers

**`LinkGate` stops being dead code.** It was built as the drop-anywhere
"link to unlock" wrapper and was imported by nothing. It is now a
blocking empty state that replaces the feature it guards, wired into
Pipelines, Policies, Users, Sources and Integrations.

**Scoped to creating and editing, never viewing.** Existing pipelines,
policies, sources and connections keep listing and running, so upgrading
an unlinked instance cannot take away something that already works. The
clicks that would open a builder or a create modal ask for the
connection first, which is the moment an admin has already declared
intent.

## Capability signal

`accountLinkAvailable` on `/api/v1/config/app-config`. Gating needs two
facts: whether the instance is linked (`LinkContext`) and whether it
*could* be (this flag). The account-link endpoints 404 when the feature
flag is off, which the client cannot distinguish from "not linked yet" —
so gating on link state alone would lock all five views on every default
install with no way out. `useConnectGate` holds that decision in one
place and shares the app-config query key, so it costs no extra request.

Read from the environment rather than `AccountLinkProperties` because
`:core` cannot depend on `:proprietary`.
2026-09-01 10:39:57 +00:00
ConnorYoh d55d8acbfa fix(portal): keep the processor's cache across a trip to the editor (#7729)
# Description of Changes

## The problem

The portal's query client was created per mount:

```ts
const [queryClient] = useState(createPortalQueryClient);
```

The portal is a route (`/processor/*`, a lazy element), and the switch
to the editor is a client-side `navigate()`. So leaving the processor
unmounts `PortalApp`, the client goes with the component, and the cache
goes with the client. Coming back refetches everything, whether or not
anything changed: four requests for the Users page alone (roster,
grants, teams, auth config), and 21 `useQuery` sites across the portal.

The editor's client sits above the router in `AppProviders` and survives
the same trip. The round trip only ever cost in one direction.

## The fix

The module already kept the instance in a module-level slot so
`tryGetPortalQueryClient()` could find it. It just replaced it on every
mount instead of reusing it, so the change is to create it lazily and
hand out the same one:

```ts
export function getPortalQueryClient(): QueryClient {
  current ??= new QueryClient({ defaultOptions: { queries: baseQueryOptions } });
  return current;
}
```

Still a separate instance from the editor's. The two namespace their
keys apart (`["portal", ...]` against `["editor", ...]`) and invalidate
independently, which this does not change.

## What this does not do

`gcTime` is 5 minutes, from the shared `baseQueryOptions`. An entry with
no observer is still collected on that timer, so this warms a quick trip
to the editor and back, not a return after a long editing session.
Raising the portal's `gcTime` is a separate decision and is not made
here.

## Why it is safe

**Signing out.** A cache that outlives a mount must not outlive a
session, because the portal's holds the admin roster, emails and roles.
Logout goes through `window.location.assign`, a full page load, so the
whole JS context is discarded and no cache can survive it. Nothing in
the codebase calls `queryClient.clear()` on sign-out, and nothing needs
to. If logout ever becomes a client-side navigation, this needs an
explicit reset, and `resetPortalQueryClient()` is the hook for it.

**The one caller of the null check.** `resolveTeam` in
`saas/portal/usersBackend.ts` uses `tryGetPortalQueryClient()` and falls
back to a direct fetch when there is no client, which its comment
describes as the unit-test path; the cache path is preferred because it
honours both `staleTime` and invalidation. A longer-lived client means
the preferred path is taken more often, not less.

## Testing

Three tests in `queryClient.test.tsx`, and the first two fail if the
client goes back to being created per call:

| | |
|---|---|
| A remount is served from cache rather than refetching | the behaviour
this changes |
| Every caller gets the same instance | the mechanism |
| No client is reported until the portal first mounts | the contract
`resolveTeam` reads |

The three existing portal caching suites called the factory expecting a
fresh client per case. They now call `resetPortalQueryClient()` in a
`beforeEach`, which is what keeps `sharing.test.tsx`'s "a later screen
refetches nothing" case honest rather than passing on a leaked cache.

`task frontend:check` passes typecheck, lint and oxfmt, and 2402 of 2404
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, are untouched here and fail the same way
on `main`.
2026-09-01 08:56:35 +00:00
stirlingbot[bot] af97e1b27b Update Backend 3rd Party Licenses (#7713)
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
2026-08-31 12:45:42 +01:00
dependabot[bot] 01c908e95d build(deps-dev): bump openai from 2.53.0 to 3.3.1 in /engine (#7700)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 11:37:12 +01:00
dependabot[bot] 0920ea9493 build(deps): bump go-task/setup-task from 2.1.0 to 2.2.0 (#7532)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 10:17:05 +01:00
dependabot[bot] 96207a7304 build(deps): bump @tanstack/react-query from 5.101.4 to 5.102.0 in /frontend in the tanstack group across 1 directory (#7749)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 10:15:11 +01:00
dependabot[bot] d93049db9f build(deps): bump log from 0.4.33 to 0.4.34 in /frontend/editor/src-tauri (#7747)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 10:14:56 +01:00
dependabot[bot] 54e839ae65 build(deps-dev): bump reportlab from 5.0.0 to 5.0.1 in /engine (#7699)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 10:14:31 +01:00
dependabot[bot] 3aca7a26f6 build(deps-dev): bump python-dotenv from 1.2.2 to 1.2.3 in /engine (#7702)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 10:14:19 +01:00
dependabot[bot] 5fe7df3933 build(deps): bump jackson2Version from 2.22.1 to 2.22.2 (#7703)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 10:14:04 +01:00
dependabot[bot] b1e857fd01 build(deps): bump com.tngtech.archunit:archunit-junit5 from 1.4.2 to 1.5.0 (#7704)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 10:13:47 +01:00
dependabot[bot] b92f88361e build(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#7711)
Bumps
[docker/setup-buildx-action](https://github.com/docker/setup-buildx-action)
from 4.2.0 to 4.3.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/setup-buildx-action/releases">docker/setup-buildx-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.3.0</h2>
<ul>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.92.0 to 0.95.0 in
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/595">docker/setup-buildx-action#595</a></li>
<li>Bump brace-expansion from 1.1.13 to 1.1.18 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/600">docker/setup-buildx-action#600</a></li>
<li>Bump js-yaml from 5.2.0 to 5.3.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/585">docker/setup-buildx-action#585</a></li>
<li>Bump postcss from 8.5.10 to 8.5.25 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/598">docker/setup-buildx-action#598</a></li>
<li>Bump undici from 6.27.0 to 6.28.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/601">docker/setup-buildx-action#601</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0">https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/37fe631027851001ddb9b187196cc803df7f5f0e"><code>37fe631</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/595">#595</a>
from docker/dependabot/npm_and_yarn/docker/actions-to...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/b5c4f91922681cc7c58d15ab7838986951f09d19"><code>b5c4f91</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/3e93b637c6430ba8fa896fad44d3aa6821899d63"><code>3e93b63</code></a>
build(deps): bump <code>@​docker/actions-toolkit</code> from 0.92.0 to
0.95.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/e527031b32c86649307d5d492506855f90470604"><code>e527031</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/600">#600</a>
from docker/dependabot/npm_and_yarn/brace-expansion-1...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/c68814b33cb66f1f7538e546190d410ae557a640"><code>c68814b</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/3f891b01bd5012a434f582800366972569aa1886"><code>3f891b0</code></a>
build(deps): bump brace-expansion from 1.1.13 to 1.1.18</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/787db26fcde8ddcabd49a81472318028f7113962"><code>787db26</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/585">#585</a>
from docker/dependabot/npm_and_yarn/js-yaml-5.2.1</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/f7793687c711790ca336bd4934f1b1bf5f778e17"><code>f779368</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/7d5e60413489a33d28077e11d71c668580cfaf8d"><code>7d5e604</code></a>
build(deps): bump js-yaml from 5.2.0 to 5.3.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/292c2fb3837a12d3ac2d1e47bbc5c00712bad939"><code>292c2fb</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/590">#590</a>
from docker/dependabot/github_actions/actions/setup-n...</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 07:14:30 +00:00
dependabot[bot] f6124223e4 build(deps): bump github/codeql-action/upload-sarif from 4.37.7 to 4.37.8 (#7750)
Bumps
[github/codeql-action/upload-sarif](https://github.com/github/codeql-action)
from 4.37.7 to 4.37.8.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/upload-sarif's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.8</h2>
<p>No user facing changes.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/upload-sarif's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.9 - 26 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4">2.26.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4106">#4106</a></li>
</ul>
<h2>4.37.8 - 21 Aug 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.7 - 13 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.3">2.26.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4085">#4085</a></li>
</ul>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28"><code>db488dd</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4102">#4102</a>
from github/update-v4.37.8-9ee088e13</li>
<li><a
href="https://github.com/github/codeql-action/commit/1845f5ba8b4057590f49ee8e246c95ef2ba4b53f"><code>1845f5b</code></a>
Update changelog for v4.37.8</li>
<li><a
href="https://github.com/github/codeql-action/commit/9ee088e13615f8d1eaef4766f9dde95d3356a8f6"><code>9ee088e</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4080">#4080</a>
from github/henrymercer/studious-giggle</li>
<li><a
href="https://github.com/github/codeql-action/commit/1aef003397c876c0ab5bd118e1b1f34c175622e9"><code>1aef003</code></a>
Address review feedback on overlay disk flags</li>
<li><a
href="https://github.com/github/codeql-action/commit/508b83bc415e8df76ce8ea08c0cf42c2529ebc63"><code>508b83b</code></a>
Merge main into overlay minimum disk feature branch</li>
<li><a
href="https://github.com/github/codeql-action/commit/d97b3428e8eebbb1810cf454d6397886d136b4ba"><code>d97b342</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4098">#4098</a>
from github/mbg/permission-error-as-configuration-error</li>
<li><a
href="https://github.com/github/codeql-action/commit/47fa6222231b12097f83215dd7a6b4a0915841fd"><code>47fa622</code></a>
Make <code>EACCES</code> a <code>ConfigurationError</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/45693cc6882bb175b58a06818c91876e201037c7"><code>45693cc</code></a>
Refactor <code>ENOSPC</code> check into
<code>isDiskConfigurationError</code> function</li>
<li><a
href="https://github.com/github/codeql-action/commit/c2fd8f54d19fa46c94ed79cb92e6dd6606d61762"><code>c2fd8f5</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4081">#4081</a>
from github/mario-campos/version-cache-to-disk</li>
<li><a
href="https://github.com/github/codeql-action/commit/c56f48e9bd458a387eb68a68534459e503e56b17"><code>c56f48e</code></a>
Log unexpected conditions during caching CLI output</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action/upload-sarif&package-manager=github_actions&previous-version=4.37.7&new-version=4.37.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 07:14:20 +00:00
dependabot[bot] 3235645203 build(deps): bump step-security/harden-runner from 2.20.0 to 2.21.0 (#7746)
Bumps
[step-security/harden-runner](https://github.com/step-security/harden-runner)
from 2.20.0 to 2.21.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/step-security/harden-runner/releases">step-security/harden-runner's
releases</a>.</em></p>
<blockquote>
<h2>v2.21.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Support for denied endpoints in block mode. This is included in the
enterprise tier. Customers can deny outbound calls, for example, to
public package registries.</li>
<li>Improved Support for AWS CodeBuild GitHub Actions Runners.</li>
<li>Bug fixes.</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/step-security/harden-runner/compare/v2.20.1...v2.21.0">https://github.com/step-security/harden-runner/compare/v2.20.1...v2.21.0</a></p>
<h2>v2.20.1</h2>
<h2>What's Changed</h2>
<ul>
<li>AWS CodeBuild-hosted runner support</li>
<li>Implicitly allow single-labeled (internal) domains in
block-mode</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/step-security/harden-runner/compare/v2.20.0...v2.20.1">https://github.com/step-security/harden-runner/compare/v2.20.0...v2.20.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/step-security/harden-runner/commit/05e31511f85b41b11d1cf0ef85d0992719546e2c"><code>05e3151</code></a>
Merge pull request <a
href="https://redirect.github.com/step-security/harden-runner/issues/684">#684</a>
from step-security/rc-42</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/0f37afa338f57c61ee3dfc274daca8834963d83e"><code>0f37afa</code></a>
fix: ignore denied-endpoints on non-enterprise tier</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/93b58ee491c5b6cf3a5324966fca2908f8d447f3"><code>93b58ee</code></a>
fix: resolve cache host read-first and never downgrade egress
policy</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/e7399dd3e93d6c159d314af54b4704bc48abf6bc"><code>e7399dd</code></a>
fix: align deny-list mode detection with agent and log when both
endpoint inp...</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/c16689f716a10cdfd9cfe22e63938b8c6c0657de"><code>c16689f</code></a>
test: add denied_endpoints to Configuration fixtures and cover deny-list
merge</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/40b99cf0c7161e4dcdc6c5508927188b65028df9"><code>40b99cf</code></a>
Merge pull request <a
href="https://redirect.github.com/step-security/harden-runner/issues/682">#682</a>
from rohan-stepsecurity/rp/feat/codebuild-self-v2</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/fedec027a205365a7d64001a81931e4c36a1af6e"><code>fedec02</code></a>
Merge branch 'rc-42' into rp/feat/codebuild-self-v2</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/5361fb178b926b2be6df52e11ee257823821567b"><code>5361fb1</code></a>
feat: add build artifacts</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/286474fffe0b8fe7c9db855f132d04a9b48ab564"><code>286474f</code></a>
feat: Support Bravo agent install on CodeBuild runners</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/051ec05283d064bd82f41279db4f70f0717bf778"><code>051ec05</code></a>
Merge pull request <a
href="https://redirect.github.com/step-security/harden-runner/issues/683">#683</a>
from h0x0er/jatin/deny-list</li>
<li>Additional commits viewable in <a
href="https://github.com/step-security/harden-runner/compare/v2.20.0...05e31511f85b41b11d1cf0ef85d0992719546e2c">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=step-security/harden-runner&package-manager=github_actions&previous-version=2.20.0&new-version=2.21.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 07:13:30 +00:00
dependabot[bot] 1f4cc2612d build(deps): bump the eclipse-temurin group across 3 directories with 1 update (#7740)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps the eclipse-temurin group with 1 update in the /docker/backend
directory: eclipse-temurin.
Bumps the eclipse-temurin group with 1 update in the /docker/base
directory: eclipse-temurin.
Bumps the eclipse-temurin group with 1 update in the /docker/embedded
directory: eclipse-temurin.

Updates `eclipse-temurin` from `fbcf915` to `b4c93a5`

Updates `eclipse-temurin` from `fbcf915` to `b4c93a5`

Updates `eclipse-temurin` from `fbcf915` to `b4c93a5`


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 07:12:18 +00:00
dependabot[bot] e539eb1ab1 build(deps): bump the ubuntu group across 2 directories with 1 update (#7698)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps the ubuntu group with 1 update in the /docker/base directory:
ubuntu.
Bumps the ubuntu group with 1 update in the /docker/unoserver directory:
ubuntu.

Updates `ubuntu` from `561618e` to `33ceb71`

Updates `ubuntu` from `561618e` to `33ceb71`

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-30 10:13:31 +00:00
stirlingbot[bot] 1bb6961414 Update Frontend 3rd Party Licenses (#7738)
Auto-generated by stirlingbot[bot]

This PR updates the frontend license report based on changes to
package.json dependencies.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-08-30 10:12:56 +00:00
briosandAnthony Stirling 34694c6f5e refactor(api): standardize syntax and simplify type declarations across security, workflow, and controller modules (#7127)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-08-29 23:19:26 +01:00
briosandAnthony Stirling 0b7b4e02c2 chore(crop): Remove invalid crop area message and related validation logic (#7160)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-08-29 23:11:41 +01:00
briosandAnthony Stirling 74be5bf0ad fix(forms): Fix checkbox export values and wide dropdown options (#7288)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-08-29 23:04:35 +01:00
briosandAnthony Stirling 8c00fffe18 refactor(api): replace com.fasterxml.jackson with tools.jackson (Jackson 2 to Jackson 3 namespace.) (#7444)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-08-29 23:04:06 +01:00
briosandAnthony Stirling c5da4177c4 refactor(ui): improve button layouts and modal sizing of formFill (#7509)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-08-29 22:22:34 +01:00
brios ddc0ac0ced fix(api): fix endpoint set concurrency and in-memory leaks (#7505) 2026-08-29 22:19:29 +01:00
dependabot[bot] 8bdd00b2fa build(deps): bump @tanstack/react-virtual from 3.13.23 to 3.14.10 in /frontend in the tanstack group across 1 directory (#7605)
Bumps the tanstack group with 1 update in the /frontend directory:
[@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual).

Updates `@tanstack/react-virtual` from 3.13.23 to 3.14.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/TanStack/virtual/releases">@​tanstack/react-virtual's
releases</a>.</em></p>
<blockquote>
<h2><code>@​tanstack/react-virtual</code><a
href="https://github.com/3"><code>@​3</code></a>.14.10</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/a0a411e06f7334a063422de35d59b12b264b3573"><code>a0a411e</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/d2cf98beea1696c7187c06b57c9e724d1957963c"><code>d2cf98b</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.8</li>
</ul>
</li>
</ul>
<h2><code>@​tanstack/react-virtual</code><a
href="https://github.com/3"><code>@​3</code></a>.14.9</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/a5417b4b0d3c82876747bb9635db7239c28d3e44"><code>a5417b4</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.7</li>
</ul>
</li>
</ul>
<h2><code>@​tanstack/react-virtual</code><a
href="https://github.com/3"><code>@​3</code></a>.14.8</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/TanStack/virtual/pull/1237">#1237</a>
<a
href="https://github.com/TanStack/virtual/commit/aa536e7746a88d9f55ca8a4b50d2f548a888fea6"><code>aa536e7</code></a>
- Fix a gap at the top of the list after an end-anchored prepend in
<code>directDomUpdates</code> mode. The prepend grows the total size and
bumps <code>scrollOffset</code> to the new bottom in the same pass, but
the size container's height was written <em>after</em>
<code>_willUpdate</code> synced the scroll position — so the browser
clamped the <code>scrollTop</code> write to the stale (shorter)
<code>scrollHeight</code>, leaving whitespace at the top until the next
scroll. The container is now grown before the scroll sync. Only affected
<code>directDomUpdates</code> mode (React-rendered sizers receive their
height during render).</p>
</li>
<li>
<p>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/7ae32b55887fd044a48c788546cd940279b338e0"><code>7ae32b5</code></a>]:</p>
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.6</li>
</ul>
</li>
</ul>
<h2><code>@​tanstack/react-virtual</code><a
href="https://github.com/3"><code>@​3</code></a>.14.7</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/1e3b908705e04e45be2615f2277580cb09f5cdef"><code>1e3b908</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/7dcfc07b877479697124157d3124c09537b87a75"><code>7dcfc07</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.5</li>
</ul>
</li>
</ul>
<h2><code>@​tanstack/react-virtual</code><a
href="https://github.com/3"><code>@​3</code></a>.14.6</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/6cbecd887df56faaee3b6a81a1aae8049de0671e"><code>6cbecd8</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/d49cc526fe248be7b5ad97ec6ac814db8271b0d0"><code>d49cc52</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/cf7834daade953fea5dfd2ab5685c15771ca300a"><code>cf7834d</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.4</li>
</ul>
</li>
</ul>
<h2><code>@​tanstack/react-virtual</code><a
href="https://github.com/3"><code>@​3</code></a>.14.5</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/767ead46e4fab761fd6e15bcf281486042723152"><code>767ead4</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/bc8643b7579e10e512654f58269de13d98b48781"><code>bc8643b</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.3</li>
</ul>
</li>
</ul>
<h2><code>@​tanstack/react-virtual</code><a
href="https://github.com/3"><code>@​3</code></a>.14.4</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/b04f9ee48f0812e89156c1dac1fa58277cc32464"><code>b04f9ee</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/37be28427ba52399ce8884e0006933e83f2645e9"><code>37be284</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.2</li>
</ul>
</li>
</ul>
<h2><code>@​tanstack/react-virtual</code><a
href="https://github.com/3"><code>@​3</code></a>.14.3</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/TanStack/virtual/pull/1201">#1201</a>
<a
href="https://github.com/TanStack/virtual/commit/2ba5eb60f108f4ba9b2bd9570bbd41f9ce618438"><code>2ba5eb6</code></a>
- Make <code>directDomUpdates</code> a no-op for direct DOM writes when
<code>containerRef</code> is omitted. Previously the virtualizer still
wrote item positions while never sizing the container (a broken
half-state). Now omitting <code>containerRef</code> skips all direct
writes while still skipping re-renders, letting consumers own the DOM
updates themselves (e.g. in <code>onChange</code>).</p>
</li>
<li>
<p>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/ef69ea31738caa2819142e922efa03d3c408e25c"><code>ef69ea3</code></a>]:</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md">@​tanstack/react-virtual's
changelog</a>.</em></p>
<blockquote>
<h2>3.14.10</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/a0a411e06f7334a063422de35d59b12b264b3573"><code>a0a411e</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/d2cf98beea1696c7187c06b57c9e724d1957963c"><code>d2cf98b</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.8</li>
</ul>
</li>
</ul>
<h2>3.14.9</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/a5417b4b0d3c82876747bb9635db7239c28d3e44"><code>a5417b4</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.7</li>
</ul>
</li>
</ul>
<h2>3.14.8</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/TanStack/virtual/pull/1237">#1237</a>
<a
href="https://github.com/TanStack/virtual/commit/aa536e7746a88d9f55ca8a4b50d2f548a888fea6"><code>aa536e7</code></a>
- Fix a gap at the top of the list after an end-anchored prepend in
<code>directDomUpdates</code> mode. The prepend grows the total size and
bumps <code>scrollOffset</code> to the new bottom in the same pass, but
the size container's height was written <em>after</em>
<code>_willUpdate</code> synced the scroll position — so the browser
clamped the <code>scrollTop</code> write to the stale (shorter)
<code>scrollHeight</code>, leaving whitespace at the top until the next
scroll. The container is now grown before the scroll sync. Only affected
<code>directDomUpdates</code> mode (React-rendered sizers receive their
height during render).</p>
</li>
<li>
<p>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/7ae32b55887fd044a48c788546cd940279b338e0"><code>7ae32b5</code></a>]:</p>
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.6</li>
</ul>
</li>
</ul>
<h2>3.14.7</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/1e3b908705e04e45be2615f2277580cb09f5cdef"><code>1e3b908</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/7dcfc07b877479697124157d3124c09537b87a75"><code>7dcfc07</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.5</li>
</ul>
</li>
</ul>
<h2>3.14.6</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/6cbecd887df56faaee3b6a81a1aae8049de0671e"><code>6cbecd8</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/d49cc526fe248be7b5ad97ec6ac814db8271b0d0"><code>d49cc52</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/cf7834daade953fea5dfd2ab5685c15771ca300a"><code>cf7834d</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.4</li>
</ul>
</li>
</ul>
<h2>3.14.5</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/767ead46e4fab761fd6e15bcf281486042723152"><code>767ead4</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/bc8643b7579e10e512654f58269de13d98b48781"><code>bc8643b</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.3</li>
</ul>
</li>
</ul>
<h2>3.14.4</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/TanStack/virtual/commit/b04f9ee48f0812e89156c1dac1fa58277cc32464"><code>b04f9ee</code></a>,
<a
href="https://github.com/TanStack/virtual/commit/37be28427ba52399ce8884e0006933e83f2645e9"><code>37be284</code></a>]:
<ul>
<li><code>@​tanstack/virtual-core</code><a
href="https://github.com/3"><code>@​3</code></a>.17.2</li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/TanStack/virtual/commit/e9874f033c74afd3251eeb9f3e60b2530cc7ae88"><code>e9874f0</code></a>
ci: Version Packages (<a
href="https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual/issues/1247">#1247</a>)</li>
<li><a
href="https://github.com/TanStack/virtual/commit/b4a76cac25ef7e334c180ceb8c0d859b7c91ab09"><code>b4a76ca</code></a>
fix(marko-virtual): consolidate Marko e2e into one in-package app, fix
test (...</li>
<li><a
href="https://github.com/TanStack/virtual/commit/deca524a9b2ed29a8a23001389580de10f8002db"><code>deca524</code></a>
ci: Version Packages (<a
href="https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual/issues/1240">#1240</a>)</li>
<li><a
href="https://github.com/TanStack/virtual/commit/32b2f2b412739015a47da1463fe2749456cdc4e9"><code>32b2f2b</code></a>
ci: Version Packages (<a
href="https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual/issues/1238">#1238</a>)</li>
<li><a
href="https://github.com/TanStack/virtual/commit/aa536e7746a88d9f55ca8a4b50d2f548a888fea6"><code>aa536e7</code></a>
fix(react-virtual): grow size container before scroll sync on
end-anchored pr...</li>
<li><a
href="https://github.com/TanStack/virtual/commit/87f689a5c67ee1ed8db1e6754021a6b6b41c8550"><code>87f689a</code></a>
ci: Version Packages (<a
href="https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual/issues/1231">#1231</a>)</li>
<li><a
href="https://github.com/TanStack/virtual/commit/ba5c47a93f597f8370bc9e0119d505551c962a09"><code>ba5c47a</code></a>
feat(angular-virtual): add chat example and require Angular 20 (<a
href="https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual/issues/1228">#1228</a>)</li>
<li><a
href="https://github.com/TanStack/virtual/commit/e2cb096862f5b74aa586957eae207b39999cb654"><code>e2cb096</code></a>
ci: Version Packages (<a
href="https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual/issues/1225">#1225</a>)</li>
<li><a
href="https://github.com/TanStack/virtual/commit/d49cc526fe248be7b5ad97ec6ac814db8271b0d0"><code>d49cc52</code></a>
fix(virtual-core): invalidate measurements when gap option changes (<a
href="https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual/issues/1223">#1223</a>)</li>
<li><a
href="https://github.com/TanStack/virtual/commit/151e9f47abd4ef2d3b11936c04be8908e6bd0607"><code>151e9f4</code></a>
ci: Version Packages (<a
href="https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual/issues/1213">#1213</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.10/packages/react-virtual">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 14:50:18 +00:00
dependabot[bot] 3718af45ff build(deps): bump the mui group across 1 directory with 2 updates (#7602)
Bumps the mui group with 1 update in the /frontend directory:
[@mui/icons-material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material).

Updates `@mui/icons-material` from 9.2.0 to 9.3.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/mui/material-ui/releases">@​mui/icons-material's
releases</a>.</em></p>
<blockquote>
<h2>v9.3.1</h2>
<p>A big thanks to the 4 contributors who made this release
possible.</p>
<h3><code>@mui/material@9.3.1</code></h3>
<ul>
<li>[transitions] Prevent exit transitions from getting stuck (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48881">#48881</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
</ul>
<h3><code>@mui/codemod@9.3.1</code></h3>
<ul>
<li>Include transforms in published package (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48934">#48934</a>)
<a
href="https://github.com/brijeshb42"><code>@​brijeshb42</code></a></li>
</ul>
<h3>Core</h3>
<ul>
<li>[blog] Clarify early bird renewal discount scope (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48906">#48906</a>)
<a href="https://github.com/DanailH"><code>@​DanailH</code></a></li>
<li>[test][pagination] Add more unit tests (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48927">#48927</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
</ul>
<p>All contributors of this release in alphabetical order: <a
href="https://github.com/brijeshb42"><code>@​brijeshb42</code></a>, <a
href="https://github.com/DanailH"><code>@​DanailH</code></a>, <a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a>,
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></p>
<h2>v9.3.0</h2>
<p>A big thanks to the 18 contributors who made this release possible.
Here are some highlights :</p>
<ul>
<li>️ Keyboard navigation in the <a
href="https://mui.com/material-ui/react-toggle-button/">Toggle Button
Group</a> now follows the roving tabindex pattern.</li>
<li>️ The <a
href="https://mui.com/material-ui/react-autocomplete/">Autocomplete</a>
announces its loading and no options messages through a new
<code>status</code> slot.</li>
</ul>
<h3><code>@mui/material@9.3.0</code></h3>
<ul>
<li>[autocomplete] Wrap the no results and loading messages in an aria
live region (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48690">#48690</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
<li>[buttongroup] Respect global disableRipple / disableFocusRipple in
grouped buttons (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48762">#48762</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[checkbox][radio] Respect global disableRipple from MuiButtonBase
defaultProps (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48795">#48795</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[formcontrollabel] Add missing <code>labelPlacementEnd</code> class
(<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48843">#48843</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[listitembutton] Fix typos in component code (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48868">#48868</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[menuitem] Add <code>aria-checked</code> for checkbox and radio menu
items (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48651">#48651</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[modal] Replace custom findIndexOf with findIndex (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48827">#48827</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[modal][dialog] Fix scrollbar compensation in Shadow DOM (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48826">#48826</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[select] Fix endAdornment overlapping the open indicator (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48723">#48723</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[tablepagination] Add focus style to default InputBase used in
Select (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48871">#48871</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
<li>[togglebuttongroup] Add roving tabindex keyboard navigation (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48849">#48849</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
</ul>
<h3><code>@mui/system@9.3.0</code></h3>
<ul>
<li>Prevent prototype pollution in cssVarsParser (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48822">#48822</a>)
<a href="https://github.com/Janpot"><code>@​Janpot</code></a></li>
</ul>
<h3><code>@mui/codemod@9.3.0</code></h3>
<ul>
<li>Don't leak state between files in v5.0.0/path-imports (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48797">#48797</a>)
<a
href="https://github.com/manbearwiz"><code>@​manbearwiz</code></a></li>
<li>Remove use of eval() (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48701">#48701</a>)
<a
href="https://github.com/oliviertassinari"><code>@​oliviertassinari</code></a></li>
<li>Transform all style exports in <code>v5.0.0/path-imports</code>
codemod (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48800">#48800</a>)
<a
href="https://github.com/manbearwiz"><code>@​manbearwiz</code></a></li>
</ul>
<h3>Docs</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/mui/material-ui/blob/master/CHANGELOG.md">@​mui/icons-material's
changelog</a>.</em></p>
<blockquote>
<h2>9.3.1</h2>
<!-- raw HTML omitted -->
<p><em>Aug 6, 2026</em></p>
<p>A big thanks to the 4 contributors who made this release
possible.</p>
<h3><code>@mui/material@9.3.1</code></h3>
<ul>
<li>[transitions] Prevent exit transitions from getting stuck (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48881">#48881</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
</ul>
<h3><code>@mui/codemod@9.3.1</code></h3>
<ul>
<li>Include transforms in published package (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48934">#48934</a>)
<a
href="https://github.com/brijeshb42"><code>@​brijeshb42</code></a></li>
</ul>
<h3>Core</h3>
<ul>
<li>[blog] Clarify early bird renewal discount scope (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48906">#48906</a>)
<a href="https://github.com/DanailH"><code>@​DanailH</code></a></li>
<li>[test][pagination] Add more unit tests (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48927">#48927</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
</ul>
<p>All contributors of this release in alphabetical order: <a
href="https://github.com/brijeshb42"><code>@​brijeshb42</code></a>, <a
href="https://github.com/DanailH"><code>@​DanailH</code></a>, <a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a>,
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></p>
<h2>9.3.0</h2>
<!-- raw HTML omitted -->
<p><em>Aug 4, 2026</em></p>
<p>A big thanks to the 18 contributors who made this release possible.
Here are some highlights :</p>
<ul>
<li>️ Keyboard navigation in the <a
href="https://mui.com/material-ui/react-toggle-button/">Toggle Button
Group</a> now follows the roving tabindex pattern.</li>
<li>️ The <a
href="https://mui.com/material-ui/react-autocomplete/">Autocomplete</a>
announces its loading and no options messages through a new
<code>status</code> slot.</li>
</ul>
<h3><code>@mui/material@9.3.0</code></h3>
<ul>
<li>[autocomplete] Wrap the no results and loading messages in an aria
live region (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48690">#48690</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
<li>[buttongroup] Respect global disableRipple / disableFocusRipple in
grouped buttons (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48762">#48762</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[checkbox][radio] Respect global disableRipple from MuiButtonBase
defaultProps (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48795">#48795</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[formcontrollabel] Add missing <code>labelPlacementEnd</code> class
(<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48843">#48843</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[listitembutton] Fix typos in component code (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48868">#48868</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[menuitem] Add <code>aria-checked</code> for checkbox and radio menu
items (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48651">#48651</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[modal] Replace custom findIndexOf with findIndex (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48827">#48827</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[modal][dialog] Fix scrollbar compensation in Shadow DOM (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48826">#48826</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[select] Fix endAdornment overlapping the open indicator (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48723">#48723</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[tablepagination] Add focus style to default InputBase used in
Select (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48871">#48871</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
<li>[togglebuttongroup] Add roving tabindex keyboard navigation (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48849">#48849</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
</ul>
<h3><code>@mui/system@9.3.0</code></h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/mui/material-ui/commit/5b91ac75008dbd43286a20ef87847042cc7a44ca"><code>5b91ac7</code></a>
[release] v9.3.1 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48935">#48935</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/da37c088786eead9c7ddaffe0798ec692ece0a11"><code>da37c08</code></a>
v9.3.0 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48909">#48909</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/2e38eb7f8f77152f4bb4047169cce332da420cb9"><code>2e38eb7</code></a>
Bump chalk to 6.0.0 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48902">#48902</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/20fe2b6aa86965e3f1e2e5b0a82e2ed38f753ffd"><code>20fe2b6</code></a>
Bump code-infra:devDependencies (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48891">#48891</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/a900cd7d7e66e37248ec0ae8da44d588d0577aa3"><code>a900cd7</code></a>
Bump react monorepo to 19.2.8 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48858">#48858</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/8cbc3ce36fb59cd6f4e3a3e925fb247b6c4b971b"><code>8cbc3ce</code></a>
Bump code-infra:devDependencies (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48830">#48830</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/a5faab53e647b92b5efb8ea26ce1ae758778736e"><code>a5faab5</code></a>
Bump react monorepo (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48770">#48770</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/ca10194ad116e97fa4ecfc95ca09421bbbb6e2a7"><code>ca10194</code></a>
Bump code-infra:devDependencies (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48767">#48767</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/620c9e95e8e57d99d91524f6f60de00d194184f1"><code>620c9e9</code></a>
Bump babel monorepo to ^7.29.7 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material/issues/48766">#48766</a>)</li>
<li>See full diff in <a
href="https://github.com/mui/material-ui/commits/v9.3.1/packages/mui-icons-material">compare
view</a></li>
</ul>
</details>
<br />

Updates `@mui/material` from 9.2.0 to 9.3.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/mui/material-ui/releases">@​mui/material's
releases</a>.</em></p>
<blockquote>
<h2>v9.3.1</h2>
<p>A big thanks to the 4 contributors who made this release
possible.</p>
<h3><code>@mui/material@9.3.1</code></h3>
<ul>
<li>[transitions] Prevent exit transitions from getting stuck (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48881">#48881</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
</ul>
<h3><code>@mui/codemod@9.3.1</code></h3>
<ul>
<li>Include transforms in published package (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48934">#48934</a>)
<a
href="https://github.com/brijeshb42"><code>@​brijeshb42</code></a></li>
</ul>
<h3>Core</h3>
<ul>
<li>[blog] Clarify early bird renewal discount scope (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48906">#48906</a>)
<a href="https://github.com/DanailH"><code>@​DanailH</code></a></li>
<li>[test][pagination] Add more unit tests (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48927">#48927</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
</ul>
<p>All contributors of this release in alphabetical order: <a
href="https://github.com/brijeshb42"><code>@​brijeshb42</code></a>, <a
href="https://github.com/DanailH"><code>@​DanailH</code></a>, <a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a>,
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></p>
<h2>v9.3.0</h2>
<p>A big thanks to the 18 contributors who made this release possible.
Here are some highlights :</p>
<ul>
<li>️ Keyboard navigation in the <a
href="https://mui.com/material-ui/react-toggle-button/">Toggle Button
Group</a> now follows the roving tabindex pattern.</li>
<li>️ The <a
href="https://mui.com/material-ui/react-autocomplete/">Autocomplete</a>
announces its loading and no options messages through a new
<code>status</code> slot.</li>
</ul>
<h3><code>@mui/material@9.3.0</code></h3>
<ul>
<li>[autocomplete] Wrap the no results and loading messages in an aria
live region (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48690">#48690</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
<li>[buttongroup] Respect global disableRipple / disableFocusRipple in
grouped buttons (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48762">#48762</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[checkbox][radio] Respect global disableRipple from MuiButtonBase
defaultProps (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48795">#48795</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[formcontrollabel] Add missing <code>labelPlacementEnd</code> class
(<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48843">#48843</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[listitembutton] Fix typos in component code (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48868">#48868</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[menuitem] Add <code>aria-checked</code> for checkbox and radio menu
items (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48651">#48651</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[modal] Replace custom findIndexOf with findIndex (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48827">#48827</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[modal][dialog] Fix scrollbar compensation in Shadow DOM (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48826">#48826</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[select] Fix endAdornment overlapping the open indicator (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48723">#48723</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[tablepagination] Add focus style to default InputBase used in
Select (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48871">#48871</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
<li>[togglebuttongroup] Add roving tabindex keyboard navigation (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48849">#48849</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
</ul>
<h3><code>@mui/system@9.3.0</code></h3>
<ul>
<li>Prevent prototype pollution in cssVarsParser (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48822">#48822</a>)
<a href="https://github.com/Janpot"><code>@​Janpot</code></a></li>
</ul>
<h3><code>@mui/codemod@9.3.0</code></h3>
<ul>
<li>Don't leak state between files in v5.0.0/path-imports (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48797">#48797</a>)
<a
href="https://github.com/manbearwiz"><code>@​manbearwiz</code></a></li>
<li>Remove use of eval() (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48701">#48701</a>)
<a
href="https://github.com/oliviertassinari"><code>@​oliviertassinari</code></a></li>
<li>Transform all style exports in <code>v5.0.0/path-imports</code>
codemod (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48800">#48800</a>)
<a
href="https://github.com/manbearwiz"><code>@​manbearwiz</code></a></li>
</ul>
<h3>Docs</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/mui/material-ui/blob/master/CHANGELOG.md">@​mui/material's
changelog</a>.</em></p>
<blockquote>
<h2>9.3.1</h2>
<!-- raw HTML omitted -->
<p><em>Aug 6, 2026</em></p>
<p>A big thanks to the 4 contributors who made this release
possible.</p>
<h3><code>@mui/material@9.3.1</code></h3>
<ul>
<li>[transitions] Prevent exit transitions from getting stuck (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48881">#48881</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
</ul>
<h3><code>@mui/codemod@9.3.1</code></h3>
<ul>
<li>Include transforms in published package (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48934">#48934</a>)
<a
href="https://github.com/brijeshb42"><code>@​brijeshb42</code></a></li>
</ul>
<h3>Core</h3>
<ul>
<li>[blog] Clarify early bird renewal discount scope (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48906">#48906</a>)
<a href="https://github.com/DanailH"><code>@​DanailH</code></a></li>
<li>[test][pagination] Add more unit tests (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48927">#48927</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
</ul>
<p>All contributors of this release in alphabetical order: <a
href="https://github.com/brijeshb42"><code>@​brijeshb42</code></a>, <a
href="https://github.com/DanailH"><code>@​DanailH</code></a>, <a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a>,
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></p>
<h2>9.3.0</h2>
<!-- raw HTML omitted -->
<p><em>Aug 4, 2026</em></p>
<p>A big thanks to the 18 contributors who made this release possible.
Here are some highlights :</p>
<ul>
<li>️ Keyboard navigation in the <a
href="https://mui.com/material-ui/react-toggle-button/">Toggle Button
Group</a> now follows the roving tabindex pattern.</li>
<li>️ The <a
href="https://mui.com/material-ui/react-autocomplete/">Autocomplete</a>
announces its loading and no options messages through a new
<code>status</code> slot.</li>
</ul>
<h3><code>@mui/material@9.3.0</code></h3>
<ul>
<li>[autocomplete] Wrap the no results and loading messages in an aria
live region (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48690">#48690</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
<li>[buttongroup] Respect global disableRipple / disableFocusRipple in
grouped buttons (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48762">#48762</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[checkbox][radio] Respect global disableRipple from MuiButtonBase
defaultProps (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48795">#48795</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[formcontrollabel] Add missing <code>labelPlacementEnd</code> class
(<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48843">#48843</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[listitembutton] Fix typos in component code (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48868">#48868</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[menuitem] Add <code>aria-checked</code> for checkbox and radio menu
items (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48651">#48651</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[modal] Replace custom findIndexOf with findIndex (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48827">#48827</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[modal][dialog] Fix scrollbar compensation in Shadow DOM (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48826">#48826</a>)
<a
href="https://github.com/ZeeshanTamboli"><code>@​ZeeshanTamboli</code></a></li>
<li>[select] Fix endAdornment overlapping the open indicator (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48723">#48723</a>)
<a
href="https://github.com/siriwatknp"><code>@​siriwatknp</code></a></li>
<li>[tablepagination] Add focus style to default InputBase used in
Select (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48871">#48871</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
<li>[togglebuttongroup] Add roving tabindex keyboard navigation (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48849">#48849</a>)
<a
href="https://github.com/silviuaavram"><code>@​silviuaavram</code></a></li>
</ul>
<h3><code>@mui/system@9.3.0</code></h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/mui/material-ui/commit/5b91ac75008dbd43286a20ef87847042cc7a44ca"><code>5b91ac7</code></a>
[release] v9.3.1 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48935">#48935</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/a13824f0bae9214534d2a35740802d15d711a373"><code>a13824f</code></a>
[transitions] Prevent exit transitions from getting stuck (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48881">#48881</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/54e1993311bbc3e61fe1684dfcf3fed784bfdcd8"><code>54e1993</code></a>
[test][pagination] Add more unit tests (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48927">#48927</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/da37c088786eead9c7ddaffe0798ec692ece0a11"><code>da37c08</code></a>
v9.3.0 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48909">#48909</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/0bb025974d5eca56f626121cd14a21b77e71e982"><code>0bb0259</code></a>
[tablepagination] Add focus style to default InputBase used in Select
(<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48871">#48871</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/20fe2b6aa86965e3f1e2e5b0a82e2ed38f753ffd"><code>20fe2b6</code></a>
Bump code-infra:devDependencies (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48891">#48891</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/7fb01101f45fb72fdbeb3d826984030583e71ea9"><code>7fb0110</code></a>
[togglebuttongroup] Add roving tabindex keyboard navigation (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48849">#48849</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/3dfeb20bb65e598f90200ef1fc1429d02fa8c4b7"><code>3dfeb20</code></a>
[internal] Fix typos in ListItemButton component code (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48868">#48868</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/27f46fa1acabd6d70898b40544f20000bea0149d"><code>27f46fa</code></a>
Bump <code>@​types/sinon</code> to 22.0.0 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48865">#48865</a>)</li>
<li><a
href="https://github.com/mui/material-ui/commit/a900cd7d7e66e37248ec0ae8da44d588d0577aa3"><code>a900cd7</code></a>
Bump react monorepo to 19.2.8 (<a
href="https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/issues/48858">#48858</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/mui/material-ui/commits/v9.3.1/packages/mui-material">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 14:50:15 +00:00
LudyandCopilot 41cbd97b48 ci: reuse shared Python dependency cache across workflows (#7693)
# Description of Changes

This PR removes workflow-specific cache suffixes from Python dependency
caching in several CI workflows.

Previously, the following workflows appended their own `cache-suffix`
even though they use the same Python dependency files:

- `ai-engine.yml`
- `check-generated-models.yml`
- `pre_commit.yml`
- `sync_files_v2.yml`

All of these workflows use the same cache dependency inputs:

- `engine/pyproject.toml`
- `engine/uv.lock`

The workflow-specific suffixes caused separate cache entries to be
created for effectively identical dependency sets. This resulted in
unnecessary cache duplication and reduced cache reuse between workflows.

By removing the suffixes, these workflows can now share the same cache
when their dependency inputs and other cache key components match.

This change reduces redundant cache storage, improves cache hit
potential across CI workflows, and avoids repeatedly creating equivalent
caches under different names.

No functional application behavior is changed. The modification only
affects CI cache key generation and reuse.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-29 14:50:11 +00:00
dependabot[bot] 993adaa3cd build(deps-dev): bump @iconify-json/material-symbols from 1.2.83 to 1.2.89 in /frontend in the iconify group across 1 directory (#7641)
Bumps the iconify group with 1 update in the /frontend directory:
[@iconify-json/material-symbols](https://github.com/iconify/icon-sets).

Updates `@iconify-json/material-symbols` from 1.2.83 to 1.2.89
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/iconify/icon-sets/commits">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 14:50:09 +00:00
ConnorYohandAnthony Stirling ead8a536d2 feat(editor): move signing sessions onto TanStack Query (#7436)
# Description of Changes

Step 4 of the TanStack Query rollout, and the first of the polling
hooks. Follows #7264, #7283, #7285.

## The problem

`useSigningSessions` hand-rolled its own fetch, loading state and
`setInterval`. Two consequences:

- **A raw `setInterval` keeps polling a hidden tab.** Browsers throttle
background timers, they do not stop them, so a backgrounded editor with
Shared Sign open keeps hitting both endpoints for as long as it is open.
- **No tests.** The hook had none, and its quietest behaviour (below) is
the easiest thing to break without noticing.

## End state

One query behind `qk.signingSessions()`, with the polling lifecycle
handed to the library:

- Polling stops while the tab is hidden, and refetches on return rather
than leaving data up to a full interval stale.
- Mounts render from cache while they revalidate, so moving between the
tool picker and the signing tool no longer flashes an empty list.
- 12 tests where there were none.

Same return shape, so no consumer files change.

### What this is not

This is not a deduplication win. The three consumers are never mounted
at the same time: `ToolPanel` renders the tool picker or the active tool
and never both, so the badge cannot be on screen with either of the
others, and `SharedSigningLauncher` and `useSigningSessionController`
sit inside two different tools. The shared key earns its keep on cache
reuse across those transitions, not on concurrent fetches.

## The bit worth reviewing

The hand-rolled `{ silent: true }` flag encoded three states, and no
single Query flag reproduces them:

| | Spinner | Toast on failure |
|---|---|---|
| First load | yes | yes |
| Background poll | no | no |
| Explicit refetch | **yes** | **yes** |

`isLoading` is false during an explicit refetch when data is already on
screen; `isFetching` is true during a background poll. Neither matches,
so the user-initiated case is tracked with a small flag and the failure
toast is gated on `isLoadingError` plus the explicit path.

## Testing

Twelve tests. Rather than trust them, each claim was checked by breaking
the implementation and confirming the relevant test fails:

| Mutation | Caught by |
|---|---|
| `refetchIntervalInBackground: true` | hidden-tab test |
| Drop `refetchOnWindowFocus` | returns-to-view test |
| Drop the user-initiated spinner flag | manual-refresh test |
| Toast on every error | background-failure-is-silent test |
| Give each observer its own key | dedupe test |

Three things worth knowing for the next conversion:

- **`waitFor` flushes renders.** Recording an index *after*
`waitFor(callCount === 2)` skips past the in-flight render, so a "did
the spinner flip on" assertion passes vacuously. The marker has to go
before the poll.
- **Fake timers hide in-flight state.** The fetch settles inside the
same `act()`, so the intermediate render never happens. That test uses
real timers and a held-open promise.
- **`visibilitychange` has to bubble.** query-core listens for it on
`window`, and the real event bubbles from `document`. A test helper
dispatching a non-bubbling event never reaches the focus manager, and
the pause behaviour still appears to work because `refetchInterval`
reads `document.visibilityState` directly at tick time rather than
through the event.

**One claim is deliberately unguarded.** `isLoading` vs `isFetching` for
a background poll produces no re-render at all, so there is nothing
observable for a test to assert and no user-visible difference to
protect.

## Pre-existing failures

`task frontend:check` passes typecheck, lint and oxfmt, and 2363 of 2365
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, fail identically with this branch's
changes reverted and are untouched by it.

## Scope

This is one of five pollers. The remaining four, `useLocalFolderPoller`,
`WatchedFolderWorkbenchView`, `SessionDetailPanel` and cloud
`TeamSection`, are separate files with their own consumers and follow
separately, now that the silent-refresh pattern has a worked example.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-08-29 10:35:40 +00:00
ConnorYoh c22d9ecf58 feat(editor): move the admin directory onto TanStack Query (#7726)
# Description of Changes

Step 5 of the TanStack Query rollout, covering the admin People, Teams
and Team details screens. Follows #7264, #7283, #7285.

## The problem

Two separate ones, in the same three files.

**Reads.** Each section fetched and held its own copy of the same
resources: People read the roster and the team list, Teams read the team
list plus the roster again when its add-member modal opened, Team
details read all three. Cost scaled with how many screens you visited
rather than with how much data exists.

**Writes.** Thirteen handlers each did the same five things by hand: set
a processing flag, call the service, toast the outcome, dig a message
out of an axios error, and reload their own slice. Refreshing was a
convention, not a mechanism, and one handler had already forgotten it.

## The fix

Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one
`useAdminMutation` helper that every write is declared against:

```ts
const createTeam = useAdminMutation({
  write: (name: string) => teamService.createTeam(name),
  invalidates: ["teams"],
  success: t("workspace.teams.createTeam.success"),
  errorFallback: t("workspace.teams.createTeam.error"),
  onDone: () => { setNewTeamName(""); setCreateModalOpened(false); },
});
```

Each write names the slices it disturbs, which is the part that only
works when reads and writes are designed together: `createTeam`
invalidates the team list, while a membership move invalidates the list,
both teams' detail rows and the roster, because it genuinely changes all
three. Invalidation refetches only mounted queries, so this costs
nothing extra.

The blanket "invalidate everything" helper survives in exactly one role:
child components (invite, password change, seat update) that write
through their own services, where the affected scopes are not visible
from the call site.

## Why it is better, measured

Request counts come from one harness driving `teams -> team details ->
back -> people`, run against the branch point and against this branch.
The assertion is committed, so it cannot silently regress.

| | Before | After |
|---|---|---|
| Requests | 7 | **3** |
| `getTeams` | 4 | **1** |
| `getUsers` | 2 | **1** |
| `getTeamDetails` | 1 | 1 |
| Committed renders | 17 | **15** |

Three is one per distinct resource, the floor for that sequence. The
four `getTeams` were the Teams table, Team details fetching the same
list for its "move to team" dropdown, the explicit refresh on the back
button, and People.

Renders barely move, which is expected: this changes where data lives,
not how often React draws. It is reported because a caching change can
quietly cost renders, and this one does not.

On the code itself, across the three sections:

| | |
|---|---|
| Net lines | **-216** |
| `useState`/`useEffect` removed | **11**, none added |
| Duplicated `isAxiosError` blocks | 13 to **1** |
| `setProcessing` calls | 19 to **0** |

`isAxiosError` is no longer imported by any of the three files.

## Bug fixed

`disableMfaByAdmin` showed a success toast and never refreshed. The menu
item renders only when `user.mfaEnabled` is true, so an admin disabled
MFA, was told it worked, and watched the option stay on screen until a
manual reload. It is covered by a test that fails if the invalidation is
removed.

## Behaviour worth checking in review

- A write no longer blocks its handler before closing the modal. The
dialog closes when the write succeeds and the table updates when the
refetch lands, rather than the button spinning through both.
- Modal submit buttons now track their own mutation rather than one
shared flag. Team details still derives a single busy flag, now from its
five mutations rather than a `useState`, so its row actions disable
together as before.
- The per-handler `console.error` is kept, once, in the shared error
path.

## Testing

Five tests, each verified by breaking the implementation and confirming
that one test, and only that one, fails:

| Mutation | Caught by |
|---|---|
| Drop the shared stale window (`staleTime: 0`) | request-count test |
| Make invalidation a no-op | write-visibility test |
| Ignore the login-enabled gate | login-disabled test |
| Stop invalidating after the MFA write | MFA-refresh test |
| Fall back to the generic error message | server-message test |

The write tests drive the real flows through their modals and menus
rather than calling hooks directly.

`task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, are untouched here and fail identically
with this branch's changes reverted.

## Scope

The three services keep their current shape; nothing outside these three
sections and the new hook module changes. Child modals that write
through their own services still refresh via the blanket helper, and
converting those is separate work.
2026-08-29 00:22:05 +00:00
Reece Browne d3708c1e63 Highlight the rail entry whose tool is open (#7723) 2026-08-28 13:02:47 +00:00
EthanHealy01 1c055f3d18 Centre modals in the viewport instead of pinning them near the top (#7715)
## What

Every dialog in the processor is the shared `.sui-modal` shell, and its
backdrop was top-aligning the panel:

```css
align-items: flex-start;
padding: 5rem 1.5rem 1.5rem;   /* 80px above, 24px below */
```

On a 900px-tall viewport that started every dialog at `y=80` with ~350px
of dead space beneath it. Phones already had an `align-items: center`
override; desktop never got one.

## Change

`frontend/editor/src/core/ui/Modal.css` only:

- Symmetric block inset, `align-items: center`.
- The inset is published as `--modal-inset-block`, and `.sui-modal`'s
`max-height` derives from it. That coupling is the point: if the two
drift apart, a tall modal overflows a centre-aligned backdrop and loses
its header off the top of the screen, unreachable.
- The phone breakpoint now only moves the variable. Measured at 375x812
it resolves to exactly the previous values (`16px 12px`, `max-height:
780px`), so mobile behaviour is unchanged.

One shared file, so this covers flow modals, source / user / pipeline /
API-key modals, billing and procurement.

## Before / After


<img width="2104" height="2284" alt="image"
src="https://github.com/user-attachments/assets/bcb50145-f75e-449e-92c6-a0b085cc091c"
/>


## Testing

- `task frontend:check` passes (lint + typecheck + 2356 tests).
- Phone breakpoint measured directly in the browser, values match the
previous behaviour.
2026-08-28 12:27:05 +00:00
ConnorYoh 4ab2505a6c Comment-quality standard, and the gate that enforces it (#7663)
## The problem

AI PRs write comments that restate the line below them, mark sections
with box drawing, and narrate the diff. Nothing in the repo said not to,
and nothing checked. `AGENTS.md` had one line about comments and it was
buried in the Python section.

Banners and `Step N:` narration have zero occurrences in the 15 months
before Aug 2025, so this is new.

## The fix

A written standard, plus a linter that enforces the mechanical part of
it on added lines only.

-
[devGuide/CODE_COMMENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/devGuide/CODE_COMMENTS.md)
holds the reasoning and worked examples; a section in
[AGENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/AGENTS.md)
holds the operative rules, kept short so they stay in an agent's
context. The two are split by kind rather than duplicated, because the
same prose in two places drifts.
- Rules in
[comment-rules.mjs](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs),
shared by both engines.
- Two engines. `.ts` / `.tsx` / `.mjs` go to an [oxlint JS
plugin](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-oxlint-plugin.mjs)
so comments come from the parser rather than a line scan; `.java` /
`.py` go to a [line
scanner](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint.mjs).
Neither reads the other's files, so they cannot disagree about one file.
- Between them they read every comment form the repo writes: `//` and
`/* */`, Javadoc and JSDoc, JSX comments, `#`, and Python docstrings.
- Runs in `task pre-commit`, so the git hook and the `pre_commit.yml` CI
job both get it, and as a Claude Code [`Stop`
hook](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-hook.mjs)
so an agent fixes the comment inside the turn that wrote it.

## The rules

The part worth arguing about. **Every rule blocks.** A rule that only
warns is a rule nobody acts on, so a finding you believe is wrong is a
bug in the rule: narrow it, or mark the line and say why.

| | Fires on |
| --- | --- |
|
[CMT001](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L71)
| Every word in the comment already appears in the code below it. Max 6
words, skipped for prose punctuation and for a bare Arrange/Act/Assert
marker |
|
[CMT002](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L92)
| 4+ rule or box-drawing characters, or a bare section label from [a
fixed
list](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L84)
(`Types`, `Helpers`, `State`, `Handlers`, ...) |
|
[CMT003](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L110)
| `Step N:` with a separator, or `Then,` / `Next,` / `Finally,`.
Suppressed in test files |
|
[CMT004](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L129)
| A comment about the code's own past: `this used to`, `renamed from`,
`was previously called`. Suppressed in test files |
|
[CMT005](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L154)
| 3+ consecutive comment lines where 2/3 [parse as
code](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L143)
|
|
[CMT006](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L31)
| A run of implementation comment over 12 lines, outside the first 5
lines of a file. Doc blocks are exempt, because the standard asks for
thorough contracts |
|
[CMT007](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L180)
| A parameter or return description that adds no word its name lacks.
Reads Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google `name:
description` |
|
[CMT008](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L239)
| An allow directive naming a rule that does not exist, or one that
silenced nothing |
|
[CMT009](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L219)
| A `TODO` / `FIXME` / `HACK` naming no issue or link. An owner is not
accepted: a username goes stale, an issue outlives it |

Each rule carries the readings it deliberately excludes, next to the
rule. Those exclusions came from running the rules over this repo, not
from taste: `CMT004` does not match a bare "no longer needed" because
that is as often about runtime lifecycle as about history, and `CMT003`
needs a separator after the number so a wrapped line beginning "step 2
unmounts + remounts the panel" reads as the prose it is.

A comment sharing a line with code is judged by the rules that do not
depend on the code below it, so a trailing `// TODO fix this` or `/*
this used to run before the flush */` still reports, while `50L * 1024 *
1024 // 50 MB` does not. `CMT001` would have been wrong about six in
seven trailing comments here, so it stays out of them.

If a finding is wrong, `// comment-lint-allow: CMT002` on the line
above. Rule-specific, [no blanket
disable](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L229).
A directive naming a rule that does not exist, or silencing nothing, is
itself a `CMT008` failure, so a typo cannot quietly disable a rule and a
stale one gets deleted rather than accumulating.

No native linter covers `CMT007`. `eslint-plugin-jsdoc`'s
`require-param-description`, Checkstyle's `NonEmptyAtclauseDescription`
and ruff's D-rules all check that a description exists, not whether it
says anything.

## Scoping

Added comment **text** only, not lines git calls new. Reindenting a file
or moving a block makes git mark untouched comments as added; findings
are matched against the comment text at the base, so only genuinely new
content reports.

The whole file is read and every comment in it evaluated. Only the
*reporting* is filtered, so a rule still sees the code a comment
introduces, the full run it belongs to, and the base version of the
file.

Existing tree is untouched. `task pre-commit:comment-lint:all` reports
it and always exits 0:

| | java | ts/js | py |
| --- | --- | --- | --- |
| findings | 1,218 | 741 | 204 |

2,163 across 542 files, mostly `CMT002` banners (1,482) and `CMT001`
restatements (456). Clearing it is separate work, by directory.

Not in this PR: an advisory LLM review layer for the things no pattern
can judge.

## Verification

Run against
[#7494](https://github.com/Stirling-Tools/Stirling-PDF/pull/7494) as CI
would, in a throwaway worktree: **two findings on a 78 file, +4,512 line
change, both genuine banners, in 952ms**. A whole-file scan of those
same files gives 11; the other 9 were withheld because that PR's author
did not write them, and they are the `@param teamId the team ID` shape
this standard exists to stop.

Both scanners blank string and character literals before looking for
comment markers, because a partial lex desynchronises everything after
it: one apostrophe in a Java comment, or one Python template whose
closing quotes start a line, is enough to read dozens of lines of code
as a single comment. Two fixtures carry canaries that stop being
reported if either engine ever desynchronises again.

The [fixture
corpus](https://github.com/Stirling-Tools/Stirling-PDF/tree/claude/ai-pr-comment-quality-dd970e/scripts/lint/fixtures)
pins all 9 rules against both engines, and `--selftest` fails if the two
disagree about the same file.

## Two things reviewers should know

**The oxlint JS plugin API is alpha.** oxlint itself is stable and
already this repo's frontend linter; the plugin API is the new
dependency. Its documented failure mode
([oxc#25203](https://github.com/oxc-project/oxc/issues/25203)) is being
skipped silently while oxlint still reports success. That affects the
standalone release binary rather than the npm package this invokes, but
the class of failure reads exactly like clean code, so the run asserts
`number_of_rules >= 1` from oxlint's own report and a broken engine
exits 2 rather than passing. If the API ever breaks, the fallback is
folding these rules into the line scanner, which already implements all
nine for Java and Python.

**`.claude/settings.json` is now committed**, carrying the hook and
nothing else: 19 lines, no `permissions`, nothing machine-specific. That
partly reverts `c35546a212` ("Ignore claude dir"), which existed because
this file had twice been committed by accident with a personal
`permissions` allowlist, once with absolute machine paths. Personal
config still belongs in `.claude/settings.local.json`, which the new
pattern keeps ignored, and hook entries merge across the two so nobody's
own hooks are lost.

If you already hand-wrote a `.claude/settings.json`, copy it somewhere
first: that path used to be git-ignored, and git overwrites an ignored
file without warning when a commit starts tracking it. Across 19 local
checkouts here, 13 have `settings.local.json` and none has a
hand-written `settings.json`.

To turn the hook off, `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in local
settings. Claude Code can only disable all hooks at once, hence the
switch. The commit-time gate still applies.

## How to test

```bash
task pre-commit:comment-lint:ci
```

The fixture corpus, then the diff. The corpus checks the rules
themselves rather than the code under review, so it runs on CI and
before a rule change, not on every local commit.

```bash
task comment-lint:branch
```

`clean (34 files in scope)`. `task comment-lint` is the same thing
scoped to uncommitted work, which is what the git hook and CI run.

To watch it bite, add `// Is banner` above `export function isBanner` in
`scripts/lint/comment-rules.mjs` and run `task comment-lint`: one
`CMT001`, exit 1. The gate covers its own source, which is why these
scripts have no section dividers.

```bash
task pre-commit:comment-lint:all
```

The standing backlog, report-only.

Verified on the pinned oxlint 1.77.0, not only the 1.79 the plugin was
prototyped against.
2026-08-28 10:56:50 +00:00
James Brunton 658aa54c20 Update tool models to fix main (#7725)
# Description of Changes
When #6697 merged, the CI didn't run for some reason so it was never
caught that the tool models were out of date. This PR updates them to
the correct state.
2026-08-28 10:35:41 +00:00
James BruntonandAnthony Stirling 0a3f0c1814 Fix redirect bugs in SaaS (#7721)
# Description of Changes
Fixes various bugs that affected SaaS (and some self-hosted):
- Refreshing on Editor caused the user to be redirected to Processor 
- User was unable to access Processor in SaaS
- Deep link hijacking fixes
- Fix double prefix `/app/app` issue
- Fix going from tool -> editor -> processor -> editor putting you back
into tool

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-08-28 10:03:52 +00:00
James Brunton 849d616451 Fix refreshing causing you to go to the Processor (#7694) 2026-08-27 23:45:10 +01:00
Anthony Stirling a48356a2d2 Deploy a dev SaaS server alongside the PR previews and main demo (#7697) 2026-08-27 23:17:21 +01:00
Reece Browne f71b0247da Quick access bar and old school sidebars (#7695) 2026-08-27 23:15:39 +01:00
Ludy be13028209 chore(logging): enable gzipped log rotation and adjust test logging (#7648) 2026-08-27 18:41:40 +01:00
Andrei BlajandAndrei Blaj d4b1862654 feat(ocr): add rotatePages option for automatic page orientation correction (#6697)
Co-authored-by: Andrei Blaj <andrei@atta.systems>
Signed-off-by: Andrei Blaj <andrei@atta.systems>
2026-08-27 18:40:26 +01:00
jayakrishnaandJames Brunton 51835a7b5e fix: clean up Add Stamp image preview blob URLs (#6779)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-08-27 18:34:43 +01:00
briosandCopilot Autofix powered by AI 97c0ccf582 refactor(deps): optimize dependency footprints, and add lazy initialization with platform-specific JPDFium bundling (#7620)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-27 18:31:54 +01:00
Ludy 4e46ba3b5a chore: prevent duplicate Dependabot Gradle PRs (#7657)
## Description of Changes

- Removed overlapping Gradle subdirectory entries from
.github/dependabot.yml.
- Dependabot now monitors the root Gradle project through /.
- Prevents duplicate pull requests for dependencies declared in Gradle
subprojects.

Closes: Not applicable

---

## Checklist

### General

- [ ] I have read the Contribution Guidelines
- [ ] I have read the Stirling-PDF Developer Guide (if applicable)
- [x] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant documentation (if applicable)
- [ ] I have read the translation tag documentation (for new translation
tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos are attached

### Testing (if applicable)

- [ ] I have tested my changes locally
2026-08-27 17:16:24 +00:00
Ludy cbe3ef8f69 fix: validate frontend dependency installation (#7625)
# Description of Changes

The current check only verifies the existence of the `node_modules`
directory. After an incomplete or corrupted installation, this can lead
to the task being incorrectly marked as complete.

`npm ls --depth=0` instead checks whether the direct frontend
dependencies are actually installed and consistent. This reliably
detects and automatically repairs corrupted installations.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-27 16:59:58 +00:00
EthanHealy01 5b5e922069 Make the upgrade banner neutral instead of gradient purple (#7696)
## What

The `promo` banner tone was a full-bleed `indigo-500 → purple-500`
gradient with white text and a black drop-shadow on the CTA. It was the
only saturated fill in the app, and against the warm neutral palette it
read as a foreign object above the workbench.

The bar is now app chrome:

| | Before | After |
|---|---|---|
| Background | 135° indigo→purple gradient | `--c-bg-raised` |
| Border | `transparent` | `--c-border-subtle` hairline |
| Icon | white glyph, no container | neutral glyph in a
`--c-surface-sunken` chip |
| Text | forced white | `--c-text` / `--c-text-muted` |
| CTA | `premium` accent (violet gradient) | `default` accent (same
primary button as the rest of the app) |


Before

<img width="1504" height="739" alt="Screenshot 2026-08-27 at 4 49 00 PM"
src="https://github.com/user-attachments/assets/a912d9e9-9590-4e1d-8202-1abb22a00f23"
/>


After

<img width="1061" height="665" alt="Screenshot 2026-08-27 at 4 48 30 PM"
src="https://github.com/user-attachments/assets/3be14aec-ccfe-4448-93b3-339a57be4937"
/>


Only caller is the friendly variant of `UpgradeBanner` (self-hosted,
under the free-tier user limit).

## Notes

- **No new theme tokens.** Every value is an existing `--c-*` semantic
token, so light and dark both follow automatically with no per-theme
overrides.
- The `premium` accent itself is untouched, so the upgrade CTAs in
`OfflineActivationCard` and `PairingPanel` are unaffected.
- `--c-hue-indigo` / `--c-hue-purple` are still used by
`SaaSOnboardingSlides`, `PaygFree` and `UpgradeModal`, so no tokens are
orphaned.
- Deleted comments describe rules that no longer exist (the gradient,
the white-on-gradient text overrides, the CTA shadow). No new comments
added.

## Verification

- `task frontend:check:all` passes (typecheck, oxlint, all four theme
linters, stylelint, format, tests, build, storybook build).
- `task frontend:storybook:a11y:changed` passes light and dark: 7
AppBanner stories, 0 violations. Both a11y baselines are empty, so this
is zero known violations rather than a baselined pass.
- Checked in Storybook under **Shared / AppBanner → All Top Bars**,
which renders every top bar the app can show side by side, in both
themes.
2026-08-27 15:57:15 +00:00
EthanHealy01 a215c30068 Add the missing en-US translations for classification labels (#7692)
Our classification labels were rendering their hardcoded English names
because the en-US locale file had no `classification` section at all, so
this adds the missing keys (labels and category names).

Also wires the category names through i18n, since those had no `t()`
call, and adds a test so a new label can't ship without its key.
2026-08-27 14:10:56 +00:00
Anthony Stirling 897c72e9d9 Fix tool panel scrolling so the action button stays reachable (#7688)
# Description of Changes

After ui rework all scrolling in all tool panels stopped working
This fixes this to allow tool panels to be scrollabe again




## What was wrong

PDF/UA is the only convert target whose settings panel overflows the
tool rail. Measured at 1920×1080: overflow was 0px for pdfa, pdfx, png,
docx, epub, and 158px for pdfua. Its action button sat at bottom: 1220
in a 1080px viewport — 140px below the fold — and the info alert was
clipped mid-sentence. The panel could be scrolled, but nothing said so
(Mantine's scrollbar auto-hides).

Normally the app would scroll the button into view for you. It didn't,
because both mechanisms built to do that were dead

## Cause:
Two separate mechanisms, both broken since the same commit (0a50e765b7,
frontend editor restructure, 2026-05-22):

1. ReviewToolStep - shared by all 47 tools. It looked for its scroll
container with:

stepRef.current.closest('[style*="overflow: auto"]')

Mantine's ScrollArea viewport sets inline overflow: scroll, not auto. I
measured it live - closest() returns null, and
document.querySelectorAll('[style*="overflow: auto"]') finds exactly 1
element anywhere in the page, and it isn't an ancestor of the panel. So
the lookup silently found nothing and the scrollTo never ran, for every
tool.

2. Convert.tsx - Convert only. It declared scrollContainerRef and a
scrollToBottom() wired to two useEffects, but the ref was never attached
to any element - createToolFlow() builds the JSX and no ref is passed
through. Always null, so both effects were no-ops.

Nothing else in the codebase has this pattern - I grepped for other
closest('[style*="overflow…"]') lookups and other
scrollToBottom/scrollContainerRef uses and both came back empty.



## The fix

createToolFlow.module.css (new) + createToolFlow.tsx:156 — the execute
button gets a position: sticky; bottom: 0 footer, the house pattern
already used by FormFill.module.css. Applied only when the review step
isn't visible, so it can never float over results. Sticky is inert when
content fits, so the other 46 tools are untouched.
ReviewToolStep.tsx:21 — real findScrollParent() walk replacing the
broken selector, scrolling by the minimum delta needed and only the
panel itself (never scrollIntoView(), which drags every ancestor). Also
added the missing clearTimeout cleanup.
Convert.tsx — deleted the dead ref and its two effects.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-27 11:55:31 +00:00
ConnorYohandJames Brunton 732ef18ae5 feat(account-link): redirect-based connect handshake for self-hosted linking (#7494)
Links a self-hosted instance to a SaaS team over an ordinary redirect,
and leaves the admin's browser holding a Stirling session at the same
time.

## The problem

A self-hosted server needs a device credential bound to a SaaS team, and
the admin's Supabase JWT must never reach the instance backend. Three
things ruled out the obvious approaches:

- **A customer hostname can never be in Supabase's redirect
allow-list**, so the sign-in cannot happen on the instance's own origin.
That is why SSO and sign-up did not work for linking at all.
- **A device credential identifies a server, not a person.** Every
attended portal read (Usage, Billing, Documents, Infrastructure) goes
through `getPortalSaasToken()` and needs a *user* session, so a
credential-only link left all of them asking for a second sign-in.
- **The previous design relayed a JWT** from the browser into the
instance, which is the thing we wanted to avoid. That path is deleted
here.

## The solution

Redirect and nonce, modelled on desktop's
`authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser
off, accept only a callback carrying that nonce back. Desktop has the OS
route the reply; self-hosted has no OS hop, so our own approval page
performs it. That is the point — the human half happens on an origin we
control.

```
instance                     SaaS                        admin's browser
   |  POST connect/request     |                                |
   |  (name, callback, nonce,  |                                |
   |   claim-secret hash)      |                                |
   |-------------------------->|                                |
   |  <- requestId + authorizeUrl                               |
   |                           |      GET /link?request=...     |
   |                           |<-------------------------------|
   |                           |  sign in (SSO works here),     |
   |                           |  see ACCOUNT + ORIGIN, approve |
   |                           |------------------------------->|
   |                           |   302 callback#nonce+session    |
   |  POST connect/claim       |                                |
   |  (requestId, claim secret)|                                |
   |-------------------------->|                                |
   |  <- device credential     |                                |
```

Four properties carry the safety, and each is stated in the code because
each is easy to lose in a refactor:

- **The redirect target is never caller-supplied.** Validated once at
creation, then read back from the stored row, so nothing in the approval
page's URL can steer the token elsewhere.
- **Approval and minting are separate.** Approval records the team and
hands out nothing usable; the credential is minted only on claim,
authenticated by a secret that never entered a browser.
- **A re-authentication cannot move a server between teams.** The team
is pinned at creation from the credential only that instance holds, so
an approver from another team gets `WRONG_TEAM` instead of a rebind.
- **The approver has to confirm what they are binding.** The page shows
the address and the signed-in account, with a way to switch, and a
checkbox naming the address gates the approve button. The name the
server reports is deliberately not shown: the requester picks it on an
unauthenticated endpoint, and its honest value is the hostname already
in the address.

The session rides the URL fragment, so it stays out of access logs and
`Referer`, and is stripped before anything awaits. The claim is
row-locked, so one approval mints once. A request lives 30 minutes; a
settled one is not offered again, since approving it fails server-side.

Signing in mid-flow no longer loses the request. The id is kept on the
SaaS origin and resumed after any sign-in, which is what makes creating
an account work: the confirmation email opens a new tab, where the
`next` parameter is gone. Reading it does not consume it — the request
may be open in two tabs — and only a recorded decision retires it.

The result lands as a modal over the portal the admin started from, and
the portal re-reads its link status so the page behind agrees with the
modal.

Plaintext `http://` callbacks are accepted rather than refused, because
many self-hosted instances legitimately run plain HTTP on a private
network; the address carries a warning icon explaining the risk, derived
server-side so a requester cannot suppress it. Hard-refusing `http://`
to a public IP literal is a reasonable follow-up; a bare hostname can't
be classified without a DNS lookup, so the warning stays the general
mechanism.

## Configuration

Four surfaces. Placeholders below, not values.

**SaaS backend**

| Setting | Needed | Why |
|---|---|---|
| `stirling.billing.account-link.enabled` | Yes, `true` | The connect
controller and service are `@ConditionalOnProperty` with no default, so
without it the endpoints do not exist. |
| `system.frontendUrl` | Only when the approval page is not on the API's
own origin | Where the approver is sent. Must include the app's base
path if it is served under one, or the redirect misses `/link`. |

**SaaS frontend**

| Setting | Needed | Why |
|---|---|---|
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes |
Its own sign-in. Must be the project the SaaS backend validates tokens
against. |
| `RUN_SUBPATH` | Only if served under a subpath | Moves the approval
page to `<base>/<subpath>/link`, so `system.frontendUrl` has to agree. |

**Self-hosted backend**

| Setting | Needed | Why |
|---|---|---|
| `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to
`false`. |
| `stirling.billing.account-link.saas-base-url` | Yes | Origin of the
SaaS API it links to. Not the SaaS frontend. |
| `system.frontendUrl` | Optional | Externally reachable base URL for
the callback. Otherwise derived from the request's `Origin`, which is
right for ordinary deployments and wrong behind a rewriting proxy. |

**Self-hosted frontend**

| Setting | Needed | Why |
|---|---|---|
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes |
Accepts the session handed over in the callback fragment. |
| `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the
SaaS API with the admin's token. Absent, those surfaces stay on the
mock. |
| `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the
portal automatically; without it there is no link UI and no callback
route. |

Two things worth stating because neither fails loudly:

- **Both frontends must use the URL *and* key of the same Supabase
project**, and the same one the SaaS backend validates against. A key
from one project with a URL from another is accepted by the browser and
rejected by Supabase, which surfaces much later as "session expired" on
Usage rather than as an error at hand-over.
- **The Supabase redirect allow-list must contain the SaaS app's
`/auth/callback`**, since a confirmation email returns through it.
Entries are matched exactly.

- **`system.frontendUrl` is the existing setting for this**, not a new
one, so each side reads its own value and there is nothing extra to
configure. It also gates share links, so on a stack with storage and
sharing already on, setting it here turns those on too.

The self-hosted side deliberately does **not** configure where the
approval page lives — SaaS answers that in the connect-request reply,
being the only party that knows.

Also here, because testing this needs two stacks side by side:
`linked:staging` / `linked:dev` (which derive `system.frontendUrl` and
`RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a
per-mode vite `cacheDir` — two dev servers in different modes otherwise
re-optimise over one shared dep cache.

## How to test

Automated and green: `task frontend:check:all` plus both backend
modules. `ConnectRequestServiceTest` covers callback validation, the
per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM`
and reauth confirming without minting; `ConnectServiceTest` covers
callback-resolution precedence including a foreign-origin callback being
discarded; `ConnectControllerTest` covers the authorize URL, including
the forwarded-header path and only the first hop being trusted;
`ConnectCallback.test.tsx` covers the fragment being stripped
synchronously and malformed fragments refused;
`LinkAccountModal.test.tsx` covers link and reauth hitting different
endpoints.

Manual walkthrough:

1. `task linked:staging` — added here; brings up a SaaS stack and a
self-hosted instance pointed at it, on discovered ports, and prints the
four addresses.
2. Open the link-account modal in the self-hosted portal and continue.
Expect the SaaS approval page at `/link?request=<id>`.
3. Sign in as a team leader, or create an account and confirm the email.
Either way you should come back to the approval page.
4. Tick the acknowledgement and approve. Expect the fragment gone from
the address bar immediately, a result modal over the portal, the portal
showing linked without a reload, and attended reads (Usage, Billing)
working without a second sign-in.
5. Repeat, approving as a member of a different team. Expect a refusal,
not a rebind.

## Outstanding

- #7415 to be reworked against this design once this lands.
- **No SaaS-side UI to disconnect a server.** `GET
/account-link/instances` and `POST /account-link/instances/{id}/revoke`
are already team-scoped and leader-gated, and the portal has a panel
that uses them, but
`portal-saas/components/settings/accountLinkSettings.tsx` exports `null`
on the reasoning that "SaaS has no account-link concept". That held when
linking was a self-hosted admin managing their own instance; here a
leader approves a server they may not administer, and has no way to
withdraw it. The seam to fill is that one file. Expected to land with
the CTA work in #7415.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-08-27 10:32:32 +00:00
EthanHealy01andClaude f7a2c626c9 Persist the workbench session across the editor/processor switch (#7654)
## What

Switching editor -> processor (or reloading) unmounts every editor
provider, which emptied the workbench. This PR mirrors the workbench
into per-tab sessionStorage and refills an empty one from that record on
the next mount:

- **Files, selection, view and active document survive** the shell
switch and reloads. Each recorded file is resolved to its *current leaf*
version on restore, so a file versioned by a policy or another tab comes
back at its latest state.
- **The switch back lands where the user left**: the processor sidebar's
"editor" button consumes a one-shot return path saved at switch time.
- **The app switch respects unsaved changes**: `useOtherAppSwitch`
(proprietary + saas) now routes through `requestNavigation`, so the same
warning guards it as any other navigation.
- Desktop shadows `WorkbenchSessionPersistence` with a stub (OS-launched
files own boot there).

## How to test

I've run through each of these manually:

- Upload several PDFs in the editor, select a couple, and switch to the
Active Files grid. Click "Open PDF Processor" in the sidebar footer,
then switch back to the editor. The same files, selection and view
should return, and you should land on the editor page you left.
- Open a document in the viewer, then reload the tab. The workbench
should refill and come back on the viewer with the same document active.
- With unsaved changes in a tool, click the processor switch. The
unsaved-changes warning should appear, and the switch should only
proceed if you confirm.
- Open a second browser tab with different files. Each tab should
restore its own workbench independently (the record is per-tab
sessionStorage).
- While in the processor, delete one of the open files from storage,
then switch back. The remaining files should restore and a warning toast
should report "Restored X of Y files".

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 10:12:48 +00:00
EthanHealy01 caeca0b88a Fix classification escalation: the local pass was claiming the server dispatch key (#7667)
Follow-up to #7580: the escalation it added could never fire.

## What's broken

The auto-run skips a policy that has already run on a file, keyed on
`(categoryId, fileId)`. `recordRunStart` claims that key — and #7580 has
the **browser-side first pass** record its own run under `categoryId:
"classification"` for the uploaded file. So the local heuristic ticks
the very key the server escalation checks, and the AI is never asked, at
any confidence.

Trigger is the default seeded setup: **Classification as the only
on-upload policy**, and a local verdict below `high`. Any other
on-upload policy masks it, because classification then targets that
policy's output — a new file id whose key was never claimed. That's why
this went unnoticed.

Two smaller faults in the same path:

- A chained output carried no `classificationConfidence`, so
`shouldDispatchToAi` waited for a verdict that could never arrive (a
tool-derived file gets no local pass).
- Browser-local runs were polled against the server: 3 × 404 per file,
after which `MAX_NOT_FOUND` marked a local run that had actually
**succeeded** as `FAILED`.

## The fix

- `PolicyRunRecord.browserLocal`; `recordRunStart` skips the dispatch
claim for such a run. It is the first pass, not the policy's run.
- The local pass meters under `classification:local-meter` instead of
the category id, so metering dedupe survives without suppressing
dispatch.
- The poll effect skips browser-local runs.
- `CONSUME_FILES` inherits `classificationConfidence` alongside the
labels, so the verdict survives a version bump.

## How to test

Download
[`low-confidence-classification.pdf`](https://github.com/Stirling-Tools/Stirling-PDF/raw/fix/chained-classification-confidence/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf)
(checked in as a fixture, verdict pinned by a test).

With **Classification as the only on-upload policy**, upload it and
watch the Network tab:

- **Before:** no `POST /api/v1/policies/{id}/run` for classification,
ever. Console shows `local-classification-*` 404s.
- **After:** exactly one, and the engine receives `POST
/api/v1/documents/classify`.

Judge it on that request, not on the resulting label — the model's
answer varies, so a label comparison can pass or fail for the wrong
reason.

Headless equivalent:

```
npx vitest run --project proprietary src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx
```

Passes here, fails on `main` on "asks the AI about an unsure verdict
even though the local pass already ran". Its other two cases pass on
both, so the guards still hold: a confident verdict still costs nothing,
and a file with no verdict yet still waits rather than racing the free
pass.

New tests drive the **real** run store — mocking it is what let this
through.

`task frontend:check`: 255 files / 2202 tests.
2026-08-26 17:34:13 +00:00
James Brunton c93feb5dfc Remove a bunch of unnecessary casts from the frontend (#7662)
# Description of Changes
Originally, I wanted to re-enable typed linting on our repo but using
Oxlint this time to avoid the memory and speed issues that ESLint was
causing. Unfortunately, it's not stable enough yet to actually use on
our repo (although it is close, I suspect it'll be stable enough fairly
soon). I was able to remove many of the unnecessary casts that it found
though, so even though this won't be enforced, it's still worth cleaning
up what I've found.
2026-08-26 14:09:55 +00:00
Anthony Stirling f945cc7dc6 Regenerate expired test certificates and guard against future expiry (#7682)
The bundled signing test certificates expired at **07:41:10 UTC on
2026-08-26**. They were issued exactly one year earlier, so they went
from fine to fatal mid-morning with no warning, and they take down
`main` and every open branch, not just one PR.

First casualty was the `docker-compose-tests` job on #6802, which
started at 07:45:

```
java.security.cert.CertificateExpiredException: NotAfter: Wed Aug 26 07:41:10 UTC 2026
    at CreateSignatureBase.checkValidity(CreateSignatureBase.java:159)
    at CertSignControllerTest.testSignPdfWithPkcs12(CertSignControllerTest.java:205)
```

```
$ openssl x509 -in app/core/src/test/resources/certs/test-cert.pem -noout -dates
notBefore=Aug 26 07:41:10 2025 GMT
notAfter =Aug 26 07:41:10 2026 GMT
```

## What was broken

`CertSignControllerTest` (7 tests) and `PdfSigningServiceImplTest` (2)
fail outright. `ValidateSignatureControllerMoreTest` and
`CertificateValidationServiceMoreTest` read the same fixtures.

Auditing the rest of the repo turned up three more time bombs that had
not gone off yet:

| Fixture | Was | Problem |
|---|---|---|
| `app/core/.../certs/test-cert.*` + `test-key.*` | expired 2026-08-26 |
**already breaking every branch** |
| `test-certs/valid-test.p12`, `valid-test.jks` (proprietary + frontend
copies) | expire 2027-03-25 | same failure, seven months out |
| `test-certs/not-yet-valid-test.p12` | valid **from** 2027-03-25 |
becomes valid, so its test silently stops proving anything, on the same
day |

## What this does

**Regenerates every fixture** with the identical subject DN, alias,
password, key size and signature algorithm as before, changing only the
validity window. Nothing that any test asserts on has moved.

- valid fixtures: `2025-01-01` to `2125-01-01`
- `not-yet-valid-test.p12`: `2125-01-01` to `2126-01-01`, so it stays in
the future
- `expired-test.p12`: pinned to its permanently-past 2024 window

**Adds `scripts/generate-test-certs.sh`** as the source of truth, so the
next regeneration is one command instead of archaeology. It documents
every DN, alias and password, pins the validity windows, and runs on
Linux, macOS and Git Bash.

**Adds two guard tests** that fail with an actionable message, naming
the script, while there is still a year of runway:

- `BundledTestCertificateExpiryTest` (app/core) checks all seven formats
parse, are in their validity window, and have more than 365 days left
- `BundledWorkflowCertificateExpiryTest` (proprietary) does the same for
the valid pair, and additionally asserts the expired fixture is still
expired and the not-yet-valid one is still in the future

That last pair matters: those two fixtures exist to test a validity
outcome, and each one silently stops testing anything once the clock
passes its window.

## Verification

Run locally against the regenerated bytes, on the exact content
committed here:

```
./gradlew :stirling-pdf:test --tests '*CertSignControllerTest*' --tests '*BundledTestCertificateExpiryTest*' \
  --tests '*PdfSigningServiceImplTest*' --tests '*ValidateSignatureControllerMoreTest*' \
  --tests '*CertificateValidationServiceMoreTest*'
BUILD SUCCESSFUL

./gradlew :proprietary:test --tests '*BundledWorkflowCertificateExpiryTest*' --tests '*CertificateValidationIntegrationTest*' \
  --tests '*SigningFinalizationServiceMoreTest*' --tests '*ServerCertificateServiceTest*' \
  --tests '*CertificateSubmissionValidatorTest*' --tests '*WorkflowSessionServiceTest*'
BUILD SUCCESSFUL
```

`spotlessCheck` passes on both modules.
2026-08-26 10:43:17 +00:00
dependabot[bot]andAnthony Stirling 72b7892312 Translations + com.squareup.okhttp3:okhttp-bom from 5.3.2 to 5.4.0 (#7599)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 08:05:45 +01:00
James Brunton 353df7a647 Improve modals in Sources page in Processor (#7664)
# Description of Changes

Various changes throughout to try and convert the bulk of the dev UI
sources modals to production quality. Changes include:

- Fixing inconsistencies between different modals
- Hide things users will rarely need to change behind advanced
- Removed clutter in the UI
- Renaming settings in terms that the user will understand and care
about

<img width="2360" height="3068" alt="image"
src="https://github.com/user-attachments/assets/2c637e8f-bc1b-4c7e-98cb-d836ae626ba5"
/>

<img width="2360" height="3008" alt="image"
src="https://github.com/user-attachments/assets/d894aadc-7c83-41d1-b614-881637a6bd34"
/>
2026-08-25 15:59:16 +00:00
James Brunton 0d75715af2 Fix flaky e2e tests (#7595)
# Description of Changes
e2e Playwright tests are currently failing intermittently on all
platforms for different reasons, most notably WebKit, which seems to
fail much more often than the others. This PR attempts to fix the
issues. I've ran the e2e tests a few times now and they don't seem to be
inconsistent any more, but it's difficult to tell if all the issues are
genuinely fixed due to the inconsistent nature. As far as I can tell,
I've not broken anything though.
2026-08-25 15:56:44 +00:00
github-actions[bot]andFrooodle b44202185a chore: update Gradle to 9.7.1 (#7673)
Automated update of the Gradle wrapper and Gradle Docker build images.

Gradle version: `9.7.1`
Docker image: `gradle:9.7.1-jdk25`

Co-authored-by: Frooodle <77850077+Frooodle@users.noreply.github.com>
2026-08-25 12:36:38 +00:00
EthanHealy01 49c1e75ced Surface recorded failures in a notification bell (Review Flow PR 4) (#7478)
Review Flow PR 4. Stacked on #7477. Recorded failures appear in a
notification bell, showing each reader the failures they are allowed to
see and the actions they can actually take.

Scope is deliberately viewing and routing only. Resolving a failure —
retry, decrypt-and-retry — is #7479, which also brings the write path
for it; nothing resolution-shaped ships here, not even dark.

## What's added

**A notification bell** in the editor and the processor shell. Polls
`GET /api/v1/notifications` every 30 seconds, shows an unread badge, and
lists open failures newest first. Each row shows the failure's title,
its message with **Copy error** and **Show full message** chips, an
occurrence count, and its available actions.

**A notification API** (`stirling.software.proprietary.notification`),
derived from failures on read rather than stored in its own table:

| Route | Purpose |
|---|---|
| `GET /api/v1/notifications` | the caller's open failures, newest first
|

Read-only by design: every action the bell offers is one the client runs
on its own device, so there is nothing to post back. Every id is
prefixed (`failure:<uuid>`), so the bell never holds a raw failure id it
could hand to a failure endpoint.

**Per-reader actions.** A `FailureKind` declares each action with an
audience (`OWNER`, `TEAM_REVIEWER`, `ANYONE_WHO_SEES`). The server
resolves that against the reader and derives `Ownership` (`MINE` /
`THEIRS` / `UNOWNED`) from the row's actor, so an admin reviewing
someone else's failure is not offered a document their browser does not
hold. Adding a failure kind requires no frontend change.

**Server-run and client-run actions are distinguished.**
`FailureActionId` carries an `Execution` facet; the registry requires a
bean only for server actions, and dispatching a client action on the
failure surface returns 400. The notification projection goes further:
it carries only client-run offers, so the bell cannot be sent a button
it would refuse to draw.

**Actions in the bell:** at most two. The owner of the document gets
**View file** (opens it in the editor); a team reviewer gets **View in
processor** (dev builds only). Dismiss stays on the failure queue in
`/processor/documents` — deciding a failure's fate belongs to the review
surface, not the panel that announces it. An action id the build has not
wired is skipped rather than rendered dead, so the server can ship new
kinds ahead of the clients that understand them.

**Attended policy runs record their document.** `POST
/api/v1/policies/{id}/run` accepts an optional opaque `fileId`, recorded
when the run carries exactly one primary document. This is what lets a
repeat fold onto one incident instead of opening a new one per upload,
lets deleting the file clear its failure, and lets the owner open the
document from the row.

## Behaviour changes

- **The bell re-reads as soon as a failure you caused is recorded**,
rather than leaving you to wait out a poll interval for news of your own
upload. Applies to a failed tool run and to a policy run reaching
`FAILED`. Other people's failures still arrive on the poll, which is
what it is for.
- **An action the reader cannot use is not rendered.** Where the server
gave a reason for withholding it, that reason appears as the row's
one-line note. An action that was never offered to that reader produces
no note.
- **Deleting a document closes every incident about it that the deleter
caused**, including a failed policy run on their own upload, so a user's
own errors leave the bell with the file rather than lingering with a
dead button.
- **The failures list in `/processor/documents` stays behind
`import.meta.env.DEV`**, and View in processor is gated to match so it
cannot navigate to a section that is not mounted. Both lift when
failures get their own review screen.
- **One poll for all bells.** The bell is mounted in three places; the
list, document lookups and read marker are shared, so mounting more than
one does not multiply requests.
- `ACKNOWLEDGE` is no longer offered by any kind. The id, bean and
status remain so existing rows stay readable.

## Known limits

- The poll does not pause when the tab is hidden.
- No retention or per-team cap on `file_run_events`.

## How to test

Needs a proprietary or SaaS build with login enabled. `task dev:all`,
then sign in.

1. **Create a failure.** Add a password-protected PDF to the editor and
choose **Skip for now** when it asks to unlock. The upload starts a
policy run that fails on it.
2. **Watch the bell.** The badge should appear within a second or two,
not after 30 — this is the refresh-on-failure path. Open it: a row
titled "Password-protected document" with the error message and the two
chips.
3. **The buttons should be View file and View in processor, nothing
else.** No Dismiss and no retries: dispositions live on the review
surface, resolutions in #7479.
4. **View file** closes the panel and selects that document in the
editor.
5. **Dismiss from the queue instead.** Open `/processor/documents` (dev
build), find the row in the failures list and dismiss it there; the bell
drops it on its next read.
6. **Confirm the local-document probe.** Create a second failure, then
delete that file from the editor and reload. Its incident closes with
it; a row whose document is still present keeps **View file**.
7. **Confirm attribution end to end.** Sign in as a plain member, run a
shared policy on your own upload so it fails. The member sees their own
row in the bell. Sign in as the team leader: they see it too, but with
**View in processor** instead of **View file**, because the document is
not in their browser.
8. **Confirm folding.** Add the same locked PDF again and skip again.
The existing row's occurrence count increases rather than a second row
appearing.
9. **Confirm one poll for many bells.** Open the editor and the
processor in two tabs. Each tab issues its own poll, but within a tab
the several mounted bells share one — the Network tab should show one
`GET /api/v1/notifications` per 30s per tab, not three.

## Migration

None. No new column and no new value in any CHECK-constrained enum;
`CheckConstrainedEnumsTest` fails if that changes.
2026-08-24 22:29:41 +00:00
stirlingbot[bot] 826e487f00 Update Frontend 3rd Party Licenses (#7650)
Auto-generated by stirlingbot[bot]

This PR updates the frontend license report based on changes to
package.json dependencies.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-08-24 22:16:25 +00:00
stirlingbot[bot] bcad2cd486 Update Backend 3rd Party Licenses (#7653)
Auto-generated by stirlingbot[bot]

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

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-08-24 22:04:00 +00:00
79686a3a09 form field editing (#6655)
# Description of Changes

Building ontop of a users draft PR for form creation tools

**Fill Form** becomes a full **Form Editor**: fill, create, modify and
delete AcroForm fields visually. Builds on the community form-creation
draft, plus a UX/UI rework pass.

- **Backend**: `/api/v1/form` endpoints — `fields-with-coordinates`,
`add/modify/delete-fields`, combined `edit-fields` (one round-trip),
`fill`, `extract-csv/xlsx`; supports text (multiline, comb), checkbox,
dropdown, list box, radio, button actions (reset/print/URL/submit) and
signature placeholders
- **Create**: type palette, click-or-drag placement with snap guides,
inline property editor, batch "Add N fields"
- **Modify**: move/resize on the page, arrow-nudge + Delete key, X/Y/W/H
inputs, staged edits/deletes with chips, discard
- **Fill**: live progress + required tracking, flatten toggle, Export
menu (JSON/CSV/XLSX), Ctrl/Cmd+S
- **Safety**: confirm dialog before discarding staged work; empty
required fields warn with "Save anyway" instead of blocking
- **UI**: consistent panel skeleton (fixed header / scrolling list /
pinned actions), empty states that link into Create, full i18n with
plural keys



[walkthrough.html](https://github.com/user-attachments/files/30508976/walkthrough.html)



---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Co-authored-by: Denys Vitali <denys@denv.it>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-24 20:45:54 +00:00
1271 changed files with 117640 additions and 18524 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node",
"args": [
"${CLAUDE_PROJECT_DIR}/scripts/lint/comment-lint-hook.mjs"
],
"timeout": 60
}
]
}
]
}
}
-147
View File
@@ -1,147 +0,0 @@
name: Local Java
description: Configure the JDK already installed on the GitHub Actions runner
inputs:
java-version:
description: JDK major version
required: false
default: "25"
distribution:
description: Java distribution to use
required: false
default: temurin
architecture:
description: Target architecture (x64 or arm64)
required: false
default: ""
runs:
using: composite
steps:
- name: Configure preinstalled JDK
shell: bash
env:
JAVA_VERSION: ${{ inputs.java-version }}
JAVA_DISTRIBUTION: ${{ inputs.distribution }}
JAVA_ARCHITECTURE: ${{ inputs.architecture }}
RUNNER_ARCHITECTURE: ${{ runner.arch }}
RUNNER_OS_NAME: ${{ runner.os }}
TEMURIN_VERSION: 25.0.4+7
TEMURIN_RELEASE_TAG: jdk-25.0.4%2B7
TEMURIN_X64_ARCHIVE: OpenJDK25U-jdk_x64_mac_hotspot_25.0.4_7.tar.gz
TEMURIN_X64_SHA256: a5ac9c46dad47ac06df35e36d096913195d8da1f3f71918828bcc2cfe33869b7
TEMURIN_ARM64_ARCHIVE: OpenJDK25U-jdk_aarch64_mac_hotspot_25.0.4_7.tar.gz
TEMURIN_ARM64_SHA256: 5a101c54abf5a9f16c0f70d8c38ba99e6567c1ba213378f0bb04497284f051bd
run: |
set -euo pipefail
if [ "${JAVA_VERSION}" != "25" ]; then
echo "This repository requires JDK 25; received ${JAVA_VERSION}" >&2
exit 1
fi
distribution="$(printf '%s' "${JAVA_DISTRIBUTION}" | tr '[:upper:]' '[:lower:]')"
case "${distribution}" in
temurin|microsoft) ;;
*)
echo "Unsupported Java distribution: ${JAVA_DISTRIBUTION}. Supported: temurin, microsoft" >&2
exit 1
;;
esac
java_home_is_valid() {
candidate="$1"
[ -x "${candidate}/bin/java" ] || return 1
version_output="$(${candidate}/bin/java -version 2>&1)"
printf '%s\n' "${version_output}" | grep -Eq "version \"${JAVA_VERSION}(\\.|\")" || return 1
case "${distribution}" in
temurin) printf '%s\n' "${version_output}" | grep -Eiq "Temurin|Eclipse Adoptium" ;;
microsoft) printf '%s\n' "${version_output}" | grep -Eiq "Microsoft" ;;
esac
}
architecture="${JAVA_ARCHITECTURE}"
if [ -z "${architecture}" ]; then
architecture="${RUNNER_ARCHITECTURE}"
fi
normalized_architecture="$(printf '%s' "${architecture}" | tr '[:lower:]' '[:upper:]')"
case "${normalized_architecture}" in
X64|AMD64|X86_64) architecture=X64 ;;
ARM64|AARCH64) architecture=ARM64 ;;
*) echo "Unsupported runner architecture: ${architecture}" >&2; exit 1 ;;
esac
runner_architecture="$(printf '%s' "${RUNNER_ARCHITECTURE}" | tr '[:lower:]' '[:upper:]')"
if [ "${RUNNER_OS_NAME}" = "macOS" ] && [ -n "${JAVA_HOME:-}" ]; then
runner_java_binary="${JAVA_HOME}/bin/java"
if [ "${runner_architecture}" = "ARM64" ] && file "${runner_java_binary}" | grep -q "arm64"; then
echo "JAVA_HOME_${JAVA_VERSION}_ARM64=${JAVA_HOME}" >> "${GITHUB_ENV}"
elif [ "${runner_architecture}" = "X64" ] && file "${runner_java_binary}" | grep -q "x86_64"; then
echo "JAVA_HOME_${JAVA_VERSION}_X64=${JAVA_HOME}" >> "${GITHUB_ENV}"
fi
fi
java_home_variable="JAVA_HOME_${JAVA_VERSION}_${architecture}"
java_home="${!java_home_variable:-}"
if [ -z "${java_home}" ] && [ "${RUNNER_OS_NAME}" = "macOS" ]; then
if [ "${architecture}" = "${runner_architecture}" ] && [ -n "${JAVA_HOME:-}" ]; then
java_home="${JAVA_HOME}"
fi
fi
if [ -z "${java_home}" ] && [ "${RUNNER_OS_NAME}" = "macOS" ] && [ "${architecture}" = "X64" ] && [ "${distribution}" = "temurin" ]; then
java_home="$(arch -x86_64 /usr/libexec/java_home -v "${JAVA_VERSION}" 2>/dev/null || true)"
fi
if [ -n "${java_home}" ] && [ "${RUNNER_OS_NAME}" = "macOS" ]; then
java_binary="${java_home}/bin/java"
if [ "${architecture}" = "X64" ] && ! file "${java_binary}" | grep -q "x86_64"; then
java_home=""
elif [ "${architecture}" = "ARM64" ] && ! file "${java_binary}" | grep -q "arm64"; then
java_home=""
fi
fi
if [ -n "${java_home}" ] && ! java_home_is_valid "${java_home}"; then
echo "Ignoring ${java_home_variable}: it does not provide ${distribution} JDK ${JAVA_VERSION}" >&2
java_home=""
fi
if [ -z "${java_home}" ] && [ "${RUNNER_OS_NAME}" = "macOS" ] && [ "${distribution}" = "temurin" ] && { [ "${architecture}" = "X64" ] || [ "${architecture}" = "ARM64" ]; }; then
jdk_version="${TEMURIN_VERSION}"
if [ "${architecture}" = "X64" ]; then
jdk_archive="${TEMURIN_X64_ARCHIVE}"
jdk_sha256="${TEMURIN_X64_SHA256}"
else
jdk_archive="${TEMURIN_ARM64_ARCHIVE}"
jdk_sha256="${TEMURIN_ARM64_SHA256}"
fi
jdk_root="${RUNNER_TEMP}/stirling-temurin-${jdk_version}-${architecture}"
archive_path="${jdk_root}/${jdk_archive}"
mkdir -p "${jdk_root}"
if [ ! -f "${archive_path}" ]; then
curl --fail --location --retry 3 --retry-delay 2 \
--output "${archive_path}" \
"https://github.com/adoptium/temurin25-binaries/releases/download/${TEMURIN_RELEASE_TAG}/${jdk_archive}"
fi
printf '%s %s\n' "${jdk_sha256}" "${archive_path}" | shasum -a 256 -c -
if [ ! -x "${jdk_root}/Contents/Home/bin/jlink" ]; then
rm -rf "${jdk_root}/Contents"
tar -xzf "${archive_path}" -C "${jdk_root}"
fi
java_home="$(find "${jdk_root}" -type d -path '*/Contents/Home' -print -quit)"
fi
if [ -z "${java_home}" ]; then
echo "JDK ${JAVA_VERSION} (${distribution}, ${architecture}) is not installed on this runner (${java_home_variable} is missing)" >&2
exit 1
fi
shell_java_home="${java_home}"
if command -v cygpath >/dev/null 2>&1; then
shell_java_home="$(cygpath --unix "${java_home}")"
fi
export JAVA_HOME="${shell_java_home}"
export PATH="${shell_java_home}/bin:${PATH}"
echo "JAVA_HOME_${JAVA_VERSION}_${architecture}=${java_home}" >> "${GITHUB_ENV}"
echo "JAVA_HOME=${java_home}" >> "${GITHUB_ENV}"
echo "${java_home}/bin" >> "${GITHUB_PATH}"
java -version 2>&1 | tee "${RUNNER_TEMP}/java-version.txt"
case "${distribution}" in
temurin) grep -Eiq "Temurin|Eclipse Adoptium" "${RUNNER_TEMP}/java-version.txt" ;;
microsoft) grep -Eiq "Microsoft" "${RUNNER_TEMP}/java-version.txt" ;;
esac
grep -Eq "version \"${JAVA_VERSION}(\.|\")" "${RUNNER_TEMP}/java-version.txt"
-5
View File
@@ -8,11 +8,6 @@ updates:
- package-ecosystem: "gradle" # See documentation for possible values
directories:
- "/" # Location of package manifests
- "/app/common"
- "/app/core"
- "/app/proprietary"
- "/app/saas"
- "/buildSrc"
schedule:
interval: "weekly"
cooldown:
+1
View File
@@ -67,6 +67,7 @@ labels:
- 'frontend/**'
- 'frontend/.*'
- 'frontend/**/.*'
- '.taskfiles/frontend.yml'
- label: 'Tauri'
files:
+1
View File
@@ -20,6 +20,7 @@ Closes #(issue_number)
- [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable)
- [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable)
- [ ] I have performed a self-review of my own code
- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
- [ ] My changes generate no new warnings
### Documentation
+48 -2
View File
@@ -182,7 +182,7 @@ jobs:
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Get version number
id: versionNumber
@@ -220,6 +220,42 @@ jobs:
echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT
fi
# The Stirling account previews connect to. Derived from the ref rather than stored as a URL
# so it cannot drift from the key: a mismatched pair is accepted by the browser and rejected
# by Supabase, surfacing much later as "session expired" on Usage rather than at sign-in.
# Secret only to match Saas-Dev-Deploy.yml, which owns the same value; a project ref is not
# itself sensitive, which is why SAAS_API_BASE_URL next to it is a plain variable.
- name: Resolve Stirling account config
id: saas
env:
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
API_BASE_OVERRIDE: ${{ vars.SAAS_API_BASE_URL }}
run: |
# Set, this is the one value both halves use: the browser's portal reads and the backend's
# register/entitlement calls have to land on the same SaaS, and nothing checks that they
# do. Unset, only the backend gets a base, from its own compiled-in default.
API_BASE="${API_BASE_OVERRIDE:-https://stirling.com/app}"
echo "backend_base=${API_BASE}" >> "$GITHUB_OUTPUT"
if [ -z "${PROJECT_REF}" ]; then
echo "Not configured for this environment: the preview will build without a Stirling"
echo "account, and the connect dialog will say so. To wire one up, set on the"
echo "pr-preview environment the secrets SAAS_DB_PROJECT_REF and"
echo "SAAS_SUPABASE_PUBLISHABLE_KEY, both from the same Supabase project."
echo "supabase_url=" >> "$GITHUB_OUTPUT"
echo "frontend_base=" >> "$GITHUB_OUTPUT"
else
# Only whether, not which: the ref is a secret here, so Actions masks it out of any
# line it appears in, derived URL included.
echo "Stirling account configured, at ${API_BASE}."
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
# Deliberately the override and not API_BASE: the backend's default is a subpath URL
# nobody has confirmed answers /api/v1, and prod CORS does not list preview hostnames,
# so portal reads stay off until someone sets a base they have checked. Empty leaves the
# committed .env default alone, which is the clean "not configured" state.
echo "frontend_base=${API_BASE_OVERRIDE}" >> "$GITHUB_OUTPUT"
fi
- name: Check if image exists
id: check-image
run: |
@@ -246,6 +282,9 @@ jobs:
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
VITE_SUPABASE_URL=${{ steps.saas.outputs.supabase_url }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
VITE_SAAS_API_URL=${{ steps.saas.outputs.frontend_base }}
platforms: linux/amd64
- name: Set up SSH
@@ -279,6 +318,13 @@ jobs:
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL: "${{ steps.saas.outputs.backend_base }}"
# Off so preview traffic never accrues against a real wallet or trips its cap. The
# 402 gate is separate and stays on, so gating is still testable here.
STIRLING_BILLING_ACCOUNT_LINK_METERING_ENABLED: "false"
# Stated rather than inferred from the request: the callback has to come back to the
# preview hostname, not to the container's own :8080 behind this proxy.
SYSTEM_FRONTENDURL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}"
SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}"
@@ -353,7 +399,7 @@ jobs:
- name: Install Task for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build and deploy Storybook
id: storybook
@@ -200,12 +200,13 @@ jobs:
key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Run Gradle Command
run: |
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
@@ -221,7 +222,7 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+246
View File
@@ -0,0 +1,246 @@
name: Auto SaaS Dev Deployment
on:
push:
branches:
- saas-prod
workflow_dispatch:
permissions:
contents: read
env:
FRONTEND_PORT: "901"
BACKEND_PORT: "902"
DEPLOY_DIR: /stirling/SAAS-DEV
jobs:
deploy-saas-dev:
runs-on: ubuntu-latest
environment: saas-dev
concurrency:
group: saas-dev-deploy
cancel-in-progress: true
permissions:
contents: read
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Check SaaS configuration
id: config
env:
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
run: |
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Get commit hash
id: commit-hash
run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT
- name: Build and push backend image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/backend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-saas-backend
cache-to: type=gha,mode=max,scope=stirling-saas-backend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest
build-args: |
VERSION_TAG=v2-alpha
STIRLING_FLAVOR=saas
platforms: linux/amd64
- name: Build and push frontend image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-saas-frontend
cache-to: type=gha,mode=max,scope=stirling-saas-frontend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest
build-args: |
VERSION_TAG=v2-alpha
STIRLING_FLAVOR=saas
VITE_BUILD_MODE=development
VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
platforms: linux/amd64
- name: Build and push AI engine image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./engine/Dockerfile
push: true
cache-from: type=gha,scope=stirling-saas-engine
cache-to: type=gha,mode=max,scope=stirling-saas-engine
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest
platforms: linux/amd64
- name: Set up SSH
env:
SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
run: |
mkdir -p ~/.ssh/
echo "$SSH_KEY" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }}
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ github.token }}
VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }}
SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }}
SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }}
SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }}
PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }}
STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }}
KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }}
KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }}
KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }}
run: |
set -euo pipefail
BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}"
yaml() {
printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')"
}
ENGINE_SECRET="$(openssl rand -hex 32)"
AI_BACKEND_VARS="
SYSTEM_AIENGINE_ENABLED: \"true\"
SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\"
APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\"
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")"
AI_SERVICE="
saas-engine:
container_name: stirling-saas-dev-engine
image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG}
environment:
ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY")
VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY")
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")
restart: on-failure:5"
cat > docker-compose.yml << EOF
version: '3.3'
services:
saas-backend:
container_name: stirling-saas-dev-backend
image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG}
ports:
- "${BACKEND_PORT}:8080"
volumes:
- ${DEPLOY_DIR}/config:/configs:rw
- ${DEPLOY_DIR}/logs:/logs:rw
- ${DEPLOY_DIR}/storage:/storage:rw
environment:
SPRING_PROFILES_ACTIVE: "saas"
DISABLE_ADDITIONAL_FEATURES: "false"
SAAS_DB_URL: $(yaml "$SAAS_DB_URL")
SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME")
SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD")
SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF")
SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET")
PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT")
STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED")
KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID")
KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN")
KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID")
SYSTEM_DEFAULTLOCALE: en-US
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "${BASE_URL}"
baseUrl: "${BASE_URL}"${AI_BACKEND_VARS}
restart: on-failure:5
saas-frontend:
container_name: stirling-saas-dev-frontend
image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG}
ports:
- "${FRONTEND_PORT}:80"
environment:
VITE_API_BASE_URL: "http://saas-backend:8080"
depends_on:
- saas-backend
restart: on-failure:5${AI_SERVICE}
EOF
SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml"
ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH
set -e
mkdir -p ${DEPLOY_DIR}/{config,logs,storage}
mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml
chmod 600 ${DEPLOY_DIR}/docker-compose.yml
cd ${DEPLOY_DIR}
printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin
docker-compose down --remove-orphans 2>/dev/null || true
docker-compose pull
docker-compose up -d
docker logout ghcr.io >/dev/null 2>&1 || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
- name: Wait for the backend to answer
env:
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
run: |
URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status"
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true)
if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi
sleep 10
done
echo "::error::SaaS dev backend did not become healthy within 10 minutes"
exit 1
- name: Cleanup temporary files
if: always()
run: rm -f ../private.key docker-compose.yml
continue-on-error: true
+1 -2
View File
@@ -34,10 +34,9 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: ai-engine
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Quality-check engine
id: engine-check
+3 -2
View File
@@ -46,12 +46,13 @@ jobs:
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK ${{ matrix.jdk-version }}
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Check Java formatting (Spotless)
# Runs once per matrix combination - pick the cheapest leg
# (core - no proprietary, no saas) so we don't wait for the
+3 -2
View File
@@ -83,9 +83,10 @@ jobs:
key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -94,7 +95,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (needed for playwright's vite preview webServer)
+2 -3
View File
@@ -62,7 +62,6 @@ jobs:
filters: .github/config/.files.yaml
gradle-cache-prime:
if: needs.files-changed.outputs.backend == 'true'
needs: [files-changed]
uses: ./.github/workflows/gradle-cache-prime.yml
secrets: inherit
@@ -167,7 +166,7 @@ jobs:
test-build-docker-images:
if: |
!cancelled() &&
always() &&
github.event_name == 'pull_request' &&
needs.files-changed.outputs.project == 'true' &&
contains(fromJSON('["success", "skipped"]'), needs.gradle-cache-prime.result) &&
@@ -255,7 +254,7 @@ jobs:
# for whatever did record. Advisory only - intentionally NOT in
# all-checks-passed, so a flaky aggregate run never blocks merging.
coverage-aggregate:
if: ${{!cancelled()}}
if: always()
needs:
- build
- playwright-e2e-live
+3 -3
View File
@@ -42,7 +42,6 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: generated-models
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
@@ -63,9 +62,10 @@ jobs:
key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -75,7 +75,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Verify generated models are up to date
id: models-check
+3 -2
View File
@@ -32,12 +32,13 @@ jobs:
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Check licenses for compatibility
run: task backend:licenses:check
env:
+3 -2
View File
@@ -33,12 +33,13 @@ jobs:
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Generate OpenAPI documentation
run: task backend:swagger
env:
+2 -1
View File
@@ -49,9 +49,10 @@ jobs:
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+2 -1
View File
@@ -36,9 +36,10 @@ jobs:
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: 25
distribution: temurin
# Keep the normal formatting path here so this smoke test exercises the
# same Gradle configuration as the backend build.
+3 -2
View File
@@ -44,9 +44,10 @@ jobs:
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
@@ -56,7 +57,7 @@ jobs:
# runtime token isn't exposed) since the docker driver can't use it.
- name: Set up Docker Buildx
if: inputs.docker-base-changed != 'true'
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
+3 -2
View File
@@ -33,9 +33,10 @@ jobs:
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -44,7 +45,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (production bundle for vite preview)
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: a11y gate (changed stories)
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
- name: Upload scan reports
@@ -97,7 +97,7 @@ jobs:
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
@@ -166,7 +166,7 @@ jobs:
});
// Filter for license check comments
const licenseComments = comments.filter(comment =>
const licenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Frontend License Check Passed') ||
comment.body.includes('## ❌ Frontend License Check Failed')
);
@@ -361,12 +361,13 @@ jobs:
key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Check licenses and generate report
id: license-check
@@ -417,7 +418,7 @@ jobs:
per_page: 100
});
const backendLicenseComments = comments.filter(comment =>
const backendLicenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Backend License Check Passed') ||
comment.body.includes('## ❌ Backend License Check Failed')
);
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Quality-check frontend
id: frontend-check
run: task frontend:check:all
+2 -1
View File
@@ -42,9 +42,10 @@ jobs:
- name: Set up JDK 25
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Resolve backend dependencies
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
+11 -8
View File
@@ -63,12 +63,13 @@ jobs:
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Get version number
id: versionNumber
run: |
@@ -154,9 +155,10 @@ jobs:
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -167,7 +169,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build JAR
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
@@ -245,12 +247,13 @@ jobs:
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
# before the second local JDK setup overwrites JAVA_HOME.
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
architecture: "x64"
- name: Capture x86_64 JAVA_HOME
@@ -259,13 +262,13 @@ jobs:
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
+5 -4
View File
@@ -38,7 +38,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Install all Playwright browsers
run: task e2e:install
@@ -89,7 +89,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: a11y gate (every story, ${{ matrix.theme }})
run: task frontend:storybook:a11y:${{ matrix.theme }}
@@ -148,9 +148,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
@@ -161,7 +162,7 @@ jobs:
engine/uv.lock
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Start the fat image with login and storage enabled
run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build
+6 -2
View File
@@ -31,10 +31,14 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: pre-commit
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Run pre-commit checks
run: task pre-commit
# The fixture corpus checks the comment rules themselves, so it runs here
# rather than on every local commit.
- name: Check the comment-lint fixture corpus
run: task pre-commit:comment-lint:selftest
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
+4 -3
View File
@@ -78,16 +78,17 @@ jobs:
key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
sarif_file: results.sarif
+3 -2
View File
@@ -45,9 +45,10 @@ jobs:
key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
@@ -62,7 +63,7 @@ jobs:
SWAGGERHUB_USER: "Frooodle"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
+1 -2
View File
@@ -59,14 +59,13 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: sync-files
- name: Install Python dependencies
run: |
uv sync --project engine --locked --group tools
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Sync translation TOML files
run: |
+4 -3
View File
@@ -194,9 +194,10 @@ jobs:
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
architecture: "x64"
- name: Capture x86_64 JAVA_HOME
@@ -205,13 +206,13 @@ jobs:
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
+5 -4
View File
@@ -121,12 +121,13 @@ jobs:
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
- name: Build application
run: task backend:build
env:
@@ -141,7 +142,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Set base image and platform for this build
id: build-params
@@ -228,7 +229,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build docker/unoserver/Dockerfile
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+2 -1
View File
@@ -27,8 +27,9 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Java
uses: ./.github/actions/java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: "25"
- name: Find latest Gradle release
+10 -2
View File
@@ -176,6 +176,8 @@ app/core/src/main/resources/static/images/google-drive.svg
*.nar
*.ear
*.zip
# Real backend archives the form-bundle reader is tested against.
!frontend/editor/src/core/tools/formFill/__fixtures__/*.zip
*.tar.gz
*.rar
*.db
@@ -296,8 +298,13 @@ docs/type3/signatures/
**/application-dev-local.properties
# Claude
.claude/
# Claude. Contents are ignored so personal config stays local, with the two
# shared pieces re-included: settings.json (the comment-lint hook) and skills/.
# The directory itself cannot be ignored or git will not look inside it.
.claude/*
!.claude/settings.json
!.claude/skills/
.claude/settings.local.json
# Playwright MCP screenshots / traces
.playwright-mcp/
@@ -305,3 +312,4 @@ docs/type3/signatures/
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/editor/screenshots/
frontend/editor/src-tauri/libs/.variant
+62 -3
View File
@@ -40,12 +40,15 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
# and cannot blank the committed default.
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -84,6 +87,8 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
APP_BASE_URL: '{{.APP_BASE_URL}}'
BASE_PATH: '{{.BASE_PATH}}'
staging:saas:
desc: "Start SaaS backend against the shared v3 staging project"
@@ -95,10 +100,47 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
APP_BASE_URL: '{{.APP_BASE_URL}}'
BASE_PATH: '{{.BASE_PATH}}'
dev:linked:
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
cmds:
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
# does not make the sharing safe, so avoid rebuilding one while the other runs.
- cmd: |
n=0
while [ "$n" -lt 150 ]; do
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
echo ">> SaaS backend is up, starting self-hosted"
break
fi
n=$((n + 1))
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
done
if [ "$n" -ge 150 ]; then
echo ">> SaaS backend never answered; starting anyway"
fi
- task: dev:proprietary
vars:
PORT: '{{.PORT}}'
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
_run:saas:
internal: true
dotenv: ['app/.env.saas.local', 'app/.env.saas']
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
# Last, because dotenv is set-if-absent: app/* still decides everything else.
dotenv:
- 'app/.env.saas.local'
- 'app/.env.saas'
- 'frontend/editor/.env.saas.local'
- 'frontend/editor/.env.saas'
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
@@ -111,12 +153,29 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
# Empty is the same as unset: the property defaults to empty and is blank-checked.
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
# Relocates configs/pipeline/logs, for a second backend in the same directory.
# Empty is the same as unset: the reader blank-checks it.
BASE_PATH: '{{.BASE_PATH | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
STIRLING_FLAVOR: saas
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
# serves it at <base>/app/link. An explicit value still wins.
SYSTEM_FRONTENDURL:
sh: |
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
echo "${SYSTEM_FRONTENDURL}"
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
else
echo "{{.APP_BASE_URL}}"
fi
cmds:
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
# against SAAS_DB_* (production).
+14 -4
View File
@@ -23,7 +23,7 @@ tasks:
- package-lock.json
- package.json
status:
- test -d node_modules
- npm ls --depth=0
env:
CI: '{{ .CI | default "false" }}'
@@ -121,17 +121,17 @@ tasks:
sh: |
case "${SAAS_ENV:-dev}" in
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
esac
echo "https://${ref}.supabase.co"
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
sh: |
case "${SAAS_ENV:-dev}" in
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
esac
cmds:
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
- 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"'
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
dev:
@@ -173,6 +173,16 @@ tasks:
OPEN: '{{.OPEN}}'
SAAS_ENV: '{{.SAAS_ENV}}'
staging:saas:
desc: "Start frontend dev server against the shared v3 staging project"
cmds:
- task: dev:saas
vars:
SAAS_ENV: staging
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
OPEN: '{{.OPEN}}'
dev:desktop:
desc: "Start frontend dev server in desktop mode"
deps:
+82
View File
@@ -11,6 +11,7 @@ vars:
'.github/scripts/*.py'
'app/core/src/main/resources/static/python/*.py'
':(exclude)*split_photos.py'
':(exclude)scripts/lint/fixtures/*'
SPELL_FILES: >-
'*.html'
'*.css'
@@ -59,6 +60,7 @@ tasks:
- task: gitleaks
- task: whitespace
- task: toml-sort
- task: comment-lint
fix:
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
@@ -75,6 +77,7 @@ tasks:
vars: { FIX: '1' }
- task: codespell
- task: gitleaks
- task: comment-lint
install:
desc: "Install the pinned pre-commit Python tools"
@@ -130,6 +133,85 @@ tasks:
cmds:
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
comment-lint:
desc: "Check comment quality on the lines this branch adds"
summary: |
Blocks a comment that restates the code below it, a section banner, or a
block of commented-out code. Everything else it reports is advisory.
Scoped to added lines, so touching an old file never surfaces the standing
backlog. The standard is devGuide/CODE_COMMENTS.md.
With no arguments it diffs the working tree against HEAD, which is what a
pre-commit run wants: the lines you are about to commit. On a CI pull request
it diffs against the target branch instead, via GITHUB_BASE_REF.
To ask what a whole branch adds instead, use the branch variant, which
needs no argument passing:
task comment-lint:branch
Full tree (report only): task pre-commit:comment-lint:all
Fixture corpus: task pre-commit:comment-lint:selftest
# Depends on the frontend install because the .ts/.tsx half of the rule set
# runs as an oxlint plugin. Without it the TS engine warns and skips, which
# would leave the frontend silently unchecked on CI.
deps: [":frontend:install"]
cmds:
- node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
comment-lint:branch:
desc: "Check comment quality on everything this branch adds over its base"
summary: |
Like `task comment-lint`, but scoped to the whole branch rather than to
uncommitted work, so it still reports after you commit.
Exists as its own task because passing `-- --since origin/main` through Task
is not portable: with the npm build of Task the launcher is a PowerShell
script, and PowerShell strips the `--` before Task sees it, leaving Task to
print its own usage.
Override the base with BASE=<ref>.
vars:
BASE: '{{.BASE | default "origin/main"}}'
deps: [":frontend:install"]
cmds:
- node scripts/lint/comment-lint.mjs --since {{.BASE}}
comment-lint:ci:
desc: "Comment gate as CI runs it: fixture corpus, then the diff"
summary: |
The corpus checks the rules themselves rather than the code under review, so
it belongs on CI and not on every local commit. Run this before changing a
rule, and let CI run it on every pull request.
deps: [":frontend:install"]
cmds:
- node scripts/lint/comment-lint.mjs --selftest
- node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
comment-lint:hook:
desc: "Comment gate for the editor hook: everything this turn changed"
summary: |
Same scope as `task comment-lint`, kept as its own name so the hook has a
stable entry point and the taskfile shows every way the linter is invoked.
Not in the frontend-install dependency chain on purpose: this runs at the end
of every turn, so it stays as short as it can be. If oxlint is missing the TS
half warns and skips.
cmds:
- node scripts/lint/comment-lint.mjs
comment-lint:all:
desc: "Report every comment finding in the tree (never fails)"
deps: [":frontend:install"]
cmds:
- node scripts/lint/comment-lint.mjs --all
comment-lint:selftest:
desc: "Check both comment-lint engines against the fixture corpus"
deps: [":frontend:install"]
cmds:
- node scripts/lint/comment-lint.mjs --selftest
gitleaks-bin:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
+38 -1
View File
@@ -21,6 +21,43 @@ Task `desc:` fields should describe **what** the task does, not **how** it does
- `task docker:build` — build standard Docker image
- `task docker:up` — start Docker compose stack
## Comments
A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it.
Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue.
Write a comment when it does one of these four jobs:
- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring.
- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason.
- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z".
- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not.
Never write:
- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise.
- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`.
- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine.
- Commented-out code. Delete it.
- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it.
- Docs on self-explanatory members with no constraint to state.
Two tests before keeping a comment:
- **Delete it.** Is any information lost? If not, it stays deleted.
- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change.
A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other.
A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies.
A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO.
A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo.
`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md
## Common Development Commands
### Build and Test
@@ -70,7 +107,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
- Avoid nested functions and nested classes unless the language construct requires them.
- Prefer composition to inheritance when combining concepts.
- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle.
- Add comments sparingly and only when they explain non-obvious intent.
- Comments follow the repo-wide rules in the "Comments" section above.
#### Python Typing and Models
- Deserialize into Pydantic models as early as possible.
+1
View File
@@ -42,6 +42,7 @@ Please make sure your Pull Request adheres to the following guidelines:
- Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests.
- Commits should be clear, concise, and easy to understand.
- References to the Issue number in the Pull Request and/or Commit message.
- Every comment in the diff should say something the code does not. See [Code comments](devGuide/CODE_COMMENTS.md); `task comment-lint` checks the mechanical part.
## Translations
+100
View File
@@ -121,6 +121,92 @@ tasks:
cmds:
- task: dev:_all
# No engine: linking never calls it.
linked:staging:
desc: "SaaS on the shared v3 project + a self-hosted instance linked to it"
cmds:
- task: linked:_all
vars: { SAAS_ENV: staging }
linked:dev:
desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it"
cmds:
- task: linked:_all
vars: { SAAS_ENV: dev }
linked:_all:
internal: true
vars:
SAAS_ENV: '{{.SAAS_ENV | default "staging"}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}'
SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}'
APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}'
deps:
# APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not
# by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not
# race the self-hosted one, which keeps ./configs and its existing database.
- task: 'backend:{{.SAAS_ENV}}:saas'
vars:
PORT: '{{.SAAS_BACKEND_PORT}}'
APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}'
BASE_PATH: 'tmp/linked-saas'
- task: frontend:dev:saas
vars:
PORT: '{{.SAAS_FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
SAAS_ENV: '{{.SAAS_ENV}}'
- task: backend:dev:linked
vars:
PORT: '{{.APP_BACKEND_PORT}}'
SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
- task: frontend:dev:proprietary
vars:
PORT: '{{.APP_FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}'
OPEN: "true"
- task: linked:_ready
vars:
SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}'
SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}'
APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}'
APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}'
# Waits for all four to answer, then prints where they landed.
linked:_ready:
internal: true
cmds:
- cmd: |
n=0
ok=0
while [ "$n" -lt 150 ]; do
ok=1
for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \
"http://localhost:{{.SAAS_FRONTEND_PORT}}" \
"http://localhost:{{.APP_BACKEND_PORT}}" \
"http://localhost:{{.APP_FRONTEND_PORT}}"; do
# Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23.
curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0
done
if [ "$ok" = 1 ]; then break; fi
n=$((n + 1))
# `sleep` is a binary, not a builtin, and Windows has none.
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
done
echo ""
if [ "$ok" = 1 ]; then
echo ">> all four answering"
else
echo ">> still waiting on one or more after 5 minutes; addresses below anyway"
fi
echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor"
echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}"
echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}"
echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}"
echo ""
dev:_all:
internal: true
vars:
@@ -180,6 +266,20 @@ tasks:
cmds:
- task: frontend:lint
- task: engine:lint
- task: comment-lint
comment-lint:
desc: "Check comment quality on the lines this branch adds"
aliases: [comments]
cmds:
- task: pre-commit:comment-lint
vars: { CLI_ARGS: '{{.CLI_ARGS}}' }
comment-lint:branch:
desc: "Check comment quality on everything this branch adds over its base"
cmds:
- task: pre-commit:comment-lint:branch
vars: { BASE: '{{.BASE}}' }
fix:
desc: "Auto-fix all components"
+32 -7
View File
@@ -3,6 +3,10 @@ bootRun {
enabled = false
}
dependencies {
// Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection).
// Declared as api here so core + proprietary (which depend on common) get it transitively,
// keeping it off modules that don't need it (e.g. saas).
api 'io.github.pixee:java-security-toolkit:1.2.3'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
@@ -22,7 +26,10 @@ dependencies {
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:9.3.2'
api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support
// MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't)
api('org.simplejavamail:outlook-module:9.3.2') {
exclude group: 'org.apache.commons', module: 'commons-math3'
}
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
@@ -36,12 +43,30 @@ dependencies {
api "com.stirling:jpdfium:${jpdfiumVersion}"
// -PjpdfiumPlatforms=all|none|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
// 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
// -PjpdfiumPlatforms=auto|all|none|<csv of linux-x64,linux-arm64,linux-musl-x64,linux-musl-arm64,darwin-x64,darwin-arm64,windows-x64> (windows-arm64 natives not published yet)
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim()
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
def jpdfiumPlatforms
if (jpdfiumPlatformsProp == 'all') {
if (jpdfiumPlatformsProp == 'auto') {
def osName = System.getProperty('os.name').toLowerCase()
def osArch = System.getProperty('os.arch').toLowerCase()
def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64')
if (osName.contains('linux')) {
jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64']
} else if (osName.contains('mac')) {
jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64']
} else if (osName.contains('win')) {
if (isArm64) {
logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.")
jpdfiumPlatforms = []
} else {
jpdfiumPlatforms = ['windows-x64']
}
} else {
// Fallback: bundle all platforms when host can't be determined
jpdfiumPlatforms = jpdfiumAllPlatforms
}
} else if (jpdfiumPlatformsProp == 'all') {
jpdfiumPlatforms = jpdfiumAllPlatforms
} else if (jpdfiumPlatformsProp == 'none') {
jpdfiumPlatforms = []
@@ -51,7 +76,7 @@ dependencies {
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
if (jpdfiumInvalid) {
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.")
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
jpdfiumPlatforms.each { platform ->
@@ -48,7 +48,7 @@ public class EndpointConfiguration {
private final ApplicationProperties applicationProperties;
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private Set<String> disabledGroups = new HashSet<>();
private Set<String> disabledGroups = ConcurrentHashMap.newKeySet();
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
@@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser {
score -= 0.3f;
}
return Math.max(0f, Math.min(1f, score));
return Math.clamp(score, 0f, 1f);
}
private Bounds tableBounds(Table table) {
@@ -1517,6 +1517,18 @@ public class ApplicationProperties {
private boolean enabled;
@ToString.Exclude private String key;
private int maxUsers;
/**
* Servers purchased, and the users each one grants. Both come from licence metadata and are
* presentation only: {@code maxUsers} is the limit that is actually enforced. They exist so
* the UI can say "2 servers, 100 users each" rather than a bare 200, and so the
* add-capacity flow knows what a single additional server buys. Zero means the licence
* predates the cap and carries no server breakdown.
*/
private int serverQuantity;
private int userBlockSize;
private ProFeatures proFeatures = new ProFeatures();
private EnterpriseFeatures enterpriseFeatures = new EnterpriseFeatures();
@@ -62,6 +62,15 @@ public class FormFieldWithCoordinates {
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
private List<WidgetCoordinates> widgets;
@Schema(description = "Maximum character count for a text field (/MaxLen); null when unset")
private Integer maxLength;
@Schema(
description =
"Push button activation action as a spec string:"
+ " 'reset', 'print', 'uri:<url>' or 'submit:<url>'")
private String buttonActionSpec;
/**
* Coordinates for a single widget annotation (visual representation of the field). A field can
* have multiple widgets if it appears on multiple pages.
@@ -94,5 +103,12 @@ public class FormFieldWithCoordinates {
@Schema(description = "Font size in PDF points")
private Float fontSize;
@Schema(
description =
"CropBox height in PDF points. Lets the frontend reverse the backend's"
+ " Y-flip when sending new widget coordinates back for"
+ " create/modify operations.")
private Float cropBoxHeight;
}
}
@@ -3,14 +3,20 @@ package stirling.software.common.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
@@ -59,6 +65,24 @@ public enum FormFieldTypeSupport {
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
if (definition.fontSize() != null && definition.fontSize() > 0) {
textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g");
}
if (Boolean.TRUE.equals(definition.multiline())) {
textField.setMultiline(true);
}
// Comb field: evenly spaced character cells (e.g. SSN, phone). Requires
// a positive MaxLen and is mutually exclusive with multiline.
if (definition.maxLength() != null && definition.maxLength() > 0) {
textField.setMaxLen(definition.maxLength());
if (!Boolean.TRUE.equals(definition.multiline())) {
try {
textField.setComb(true);
} catch (Exception e) {
log.debug("Unable to set comb flag: {}", e.getMessage());
}
}
}
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
@@ -272,14 +296,108 @@ public enum FormFieldTypeSupport {
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
// Empty signature placeholder: no value to apply (signed later by a sign tool).
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
if (field.getWidgets().isEmpty()) {
return;
}
PDAnnotationWidget widget = field.getWidgets().get(0);
// Visible caption (/MK /CA).
String caption = definition.label();
if (caption == null || caption.isBlank()) {
caption = definition.name();
}
if (caption != null && !caption.isBlank()) {
PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics();
if (mk == null) {
mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
widget.setAppearanceCharacteristics(mk);
}
mk.setNormalCaption(caption);
}
widget.setPrinted(true);
applyButtonAction(widget, definition.buttonAction());
}
};
/**
* Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec,
* returning why it could not, or null on success. A blank spec clears the action.
*/
public static String applyButtonAction(PDAnnotationWidget widget, String action) {
if (action == null) {
return null;
}
if (action.isBlank()) {
// An explicit blank clears the action rather than leaving the old one behind.
widget.getCOSObject().removeItem(COSName.A);
return null;
}
String spec = action.trim();
if (!ACTION_SPEC.matcher(spec).matches()) {
return "'" + action + "' is not a button action this editor understands";
}
// The editor emits "uri:" the moment that kind is picked, before a URL is typed; an
// empty target is not yet an action, so clear rather than write an inert one.
int colon = spec.indexOf(':');
if (colon >= 0 && spec.substring(colon + 1).isBlank()) {
widget.getCOSObject().removeItem(COSName.A);
return null;
}
try {
String lower = spec.toLowerCase(Locale.ROOT);
if (lower.equals("reset")) {
widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject());
} else if (lower.equals("print")) {
PDActionNamed named = new PDActionNamed();
named.setN("Print");
widget.getCOSObject().setItem(COSName.A, named.getCOSObject());
} else if (lower.startsWith("uri:")) {
PDActionURI uri = new PDActionURI();
uri.setURI(spec.substring(4));
widget.getCOSObject().setItem(COSName.A, uri.getCOSObject());
} else if (lower.startsWith("submit:")) {
PDActionSubmitForm submit = new PDActionSubmitForm();
// Store the target URL on the action dictionary's /F entry.
submit.getCOSObject().setString(COSName.F, spec.substring(7));
widget.getCOSObject().setItem(COSName.A, submit.getCOSObject());
}
return null;
} catch (Exception e) {
log.debug("Unable to apply button action '{}': {}", action, e.getMessage());
return e.getMessage();
}
}
/** The spec forms applyButtonAction understands; anything else is reported, not dropped. */
private static final Pattern ACTION_SPEC =
Pattern.compile(
"^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
Arrays.stream(values())
.collect(
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport {
@Override
public void setAsText(String text) throws IllegalArgumentException {
try {
TypeReference<HashMap<String, String>> typeRef = new TypeReference<>() {};
TypeReference<HashMap<String, String>> typeRef =
new TypeReference<HashMap<String, String>>() {};
Map<String, String> map = objectMapper.readValue(text, typeRef);
setValue(map);
} catch (Exception e) {
@@ -0,0 +1,116 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSObjectKey;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** Pins how a choice field's options survive a save, which real forms rely on. */
class ChoiceOptionRoundTripTest {
private static PDComboBox combo(PDDocument document, List<String> options) throws IOException {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
PDComboBox field = new PDComboBox(form);
field.setPartialName("state");
field.setOptions(options);
form.getFields().add(field);
return field;
}
@Test
@DisplayName("a whitespace-only option survives a load, save and reload")
void whitespaceOptionSurvivesRoundTrip() throws IOException {
List<String> options = List.of(" ", "Alabama", "Alaska");
byte[] first;
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
combo(document, options);
document.save(out);
first = out.toByteArray();
}
// The real path edits a document loaded from bytes, not one built in memory.
byte[] saved;
try (PDDocument loaded = Loader.loadPDF(first);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
loaded.save(out);
saved = out.toByteArray();
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDComboBox reread =
(PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state");
assertEquals(
options,
reread.getOptionsExportValues(),
"an option must not vanish because the writer made it indirect");
}
}
@Test
@DisplayName("an option stored as an indirect reference is still reported")
void indirectOptionIsStillReported() throws IOException {
try (PDDocument document = new PDDocument()) {
PDComboBox field = combo(document, List.of(" ", "Alabama"));
// Real forms reference option strings indirectly; the reader must follow the reference.
COSArray options = new COSArray();
options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0)));
options.add(new COSString("Alabama"));
field.getCOSObject().setItem(COSName.OPT, options);
// Every read path runs this repair first, which is where the reference is followed.
FormUtils.repairMissingWidgetPageReferences(document);
assertEquals(
List.of(" ", "Alabama"),
field.getOptionsExportValues(),
"an indirectly stored option must not be dropped");
}
}
@Test
@DisplayName("a signature field reports no value rather than a JVM identity hash")
void signatureValueIsNotAnIdentityHash() throws IOException {
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
PDSignatureField signature = new PDSignatureField(form);
signature.setPartialName("approval");
// Only a field that actually holds a signature hits getValueAsString's toString().
signature.setValue(new PDSignature());
form.getFields().add(signature);
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
FormUtils.FormFieldInfo field =
fields.stream()
.filter(f -> "approval".equals(f.name()))
.findFirst()
.orElseThrow();
// An identity hash differs per load, so the same document would describe itself twice.
assertNull(field.value(), "a signature has no text value");
}
}
}
@@ -0,0 +1,62 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */
class DeepFieldTreeTest {
private static byte[] chainOfKids(int depth) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
COSDictionary root = new COSDictionary();
root.setString(COSName.T, "n0");
COSDictionary cursor = root;
for (int i = 1; i < depth; i++) {
COSDictionary kid = new COSDictionary();
kid.setString(COSName.T, "n" + i);
kid.setItem(COSName.PARENT, cursor);
COSArray kids = new COSArray();
kids.add(kid);
cursor.setItem(COSName.KIDS, kids);
cursor = kid;
}
cursor.setItem(COSName.FT, COSName.getPDFName("Tx"));
COSArray fields = new COSArray();
fields.add(root);
form.getCOSObject().setItem(COSName.FIELDS, fields);
document.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("a deeply nested field tree extracts without overflowing the stack")
void deepKidsChainDoesNotOverflow() throws IOException {
// 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in
// the writer, not in extraction, so it is not something a read endpoint would hit.
byte[] pdf = chainOfKids(2000);
try (PDDocument document = Loader.loadPDF(pdf)) {
assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document));
}
}
}
@@ -0,0 +1,201 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.FormFieldWithCoordinates;
/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */
class FormEditSafetyTest {
private static PDDocument formWith(String name, String type) throws IOException {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.A4));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
name,
null,
type,
0,
50f,
700f,
200f,
20f,
null,
null,
type.equals("radio") ? List.of("a", "b") : null,
null,
null,
null,
null,
null,
null,
null)));
return document;
}
private static FormUtils.ModifyFormFieldDefinition modify(
String target, String type, Float width, Float height) {
// Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest.
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, type, null, null, null, width, height, null, null, null, null,
null, null, null, null, null, null);
}
@Test
@DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field")
void unrebuildableTypeIsRefused() throws IOException {
try (PDDocument document = formWith("choice", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("choice", "radio", null, null)), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice");
assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller");
assertFalse(
field instanceof PDRadioButton,
"it could not become a radio, so it must not claim to be one");
assertEquals(
"text",
FormUtils.extractFormFields(document).getFirst().type(),
"the original field must survive untouched rather than be retyped");
}
}
@Test
@DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked")
void rebuiltCheckboxIsUsable() throws IOException {
try (PDDocument document = formWith("agree", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("agree", "checkbox", null, null)), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree");
assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox");
assertNotNull(
field.getWidgets().getFirst().getAppearance(),
"without an appearance the checkbox renders blank and cannot be ticked");
}
}
@Test
@DisplayName("a size of zero or infinity is refused rather than written into the page")
void unusableSizeIsRefused() throws IOException {
for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) {
try (PDDocument document = formWith("box", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("box", null, bad, 20f)), skipped);
PDRectangle rect =
document.getDocumentCatalog()
.getAcroForm(null)
.getField("box")
.getWidgets()
.getFirst()
.getRectangle();
assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad);
assertEquals(
200f,
rect.getWidth(),
0.01f,
"the original size must survive: width " + bad);
}
}
}
@Test
@DisplayName("a widget off the page still reports its geometry instead of dropping the field")
void offPageWidgetKeepsItsGeometry() throws IOException {
try (PDDocument document = formWith("stray", "text")) {
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray");
// Above the page top: legal PDF, and the user needs the coordinates to drag it back.
field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(document);
FormFieldWithCoordinates stray =
fields.stream()
.filter(f -> "stray".equals(f.getName()))
.findFirst()
.orElseThrow();
assertNotNull(stray.getWidgets(), "the field must keep its widget list");
assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported");
assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay");
}
}
private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, null, null, null, null, null, null, null, null, value,
null, null, null, null, null, null);
}
private static FormUtils.ModifyFormFieldDefinition withOptions(
String target, List<String> options) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, null, null, null, null, null, null, null, options, null,
null, null, null, null, null, null);
}
@Test
@DisplayName("a value a radio group cannot hold does not destroy the group")
void badRadioValueLeavesTheGroupIntact() throws IOException {
try (PDDocument document = formWith("plan", "radio")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(withValue("plan", "not-an-option")), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
assertTrue(
field instanceof PDRadioButton,
"a rejected value must not turn the group into another kind of field");
assertEquals(
2,
field.getWidgets().size(),
"the group's options must survive a rejected value");
assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied");
}
}
@Test
@DisplayName("editing a radio group's options is either applied or reported, never ignored")
void radioOptionEditIsNotSilentlyDropped() throws IOException {
try (PDDocument document = formWith("plan", "radio")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
boolean applied = field.getWidgets().size() == 3;
assertTrue(
applied || !skipped.isEmpty(),
"a change the UI shows as saved must either happen or be reported as skipped");
}
}
}
@@ -0,0 +1,61 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* A field name is caller-supplied and reaches several loggers. A line break in one would forge a
* second log line (CWE-117), so names carrying control characters are refused outright.
*/
class FormFieldNameSafetyTest {
@Test
void aNameWithCrLfIsRefused() {
String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4";
String reason = FormUtils.invalidFieldNameReason(forged);
assertNotNull(reason, "a name containing CR/LF must be refused");
assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break");
assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return");
}
@Test
void otherControlCharactersAreRefusedToo() {
assertNotNull(FormUtils.invalidFieldNameReason("tab\there"));
assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte"));
}
@Test
void ordinaryNamesStillPass() {
assertNull(FormUtils.invalidFieldNameReason("Full Name"));
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
assertNull(FormUtils.invalidFieldNameReason("Mr Smith"));
}
@Test
void thePeriodRefusalDoesNotEchoControlCharacters() {
// Both problems at once: the period branch must not leak the raw name into a log line.
String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED");
assertNotNull(reason);
assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason);
}
@Test
void sanitizeForLogFlattensControlCharacters() {
assertEquals("a b", FormUtils.sanitizeForLog("a\nb"));
assertEquals("a b", FormUtils.sanitizeForLog("a\rb"));
assertEquals("plain", FormUtils.sanitizeForLog("plain"));
assertNull(FormUtils.sanitizeForLog(null));
}
@Test
void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() {
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
assertNotNull(reason);
assertTrue(reason.contains("period"), "the message should name the problem: " + reason);
}
}
@@ -130,13 +130,15 @@ class FormFieldTypeSupportTest {
}
@Test
void doesNotSupportsDefinitionCreation_signatureReturnsTrue() {
assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
void doesNotSupportsDefinitionCreation_signatureReturnsFalse() {
// Signature placeholders are now creatable via the editor.
assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
}
@Test
void doesNotSupportsDefinitionCreation_buttonReturnsTrue() {
assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
void doesNotSupportsDefinitionCreation_buttonReturnsFalse() {
// Push buttons (with actions) are now creatable via the editor.
assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
}
@Test
@@ -0,0 +1,911 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
/**
* Guards the form editor against silently destroying a field it edits. Assertions run after a
* save/reload cycle because only the serialised document reflects what a viewer sees.
*/
class FormUtilsEditRegressionTest {
private static PDAcroForm setupForm(PDDocument document) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float x, float y, float w, float h, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
null, null);
}
/** Moves a field to a rect; null width/height leave the size alone. */
private static FormUtils.ModifyFormFieldDefinition moveTo(
String target, float x, float y, Float w, Float h) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null,
null, null, null);
}
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
PDField field = acroForm.getField(name);
assertNotNull(field, "field '" + name + "' should exist");
return field.getWidgets().get(0).getRectangle();
}
/** The /AP /N state names on a widget. */
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
PDAppearanceDictionary appearance = widget.getAppearance();
assertNotNull(appearance, "widget should have an /AP dictionary");
PDAppearanceEntry normal = appearance.getNormalAppearance();
assertNotNull(normal, "widget should have an /AP /N entry");
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
return normal.getSubDictionary().keySet().stream()
.map(COSName::getName)
.collect(Collectors.toSet());
}
@Test
void movingCheckboxKeepsItFillable() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("agree");
assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox");
assertFalse(
((PDCheckBox) field).getOnValue().isEmpty(),
"a moved checkbox must keep an on-state, or it can never be ticked again");
assertTrue(
normalStateNames(field.getWidgets().get(0)).size() >= 2,
"both /AP /N states must survive a move");
PDRectangle rect = firstWidgetRect(acroForm, "agree");
assertEquals(200f, rect.getLowerLeftX(), 0.5f);
assertEquals(400f, rect.getLowerLeftY(), 0.5f);
}
}
@Test
void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree");
assertFalse(
checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state");
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt");
PDRectangle bbox =
widget.getAppearance()
.getNormalAppearance()
.getSubDictionary()
.get(COSName.getPDFName(checkBox.getOnValue()))
.getBBox();
assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size");
}
}
/** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */
@Test
void resizingCheckboxKeepsItChecked() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
((PDCheckBox) form.getField("agree")).check();
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
((PDCheckBox) acroForm.getField("agree")).isChecked(),
"a resize must not silently untick the box");
}
}
/** Only widgets.get(0) used to move, so a radio group lost every option but the first. */
@Test
void movingRadioGroupMovesEveryOption() throws IOException {
byte[] saved;
float[] before = new float[6];
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C"))));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
assertEquals(3, widgets.size(), "the fixture needs three option widgets");
for (int i = 0; i < 3; i++) {
before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX();
before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY();
}
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("choice");
assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group");
List<PDAnnotationWidget> widgets = field.getWidgets();
assertEquals(3, widgets.size(), "no option may be left behind");
float dx = 90f - before[0];
float dy = 670f - before[1];
for (int i = 0; i < 3; i++) {
PDRectangle rect = widgets.get(i).getRectangle();
assertEquals(
before[i * 2] + dx,
rect.getLowerLeftX(),
0.5f,
"option " + i + " should shift by the same delta");
assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f);
}
}
}
/** A signature's /AP is the signature, so it must never be dropped. */
@Test
void movingSignatureKeepsItsAppearance() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("signature", "sig", 50, 700, 120, 40, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getField("sig") instanceof PDSignatureField,
"'sig' should still be a signature");
assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f);
}
}
@Test
void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() {
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
assertNotNull(reason, "a period must be refused, not silently dropped");
assertTrue(reason.contains("period"), "the message should name the offending character");
assertNull(FormUtils.invalidFieldNameReason("Has Space"));
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
assertNull(FormUtils.invalidFieldNameReason(null));
}
/** Dropped operations used to log a warning and still report success. */
@Test
void applyFieldEdits_reportsEveryDroppedOperation() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "present", 50, 700, 200, 20, null)));
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.applyFieldEdits(
document,
List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)),
List.of(moveTo("ghost", 10f, 10f, null, null)),
List.of("alsoGhost"),
skipped);
assertEquals(3, skipped.size(), "each dropped operation should be reported");
assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation())));
assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation())));
assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation())));
assertNotNull(
document.getDocumentCatalog().getAcroForm(null).getField("present"),
"the rest of the document must still be applied");
}
}
/** A clean batch must not report anything, or the UI would cry wolf on every save. */
@Test
void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.applyFieldEdits(
document,
List.of(newField("text", "fine", 50, 700, 200, 20, null)),
List.of(),
List.of(),
skipped);
assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips");
}
}
/** A drag must not normalise other options to the dragged widget's size. */
@Test
void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B"))));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
// Hand-authored groups legitimately have option boxes of differing size.
PDRectangle second = widgets.get(1).getRectangle();
widgets.get(1)
.setRectangle(
new PDRectangle(
second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f));
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = acroForm.getField("choice").getWidgets();
assertEquals(
40f,
widgets.get(1).getRectangle().getWidth(),
0.5f,
"a pure drag must not shrink the other options");
assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f);
}
}
/** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */
@Test
void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) form.getField("agree");
// A NeedAppearances form exported by Word/LibreOffice looks exactly like this.
box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP);
box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On"));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
assertEquals(
"On",
box.getOnValue(),
"the export value must survive; inventing 'Yes' would orphan /V");
assertTrue(box.isChecked(), "the box was ticked and must stay ticked");
}
}
/** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */
@Test
void renameProblem_ignoresAnUnchangedQualifiedName() {
assertNull(
FormUtils.renameProblem("Customer.Name", "Customer.Name"),
"a field standing still must not be rejected for its parent's period");
assertNull(FormUtils.renameProblem("plain", null));
assertNotNull(
FormUtils.renameProblem("plain", "New.Name"),
"an actual rename introducing a period must still be refused");
}
/** A nested field whose name box was left at its qualified name must still be modified. */
@Test
void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
// Re-parent it so its qualified name legitimately contains a period.
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Name",
null,
null,
0,
90f,
600f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertTrue(
skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("Customer.Name");
assertNotNull(field, "the nested field must survive the edit");
assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f);
}
}
/** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */
@Test
void maxLengthZeroClearsTheCombSetting() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
null, null, null, null, null, 8, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen());
FormUtils.ModifyFormFieldDefinition clear =
new FormUtils.ModifyFormFieldDefinition(
"code", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, 0, null);
FormUtils.modifyFormFields(document, List.of(clear));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertEquals(
-1,
((PDTextField) acroForm.getField("code")).getMaxLen(),
"/MaxLen should be gone, not merely zero");
}
}
/** An unrecognised button action must be reported rather than silently ignored. */
@Test
void unknownButtonActionIsReported() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"launchTheMissiles");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertEquals(1, skipped.size(), "an unusable action spec should be reported");
assertTrue(skipped.get(0).reason().contains("launchTheMissiles"));
}
}
/** Renaming a nested field must not re-parent it to the top level. */
@Test
void renamingNestedFieldKeepsItUnderItsParent() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition rename =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Phone",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(rename), skipped);
assertTrue(
skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(
acroForm.getField("Customer.Phone"),
"the field should still live under Customer, not at the top level");
assertNull(acroForm.getField("Customer.Name"), "the old name should be gone");
}
}
/** One rejected action on a multi-widget button is one report, not one per widget. */
@Test
void unknownButtonActionIsReportedOncePerField() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
PDField button = form.getField("go");
// Give it a second widget, as a button repeated on two pages would have.
PDAnnotationWidget extra = new PDAnnotationWidget();
extra.setRectangle(new PDRectangle(50, 600, 100, 24));
extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject());
List<PDAnnotationWidget> widgets = new ArrayList<>(button.getWidgets());
widgets.add(extra);
button.getCOSObject()
.setItem(
COSName.KIDS,
new org.apache.pdfbox.cos.COSArray() {
{
for (PDAnnotationWidget w : widgets) add(w.getCOSObject());
}
});
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"launchTheMissiles");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertEquals(1, skipped.size(), "one field, one report: " + skipped);
}
}
/** A clamped page index still creates the field, so it is not a dropped edit. */
@Test
void clampedPageIsNotReportedAsSkipped() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null,
null, null, null, null, null, null, null)),
skipped);
assertNotNull(
document.getDocumentCatalog().getAcroForm(null).getField("late"),
"the field is created on the clamped page");
assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped);
}
}
/** Recreation builds a top-level field, so it must refuse rather than re-parent. */
@Test
void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition retype =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
null,
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(retype), skipped);
assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(
acroForm.getField("Customer.Name"),
"the original nested field must be left intact");
assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level");
}
}
/** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */
@Test
void incompleteUrlActionClearsRatherThanFailing() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition pickUri =
new FormUtils.ModifyFormFieldDefinition(
"go", null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, "uri:");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(pickUri), skipped);
assertTrue(
skipped.isEmpty(),
"choosing a URL action before typing the URL is not an error: " + skipped);
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
assertNull(
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
"an empty target must leave no action behind");
}
}
/** A real URL still writes a real action. */
@Test
void completeUrlActionIsApplied() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition setUri =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"uri:https://example.com");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(setUri), skipped);
assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped);
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
assertNotNull(
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
"the action should be written");
}
}
/** Builds a parent with the given terminal children already attached. */
private static PDNonTerminalField nest(
PDDocument document, PDAcroForm form, String parentName, String... childNames)
throws IOException {
List<FormUtils.NewFormFieldDefinition> defs = new ArrayList<>();
for (int i = 0; i < childNames.length; i++) {
defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null));
}
FormUtils.addNewFields(document, defs);
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName(parentName);
List<PDField> kids = new ArrayList<>();
for (String child : childNames) {
PDField field = form.getField(child);
field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
kids.add(field);
}
parent.setChildren(kids);
form.setFields(List.of(parent));
return parent;
}
/** A refused edit must not release the name the field still really has. */
@Test
void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException {
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
nest(document, form, "Customer", "Name", "Email");
// Edit 1 is refused (type change on a nested field). Edit 2 then asks for the
// name edit 1 still occupies, which must not be handed out.
FormUtils.ModifyFormFieldDefinition refused =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Foo",
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.ModifyFormFieldDefinition rename =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Email",
"Customer.Name",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(refused, rename), skipped);
List<String> names = new ArrayList<>();
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName());
}
assertEquals(
names.size(),
new java.util.HashSet<>(names).size(),
"two fields must never share a qualified name: " + names);
assertTrue(
names.contains("Customer.Name"), "the refused field keeps its name: " + names);
}
}
/** A group name occupies the namespace, so a new field must not be able to take it. */
@Test
void groupNamesParticipateInCollisionChecks() throws IOException {
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
nest(document, form, "Customer", "Name");
FormUtils.addNewFields(
document, List.of(newField("text", "Customer", 50, 500, 100, 20, null)));
List<String> names = new ArrayList<>();
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
String fqn = f.getFullyQualifiedName();
if (fqn != null) names.add(fqn);
}
assertEquals(
names.size(),
new java.util.HashSet<>(names).size(),
"the new field must not take the group's name: " + names);
}
}
/** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */
@Test
void renameToBareParentPrefixIsRefused() {
assertNotNull(
FormUtils.renameProblem("Customer.Name", "Customer."),
"a name with nothing after the parent prefix is not a rename");
assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone"));
}
/** A type change must leave the field on its own page, not relocate it to the last one. */
@Test
void typeChangeKeepsTheFieldOnItsPage() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = new PDAcroForm(document);
for (int i = 0; i < 5; i++) {
document.addPage(new PDPage(PDRectangle.A4));
}
form.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(form);
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"onPageTwo",
null,
"text",
1,
50f,
700f,
200f,
20f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null)));
FormUtils.ModifyFormFieldDefinition retype =
new FormUtils.ModifyFormFieldDefinition(
"onPageTwo",
null,
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(document, List.of(retype));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("onPageTwo");
assertNotNull(field, "the retyped field should exist");
int page = -1;
for (int i = 0; i < reloaded.getNumberOfPages(); i++) {
for (var annot : reloaded.getPage(i).getAnnotations()) {
if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i;
}
}
assertEquals(
1, page, "a retyped field must stay on its own page, not move to the last");
}
}
}
@@ -0,0 +1,118 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.junit.jupiter.api.Test;
/** An edit the backend cannot honour must be reported, not logged and reported as success. */
class FormUtilsEditReportingTest {
private static FormUtils.NewFormFieldDefinition field(String type, String name) {
return new FormUtils.NewFormFieldDefinition(
name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null,
null, null, null);
}
private static PDDocument blank() {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
return document;
}
@Test
void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = blank()) {
FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped);
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getFields().isEmpty(),
"an unsupported type must not quietly become a text field");
}
assertEquals(1, skipped.size(), "the caller must be told: " + skipped);
assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason());
}
@Test
void aLyingPageCountIsSurvivable() throws IOException {
// /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws.
byte[] broken =
("%PDF-1.4\n"
+ "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
+ "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n"
+ "trailer << /Root 1 0 R >>\n")
.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = Loader.loadPDF(broken)) {
// Must not throw; the field is reported as skipped instead.
FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped);
} catch (IOException loadFailure) {
// A parser that refuses the file outright is an equally acceptable outcome.
return;
}
assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown");
}
@Test
void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage(PDRectangle.LETTER));
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"agree",
"agree",
"checkbox",
0,
60f,
700f,
14f,
14f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null)),
new ArrayList<>());
FormUtils.modifyFormFields(
document,
List.of(
new FormUtils.ModifyFormFieldDefinition(
"agree", null, null, null, 0, 200f, 400f, null, null, null,
null, null, null, null, null, null, null, null, null)));
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
saved = out.toByteArray();
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
assertNotNull(box);
assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable");
}
}
}
@@ -0,0 +1,467 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText;
import org.junit.jupiter.api.Test;
/**
* Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit
* {@code /Kids}, so only the serialised document reflects what a viewer sees.
*/
class FormUtilsEditingTest {
private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) {
PDPage page = new PDPage(pageSize);
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
private static FormUtils.NewFormFieldDefinition newText(
String name, float x, float y, float w, float h) {
return new FormUtils.NewFormFieldDefinition(
name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null,
null, null);
}
private static FormUtils.NewFormFieldDefinition newField(
String type,
String name,
float x,
float y,
float w,
float h,
List<String> options,
Integer maxLength,
String buttonAction) {
return new FormUtils.NewFormFieldDefinition(
name,
null,
type,
0,
x,
y,
w,
h,
null,
null,
options,
null,
null,
null,
null,
null,
maxLength,
buttonAction);
}
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
PDField field = acroForm.getField(name);
assertNotNull(field, "field '" + name + "' should exist");
assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget");
return field.getWidgets().get(0).getRectangle();
}
/**
* PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an
* explicit appearance a created checkbox or radio renders blank and resolves to Off.
*/
@Test
void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField("checkbox", "agree", 50, 600, 20, 20, null, null, null),
newField(
"radio",
"choice",
50,
500,
20,
20,
List.of("Yes", "No"),
null,
null),
newText("fullname", 50, 400, 200, 24)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm);
// NeedAppearances=false means viewers trust our streams, so they must exist.
assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run");
PDField checkBox = acroForm.getField("agree");
assertTrue(checkBox instanceof PDCheckBox);
assertEquals(
Set.of("Off", "Yes"),
normalStateNames(checkBox.getWidgets().get(0)),
"checkbox needs an Off and an on-state appearance");
PDField radio = acroForm.getField("choice");
assertTrue(radio instanceof PDRadioButton);
assertEquals(2, radio.getWidgets().size());
assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0)));
assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1)));
// A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for
// the whole form and every field above loses its appearance too.
PDField text = acroForm.getField("fullname");
assertNotNull(
text.getWidgets().get(0).getAppearance().getNormalAppearance(),
"text field should have a generated appearance");
}
}
/** The /AP /N state names on a widget. */
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
PDAppearanceDictionary appearance = widget.getAppearance();
assertNotNull(appearance, "widget should have an /AP dictionary");
PDAppearanceEntry normal = appearance.getNormalAppearance();
assertNotNull(normal, "widget should have an /AP /N entry");
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
return normal.getSubDictionary().keySet().stream()
.map(COSName::getName)
.collect(java.util.stream.Collectors.toSet());
}
@Test
void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm, "AcroForm should exist after reload");
assertTrue(acroForm.getField("created") instanceof PDTextField);
PDRectangle rect = firstWidgetRect(acroForm, "created");
assertNotNull(rect, "created widget should keep its rectangle after reload");
assertEquals(50f, rect.getLowerLeftX(), 0.5f);
assertEquals(700f, rect.getLowerLeftY(), 0.5f);
assertEquals(200f, rect.getWidth(), 0.5f);
assertEquals(20f, rect.getHeight(), 0.5f);
}
}
@Test
void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
// Shift the CropBox origin; the frontend sends CropBox-relative coords.
document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700));
FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRectangle rect = firstWidgetRect(acroForm, "shifted");
// Absolute = CropBox-relative + CropBox lower-left offset.
assertEquals(15f, rect.getLowerLeftX(), 0.5f);
assertEquals(25f, rect.getLowerLeftY(), 0.5f);
}
}
@Test
void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.NewFormFieldDefinition def =
new FormUtils.NewFormFieldDefinition(
"opts",
null,
"text",
0,
10f,
10f,
120f,
18f,
null,
null,
null,
null,
null,
18f,
Boolean.TRUE,
Boolean.TRUE,
null,
null);
FormUtils.addNewFields(document, List.of(def));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("opts");
assertNotNull(field);
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
assertTrue(field instanceof PDTextField);
assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload");
String da = ((PDVariableText) field).getDefaultAppearance();
assertTrue(da.contains("18"), "default appearance should carry the font size: " + da);
}
}
@Test
void modifyFormFields_movesAndResizesWidget() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null,
null, null, null, null, null, null, null);
FormUtils.modifyFormFields(document, List.of(mod));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRectangle rect = firstWidgetRect(acroForm, "movable");
assertEquals(100f, rect.getLowerLeftX(), 0.5f);
assertEquals(600f, rect.getLowerLeftY(), 0.5f);
assertEquals(150f, rect.getWidth(), 0.5f);
assertEquals(30f, rect.getHeight(), 0.5f);
}
}
@Test
void modifyFormFields_setsReadOnlyAndFontSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"editable",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
22f,
Boolean.TRUE,
null,
null,
null);
FormUtils.modifyFormFields(document, List.of(mod));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("editable");
assertNotNull(field);
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
String da = ((PDVariableText) field).getDefaultAppearance();
assertTrue(da.contains("22"), "font size should be reflected in DA: " + da);
}
}
@Test
void deleteFormFields_removesField() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm acroForm = setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20)));
FormUtils.deleteFormFields(document, List.of("temp"));
// After delete the AcroForm may still exist; the field must be gone.
if (acroForm != null) {
assertNull(acroForm.getField("temp"));
}
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(acroForm == null || acroForm.getField("temp") == null);
}
}
@Test
void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"choice",
60,
700,
16,
16,
List.of("Yes", "No"),
null,
null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("choice");
assertNotNull(field, "radio field should exist");
assertTrue(field instanceof PDRadioButton, "should be a radio button group");
assertEquals(2, field.getWidgets().size(), "one widget per option");
assertTrue(((PDRadioButton) field).getExportValues().contains("Yes"));
assertTrue(((PDRadioButton) field).getExportValues().contains("No"));
}
}
@Test
void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException {
// A radio group's label is its field name, not its first option, so the viewer label
// matches the name shown in the editor.
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"Choice",
60,
700,
16,
16,
List.of("Yes", "No"),
null,
null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
FormUtils.FormFieldInfo choice =
FormUtils.extractFormFields(reloaded).stream()
.filter(f -> "Choice".equals(f.name()))
.findFirst()
.orElse(null);
assertNotNull(choice, "radio field should be extracted");
assertEquals(
"Choice", choice.label(), "field name should win over the first option value");
}
}
@Test
void addNewFields_createsCombTextField() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDTextField field = (PDTextField) acroForm.getField("ssn");
assertNotNull(field);
assertEquals(9, field.getMaxLen(), "comb max length should persist");
assertTrue(field.isComb(), "comb flag should be set");
}
}
@Test
void addNewFields_createsSignatureAndButton() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField("signature", "sig", 50, 600, 200, 60, null, null, null),
newField("button", "btn", 50, 500, 120, 24, null, null, "reset")));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getField("sig") instanceof PDSignatureField,
"signature placeholder should exist");
assertTrue(
acroForm.getField("btn") instanceof PDPushButton, "push button should exist");
}
}
@Test
void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20)));
FormUtils.applyFieldEdits(
document,
List.of(newText("fresh", 50, 600, 200, 20)),
List.of(),
List.of("old"));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm.getField("fresh"), "added field should be present");
assertNull(acroForm.getField("old"), "deleted field should be gone");
}
}
}
@@ -705,10 +705,20 @@ class FormUtilsGapTest {
"newName",
"New Label",
null, // keep type (text) -> in-place path
null,
null,
null,
null,
null,
Boolean.TRUE,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -731,7 +741,8 @@ class FormUtilsGapTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"missing", null, null, null, null, null, null, null, null);
"missing", null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -754,7 +765,8 @@ class FormUtilsGapTest {
mods.add(null);
mods.add(
new FormUtils.ModifyFormFieldDefinition(
" ", null, null, null, null, null, null, null, null));
" ", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null));
FormUtils.modifyFormFields(doc, mods);
assertEquals(1, FormUtils.extractFormFields(doc).size());
@@ -285,13 +285,13 @@ class FormUtilsMoreTest {
}
@Test
void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException {
void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("offpage");
// Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates
// returns null, which is still added to the per-field widget list.
// Off the page is legal PDF; dropping it would leave the user unable to drag it
// back.
attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20));
List<FormFieldWithCoordinates> fields =
@@ -301,7 +301,8 @@ class FormUtilsMoreTest {
fields.get(0).getWidgets();
assertNotNull(widgets);
assertEquals(1, widgets.size());
assertNull(widgets.get(0));
assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay");
assertEquals(50f, widgets.get(0).getX(), 0.01f);
}
}
@@ -476,8 +477,18 @@ class FormUtilsMoreTest {
"combobox",
null,
null,
null,
null,
null,
null,
null,
List.of("One", "Two"),
"One",
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -505,10 +516,20 @@ class FormUtilsMoreTest {
null,
"listbox", // same type -> in-place path
null,
null,
null,
null,
null,
null,
Boolean.TRUE,
List.of("X", "Y", "Z"),
null,
"Choose items");
"Choose items",
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -529,7 +550,25 @@ class FormUtilsMoreTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"keep", null, null, "bogusType", null, null, null, null, null);
"keep",
null,
null,
"bogusType",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
// The field is preserved unchanged because the target type is unsupported.
@@ -554,7 +593,8 @@ class FormUtilsMoreTest {
// Rename beta -> alpha; should be uniquified to avoid the collision.
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"beta", "alpha", null, null, null, null, null, null, null);
"beta", "alpha", null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -575,7 +615,8 @@ class FormUtilsMoreTest {
doc.addPage(new PDPage());
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"x", null, null, null, null, null, null, null, null);
"x", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
}
}
@@ -0,0 +1,102 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
/**
* Most real PDFs have no AcroForm at all, so adding the very first field has to build one that
* PDFBox will accept.
*/
class FormUtilsNoAcroFormTest {
private static final Path PLAIN_PDF =
Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf");
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float y, List<String> options, String defaultValue) {
// name, label, type, pageIndex, x, y, width, height, required, multiSelect,
// options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction
return new FormUtils.NewFormFieldDefinition(
name,
name,
type,
0,
60f,
y,
200f,
20f,
null,
null,
options,
defaultValue,
null,
null,
null,
null,
null,
null);
}
private static PDDocument loadPlain() throws IOException {
return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF));
}
@Test
void plainPdfReallyHasNoAcroForm() throws IOException {
try (PDDocument document = loadPlain()) {
assertNull(
document.getDocumentCatalog().getAcroForm(null),
"fixture must have no AcroForm or this test proves nothing");
}
}
@Test
void addsFirstFieldToAPdfWithNoAcroForm() throws IOException {
byte[] saved;
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = loadPlain()) {
FormUtils.addNewFields(
document,
List.of(
newField("text", "fullName", 700f, null, "Ada"),
newField("checkbox", "agree", 660f, null, null),
newField("radio", "contact", 600f, List.of("Email", "Post"), null)),
skipped);
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
saved = out.toByteArray();
}
assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped);
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm, "an AcroForm should have been created");
assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text");
assertTrue(
acroForm.getDefaultAppearance() != null
&& !acroForm.getDefaultAppearance().isBlank(),
"/DA is required for variable text");
PDTextField text = (PDTextField) acroForm.getField("fullName");
assertNotNull(text, "the text field should exist");
assertEquals("Ada", text.getValueAsString());
assertNotNull(acroForm.getField("agree"));
assertNotNull(acroForm.getField("contact"));
}
}
}
@@ -0,0 +1,175 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
/**
* Option captions belong to the viewer, not the page. Drawing them into the content stream left
* orphan text behind on every move and delete, so these pin the page staying clean.
*/
class FormUtilsRadioCaptionTest {
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float x, float y, float w, float h, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
null, null);
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
return out.toByteArray();
}
private static PDDocument blankWithForm() {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
return document;
}
private static String textOf(byte[] pdf) throws IOException {
try (PDDocument reloaded = Loader.loadPDF(pdf)) {
return new PDFTextStripper().getText(reloaded);
}
}
@Test
void radioOptionsAreNotBakedIntoThePage() throws IOException {
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"contact",
72,
600,
12,
12,
List.of("Email", "Telephone", "Post"))));
saved = save(document);
}
// The caption is the viewer's job; page content cannot follow a widget that moves.
String text = textOf(saved);
assertFalse(text.contains("Email"), "options must not be page content: " + text);
assertFalse(text.contains("Telephone"), "options must not be page content: " + text);
assertFalse(text.contains("Post"), "options must not be page content: " + text);
}
@Test
void captionsDoNotReplaceTheWidgetsThemselves() throws IOException {
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L"))));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRadioButton radio = (PDRadioButton) acroForm.getField("size");
assertEquals(3, radio.getWidgets().size(), "one widget per option");
assertFalse(radio.getExportValues().isEmpty(), "export values must survive");
}
}
@Test
void aTextFieldDrawsNoStrayCaption() throws IOException {
// Control: proves the assertions above read the captions and not some unrelated content.
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document, List.of(newField("text", "fullName", 72, 600, 200, 18, null)));
saved = save(document);
}
assertTrue(textOf(saved).isBlank(), "a text field should add no page content");
}
@Test
void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException {
byte[] withRadio;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"contact",
72,
600,
12,
12,
List.of("Email", "Telephone", "Post"))));
withRadio = save(document);
}
assertFalse(
textOf(withRadio).contains("Telephone"),
"the group adds no page text to begin with");
byte[] afterDelete;
try (PDDocument document = Loader.loadPDF(withRadio)) {
FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact"));
afterDelete = save(document);
}
String text = textOf(afterDelete);
assertFalse(
text.contains("Telephone"),
"a deleted radio group must not leave its captions on the page: " + text);
}
@Test
void theDrawnBoxIsTheWholeGroupNotOneOption() {
// A 90pt box used to become a 360pt stack because each option got the full height.
PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f);
var rects = FormUtils.radioOptionRects(box, 3, null, null);
assertEquals(3, rects.size());
float top = rects.get(0).getUpperRightY();
float bottom = rects.get(2).getLowerLeftY();
assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height");
assertEquals(
box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge");
for (PDRectangle r : rects) {
assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square");
assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box");
}
}
@Test
void explicitSizeAndGapWin() {
PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f);
var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f);
for (PDRectangle r : rects) {
assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim");
}
float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY();
assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim");
}
@Test
void aSingleOptionStillFitsTheBox() {
var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null);
assertEquals(1, rects.size());
assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box");
}
}
@@ -0,0 +1,57 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** A form with no default resources is ordinary; adding a field to it must still work. */
class MissingDefaultResourcesTest {
@Test
@DisplayName("a text field can be added to a form that has no default resources")
void addsToFormWithoutDefaultResources() throws IOException {
// A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all.
byte[] pdf;
try (PDDocument built = new PDDocument();
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
built.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(built);
// A /DA naming a font with no /DR to resolve it is what PDFBox refuses.
form.setDefaultAppearance("/Helv 0 Tf 0 g");
form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR);
built.getDocumentCatalog().setAcroForm(form);
built.save(out);
pdf = out.toByteArray();
}
try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
null, null, null, null, null, null, null)),
skipped);
assertTrue(
skipped.isEmpty(),
"adding a plain text field should not be refused: " + skipped);
assertEquals(
1,
FormUtils.extractFormFields(document).size(),
"the field should be in the document");
}
}
}
@@ -113,6 +113,16 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
}
@Test
void testIsFrontendRoute_editorRouteOwnedByFrontend() {
// /editor (and its tool routes) is an SPA route: a direct-nav/refresh must
// serve index.html, not the auth filter's 302-to-/login. Regression test for
// the editor moving from / to /editor, whose refresh bounced processor users
// to the processor because the redirect dropped the return path.
assertTrue(RequestUriUtils.isFrontendRoute("", "/editor"));
assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor"));
}
@Test
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
// /files and /files/<folder-uuid> are FileManagerView routes - they
+1
View File
@@ -106,6 +106,7 @@ SwaggerDoc.json
# Log file
*.log
*.log.gz
# BlueJ files
*.ctxt
+17 -2
View File
@@ -62,8 +62,16 @@ dependencies {
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module.
// Exclude commons-beanutils + commons-collections.
implementation('com.opencsv:opencsv:5.12.0') {
exclude group: 'commons-beanutils', module: 'commons-beanutils'
exclude group: 'commons-collections', module: 'commons-collections'
}
// POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3.
implementation('org.apache.poi:poi-ooxml:5.5.1') {
exclude group: 'org.apache.commons', module: 'commons-math3'
}
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
@@ -129,6 +137,10 @@ bootJar {
exclude 'META-INF/*.RSA'
exclude 'META-INF/*.EC'
// Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime
exclude 'static/pdfjs-legacy/**/*.map'
exclude 'static/**/*.map'
manifest {
attributes(
'Implementation-Title': 'Stirling-PDF',
@@ -294,6 +306,9 @@ tasks.register('copyFrontendAssets', Copy) {
// Exclude files that conflict with backend static resources
exclude 'robots.txt' // Backend already has this
exclude 'favicon.ico' // Backend already has this
// Backend ships its own NotoSans-Regular.ttf here and it is git-tracked;
// letting the editor's copy win would dirty the source tree on every build.
exclude 'fonts/NotoSans-Regular.ttf'
}
into resourcesStaticDir
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
@@ -183,7 +183,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -229,7 +231,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
} else {
@@ -256,7 +260,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
}
@@ -237,7 +237,7 @@ public class EditTextController {
Matcher matcher = edit.pattern().matcher(joined);
List<MatchSpan> spans = new ArrayList<>();
StringBuffer interpolation = new StringBuffer();
StringBuilder interpolation = new StringBuilder();
int previousAppendPosition = 0;
while (matcher.find()) {
if (matcher.start() == matcher.end()) {
@@ -0,0 +1,598 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
/**
* Charcode-encode helper for the v2 PDF text editor.
*
* <p>The frontend editor uses PDFium-WASM, which exposes {@code FPDFText_SetCharcodes} for writing
* new text using raw font charcodes (skipping PDFium's broken reverse Unicode→CID lookup for
* embedded subset fonts). What PDFium does NOT expose is the byte-encoding side of an existing font
* - given a PDFont and a Unicode string, what are the bytes the font's encoding produces? PDFBox
* does have that ({@link PDFont#encode}).
*
* <p>This endpoint accepts the source PDF + a "locator" describing where to find the font in
* question (page index + a sample char known to render in the target font, optionally narrowed by
* the font's /BaseFont name) + the Unicode text the frontend wants to encode. It returns the
* charcode sequence the frontend can pass to {@code FPDFText_SetCharcodes}.
*
* <p>If the locator can't find a matching text fragment, or if the font can't encode some chars,
* the response reports which chars are missing so the frontend can fall back to Helvetica per char.
*/
@Slf4j
@GeneralApi
@RequiredArgsConstructor
public class PdfTextEditorCharcodeController {
/** Reject JSON bodies whose base64 implies a decoded PDF larger than this. */
private static final int MAX_PDF_BYTES = 100 * 1024 * 1024;
/**
* Upper bound on {@code request.text} code units. Editor requests are word-sized; an unbounded
* text drove a per-code-point encode/exception loop (CPU burn) on crafted requests.
*/
private static final int MAX_TEXT_CHARS = 4096;
/** Nested form-XObject resource dictionaries visited per lookup (cycle/DoS guard). */
private static final int MAX_RESOURCE_DICTS = 32;
/** Bound on the reverse-map cache so a busy multi-document server can't grow it forever. */
private static final int REVERSE_MAP_CACHE_MAX = 32;
/** Access-ordered LRU bounded at {@link #REVERSE_MAP_CACHE_MAX} entries. */
private static final class BoundedReverseMapCache
extends java.util.LinkedHashMap<String, java.util.Map<String, Long>> {
private static final long serialVersionUID = 1L;
BoundedReverseMapCache() {
super(16, 0.75f, true);
}
@Override
protected boolean removeEldestEntry(
java.util.Map.Entry<String, java.util.Map<String, Long>> eldest) {
return size() > REVERSE_MAP_CACHE_MAX;
}
}
private static final java.util.Map<String, java.util.Map<String, Long>> REVERSE_MAP_CACHE =
java.util.Collections.synchronizedMap(new BoundedReverseMapCache());
private final CustomPDFDocumentFactory pdfDocumentFactory;
// NOTE: PDFBox's PDSimpleFont emits one "No Unicode mapping for .notdef" WARN per probed
// charcode when buildReverseUnicodeMap iterates 0..0xFFFF, which once flooded info.log to
// ~1.4 GB overnight. That logger is silenced DECLARATIVELY in logback.xml (a config entry ops
// can see and revert) rather than by mutating the global logger from a static block here -
// mutating it at class-load time hid the same warnings from every other tool in the JVM with
// no trace in configuration.
@Data
public static class EncodeCharcodesRequest {
/** Base64-encoded original PDF. The frontend already has the bytes loaded. */
private String pdfBase64;
/** 0-based page index containing the font sample. */
private int pageIndex;
/**
* A char known to exist on the page in the target font. Combined with {@code fontName}
* (when supplied) it locates the source PDFont via its ToUnicode CMap.
*/
private String locatorChar;
/**
* Optional /BaseFont name of the target font (as PDFium's FPDFFont_GetBaseFontName reports
* it). When a page has TWO fonts that both render {@code locatorChar}, this disambiguates
* which one to encode against - otherwise the first font found wins and a cross-font edit
* gets the wrong font's charcode. Null = keep the legacy first-match behaviour.
*/
private String fontName;
/**
* Optional SHA-256 (lowercase hex) of the target font's embedded program bytes (what
* PDFium's FPDFFont_GetFontData returns = the decoded FontFile/FontFile2/FontFile3 stream).
* This is the ONLY unambiguous font identity: PDFium strips the "ABCDEF+" subset tag from
* font names, so every subset of one family reports the same {@code fontName} and a
* name-based lookup can land on a SIBLING subset whose charcode space is different -
* returning valid-but-wrong charcodes that scramble the edited text. When present and a
* font on the page matches, it wins over name matching.
*/
private String fontSha256;
/** Unicode text the frontend wants to encode. */
private String text;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class EncodeCharcodesResponse {
/**
* Per-char charcode array (one entry per code point in {@code request.text}). When the
* font's encoding produces multi-byte sequences, each char gets the full unsigned int value
* of its bytes packed big-endian (so a 2-byte CID like 0x004D becomes 77).
*/
private List<Long> charcodes;
/** Chars from the request that the font couldn't encode. */
private List<String> missing;
/** Diagnostic note - included so the frontend HUD can show what happened. */
private String note;
/** Set when the request failed entirely (bad pdf bytes, no matching font, etc.). */
private String error;
}
@Operation(
summary = "Encode Unicode → font charcodes for the v2 PDF text editor",
description =
"""
Frontend-only helper: takes the source PDF, a locator pointing at an existing
char rendered in the target font, and a Unicode string. Returns the byte
sequence the target font produces for that Unicode, packed as one unsigned
int per char. The frontend then calls FPDFText_SetCharcodes with the
returned ints to inject new text that reuses the embedded font's actual
glyphs. Chars the font can't encode are listed in `missing` so the caller
can fall back per-char.
""")
@PostMapping(
value = "/pdf-text-editor/encode-charcodes",
consumes = "application/json",
produces = "application/json")
public ResponseEntity<EncodeCharcodesResponse> encodeCharcodes(
@RequestBody EncodeCharcodesRequest request) {
EncodeCharcodesResponse resp = new EncodeCharcodesResponse();
if (request == null
|| request.getPdfBase64() == null
|| request.getText() == null
|| request.getLocatorChar() == null) {
resp.setError("missing required fields");
return ResponseEntity.badRequest().body(resp);
}
// length/4*3 bounds the decoded size without decoding, so we reject early before
// allocating.
String b64 = request.getPdfBase64();
if ((long) b64.length() / 4 * 3 > MAX_PDF_BYTES) {
resp.setError("pdf too large");
return ResponseEntity.status(413).body(resp);
}
// Reported separately: a combined check names only one cause and misleads the caller.
if (request.getText().length() > MAX_TEXT_CHARS) {
resp.setError("text too long");
return ResponseEntity.badRequest().body(resp);
}
if (request.getLocatorChar().length() > 4) {
resp.setError("locatorChar too long");
return ResponseEntity.badRequest().body(resp);
}
byte[] pdfBytes;
try {
pdfBytes = Base64.getDecoder().decode(b64);
} catch (IllegalArgumentException e) {
resp.setError("pdfBase64 is not valid base64");
return ResponseEntity.badRequest().body(resp);
}
try (PDDocument doc = pdfDocumentFactory.load(pdfBytes, true)) {
if (request.getPageIndex() < 0 || request.getPageIndex() >= doc.getNumberOfPages()) {
resp.setError("pageIndex out of range");
return ResponseEntity.badRequest().body(resp);
}
PDPage page = doc.getPage(request.getPageIndex());
// Skip walking the page's content stream (it crashes on Type3 fonts with
// UnsupportedOperationException("Not implemented: Type3") before we can do anything
// useful). Instead enumerate the page's font resources and pick the one identified by
// the request's font-program hash (definitive), falling back to name matching.
// For Chrome/Skia-printed PDFs that emit one Type3 font per glyph, this lands on
// the exact font that renders the locator char.
ResourceFont located =
findFontByToUnicode(
page,
request.getLocatorChar(),
request.getFontName(),
request.getFontSha256(),
doc);
if (located == null) {
resp.setError(
"no font on page "
+ request.getPageIndex()
+ " renders locatorChar="
+ request.getLocatorChar()
+ (request.getFontName() != null
? " (fontName=" + request.getFontName() + ")"
: ""));
return ResponseEntity.ok(resp);
}
// Build a reverse Unicode→charcode map by walking the font's ToUnicode CMap.
// This is the ONLY path that works for Type3 fonts (PDFBox's font.encode() throws
// "Not implemented: Type3" on them), and it also acts as a more reliable fallback
// for subset fonts whose encode() rejects chars not in the original document.
//
// For Sample.pdf specifically, every embedded font is Type3 (Chrome/Skia output),
// but they all carry a ToUnicode CMap mapping CIDs back to Unicode. We iterate
// charcodes 0..0xFFFF, call font.toUnicode(cc) for each, and record the inverse
// mapping for the chars the user wants to write.
PDFont font = located.font();
java.util.Map<String, Long> reverseMap =
buildReverseUnicodeMap(pdfBytes, located, request.getPageIndex());
List<Long> charcodes = new ArrayList<>();
List<String> missing = new ArrayList<>();
String text = request.getText();
int i = 0;
while (i < text.length()) {
int cp = text.codePointAt(i);
String oneChar = new String(Character.toChars(cp));
i += Character.charCount(cp);
// Whitespace is NEVER charcode-reused. Subset Type1/LaTeX fonts
// usually have no real space glyph, yet font.encode(0x20) still
// returns code 0x20 without throwing - and SetCharcodes(0x20)
// then paints whatever glyph sits at that subset code (e.g. „
// quotedblbase in LMRoman). Report whitespace as missing so the
// frontend emits it as a positional gap instead.
if (Character.isWhitespace(cp)) {
missing.add(oneChar);
continue;
}
// 1st try: font.encode() - works for Type0/TrueType/Type1
Long packed = null;
try {
byte[] encoded = font.encode(oneChar);
long p = 0L;
for (byte b : encoded) p = (p << 8) | (b & 0xff);
packed = p;
} catch (IOException
| IllegalArgumentException
| UnsupportedOperationException encodeEx) {
// 2nd try: ToUnicode reverse lookup - works for Type3 + anything with a CMap
packed = reverseMap.get(oneChar);
}
if (packed != null) charcodes.add(packed);
else missing.add(oneChar);
}
resp.setCharcodes(charcodes);
if (!missing.isEmpty()) resp.setMissing(missing);
resp.setNote(
"font="
+ font.getName()
+ " encoded "
+ charcodes.size()
+ " of "
+ (charcodes.size() + missing.size())
+ " chars");
return ResponseEntity.ok(resp);
} catch (IOException e) {
log.warn("encodeCharcodes: failed to load PDF", e);
resp.setError("failed to load PDF");
return ResponseEntity.badRequest().body(resp);
} catch (RuntimeException e) {
log.warn("encodeCharcodes: unexpected error", e);
resp.setError("unexpected error");
return ResponseEntity.status(500).body(resp);
}
}
/**
* Locate the font the request targets. Identity sources, strongest first:
*
* <ol>
* <li><b>Program hash</b>: SHA-256 of the embedded font program bytes. Definitive - two
* different subsets NEVER share program bytes, and PDFium's FPDFFont_GetFontData returns
* exactly the decoded FontFile stream, so frontend and backend hash the same bytes.
* <li><b>Exact /BaseFont name</b> (subset tag included), then <b>tag-stripped name</b>. Name
* matches are only accepted when UNAMBIGUOUS: PDFium reports subset fonts WITHOUT their
* "ABCDEF+" tag, so a page with several subsets of one family ("AAAAAC+Garamond",
* "AAAAAG+Garamond", ...) has them ALL match the stripped name - and encoding against the
* wrong sibling returns valid-but-wrong charcodes that scramble the edited text ("RUSSELL
* W. MANGUM" rendered "US EEL W. MANGS M"). With 2+ candidates we return null so the
* frontend takes its safe fallback instead of a coin flip.
* </ol>
*
* <p>This avoids running PDFStreamEngine.processPage, which throws
* UnsupportedOperationException on Type3 font glyph rendering. The PDFont lookup itself is
* purely metadata-driven and works on all subtypes.
*/
private static ResourceFont findFontByToUnicode(
PDPage page, String wantChar, String fontName, String fontSha256, PDDocument doc) {
try {
List<ResourceFont> fonts = collectResourceTreeFonts(page.getResources());
// 1) Program-hash identity. When several dicts share one program (identical bytes
// re-embedded), any of them renders the same glyphs for the same codes; prefer the
// one whose ToUnicode covers the locator char so the reverse map is usable.
if (fontSha256 != null && !fontSha256.isEmpty()) {
List<ResourceFont> hashMatches = new ArrayList<>();
for (ResourceFont rf : fonts) {
String sha = fontProgramSha256(rf.font());
if (fontSha256.equalsIgnoreCase(sha)) hashMatches.add(rf);
}
for (ResourceFont rf : hashMatches) {
if (probesToUnicode(rf.font(), wantChar)) return rf;
}
if (!hashMatches.isEmpty()) return hashMatches.get(0);
// No program on this page hashes to what the frontend is editing (e.g. PDFium
// returned a substitute font's bytes for a non-embedded font). Fall through to
// name matching rather than failing outright.
}
// 2) Name identity - exact tag-included first, then tag-stripped - each accepted
// only when it selects a single font.
if (fontName != null && !fontName.isEmpty()) {
ResourceFont exact =
selectUnambiguous(
fonts, wantChar, f -> fontName.equals(f.getName()), "exact");
if (exact != null) return exact;
String wantStripped = stripSubsetTag(fontName);
ResourceFont stripped =
selectUnambiguous(
fonts,
wantChar,
f -> wantStripped.equals(stripSubsetTag(f.getName())),
"stripped");
if (stripped != null) return stripped;
// The frontend NAMED the font it is editing. Falling back to "any font that
// renders the char" would hand back a DIFFERENT font's charcodes, which the
// frontend then writes into the named font's text object - wrong glyph, and the
// backend strategy skips all frontend validation. Report the char missing
// instead so the caller takes its own fallback path.
return null;
}
// 3) Legacy locator-only behaviour: first font whose ToUnicode renders the char.
for (ResourceFont rf : fonts) {
if (probesToUnicode(rf.font(), wantChar)) return rf;
}
} catch (RuntimeException ignore) {
// Be defensive: any single bad font shouldn't sink the whole request.
}
return null;
}
/**
* Apply {@code nameFilter}, then decide: exactly one candidate whose ToUnicode covers {@code
* wantChar} wins; two+ probe-hits are AMBIGUOUS (null). With zero probe-hits, a single
* name-matching font is still returned (font.encode() may handle chars without a ToUnicode -
* common for Type0/Identity-H), but two+ name matches are again ambiguous.
*/
private static ResourceFont selectUnambiguous(
List<ResourceFont> fonts,
String wantChar,
java.util.function.Predicate<PDFont> nameFilter,
String modeLabel) {
List<ResourceFont> named = new ArrayList<>();
for (ResourceFont rf : fonts) {
try {
if (rf.font().getName() != null && nameFilter.test(rf.font())) named.add(rf);
} catch (RuntimeException ignore) {
}
}
if (named.isEmpty()) return null;
List<ResourceFont> probed = new ArrayList<>();
for (ResourceFont rf : named) {
if (probesToUnicode(rf.font(), wantChar)) probed.add(rf);
}
if (probed.size() == 1) return probed.get(0);
if (probed.size() > 1) {
log.debug(
"encodeCharcodes: {} name match ambiguous ({} fonts render locator '{}') -"
+ " refusing cross-subset guess",
modeLabel,
probed.size(),
wantChar);
return null;
}
return named.size() == 1 ? named.get(0) : null;
}
/** True when some charcode in the font's ToUnicode CMap maps to {@code wantChar}. */
private static boolean probesToUnicode(PDFont font, String wantChar) {
// Cheap inverse-CMap probe: iterate codes until we hit one whose toUnicode is wantChar.
// For Type3 with at most ~16 glyphs, this is microseconds. For full Type0 subsets
// it's a few-thousand-iteration scan.
int upper = font.isStandard14() ? 256 : 0x10000;
for (int cc = 0; cc < upper; cc++) {
String u;
try {
u = font.toUnicode(cc);
} catch (Exception ignore) {
continue;
}
if (u != null && u.equals(wantChar)) return true;
}
return false;
}
private record ResourceFont(PDFont font, String path) {}
private record PendingResources(PDResources resources, String path) {}
/**
* Breadth-first collection of every distinct font reachable from the page's resources AND every
* nested form XObject's resources (bounded by {@link #MAX_RESOURCE_DICTS}, cycle-safe, deduped
* by COS dictionary identity). The v2 reader surfaces form-XObject text as editable, so its
* fonts must be findable too.
*/
private static List<ResourceFont> collectResourceTreeFonts(PDResources resources) {
List<ResourceFont> out = new ArrayList<>();
java.util.ArrayDeque<PendingResources> queue = new java.util.ArrayDeque<>();
java.util.Set<org.apache.pdfbox.cos.COSDictionary> seenDicts =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
java.util.Set<org.apache.pdfbox.cos.COSDictionary> seenFonts =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
if (resources != null) queue.add(new PendingResources(resources, ""));
int visited = 0;
// Bound a crafted page declaring many fonts none of which match (CPU-DoS guard).
final int MAX_FONTS = 64;
while (!queue.isEmpty() && visited < MAX_RESOURCE_DICTS) {
PendingResources pending = queue.poll();
PDResources res = pending.resources();
if (!seenDicts.add(res.getCOSObject())) continue;
visited++;
for (org.apache.pdfbox.cos.COSName name : res.getFontNames()) {
if (out.size() >= MAX_FONTS) break;
PDFont font;
try {
font = res.getFont(name);
} catch (IOException | RuntimeException e) {
continue;
}
if (font == null || !seenFonts.add(font.getCOSObject())) continue;
out.add(new ResourceFont(font, pending.path() + "/" + name.getName()));
}
try {
for (org.apache.pdfbox.cos.COSName xn : res.getXObjectNames()) {
try {
org.apache.pdfbox.pdmodel.graphics.PDXObject xo = res.getXObject(xn);
if (xo
instanceof
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject form) {
PDResources fr = form.getResources();
if (fr != null) {
queue.add(
new PendingResources(
fr, pending.path() + "/" + xn.getName()));
}
}
} catch (IOException | RuntimeException ignore) {
}
}
} catch (RuntimeException ignore) {
}
}
return out;
}
/**
* SHA-256 (lowercase hex) of a font's embedded program bytes - the decoded
* FontFile/FontFile2/FontFile3 stream, which is byte-identical to what PDFium's
* FPDFFont_GetFontData hands the frontend. Null when the font embeds no program.
*/
private static String fontProgramSha256(PDFont font) {
try {
org.apache.pdfbox.pdmodel.font.PDFontDescriptor fd = font.getFontDescriptor();
if (fd == null && font instanceof org.apache.pdfbox.pdmodel.font.PDType0Font type0) {
fd = type0.getDescendantFont().getFontDescriptor();
}
if (fd == null) return null;
org.apache.pdfbox.pdmodel.common.PDStream stream = fd.getFontFile2();
if (stream == null) stream = fd.getFontFile3();
if (stream == null) stream = fd.getFontFile();
if (stream == null) return null;
return sha256Hex(stream.toByteArray());
} catch (IOException | RuntimeException e) {
return null;
}
}
/** Drop the 6-letter "ABCDEF+" subset prefix PDF puts on subset /BaseFont names. */
private static String stripSubsetTag(String fontName) {
if (fontName == null) return null;
if (fontName.length() > 7
&& fontName.charAt(6) == '+'
&& fontName.chars().limit(6).allMatch(c -> c >= 'A' && c <= 'Z')) {
return fontName.substring(7);
}
return fontName;
}
/**
* Build a Unicode→charcode map for a font by iterating every charcode in 0..0xFFFF and asking
* the font's ToUnicode CMap what Unicode it maps to. Charcodes that aren't in the CMap throw
* inside toUnicode (PDFBox returns null or throws depending on font subtype), and those are
* skipped silently.
*
* <p>This is the encoding inverse PDFBox doesn't expose directly. For Type3 fonts (where
* font.encode() throws "Not implemented"), this is the ONLY way to write text in the same font
* - we look up the user's char in the reverse map and pass that charcode to
* FPDFText_SetCharcodes on the frontend.
*
* <p>The 0..0xFFFF range is sufficient for Type0/CIDFontType2 fonts (CIDs are 16-bit). For
* single-byte fonts the loop short-circuits after 256. We don't go higher because no PDF font
* has a CID outside that range in practice; the per-font result is memoised in {@link
* #REVERSE_MAP_CACHE} so the 65 536-entry probe runs once per document+font, not per request.
*/
private static java.util.Map<String, Long> buildReverseUnicodeMap(
byte[] pdfBytes, ResourceFont located, int pageIndex) {
String key = sha256Hex(pdfBytes) + "|" + fontCacheIdentity(located, pageIndex);
// Compound get/put under the map's own monitor. The 0..0xFFFF probe runs OUTSIDE the
// lock so one slow build can't block every other request on the shared cache.
java.util.Map<String, Long> cached;
synchronized (REVERSE_MAP_CACHE) {
cached = REVERSE_MAP_CACHE.get(key);
}
if (cached != null) return cached;
java.util.Map<String, Long> built = computeReverseUnicodeMap(located.font());
synchronized (REVERSE_MAP_CACHE) {
java.util.Map<String, Long> raced = REVERSE_MAP_CACHE.putIfAbsent(key, built);
return raced != null ? raced : built;
}
}
private static String fontCacheIdentity(ResourceFont located, int pageIndex) {
org.apache.pdfbox.cos.COSObjectKey objectKey = null;
try {
objectKey = located.font().getCOSObject().getKey();
} catch (RuntimeException ignore) {
}
if (objectKey != null) {
return "obj|" + objectKey.getNumber() + "." + objectKey.getGeneration();
}
return "res|p" + pageIndex + located.path();
}
/** Lowercase hex SHA-256 of the PDF bytes; used as the reverse-map cache key. */
private static String sha256Hex(byte[] bytes) {
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes);
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xf, 16));
sb.append(Character.forDigit(b & 0xf, 16));
}
return sb.toString();
} catch (java.security.NoSuchAlgorithmException e) {
// SHA-256 is always present in a JRE; fall back to a length+hash key just in case so
// the cache still functions (correctness holds - collisions only cost a rebuild).
return bytes.length + ":" + java.util.Arrays.hashCode(bytes);
}
}
private static java.util.Map<String, Long> computeReverseUnicodeMap(PDFont font) {
java.util.Map<String, Long> out = new java.util.HashMap<>();
int upper = font.isStandard14() ? 256 : 0x10000;
for (int cc = 0; cc < upper; cc++) {
String u;
try {
u = font.toUnicode(cc);
} catch (Exception ignore) {
continue;
}
if (u == null || u.isEmpty()) continue;
// First charcode wins for a given Unicode (the canonical mapping).
out.putIfAbsent(u, (long) cc);
}
return out;
}
}
@@ -95,7 +95,8 @@ public class UIDataController {
try (InputStream is = resource.getInputStream()) {
Map<String, List<Dependency>> licenseData =
objectMapper.readValue(is, new TypeReference<>() {});
objectMapper.readValue(
is, new TypeReference<Map<String, List<Dependency>>>() {});
data.setDependencies(licenseData.get("dependencies"));
} catch (IOException e) {
log.error("Failed to load licenses data", e);
@@ -2,10 +2,20 @@ package stirling.software.SPDF.controller.api.form;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.poi.ss.usermodel.*;
@@ -35,6 +45,7 @@ import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -59,6 +70,25 @@ import tools.jackson.databind.ObjectMapper;
@RequiredArgsConstructor
public class FormFillController {
/** Carries the edits a request asked for but the document could not take, as base64 JSON. */
public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits";
/** How many were skipped in total, which may exceed the number listed in the header above. */
public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total";
/** Keeps the header well inside Jetty's response-header budget. */
private static final int MAX_REPORTED_SKIPS = 20;
/** Bytes of encoded header value, well under the container's limit for the whole header set. */
private static final int MAX_SKIP_HEADER_BYTES = 4096;
private static final int MAX_SKIP_FIELD_CHARS = 120;
/** Entry names inside the {@code ?includeFields=true} bundle. */
private static final String FIELDS_ENTRY = "fields.json";
private static final String DOCUMENT_ENTRY = "document.pdf";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
@@ -68,6 +98,72 @@ public class FormFillController {
return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager);
}
/**
* Rejects field names PDFBox cannot store before the document is touched, so the caller gets a
* 400 naming the offending character instead of a 200 with the field quietly missing.
*/
private static void requireUsableFieldNames(
List<FormUtils.NewFormFieldDefinition> adds,
List<FormUtils.ModifyFormFieldDefinition> modifies) {
Stream<String> problems =
Stream.concat(
adds.stream()
.map(FormUtils.NewFormFieldDefinition::name)
.map(FormUtils::invalidFieldNameReason),
// A rename to the same name is not a rename, so a nested field whose
// qualified name already contains a period is left alone.
modifies.stream()
.map(m -> FormUtils.renameProblem(m.targetName(), m.name())));
problems.filter(Objects::nonNull)
.findFirst()
.ifPresent(
reason -> {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "{0}", reason);
});
}
/**
* The body is the updated PDF, so dropped edits travel as a base64 JSON header;
* percent-encoding would turn every space into a plus sign.
*/
private ResponseEntity<Resource> withSkippedEdits(
ResponseEntity<Resource> response, List<FormUtils.SkippedFieldEdit> skipped) {
if (skipped.isEmpty()) {
return response;
}
// A count cap alone is not enough: one very long field name can still overflow the
// header budget and turn the response into an error page, losing the edited PDF.
List<FormUtils.SkippedFieldEdit> reported = new ArrayList<>();
String encoded = "";
for (FormUtils.SkippedFieldEdit edit : skipped) {
if (reported.size() >= MAX_REPORTED_SKIPS) {
break;
}
reported.add(
new FormUtils.SkippedFieldEdit(
edit.operation(),
FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS),
FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS)));
String candidate =
Base64.getEncoder()
.encodeToString(
objectMapper
.writeValueAsString(reported)
.getBytes(StandardCharsets.UTF_8));
if (candidate.length() > MAX_SKIP_HEADER_BYTES) {
reported.removeLast();
break;
}
encoded = candidate;
}
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size()))
.header(SKIPPED_EDITS_HEADER, encoded)
.body(response.getBody());
}
private static String buildBaseName(MultipartFile file, String suffix) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
@@ -257,6 +353,110 @@ public class FormFillController {
}
}
@PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Add new form fields",
description =
"Creates new form fields in the provided PDF and returns the updated file")
public ResponseEntity<Resource> addFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON array of new field definitions",
example =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]")
@RequestPart(value = "fields", required = false)
byte[] fieldsPayload)
throws IOException {
String rawFields = decodePart(fieldsPayload);
List<FormUtils.NewFormFieldDefinition> definitions =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields);
if (definitions.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"fields payload");
}
requireUsableFieldNames(definitions, List.of());
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.addNewFields(document, definitions, skipped)),
skipped);
}
@PostMapping(value = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Apply a batch of form field edits",
description =
"Adds, modifies, and deletes form fields in a single request (one document"
+ " load/save) and returns the updated file")
public ResponseEntity<Resource> editFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description =
"JSON object with optional 'add', 'modify' and 'delete'"
+ " sections",
example =
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}],"
+ "\"modify\":[],\"delete\":[]}")
@RequestPart(value = "edits", required = false)
byte[] editsPayload,
@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.")
@RequestParam(value = "includeFields", defaultValue = "false")
boolean includeFields)
throws IOException {
String rawEdits = decodePart(editsPayload);
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits);
if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired", "{0} must contain at least one edit", "edits payload");
}
requireUsableFieldNames(batch.add(), batch.modify());
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
includeFields,
document ->
FormUtils.applyFieldEdits(
document,
batch.add(),
batch.modify(),
batch.delete(),
skipped)),
skipped);
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
@@ -285,8 +485,15 @@ public class FormFillController {
"updates payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
requireUsableFieldNames(List.of(), modifications);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.modifyFormFields(document, modifications, skipped)),
skipped);
}
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -319,8 +526,13 @@ public class FormFillController {
"error.dataRequired", "{0} must contain at least one value", "names payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.deleteFormFields(document, names));
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.deleteFormFields(document, names, skipped)),
skipped);
}
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -358,13 +570,81 @@ public class FormFillController {
private ResponseEntity<Resource> processSingleFile(
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
return processSingleFile(file, suffix, false, processor);
}
private ResponseEntity<Resource> processSingleFile(
MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor)
throws IOException {
requirePdf(file);
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
FormUtils.repairMissingWidgetPageReferences(document);
processor.accept(document);
return saveDocument(document, baseName);
return includeFields
? saveDocumentWithFields(document, baseName)
: saveDocument(document, baseName);
}
}
/**
* Answers "what fields does the saved file have?" from the document still open here, so the
* caller does not have to upload the result back to ask.
*/
private ResponseEntity<Resource> saveDocumentWithFields(PDDocument document, String baseName)
throws IOException {
TempFile zip = null;
boolean zipTransferred = false;
try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) {
document.save(pdf.getFile());
// Read the fields after the save so they describe the bytes actually being returned.
byte[] fields =
objectMapper.writeValueAsBytes(
FormUtils.extractFormFieldsWithCoordinates(document));
zip = tempFileManager.createManagedTempFile(".zip");
writeFieldBundle(zip.getPath(), pdf.getPath(), fields);
ResponseEntity<Resource> response =
WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip");
zipTransferred = true;
return response;
} finally {
if (zip != null && !zipTransferred) {
zip.close();
}
}
}
/**
* Deflates the JSON because it is text, but stores the PDF: its streams are already compressed,
* so deflating costs ~25ms per MB to save a few percent.
*/
private static void writeFieldBundle(Path zipPath, Path pdfPath, byte[] fields)
throws IOException {
long pdfSize = Files.size(pdfPath);
CRC32 crc = new CRC32();
try (InputStream in = Files.newInputStream(pdfPath)) {
byte[] buffer = new byte[8192];
for (int read; (read = in.read(buffer)) != -1; ) {
crc.update(buffer, 0, read);
}
}
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) {
ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY);
fieldsEntry.setMethod(ZipEntry.DEFLATED);
zip.putNextEntry(fieldsEntry);
zip.write(fields);
zip.closeEntry();
ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY);
documentEntry.setMethod(ZipEntry.STORED);
documentEntry.setSize(pdfSize);
documentEntry.setCompressedSize(pdfSize);
documentEntry.setCrc(crc.getValue());
zip.putNextEntry(documentEntry);
Files.copy(pdfPath, zip);
zip.closeEntry();
zip.finish();
}
}
@@ -25,10 +25,15 @@ final class FormPayloadParser {
private static final String KEY_VALUE = "value";
private static final String KEY_DEFAULT_VALUE = "defaultValue";
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<Map<String, Object>> MAP_TYPE =
new TypeReference<Map<String, Object>>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
MODIFY_FIELD_LIST_TYPE =
new TypeReference<List<FormUtils.ModifyFormFieldDefinition>>() {};
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE =
new TypeReference<List<String>>() {};
private FormPayloadParser() {}
@@ -94,6 +99,43 @@ final class FormPayloadParser {
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<FormUtils.NewFormFieldDefinition> parseNewFieldDefinitions(
ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE);
}
/**
* Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each
* section is optional. The delete section accepts the same shapes as {@link #parseNameList}.
*/
static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of());
}
final JsonNode root = objectMapper.readTree(json);
List<FormUtils.NewFormFieldDefinition> adds = List.of();
List<FormUtils.ModifyFormFieldDefinition> modifies = List.of();
List<String> deletes = List.of();
if (root != null && root.isObject()) {
final JsonNode addNode = root.get("add");
if (addNode != null && addNode.isArray()) {
adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE);
}
final JsonNode modifyNode = root.get("modify");
if (modifyNode != null && modifyNode.isArray()) {
modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE);
}
final JsonNode deleteNode = root.get("delete");
if (deleteNode != null && !deleteNode.isNull()) {
deletes = parseNameList(objectMapper, deleteNode.toString());
}
}
return new FormUtils.FieldEditBatch(adds, modifies, deletes);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
@@ -96,7 +96,9 @@ public class AddCommentsController {
List<CommentSpecDto> dtos;
try {
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
dtos =
objectMapper.readValue(
commentsJson, new TypeReference<List<CommentSpecDto>>() {});
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
@@ -338,6 +338,19 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// Whether this instance can link a Stirling (SaaS) account at all. The account-link
// beans live in :proprietary and are @ConditionalOnProperty on this same key, so when
// it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot
// tell that 404 apart from "not linked yet", so it needs this told to it explicitly
// before it can prompt anyone to link. Read from the environment rather than
// AccountLinkProperties because :core must not depend on :proprietary.
configData.put(
"accountLinkAvailable",
applicationContext
.getEnvironment()
.getProperty(
"stirling.billing.account-link.enabled", Boolean.class, false));
// AI Engine settings
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
@@ -114,6 +114,7 @@ public class OCRController {
List<String> selectedLanguages = request.getLanguages();
boolean sidecar = request.isSidecar();
Boolean deskew = request.isDeskew();
Boolean rotatePages = request.isRotatePages();
Boolean clean = request.isClean();
Boolean cleanFinal = request.isCleanFinal();
String ocrType = request.getOcrType();
@@ -154,6 +155,7 @@ public class OCRController {
selectedLanguages,
sidecar,
deskew,
rotatePages,
clean,
cleanFinal,
ocrType,
@@ -236,6 +238,7 @@ public class OCRController {
List<String> selectedLanguages,
Boolean sidecar,
Boolean deskew,
Boolean rotatePages,
Boolean clean,
Boolean cleanFinal,
String ocrType,
@@ -268,6 +271,10 @@ public class OCRController {
if (deskew != null && deskew) {
command.add("--deskew");
}
if (rotatePages != null && rotatePages) {
// Tesseract OSD-based automatic page orientation correction (90/180/270)
command.add("--rotate-pages");
}
if (clean != null && clean) {
command.add("--clean");
}
@@ -221,6 +221,10 @@ public class RedactController {
.normalizeFonts(false)
.fixToUnicode(false)
.glyphAware(true)
.ligatureAware(true)
.bidiAware(true)
.graphemeSafe(true)
.sanitizeStructure(false) // WIP/Experimental API
.redactMetadata(true)
.build();
@@ -110,6 +110,10 @@ class TextRedactionService {
.fixToUnicode(false)
.repairWidths(false)
.glyphAware(true)
.ligatureAware(true)
.bidiAware(true)
.graphemeSafe(true)
.sanitizeStructure(false)
.build();
try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) {
@@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile {
@Schema(description = "Deskew the input file if set to true")
private boolean deskew;
@Schema(
description =
"Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true")
private boolean rotatePages;
@Schema(description = "Clean the input file if set to true")
private boolean clean;
@@ -4,7 +4,9 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
@@ -21,7 +23,7 @@ public class WeeklyActiveUsersService {
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
// Track total unique browsers seen (overall)
private long totalUniqueBrowsers = 0;
private final AtomicLong totalUniqueBrowsers = new AtomicLong(0);
// Application start time
private final Instant startTime = Instant.now();
@@ -36,12 +38,12 @@ public class WeeklyActiveUsersService {
return;
}
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
activeBrowsers.put(browserId, Instant.now());
Instant now = Instant.now();
Instant previous = activeBrowsers.put(browserId, now);
if (isNewBrowser) {
totalUniqueBrowsers++;
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
if (previous == null) {
long total = totalUniqueBrowsers.incrementAndGet();
log.debug("New browser recorded: {} (Total: {})", browserId, total);
}
}
@@ -61,7 +63,7 @@ public class WeeklyActiveUsersService {
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
return totalUniqueBrowsers;
return totalUniqueBrowsers.get();
}
/**
@@ -88,7 +90,8 @@ public class WeeklyActiveUsersService {
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
}
/** Manual cleanup trigger (can be called by scheduled task if needed) */
/** Scheduled cleanup trigger running every hour */
@Scheduled(fixedRate = 3600000)
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
@@ -154,7 +154,8 @@ public class PdfJsonFontService {
return "otf";
}
if (signature == 0x74746366) {
return "cff";
log.debug("[FONT-DEBUG] TrueType Collection ('ttcf') font program is unsupported");
return null;
}
return null;
}
@@ -175,7 +176,8 @@ public class PdfJsonFontService {
return "otf";
}
if (signature == 0x74746366) {
return "cff";
log.debug("[FONT-DEBUG] TrueType Collection ('ttcf') FontFile2 is unsupported");
return null;
}
return null;
}
+46 -7
View File
@@ -15,24 +15,63 @@
<encoder>
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>1</maxHistory>
<!-- SizeAndTime, not Time alone: the size trigger is what stops a
runaway logger filling the disk (see GENERAL appender note).
Archives are gzipped, so 64 MB of them holds far more than a
day. Worst case on disk is one 100 MB live file plus the cap. -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>64MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Rolling File Appender for General Logs -->
<!-- Rolling File Appender for General Logs
Why SizeAndTimeBased + totalSizeCap: a previous build of the v2 PDF
text editor's reverse-CMap probe loop triggered PDSimpleFont to emit
one "No Unicode mapping for .notdef" WARN per probed charcode per
font per request. With TimeBasedRollingPolicy alone there was no
size ceiling; info.log grew to 1.4 GB in a single day before the JVM
choked. The class-level silencer fixes the specific offender, but
this size cap is the defence-in-depth: any future logger that
floods unexpectedly will roll + auto-delete instead of starving
disk + Jetty threads. -->
<appender name="GENERAL" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/info.log</file>
<encoder>
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>1</maxHistory>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>256MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Suppress PDFBox PDSimpleFont's per-charcode .notdef WARN.
Required by the v2 PDF text editor's `buildReverseUnicodeMap`
which DELIBERATELY iterates every charcode in 0..0xFFFF to
discover the encoding-to-Unicode map of an embedded subset
font. For any subset font ~99% of those probes hit .notdef,
and the default WARN level for those misses turned info.log
into a 1.4 GB monster overnight.
This declarative logback entry is the SOLE mechanism: it is
visible to ops and revertable via configuration. An earlier
build also mutated this logger's level from a static block in
PdfTextEditorCharcodeController, which silenced the same
warnings JVM-wide with no trace in any config file - that
static block has been removed in favour of this entry. -->
<logger name="org.apache.pdfbox.pdmodel.font.PDSimpleFont"
level="ERROR" additivity="false">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="GENERAL"/>
</logger>
<!-- Root Logger -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
@@ -186,7 +186,7 @@ system:
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
mobileScannerSettings:
@@ -17,7 +17,7 @@
{
"moduleName": "ch.qos.logback:logback-classic",
"moduleUrl": "http://www.qos.ch",
"moduleVersion": "1.6.1",
"moduleVersion": "1.6.3",
"moduleLicense": "LGPL-2.1-only",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
@@ -31,7 +31,7 @@
{
"moduleName": "ch.qos.logback:logback-core",
"moduleUrl": "http://www.qos.ch",
"moduleVersion": "1.6.1",
"moduleVersion": "1.6.3",
"moduleLicense": "LGPL-2.1-only",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
@@ -94,7 +94,7 @@
{
"moduleName": "com.fasterxml.jackson.core:jackson-core",
"moduleUrl": "https://github.com/FasterXML/jackson-core",
"moduleVersion": "2.22.1",
"moduleVersion": "2.22.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -108,7 +108,7 @@
{
"moduleName": "com.fasterxml.jackson.core:jackson-databind",
"moduleUrl": "https://github.com/FasterXML/jackson",
"moduleVersion": "2.22.1",
"moduleVersion": "2.22.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -143,7 +143,7 @@
{
"moduleName": "com.fasterxml.jackson:jackson-bom",
"moduleUrl": "https://github.com/FasterXML/jackson-bom",
"moduleVersion": "2.22.1",
"moduleVersion": "2.22.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -440,42 +440,14 @@
{
"moduleName": "com.stirling:jpdfium",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.4",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-darwin-arm64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.4",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-darwin-x64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.4",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-linux-arm64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.4",
"moduleVersion": "1.1.3",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-linux-x64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.4",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "com.stirling:jpdfium-natives-windows-x64",
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
"moduleVersion": "1.0.4",
"moduleVersion": "1.1.3",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@@ -521,36 +493,18 @@
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "com.twelvemonkeys.common:common-image",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-image",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-io",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-io",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-lang",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-lang",
"moduleVersion": "3.14.0",
@@ -569,12 +523,6 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
"moduleVersion": "3.14.0",
@@ -587,12 +535,6 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
"moduleVersion": "3.14.0",
@@ -605,24 +547,12 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-webp",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-webp",
"moduleVersion": "3.14.0",
@@ -769,13 +699,6 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-beanutils:commons-beanutils",
"moduleUrl": "https://commons.apache.org/proper/commons-beanutils",
"moduleVersion": "1.11.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-cli:commons-cli",
"moduleUrl": "http://commons.apache.org/proper/commons-cli/",
@@ -790,13 +713,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-collections:commons-collections",
"moduleUrl": "http://commons.apache.org/collections/",
"moduleVersion": "3.2.2",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
@@ -1064,21 +980,14 @@
{
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
"moduleVersion": "2.2.46",
"moduleVersion": "2.2.47",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
"moduleVersion": "2.2.47",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
"moduleVersion": "2.2.46",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -1090,9 +999,9 @@
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
"moduleVersion": "2.2.46",
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -1103,6 +1012,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "jakarta.activation:jakarta.activation-api",
"moduleUrl": "https://www.eclipse.org",
@@ -1360,13 +1276,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-math3",
"moduleUrl": "http://commons.apache.org/proper/commons-math/",
"moduleVersion": "3.6.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-text",
"moduleUrl": "https://commons.apache.org/proper/commons-text",
@@ -2304,7 +2213,7 @@
},
{
"moduleName": "org.simplejavamail:core-module",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -2317,13 +2226,13 @@
},
{
"moduleName": "org.simplejavamail:outlook-module",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.simplejavamail:simple-java-mail",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -2343,10 +2252,10 @@
},
{
"moduleName": "org.snakeyaml:snakeyaml-engine",
"moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine",
"moduleVersion": "3.0.1",
"moduleUrl": "https://codeberg.org/snakeyaml/snakeyaml-engine",
"moduleVersion": "3.1.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.springdoc:springdoc-openapi-starter-common",
@@ -57,6 +57,8 @@ class ToolIODeclarationCoverageTest {
// documents.
"/api/v1/convert/pdf/text-editor",
"/api/v1/convert/text-editor/pdf",
// Charcode lookup for the v2 editor: returns glyph mappings, not a document.
"/api/v1/general/pdf-text-editor",
// Signing sessions, certificate checks and hardware token enumeration; the
// signing tool itself is /api/v1/security/cert-sign, which is declared.
"/api/v1/security/cert-sign/sessions",
@@ -0,0 +1,516 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.imageio.ImageIO;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/**
* Probe: what can PDFBox actually do for font ENCODING on real-world PDFs. This is a diagnostic
* test (not a regression) - run with --tests PdfBoxFontEncodingProbeTest -i to see stdout.
*
* <p>Answers these questions:
*
* <ol>
* <li>Type0/CIDFontType2 subset: can we add a new glyph not in the original subset? (no, encode
* throws IllegalArgumentException).
* <li>Type1: same question.
* <li>TrueType: same question.
* <li>Can we load a fresh TTF via PDType0Font.load(doc, file) and write text with it? (yes,
* primary path).
* <li>Round-trip via getFontStream / re-embed - can it rehabilitate Type3? (no - Type3 has no
* FontFile* program at all).
* <li>What fonts ship with PDFBox / fontbox? (only LiberationSans-Regular.ttf + AFM for the 14
* standard fonts; CFF/Type1 binaries are NOT bundled - Standard14Fonts.getMappedFontName
* redirects unmappable ones to LiberationSans).
* </ol>
*/
@Disabled(
"Diagnostic probe: dumps PDFBox font encoding tables to stdout and asserts nothing. Kept for font debugging; run manually.")
public class PdfBoxFontEncodingProbeTest {
private static final Path PROJECT_ROOT =
Paths.get(System.getProperty("user.dir")).getParent().getParent();
private static final Path SAMPLE =
PROJECT_ROOT.resolve("frontend/editor/public/samples/Sample.pdf");
private static final Path[] EXTRA_FIXTURES = {
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf"),
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/multi-page-sample.pdf"),
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/big-sample.pdf"),
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf"),
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/user-sample.pdf"),
};
/**
* Rasterize the Q4b output (Sample.pdf with injected Liberation text) to confirm the new text
* actually renders on top of the existing Type3 content.
*/
@Test
public void probeRenderInjectedSample() throws IOException {
Path liberation =
PROJECT_ROOT.resolve(
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDPage page = doc.getPage(0);
PDType0Font ttf;
try (InputStream in = Files.newInputStream(liberation)) {
ttf = PDType0Font.load(doc, in, true);
}
try (PDPageContentStream cs =
new PDPageContentStream(
doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(ttf, 24);
cs.newLineAtOffset(50, 120);
cs.showText("INJECTED via PDType0Font.load - $@#&Z");
cs.endText();
}
doc.save(out);
}
// Rasterize page 0 to a PNG so we can eyeball it.
try (PDDocument check = Loader.loadPDF(out.toByteArray())) {
PDFRenderer renderer = new PDFRenderer(check);
java.awt.image.BufferedImage img = renderer.renderImageWithDPI(0, 100);
// Build dir, not the repo root: this render is a debugging aid and was
// twice committed by accident when it landed in the working tree.
Path png =
Paths.get(System.getProperty("user.dir"), "build", "probe-output")
.resolve("pdfbox-probe-q4b-rendered.png");
Files.createDirectories(png.getParent());
ImageIO.write(img, "PNG", png.toFile());
System.out.println(
"Rendered injected sample to "
+ png
+ " - "
+ img.getWidth()
+ "x"
+ img.getHeight());
}
}
/**
* Build a PDF in memory that uses a Type0/CIDFontType2 subset font (the kind Word / InDesign /
* LibreOffice produce), then probe whether encode() can add a glyph that wasn't in the original
* subset.
*/
@Test
public void probeType0CIDFontType2Subset() throws IOException {
System.out.println(
"\n##################################################################\n"
+ "Q1 probe: Type0/CIDFontType2 SUBSET can/cannot add new glyphs\n"
+ "##################################################################\n");
Path liberation =
PROJECT_ROOT.resolve(
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
// Build a PDF that contains only "abc" subsetted from LiberationSans.
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
PDType0Font subset;
try (InputStream in = Files.newInputStream(liberation)) {
subset = PDType0Font.load(doc, in, true /* embedSubset */);
}
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(subset, 12);
cs.newLineAtOffset(100, 700);
cs.showText("abc");
cs.endText();
}
doc.save(baos);
}
// Reload the produced PDF and try to add a NEW glyph through the embedded subset font.
byte[] subsetPdf = baos.toByteArray();
try (PDDocument doc = Loader.loadPDF(subsetPdf)) {
PDResources res = doc.getPage(0).getResources();
for (COSName fn : res.getFontNames()) {
PDFont f = res.getFont(fn);
System.out.println(
" Subset font in saved PDF: "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ ", subType="
+ f.getSubType()
+ ")");
for (String ch : new String[] {"a", "b", "c", "Z", "z", "0", "$", "@", "X", " "}) {
try {
byte[] enc = f.encode(ch);
StringBuilder hex = new StringBuilder();
for (byte b : enc) hex.append(String.format("%02X ", b & 0xff));
System.out.println(
" encode('" + ch + "') -> [" + hex.toString().trim() + "] OK");
} catch (UnsupportedOperationException uoe) {
System.out.println(" encode('" + ch + "') UNSUPPORTED");
} catch (IllegalArgumentException iae) {
System.out.println(
" encode('" + ch + "') MISSING - " + iae.getMessage());
} catch (IOException ioe) {
System.out.println(" encode('" + ch + "') IO ERR - " + ioe.getMessage());
}
}
}
}
}
@Test
public void probeExtraFixtures() throws IOException {
System.out.println(
"\n##################################################################\n"
+ "Extra fixture font-class probe\n"
+ "##################################################################\n");
for (Path fixture : EXTRA_FIXTURES) {
if (!Files.exists(fixture)) {
System.out.println("(missing) " + fixture);
continue;
}
System.out.println("\n=== " + fixture.getFileName() + " ===");
byte[] bytes = Files.readAllBytes(fixture);
try (PDDocument doc = Loader.loadPDF(bytes)) {
Set<COSName> seen = new HashSet<>();
for (int p = 0; p < doc.getNumberOfPages(); p++) {
PDPage page = doc.getPage(p);
PDResources res = page.getResources();
if (res == null) continue;
for (COSName name : res.getFontNames()) {
if (!seen.add(name)) continue;
try {
PDFont f = res.getFont(name);
if (f == null) continue;
String fontFile = "none";
PDFontDescriptor d = f.getFontDescriptor();
if (d != null) {
if (d.getFontFile() != null) fontFile = "FontFile";
else if (d.getFontFile2() != null) fontFile = "FontFile2";
else if (d.getFontFile3() != null) fontFile = "FontFile3";
}
String z = "?";
try {
f.encode("Z");
z = "OK";
} catch (UnsupportedOperationException ex) {
z = "UNSUPPORTED";
} catch (IllegalArgumentException ex) {
z = "MISSING";
} catch (IOException ex) {
z = "IO_ERR";
}
System.out.println(
" page "
+ p
+ " "
+ name.getName()
+ " -> "
+ f.getName()
+ " "
+ f.getClass().getSimpleName()
+ " ("
+ f.getSubType()
+ ", "
+ fontFile
+ ", embed="
+ f.isEmbedded()
+ ") encode('Z')="
+ z);
} catch (IOException e) {
System.out.println(
" page "
+ p
+ " "
+ name.getName()
+ " load failed: "
+ e.getMessage());
}
}
}
}
}
}
@Test
public void probeAllQuestions() throws IOException {
System.out.println(
"\n##################################################################\n"
+ "PDFBox font-encoding probe (Sample.pdf + bundled fallback fonts)\n"
+ "##################################################################\n");
// Discover every font in Sample.pdf so we have a real-world test set.
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
List<PDFont> allFonts = new ArrayList<>();
Set<COSName> seen = new HashSet<>();
for (int p = 0; p < doc.getNumberOfPages(); p++) {
PDPage page = doc.getPage(p);
PDResources res = page.getResources();
if (res == null) continue;
for (COSName name : res.getFontNames()) {
if (!seen.add(name)) continue;
try {
PDFont f = res.getFont(name);
if (f != null) allFonts.add(f);
} catch (Exception e) {
System.out.println(
" (skipped " + name.getName() + " - " + e.getMessage() + ")");
}
}
}
System.out.println(
"Discovered " + allFonts.size() + " unique fonts across Sample.pdf:");
for (PDFont f : allFonts) {
System.out.println(
" - "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ ", subType="
+ f.getSubType()
+ ", embedded="
+ f.isEmbedded()
+ ")");
}
// Q1/Q2/Q3
// Try encoding a char that is NEVER in Sample.pdf via each font.
// 'Z' is unlikely to be in the subset for most marketing pages.
// Try several candidates to surface what each font can/can't add.
String[] candidates = {"Z", "$", "@", "#", "Q", "&", "A", "0", "M"};
for (PDFont f : allFonts) {
System.out.println("\n=== Encode-probe for font: " + f.getName() + " ===");
for (String ch : candidates) {
try {
byte[] enc = f.encode(ch);
StringBuilder hex = new StringBuilder();
for (byte b : enc) hex.append(String.format("%02X ", b & 0xff));
System.out.println(
" encode('" + ch + "') -> [" + hex.toString().trim() + "] OK");
} catch (UnsupportedOperationException uoe) {
System.out.println(
" encode('" + ch + "') UNSUPPORTED: " + uoe.getMessage());
} catch (IllegalArgumentException iae) {
System.out.println(" encode('" + ch + "') MISSING: " + iae.getMessage());
} catch (IOException ioe) {
System.out.println(" encode('" + ch + "') IO ERR: " + ioe.getMessage());
}
}
}
// Q5
// For each font, see what's in the FontFile* stream - this is what we'd
// have to round-trip through to "rehabilitate" a Type3 font.
System.out.println("\n=== FontFile stream availability (Q5) ===");
for (PDFont f : allFonts) {
String kind = "none";
int size = 0;
PDFontDescriptor d = f.getFontDescriptor();
if (d != null) {
if (d.getFontFile() != null) {
kind = "FontFile (Type1)";
size = streamBytes(d.getFontFile().getCOSObject().createInputStream());
} else if (d.getFontFile2() != null) {
kind = "FontFile2 (TTF)";
size = streamBytes(d.getFontFile2().getCOSObject().createInputStream());
} else if (d.getFontFile3() != null) {
kind = "FontFile3 (CFF/OpenType)";
size = streamBytes(d.getFontFile3().getCOSObject().createInputStream());
}
}
System.out.println(
" "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ "): "
+ kind
+ " ("
+ size
+ " bytes)");
if (f instanceof PDType3Font) {
System.out.println(
" -> Type3 has CharProc streams, NOT a FontFile binary."
+ " getFontStream() returns null. Round-trip rehab is impossible:");
System.out.println(
" each glyph is a mini content stream, not a glyph outline in a"
+ " standard font format. We'd need to rasterize each CharProc to"
+ " glyph outlines + build a fresh TTF/CFF from scratch.");
}
}
}
// Q4: PDType0Font.load(doc, file) round-trip
System.out.println("\n=== Q4: load fresh TTF and write text to a fresh PDF ===");
Path liberation =
PROJECT_ROOT.resolve(
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
if (!Files.exists(liberation)) {
System.out.println(" Liberation TTF not found at " + liberation);
} else {
try (PDDocument out = new PDDocument()) {
PDPage page = new PDPage();
out.addPage(page);
PDType0Font ttf;
try (InputStream in = Files.newInputStream(liberation)) {
ttf = PDType0Font.load(out, in, true /* embedSubset */);
}
System.out.println(
" Loaded TTF -> "
+ ttf.getName()
+ " ("
+ ttf.getClass().getSimpleName()
+ ")");
String testText = "Hello world! 0123 Z $ @";
byte[] encoded = ttf.encode(testText);
System.out.println(
" Encoded "
+ testText.length()
+ " chars -> "
+ encoded.length
+ " bytes (Identity-H = 2 bytes/glyph)");
try (PDPageContentStream cs = new PDPageContentStream(out, page)) {
cs.beginText();
cs.setFont(ttf, 12);
cs.newLineAtOffset(100, 700);
cs.showText(testText);
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
out.save(baos);
Path tmp = Files.createTempFile("pdfbox-probe-q4-", ".pdf");
Files.write(tmp, baos.toByteArray());
System.out.println(
" Wrote fresh-TTF PDF to "
+ tmp
+ " ("
+ baos.size()
+ " bytes) - opens cleanly.");
// Re-load to confirm the new font is embedded properly.
try (PDDocument check = Loader.loadPDF(baos.toByteArray())) {
PDResources res = check.getPage(0).getResources();
for (COSName fn : res.getFontNames()) {
PDFont f = res.getFont(fn);
System.out.println(
" embedded font: "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ ", embedded="
+ f.isEmbedded()
+ ")");
}
}
}
}
// Q4b: load TTF into an EXISTING PDF (Sample.pdf) and append text
System.out.println(
"\n=== Q4b: load TTF into EXISTING Sample.pdf and write text on page 0 ===");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDPage page = doc.getPage(0);
PDType0Font ttf;
try (InputStream in = Files.newInputStream(liberation)) {
ttf = PDType0Font.load(doc, in, true);
}
// append-mode content stream so we don't disturb existing graphics
try (PDPageContentStream cs =
new PDPageContentStream(
doc,
page,
PDPageContentStream.AppendMode.APPEND,
true /* compress */,
true /* resetContext */)) {
cs.beginText();
cs.setFont(ttf, 12);
cs.newLineAtOffset(50, 50);
cs.showText("Injected via PDType0Font.load - $@#&");
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
Path tmp = Files.createTempFile("pdfbox-probe-q4b-", ".pdf");
Files.write(tmp, baos.toByteArray());
System.out.println(
" Wrote injected-text PDF to " + tmp + " (" + baos.size() + " bytes).");
// Verify by re-reading: how many fonts now on page 0?
try (PDDocument check = Loader.loadPDF(baos.toByteArray())) {
PDResources res = check.getPage(0).getResources();
int count = 0;
for (COSName fn : res.getFontNames()) {
PDFont f = res.getFont(fn);
count++;
System.out.println(
" page-0 font: "
+ fn.getName()
+ " -> "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ ")");
}
System.out.println(" Total fonts on page 0: " + count);
}
}
// Q6: what fonts ship in PDFBox / fontbox
System.out.println("\n=== Q6: bundled fonts (Standard14 redirect probe) ===");
for (Standard14Fonts.FontName fn : Standard14Fonts.FontName.values()) {
PDType1Font f = new PDType1Font(fn);
String mapped = "" + Standard14Fonts.getMappedFontName(fn.getName());
System.out.println(
" Standard14 "
+ fn.getName()
+ " -> mapped='"
+ mapped
+ "' name="
+ f.getName());
}
System.out.println(
" (PDFBox bundles ONLY LiberationSans-Regular.ttf as a binary; the AFMs cover"
+ " metrics for the 14 standard fonts but rendering Helvetica/Times/Courier"
+ " glyphs falls back to LiberationSans glyphs at runtime when no system font"
+ " is found.)");
}
private static int streamBytes(InputStream is) {
try (InputStream it = is) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = it.read(buf)) >= 0) baos.write(buf, 0, n);
return baos.size();
} catch (IOException e) {
return -1;
}
}
}
@@ -0,0 +1,755 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.Base64;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import stirling.software.SPDF.controller.api.PdfTextEditorCharcodeController.EncodeCharcodesRequest;
import stirling.software.SPDF.controller.api.PdfTextEditorCharcodeController.EncodeCharcodesResponse;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
/**
* Regression coverage for the v2 text editor "spaces render as „" bug.
*
* <p>mushroom-life.pdf is a LaTeX document whose embedded LMRoman subset font has NO real space
* glyph, yet {@code font.encode(" ")} still returns charcode 0x20 without throwing. Reusing that
* code via {@code FPDFText_SetCharcodes} paints whatever glyph sits at subset code 0x20 - the
* quotedblbase „. The controller must therefore report whitespace as {@code missing} so the
* frontend emits it as a positional gap instead of a reused glyph.
*/
class PdfTextEditorCharcodeControllerTest {
private static PdfTextEditorCharcodeController controller() {
return new PdfTextEditorCharcodeController(
new CustomPDFDocumentFactory(mock(PdfMetadataService.class)));
}
private static String mushroomBase64() throws Exception {
try (InputStream in =
PdfTextEditorCharcodeControllerTest.class.getResourceAsStream(
"/pdftexteditor/mushroom-life.pdf")) {
assertThat(in).as("mushroom-life.pdf test resource").isNotNull();
return Base64.getEncoder().encodeToString(in.readAllBytes());
}
}
private static EncodeCharcodesRequest request(String text) throws Exception {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(mushroomBase64());
req.setPageIndex(0);
// findFontByToUnicode locates the font via the ToUnicode CMap - "M" exists on page 0.
req.setLocatorChar("M");
req.setText(text);
return req;
}
@Test
void spaceIsReportedMissingNeverEncoded() throws Exception {
PdfTextEditorCharcodeController controller = controller();
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request(" "));
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
// The space must be reported missing, NOT handed back as a charcode
// (0x20) the frontend would reuse into the „ glyph.
assertThat(body.getMissing()).containsExactly(" ");
assertThat(body.getCharcodes()).isNullOrEmpty();
}
@Test
void realCharsEncodeWhileWhitespaceStaysAGap() throws Exception {
PdfTextEditorCharcodeController controller = controller();
// "M M" - both M's must encode to real charcodes; only the space is a gap.
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request("M M"));
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getCharcodes()).as("both M glyphs encode").hasSize(2);
assertThat(body.getMissing()).containsExactly(" ");
}
@Test
void tabAndNewlineAreAlsoTreatedAsGaps() throws Exception {
PdfTextEditorCharcodeController controller = controller();
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request("\t\n"));
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getMissing()).containsExactly("\t", "\n");
assertThat(body.getCharcodes()).isNullOrEmpty();
}
/**
* A page with two fonts that BOTH render 'A'. {@code fontName} must select which one to encode
* against - the cross-font fix. Without it the first font in resources order won wins and a
* cross-font edit got the wrong font's charcode.
*/
private static String twoFontBase64() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font times = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(helvetica, 12);
cs.newLineAtOffset(72, 720);
cs.showText("A");
cs.endText();
cs.beginText();
cs.setFont(times, 12);
cs.newLineAtOffset(72, 700);
cs.showText("A");
cs.endText();
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static EncodeCharcodesRequest twoFontRequest(String fontName) throws Exception {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(twoFontBase64());
req.setPageIndex(0);
req.setLocatorChar("A");
req.setFontName(fontName);
req.setText("A");
return req;
}
@Test
void fontNameDisambiguatesBetweenTwoFontsRenderingTheSameChar() throws Exception {
PdfTextEditorCharcodeController controller = controller();
// Targeting Times-Roman must encode against Times-Roman, not whichever
// font happens to appear first in the page's font resources.
EncodeCharcodesResponse times =
controller.encodeCharcodes(twoFontRequest("Times-Roman")).getBody();
assertThat(times).isNotNull();
assertThat(times.getError()).isNull();
assertThat(times.getNote()).contains("Times-Roman");
assertThat(times.getCharcodes()).hasSize(1);
// Targeting Helvetica must encode against Helvetica.
EncodeCharcodesResponse helv =
controller.encodeCharcodes(twoFontRequest("Helvetica")).getBody();
assertThat(helv).isNotNull();
assertThat(helv.getError()).isNull();
assertThat(helv.getNote()).contains("Helvetica");
assertThat(helv.getCharcodes()).hasSize(1);
}
@Test
void unknownFontNameReportsNoFontInsteadOfWrongFont() throws Exception {
PdfTextEditorCharcodeController controller = controller();
// A name that matches no font on the page must NOT silently encode
// against a different font: the frontend writes the returned charcodes
// into the NAMED font's text object, so a first-match fallback would
// bake wrong glyphs. It must report failure so the caller falls back.
EncodeCharcodesResponse body =
controller.encodeCharcodes(twoFontRequest("DoesNotExist")).getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).contains("no font");
assertThat(body.getCharcodes()).isNull();
}
@Test
void missingRequiredFieldsReturns400() {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64("AAAA");
req.setLocatorChar("M");
// text is null
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().value()).isEqualTo(400);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("missing required fields");
}
@Test
void invalidBase64Returns400() {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64("!!!notbase64!!!");
req.setLocatorChar("M");
req.setText("M");
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().value()).isEqualTo(400);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("pdfBase64 is not valid base64");
}
@Test
void pageIndexOutOfRangeReturns400() throws Exception {
EncodeCharcodesRequest req = request("M");
req.setPageIndex(999);
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().value()).isEqualTo(400);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("pageIndex out of range");
}
@Test
void nonPdfBytesReturnsGenericError() {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(Base64.getEncoder().encodeToString("not a pdf".getBytes()));
req.setLocatorChar("M");
req.setText("M");
// Must not throw, and must not leak the raw PDFBox parser message.
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().is4xxClientError()).isTrue();
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("failed to load PDF");
}
@Test
void absentLocatorCharReturns200WithError() throws Exception {
// U+FFFF never appears in the document, so no font matches.
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(requestWithLocator("￿"));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNotNull();
assertThat(body.getCharcodes()).isNull();
}
@Test
void oversizePdfRejected() {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
// A base64 string long enough that length/4*3 exceeds the 100MB cap, without
// ever allocating the decoded bytes (the guard runs before decode).
char[] huge = new char[140 * 1024 * 1024];
java.util.Arrays.fill(huge, 'A');
req.setPdfBase64(new String(huge));
req.setLocatorChar("M");
req.setText("M");
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().value()).isEqualTo(413);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("pdf too large");
}
private static EncodeCharcodesRequest requestWithLocator(String locator) throws Exception {
EncodeCharcodesRequest req = request("M");
req.setLocatorChar(locator);
return req;
}
/**
* Build a page whose resources declare {@code filler} fonts that do NOT render 'A' (Symbol /
* ZapfDingbats have non-Latin encodings) plus, optionally, a trailing Helvetica that does. The
* Standard14 probe upper bound is 256 so each scan is cheap.
*/
private static String manyFontsBase64(int filler, boolean trailingTarget) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.pdmodel.PDResources resources =
new org.apache.pdfbox.pdmodel.PDResources();
for (int n = 0; n < filler; n++) {
Standard14Fonts.FontName fn =
(n % 2 == 0)
? Standard14Fonts.FontName.SYMBOL
: Standard14Fonts.FontName.ZAPF_DINGBATS;
resources.put(
org.apache.pdfbox.cos.COSName.getPDFName("Ff" + n), new PDType1Font(fn));
}
if (trailingTarget) {
resources.put(
org.apache.pdfbox.cos.COSName.getPDFName("Target"),
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
}
page.setResources(resources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static EncodeCharcodesRequest manyFontsRequest(String base64) {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(base64);
req.setPageIndex(0);
req.setLocatorChar("A");
req.setText("A");
return req;
}
@Test
void targetFontFoundAmongManyFonts() throws Exception {
// 60 non-matching fonts then the Helvetica target, all within the 64-font cap.
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(manyFontsRequest(manyFontsBase64(60, true)));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getCharcodes()).hasSize(1);
}
@Test
void targetBeyondFontCapReturnsGracefulNoFont() throws Exception {
// 64 non-matching fonts then the target at position 65 - the scan cap stops
// before reaching it, so we get a graceful no-font error rather than a full scan.
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(manyFontsRequest(manyFontsBase64(64, true)));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNotNull();
assertThat(body.getCharcodes()).isNull();
}
// Same-family sibling subsets. One document can embed several subsets of
// one family, each re-encoded by order of first glyph use, so a letter has
// a different charcode in each ("R" = 0x21 in one, 0x22 in its sibling).
// FPDFFont_GetBaseFontName strips the "ABCDEF+" tag, so a name-based
// lookup cannot tell them apart and borrows the wrong subset's codes.
//
// The doc below mirrors that with two TrueType subsets differing only by
// subset tag. PUA code points keep it deterministic: font.encode() cannot
// resolve them by glyph name, so the charcode can only come from the
// selected font's ToUnicode reverse map - proving WHICH font was picked.
private static final String PUA = "";
/** ToUnicode CMap mapping each supplied charcode to a BMP code point. */
private static byte[] toUnicodeCmap(int[][] codeToUnicode) {
StringBuilder sb =
new StringBuilder(
"""
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
/CMapName /Adobe-Identity-UCS def
/CMapType 2 def
1 begincodespacerange
<00><FF>
endcodespacerange
""");
sb.append(codeToUnicode.length).append(" beginbfchar\n");
for (int[] pair : codeToUnicode) {
sb.append(String.format("<%02X><%04X>%n", pair[0], pair[1]));
}
sb.append(
"""
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end
""");
return sb.toString().getBytes(java.nio.charset.StandardCharsets.US_ASCII);
}
private static org.apache.pdfbox.cos.COSDictionary subsetFontDict(
PDDocument doc, String baseName, byte[] fontProgram, byte[] toUnicode)
throws Exception {
org.apache.pdfbox.cos.COSDictionary font = new org.apache.pdfbox.cos.COSDictionary();
font.setItem(org.apache.pdfbox.cos.COSName.TYPE, org.apache.pdfbox.cos.COSName.FONT);
font.setItem(
org.apache.pdfbox.cos.COSName.SUBTYPE, org.apache.pdfbox.cos.COSName.TRUE_TYPE);
if (baseName != null) {
font.setName(org.apache.pdfbox.cos.COSName.BASE_FONT, baseName);
}
font.setInt(org.apache.pdfbox.cos.COSName.FIRST_CHAR, 0x21);
font.setInt(org.apache.pdfbox.cos.COSName.LAST_CHAR, 0x22);
org.apache.pdfbox.cos.COSArray widths = new org.apache.pdfbox.cos.COSArray();
widths.add(org.apache.pdfbox.cos.COSInteger.get(500));
widths.add(org.apache.pdfbox.cos.COSInteger.get(500));
font.setItem(org.apache.pdfbox.cos.COSName.WIDTHS, widths);
org.apache.pdfbox.cos.COSDictionary fd = new org.apache.pdfbox.cos.COSDictionary();
fd.setItem(org.apache.pdfbox.cos.COSName.TYPE, org.apache.pdfbox.cos.COSName.FONT_DESC);
if (baseName != null) {
fd.setName(org.apache.pdfbox.cos.COSName.FONT_NAME, baseName);
}
fd.setInt(org.apache.pdfbox.cos.COSName.FLAGS, 4);
fd.setItem(
org.apache.pdfbox.cos.COSName.FONT_BBOX,
new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 1000, 1000).getCOSArray());
fd.setInt(org.apache.pdfbox.cos.COSName.ITALIC_ANGLE, 0);
fd.setInt(org.apache.pdfbox.cos.COSName.ASCENT, 800);
fd.setInt(org.apache.pdfbox.cos.COSName.DESCENT, -200);
fd.setInt(org.apache.pdfbox.cos.COSName.CAP_HEIGHT, 700);
fd.setInt(org.apache.pdfbox.cos.COSName.STEM_V, 80);
if (fontProgram != null) {
org.apache.pdfbox.pdmodel.common.PDStream ff2 =
new org.apache.pdfbox.pdmodel.common.PDStream(
doc, new java.io.ByteArrayInputStream(fontProgram));
ff2.getCOSObject().setInt(org.apache.pdfbox.cos.COSName.LENGTH1, fontProgram.length);
fd.setItem(org.apache.pdfbox.cos.COSName.FONT_FILE2, ff2.getCOSObject());
}
font.setItem(org.apache.pdfbox.cos.COSName.FONT_DESC, fd);
org.apache.pdfbox.pdmodel.common.PDStream tu =
new org.apache.pdfbox.pdmodel.common.PDStream(
doc, new java.io.ByteArrayInputStream(toUnicode));
font.setItem(org.apache.pdfbox.cos.COSName.getPDFName("ToUnicode"), tu.getCOSObject());
return font;
}
// Distinct fake font programs - hashing distinguishes the subsets by these bytes.
private static final byte[] PROGRAM_A =
"fake-ttf-program-A".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
private static final byte[] PROGRAM_B =
"fake-ttf-program-B".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
/**
* Two sibling subsets of "FakeGaramond" whose ToUnicode maps give U+E000 DIFFERENT charcodes:
* 0x22 in subset A (AAAAAC+), 0x21 in subset B (AAAAAG+) - exactly the CV's shifted-code
* layout. {@code includeSecond=false} keeps only subset A for the unambiguous-fallback case.
*/
private static String siblingSubsetsBase64(boolean includeSecond) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.cos.COSDictionary fonts = new org.apache.pdfbox.cos.COSDictionary();
fonts.setItem(
org.apache.pdfbox.cos.COSName.getPDFName("TTA"),
subsetFontDict(
doc,
"AAAAAC+FakeGaramond",
PROGRAM_A,
toUnicodeCmap(new int[][] {{0x21, 0xE001}, {0x22, 0xE000}})));
if (includeSecond) {
fonts.setItem(
org.apache.pdfbox.cos.COSName.getPDFName("TTB"),
subsetFontDict(
doc,
"AAAAAG+FakeGaramond",
PROGRAM_B,
toUnicodeCmap(new int[][] {{0x21, 0xE000}, {0x22, 0xE002}})));
}
org.apache.pdfbox.pdmodel.PDResources resources =
new org.apache.pdfbox.pdmodel.PDResources();
resources.getCOSObject().setItem(org.apache.pdfbox.cos.COSName.FONT, fonts);
page.setResources(resources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static String sha256Hex(byte[] bytes) throws Exception {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes);
StringBuilder sb = new StringBuilder();
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
}
private static EncodeCharcodesRequest siblingRequest(
String base64, String fontName, String fontSha256) {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(base64);
req.setPageIndex(0);
req.setLocatorChar(PUA);
req.setFontName(fontName);
req.setFontSha256(fontSha256);
req.setText(PUA);
return req;
}
@Test
void fontProgramHashSelectsTheExactSubset() throws Exception {
String base64 = siblingSubsetsBase64(true);
PdfTextEditorCharcodeController controller = controller();
// Both requests carry the SAME tag-stripped name PDFium reports ("FakeGaramond"),
// so only the program hash can tell the subsets apart.
EncodeCharcodesResponse viaA =
controller
.encodeCharcodes(
siblingRequest(base64, "FakeGaramond", sha256Hex(PROGRAM_A)))
.getBody();
assertThat(viaA).isNotNull();
assertThat(viaA.getError()).isNull();
assertThat(viaA.getNote()).contains("AAAAAC+FakeGaramond");
assertThat(viaA.getCharcodes()).containsExactly(0x22L);
EncodeCharcodesResponse viaB =
controller
.encodeCharcodes(
siblingRequest(base64, "FakeGaramond", sha256Hex(PROGRAM_B)))
.getBody();
assertThat(viaB).isNotNull();
assertThat(viaB.getError()).isNull();
assertThat(viaB.getNote()).contains("AAAAAG+FakeGaramond");
assertThat(viaB.getCharcodes()).containsExactly(0x21L);
}
@Test
void ambiguousStrippedNameRefusesToGuessBetweenSiblingSubsets() throws Exception {
// No hash, and the tag-stripped name matches BOTH subsets which both render the
// locator char. Guessing here is what scrambled "RUSSELL W. MANGUM III" into
// "US EEL W. MANGS M III" - the sibling's codes hit different glyphs. The
// backend must refuse so the frontend takes its safe fallback.
EncodeCharcodesResponse body =
controller()
.encodeCharcodes(
siblingRequest(siblingSubsetsBase64(true), "FakeGaramond", null))
.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).contains("no font");
assertThat(body.getCharcodes()).isNull();
}
@Test
void exactTaggedNameStillSelectsItsSubset() throws Exception {
// A caller that DOES know the full tagged /BaseFont name keeps working.
EncodeCharcodesResponse body =
controller()
.encodeCharcodes(
siblingRequest(
siblingSubsetsBase64(true), "AAAAAG+FakeGaramond", null))
.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getNote()).contains("AAAAAG+FakeGaramond");
assertThat(body.getCharcodes()).containsExactly(0x21L);
}
@Test
void strippedNameStillWorksWhenUnambiguous() throws Exception {
// With a SINGLE subset on the page, the tag-stripped name (what PDFium
// reports) must keep resolving - the ambiguity guard only bites when
// two+ siblings could answer.
EncodeCharcodesResponse body =
controller()
.encodeCharcodes(
siblingRequest(siblingSubsetsBase64(false), "FakeGaramond", null))
.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getNote()).contains("AAAAAC+FakeGaramond");
assertThat(body.getCharcodes()).containsExactly(0x22L);
}
@Test
void staleHashFallsBackToNameMatching() throws Exception {
// A hash matching NO font on the page (e.g. PDFium handed back a substitute
// font's bytes) must not brick the request: name matching still runs, and an
// exact tagged name resolves.
EncodeCharcodesResponse body =
controller()
.encodeCharcodes(
siblingRequest(
siblingSubsetsBase64(true),
"AAAAAC+FakeGaramond",
"0000000000000000000000000000000000000000000000000000000000000000"))
.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getNote()).contains("AAAAAC+FakeGaramond");
assertThat(body.getCharcodes()).containsExactly(0x22L);
}
private static final String PUA_E000 = "";
private static final String PUA_E002 = "";
private static final byte[] SHARED_PROGRAM =
"fake-ttf-program-shared".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
private static String cacheIdentityPairBase64(String baseName, byte[] program)
throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.cos.COSDictionary fonts = new org.apache.pdfbox.cos.COSDictionary();
fonts.setItem(
org.apache.pdfbox.cos.COSName.getPDFName("C1"),
subsetFontDict(
doc,
baseName,
program,
toUnicodeCmap(new int[][] {{0x21, 0xE001}, {0x22, 0xE000}})));
fonts.setItem(
org.apache.pdfbox.cos.COSName.getPDFName("C2"),
subsetFontDict(
doc,
baseName,
program,
toUnicodeCmap(new int[][] {{0x21, 0xE002}, {0x22, 0xE003}})));
org.apache.pdfbox.pdmodel.PDResources resources =
new org.apache.pdfbox.pdmodel.PDResources();
resources.getCOSObject().setItem(org.apache.pdfbox.cos.COSName.FONT, fonts);
page.setResources(resources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static EncodeCharcodesRequest cacheIdentityRequest(
String base64, String locator, String fontName, String fontSha256) {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(base64);
req.setPageIndex(0);
req.setLocatorChar(locator);
req.setFontName(fontName);
req.setFontSha256(fontSha256);
req.setText(locator);
return req;
}
@Test
void unnamedFontsSharingOneProgramDoNotShareACachedMap() throws Exception {
String base64 = cacheIdentityPairBase64(null, SHARED_PROGRAM);
String sha = sha256Hex(SHARED_PROGRAM);
PdfTextEditorCharcodeController controller = controller();
EncodeCharcodesResponse first =
controller
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E000, null, sha))
.getBody();
assertThat(first).isNotNull();
assertThat(first.getError()).isNull();
assertThat(first.getCharcodes()).containsExactly(0x22L);
EncodeCharcodesResponse second =
controller
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E002, null, sha))
.getBody();
assertThat(second).isNotNull();
assertThat(second.getError()).isNull();
assertThat(second.getMissing()).isNullOrEmpty();
assertThat(second.getCharcodes())
.as("second font must not be served the first font's cached map")
.containsExactly(0x21L);
}
@Test
void fontsSharingOneNameDoNotShareACachedMap() throws Exception {
String base64 = cacheIdentityPairBase64("SharedName", null);
PdfTextEditorCharcodeController controller = controller();
EncodeCharcodesResponse first =
controller
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E000, "SharedName", null))
.getBody();
assertThat(first).isNotNull();
assertThat(first.getError()).isNull();
assertThat(first.getCharcodes()).containsExactly(0x22L);
EncodeCharcodesResponse second =
controller
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E002, "SharedName", null))
.getBody();
assertThat(second).isNotNull();
assertThat(second.getError()).isNull();
assertThat(second.getMissing()).isNullOrEmpty();
assertThat(second.getCharcodes())
.as("same-name fonts must not share one cached map")
.containsExactly(0x21L);
}
private static String formXObjectFontBase64() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject outer =
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
outer.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 200, 200));
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject inner =
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
inner.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
org.apache.pdfbox.pdmodel.PDResources innerResources =
new org.apache.pdfbox.pdmodel.PDResources();
innerResources.put(
org.apache.pdfbox.cos.COSName.getPDFName("F1"),
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
inner.setResources(innerResources);
org.apache.pdfbox.pdmodel.PDResources outerResources =
new org.apache.pdfbox.pdmodel.PDResources();
outerResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm1"), inner);
outer.setResources(outerResources);
org.apache.pdfbox.pdmodel.PDResources pageResources =
new org.apache.pdfbox.pdmodel.PDResources();
pageResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm0"), outer);
page.setResources(pageResources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static String cyclicFormXObjectsBase64() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject formA =
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
formA.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject formB =
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
formB.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
org.apache.pdfbox.pdmodel.PDResources resA =
new org.apache.pdfbox.pdmodel.PDResources();
org.apache.pdfbox.pdmodel.PDResources resB =
new org.apache.pdfbox.pdmodel.PDResources();
resA.put(org.apache.pdfbox.cos.COSName.getPDFName("Self"), formA);
resA.put(org.apache.pdfbox.cos.COSName.getPDFName("Fb"), formB);
resB.put(org.apache.pdfbox.cos.COSName.getPDFName("Fa"), formA);
resB.put(
org.apache.pdfbox.cos.COSName.getPDFName("F1"),
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
formA.setResources(resA);
formB.setResources(resB);
org.apache.pdfbox.pdmodel.PDResources pageResources =
new org.apache.pdfbox.pdmodel.PDResources();
pageResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm0"), formA);
page.setResources(pageResources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
@Test
void fontReachableOnlyThroughAFormXObjectIsFound() throws Exception {
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(manyFontsRequest(formXObjectFontBase64()));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getNote()).contains("Helvetica");
assertThat(body.getCharcodes()).containsExactly((long) 'A');
}
@Test
@org.junit.jupiter.api.Timeout(60)
void cyclicFormXObjectResourcesTerminate() throws Exception {
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(manyFontsRequest(cyclicFormXObjectsBase64()));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getCharcodes()).containsExactly((long) 'A');
}
}
@@ -0,0 +1,340 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.contentstream.PDFStreamEngine;
import org.apache.pdfbox.contentstream.operator.state.Concatenate;
import org.apache.pdfbox.contentstream.operator.state.Restore;
import org.apache.pdfbox.contentstream.operator.state.Save;
import org.apache.pdfbox.contentstream.operator.state.SetGraphicsStateParameters;
import org.apache.pdfbox.contentstream.operator.state.SetMatrix;
import org.apache.pdfbox.contentstream.operator.text.BeginText;
import org.apache.pdfbox.contentstream.operator.text.EndText;
import org.apache.pdfbox.contentstream.operator.text.SetFontAndSize;
import org.apache.pdfbox.contentstream.operator.text.SetTextHorizontalScaling;
import org.apache.pdfbox.contentstream.operator.text.SetTextLeading;
import org.apache.pdfbox.contentstream.operator.text.SetTextRenderingMode;
import org.apache.pdfbox.contentstream.operator.text.SetTextRise;
import org.apache.pdfbox.contentstream.operator.text.SetWordSpacing;
import org.apache.pdfbox.contentstream.operator.text.ShowText;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/**
* Diagnostic test: enumerate every font referenced by Sample.pdf and dump its subtype, encoding,
* ToUnicode, and embedded font program info. For Type3 fonts also dump CharProcs glyph names and
* the content stream of one glyph (the 'M' if present).
*
* <p>Not a real regression test - run with --tests SamplePdfFontDumpTest -i to see the stdout
* output.
*/
@Disabled(
"Diagnostic probe: dumps Sample.pdf font internals to stdout and asserts nothing. Kept for font debugging; run manually.")
public class SamplePdfFontDumpTest {
private static final Path SAMPLE =
Paths.get(System.getProperty("user.dir"))
.getParent()
.getParent()
.resolve("frontend/editor/public/samples/Sample.pdf");
@Test
public void dumpFonts() throws IOException {
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
int numPages = doc.getNumberOfPages();
System.out.println("Sample.pdf has " + numPages + " pages.");
Set<COSDictionary> seenFontDicts = new HashSet<>();
for (int p = 0; p < numPages; p++) {
PDPage page = doc.getPage(p);
System.out.println("\n=== Page " + p + " ===");
PDResources resources = page.getResources();
if (resources == null) {
System.out.println(" (no resources)");
continue;
}
for (COSName fontName : resources.getFontNames()) {
PDFont font;
try {
font = resources.getFont(fontName);
} catch (IOException e) {
System.out.println(
" Font "
+ fontName.getName()
+ ": failed to load - "
+ e.getMessage());
continue;
}
if (font == null) continue;
COSDictionary dict = font.getCOSObject();
if (!seenFontDicts.add(dict)) {
System.out.println(
" Font " + fontName.getName() + " -> already seen above");
continue;
}
dumpFont(fontName.getName(), font);
}
}
// Scan: for every text-show operation, record per-font (charcode, unicode) pairs.
System.out.println("\n=== All (font, charcode, unicode) seen on page ===");
for (int p = 0; p < numPages; p++) {
PDPage page = doc.getPage(p);
AllCharsScanner scanner = new AllCharsScanner();
scanner.processPage(page);
System.out.println("\nPage " + p + ":");
for (var entry : scanner.perFont.entrySet()) {
PDFont font = entry.getKey();
var seen = entry.getValue();
System.out.println(" Font " + font.getName() + " " + font.getSubType() + ":");
var sortedSeen = new java.util.TreeMap<Integer, String>(seen);
for (var s : sortedSeen.entrySet()) {
System.out.println(
" charcode 0x"
+ Integer.toHexString(s.getKey())
+ " ("
+ s.getKey()
+ ") -> '"
+ s.getValue()
+ "'");
}
}
}
// Confirm font.encode() works for Type3 fonts.
System.out.println("\n=== Can we encode existing chars in F27/F28? ===");
PDPage page0 = doc.getPage(0);
PDResources r0 = page0.getResources();
for (String fname : new String[] {"F27", "F28"}) {
PDFont f = r0.getFont(COSName.getPDFName(fname));
if (f == null) {
System.out.println(" " + fname + ": NOT FOUND on page 0");
continue;
}
System.out.println(" " + fname + ": " + f.getClass().getSimpleName());
for (String ch : new String[] {"M", "0", "1", "+", "Z", "a"}) {
try {
byte[] enc = f.encode(ch);
StringBuilder sb = new StringBuilder();
for (byte b : enc) sb.append(String.format("%02X ", b & 0xff));
System.out.println(
" encode('" + ch + "') -> [" + sb.toString().trim() + "]");
} catch (Exception e) {
System.out.println(
" encode('"
+ ch
+ "') FAILED: "
+ e.getClass().getSimpleName()
+ " "
+ e.getMessage());
}
}
}
// Dump page 0 content stream so we can see how "10M+" is composed.
System.out.println("\n=== Page 0 RAW content stream (first 4kb) ===");
try (InputStream is = doc.getPage(0).getContents()) {
byte[] bytes = is.readAllBytes();
System.out.println("Total content stream size: " + bytes.length + " bytes");
String asStr = new String(bytes, StandardCharsets.ISO_8859_1);
int idx = asStr.indexOf("F27");
if (idx >= 0) {
int start = Math.max(0, idx - 100);
int end = Math.min(asStr.length(), idx + 2500);
System.out.println("--- F27 context ---");
System.out.println(asStr.substring(start, end));
System.out.println("---");
}
int idx2 = asStr.indexOf("F28");
if (idx2 >= 0) {
int start = Math.max(0, idx2 - 200);
int end = Math.min(asStr.length(), idx2 + 600);
System.out.println("--- F28 context ---");
System.out.println(asStr.substring(start, end));
System.out.println("---");
}
}
// Dump a CharProc for each font's first non-zero glyph, with focus on any 'M' or "0".
System.out.println("\n=== Sample CharProc dumps for Type3 fonts ===");
Set<COSDictionary> printed = new HashSet<>();
for (int p = 0; p < numPages; p++) {
PDPage page = doc.getPage(p);
PDResources resources = page.getResources();
if (resources == null) continue;
for (COSName fn : resources.getFontNames()) {
PDFont font = resources.getFont(fn);
if (!(font instanceof PDType3Font)) continue;
if (!printed.add(font.getCOSObject())) continue;
PDType3Font t3 = (PDType3Font) font;
// Iterate charcodes 0..255 looking for any that map to 'M' or '0' or '+'.
for (int cc = 0; cc < 256; cc++) {
String u = null;
try {
u = t3.toUnicode(cc);
} catch (Exception e) {
/* */
}
if (u == null) continue;
if (u.equals("M") || u.equals("0") || u.equals("+") || u.equals("1")) {
System.out.println(
"Page "
+ p
+ " font '"
+ fn.getName()
+ "' charcode "
+ cc
+ " maps to '"
+ u
+ "':");
dumpType3Glyph(t3, cc);
}
}
}
}
}
}
private void dumpFont(String resourceName, PDFont font) {
COSDictionary dict = font.getCOSObject();
String subtype = dict.getNameAsString(COSName.SUBTYPE);
String baseFont = dict.getNameAsString(COSName.BASE_FONT);
boolean hasEncoding = dict.containsKey(COSName.ENCODING);
boolean hasToUnicode = dict.containsKey(COSName.TO_UNICODE);
PDFontDescriptor descriptor = font.getFontDescriptor();
boolean hasEmbedded = false;
String embeddedKind = "none";
if (descriptor != null) {
COSDictionary dDict = descriptor.getCOSObject();
if (dDict.containsKey(COSName.FONT_FILE)) {
hasEmbedded = true;
embeddedKind = "FontFile (Type1)";
} else if (dDict.containsKey(COSName.FONT_FILE2)) {
hasEmbedded = true;
embeddedKind = "FontFile2 (TrueType)";
} else if (dDict.containsKey(COSName.FONT_FILE3)) {
hasEmbedded = true;
COSBase ff3 = dDict.getDictionaryObject(COSName.FONT_FILE3);
if (ff3 instanceof COSStream) {
String ff3Subtype = ((COSStream) ff3).getNameAsString(COSName.SUBTYPE);
embeddedKind = "FontFile3 (" + ff3Subtype + ")";
} else {
embeddedKind = "FontFile3";
}
}
}
System.out.println(
" Font resource '"
+ resourceName
+ "': base='"
+ baseFont
+ "' subtype="
+ subtype
+ " hasEncoding="
+ hasEncoding
+ " hasToUnicode="
+ hasToUnicode
+ " embedded="
+ hasEmbedded
+ " ("
+ embeddedKind
+ ")");
if (font instanceof PDType3Font) {
PDType3Font t3 = (PDType3Font) font;
COSDictionary charProcs = t3.getCharProcs();
int count = charProcs == null ? 0 : charProcs.size();
System.out.println(" Type3 CharProcs count = " + count);
if (charProcs != null) {
TreeSet<String> names = new TreeSet<>();
for (COSName k : charProcs.keySet()) names.add(k.getName());
System.out.println(" glyph names: " + names);
}
}
}
private void dumpType3Glyph(PDType3Font font, int charcode) throws IOException {
String name = font.getEncoding() != null ? font.getEncoding().getName(charcode) : null;
System.out.println(" Type3 charcode " + charcode + " -> glyph name '" + name + "'");
PDType3CharProc proc = font.getCharProc(charcode);
if (proc == null) {
System.out.println(" (no CharProc for that charcode)");
return;
}
COSStream stream = proc.getCOSObject();
byte[] raw;
try (InputStream is = stream.createInputStream()) {
raw = is.readAllBytes();
}
System.out.println(" CharProc content stream (" + raw.length + " bytes):");
System.out.println("---");
System.out.println(new String(raw, StandardCharsets.ISO_8859_1));
System.out.println("---");
}
/** Records every (font, charcode -> unicode) tuple seen on a page. */
static final class AllCharsScanner extends PDFStreamEngine {
final java.util.LinkedHashMap<PDFont, java.util.Map<Integer, String>> perFont =
new java.util.LinkedHashMap<>();
AllCharsScanner() {
addOperator(new BeginText(this));
addOperator(new EndText(this));
addOperator(new SetFontAndSize(this));
addOperator(new SetTextHorizontalScaling(this));
addOperator(new SetTextLeading(this));
addOperator(new SetTextRenderingMode(this));
addOperator(new SetTextRise(this));
addOperator(new SetWordSpacing(this));
addOperator(new SetMatrix(this));
addOperator(new Save(this));
addOperator(new Restore(this));
addOperator(new Concatenate(this));
addOperator(new SetGraphicsStateParameters(this));
addOperator(new ShowText(this));
}
@Override
protected void showText(byte[] string) throws IOException {
PDFont font = getGraphicsState().getTextState().getFont();
if (font == null) return;
var seen = perFont.computeIfAbsent(font, k -> new java.util.LinkedHashMap<>());
ByteArrayInputStream in = new ByteArrayInputStream(string);
while (in.available() > 0) {
int code;
try {
code = font.readCode(in);
} catch (IOException e) {
break;
}
String u;
try {
u = font.toUnicode(code);
} catch (RuntimeException e) {
u = null;
}
seen.putIfAbsent(code, u);
}
}
}
}
@@ -0,0 +1,374 @@
package stirling.software.SPDF.controller.api.form;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays
* interchangeable with the follow-up request it exists to remove.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("edit-fields field bundle")
class FormFieldBundleTest {
/** Set to a directory to dump the produced archives for the frontend reader's fixtures. */
private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures");
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private FormFillController controller;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
invocation -> {
File file =
Files.createTempFile(
"bundle", invocation.<String>getArgument(0))
.toFile();
TempFile temp = mock(TempFile.class);
lenient().when(temp.getFile()).thenReturn(file);
lenient().when(temp.getPath()).thenReturn(file.toPath());
return temp;
});
objectMapper = JsonMapper.builder().build();
var field = FormFillController.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(controller, objectMapper);
}
// -- document shapes ----------------------------------------------
private record Style(
String name, int pages, int rotation, List<FormUtils.NewFormFieldDefinition> fields) {}
private static FormUtils.NewFormFieldDefinition field(
String name, String type, int page, float y, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null,
null, null, null, null);
}
static List<Style> styles() {
List<Style> styles = new ArrayList<>();
styles.add(new Style("text-only", 1, 0, List.of(field("fullName", "text", 0, 700f, null))));
styles.add(
new Style(
"checkbox-and-radio",
1,
0,
List.of(
field("agree", "checkbox", 0, 700f, null),
field("plan", "radio", 0, 650f, List.of("basic", "pro")))));
styles.add(
new Style(
"choice-widgets",
1,
0,
List.of(
field("country", "dropdown", 0, 700f, List.of("UK", "IE", "FR")),
field("tags", "listbox", 0, 640f, List.of("a", "b", "c")))));
styles.add(
new Style(
"signature", 1, 0, List.of(field("approval", "signature", 0, 700f, null))));
styles.add(
new Style(
"multi-page",
3,
0,
List.of(
field("p1", "text", 0, 700f, null),
field("p2", "text", 1, 700f, null),
field("p3", "text", 2, 700f, null))));
styles.add(new Style("rotated-90", 1, 90, List.of(field("rot", "text", 0, 700f, null))));
styles.add(new Style("rotated-270", 1, 270, List.of(field("rot", "text", 0, 700f, null))));
styles.add(
new Style(
"unicode-names",
1,
0,
List.of(
field("nom_complet", "text", 0, 700f, null),
field("adresse postale", "text", 0, 660f, null))));
List<FormUtils.NewFormFieldDefinition> many = new ArrayList<>();
for (int i = 0; i < 120; i++) {
many.add(
field(
"field_" + i,
i % 3 == 0 ? "checkbox" : "text",
i / 40,
740f - (i % 40) * 18f,
null));
}
styles.add(new Style("many-fields", 3, 0, many));
return styles;
}
private byte[] blankPdf(int pages, int rotation) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
page.setRotation(rotation);
document.addPage(page);
}
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
document.save(out);
return out.toByteArray();
}
}
// -- the test ------------------------------------------------------
@ParameterizedTest(name = "{0}")
@MethodSource("styles")
@DisplayName("bundled list matches the follow-up request it replaces")
void bundleMatchesRefetch(Style style) throws Exception {
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, byte[]> bundle = unzip(zipBytes);
assertThat(bundle).containsKeys("document.pdf", "fields.json");
byte[] editedPdf = bundle.get("document.pdf");
assertThat(new String(editedPdf, 0, 5, StandardCharsets.UTF_8)).isEqualTo("%PDF-");
// The comparison that matters: ask the endpoint this feature stops re-calling,
// and demand a match.
MockMultipartFile saved =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", editedPdf);
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
controller.listFieldsWithCoordinates(saved);
assertThat(new String(bundle.get("fields.json"), StandardCharsets.UTF_8))
.isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
}
dumpFixture(style.name(), zipBytes);
}
@ParameterizedTest(name = "{0}")
@MethodSource("styles")
@DisplayName("pdf entry is stored and json entry is deflated")
void perEntryCompression(Style style) throws Exception {
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, Integer> methods = methodsOf(zipBytes);
assertThat(methods.get("document.pdf")).isEqualTo(ZipEntry.STORED);
assertThat(methods.get("fields.json")).isEqualTo(ZipEntry.DEFLATED);
}
@Test
@DisplayName("hierarchical field names survive the bundle")
void nestedFieldNames() throws Exception {
byte[] source = nestedPdf();
MockMultipartFile upload =
new MockMultipartFile("file", "nested.pdf", "application/pdf", source);
byte[] edits =
objectMapper.writeValueAsBytes(
Map.of(
"modify",
List.of(
Map.of(
"targetName",
"Customer.Name",
"defaultValue",
"Ada"))));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, byte[]> bundle = unzip(zipBytes);
byte[] editedPdf = bundle.get("document.pdf");
MockMultipartFile saved =
new MockMultipartFile("file", "nested.pdf", "application/pdf", editedPdf);
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
controller.listFieldsWithCoordinates(saved);
String bundled = new String(bundle.get("fields.json"), StandardCharsets.UTF_8);
assertThat(bundled).contains("Customer.Name");
assertThat(bundled).isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
}
}
/** Builds a parent field with two children, which add-fields cannot express. */
private byte[] nestedPdf() throws IOException {
try (PDDocument document = Loader.loadPDF(blankPdf(1, 0));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
FormUtils.addNewFields(
document,
List.of(
field("Name", "text", 0, 700f, null),
field("Email", "text", 0, 660f, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
List<PDField> kids = new ArrayList<>();
for (String child : List.of("Name", "Email")) {
PDField kid = form.getField(child);
kid.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
kids.add(kid);
}
parent.setChildren(kids);
form.setFields(List.of(parent));
document.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("bundle stays close to the wire cost of the two calls it replaces")
void wireCost() throws Exception {
Style style =
styles().stream()
.filter(s -> s.name().equals("many-fields"))
.findFirst()
.orElseThrow();
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", "cost.pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
Map<String, byte[]> bundle;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
bundle = unzip(zipBytes);
int pdfSize = bundle.get("document.pdf").length;
int jsonSize = bundle.get("fields.json").length;
System.out.printf(
"wire: pdf=%d json=%d zip=%d overhead=%d bytes (%.2f%% over the pdf alone)%n",
pdfSize,
jsonSize,
zipBytes.length,
zipBytes.length - pdfSize,
100.0 * (zipBytes.length - pdfSize) / pdfSize);
// True for a field list this repetitive; on a tiny list the ~200 bytes of zip framing can
// exceed what deflate saves, so this is a property of the fixture, not of every document.
assertThat(zipBytes.length).isLessThan(pdfSize + jsonSize);
}
// -- helpers -------------------------------------------------------
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(out);
}
return out.toByteArray();
}
private static Map<String, byte[]> unzip(byte[] zipBytes) throws IOException {
Map<String, byte[]> entries = new HashMap<>();
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
in.transferTo(out);
entries.put(entry.getName(), out.toByteArray());
}
}
return entries;
}
private static Map<String, Integer> methodsOf(byte[] zipBytes) throws IOException {
Map<String, Integer> methods = new HashMap<>();
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
methods.put(entry.getName(), entry.getMethod());
in.transferTo(OutputStream.nullOutputStream());
}
}
return methods;
}
private static void dumpFixture(String name, byte[] zipBytes) throws IOException {
if (FIXTURE_DIR == null) {
return;
}
Path dir = Paths.get(FIXTURE_DIR);
Files.createDirectories(dir);
Files.write(dir.resolve(name + ".zip"), zipBytes);
}
}
@@ -29,6 +29,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -330,6 +331,160 @@ class FormFillControllerTest {
}
}
// ── addFields ──────────────────────────────────────────────────────
@Nested
@DisplayName("addFields")
class AddFields {
@Test
@DisplayName("throws when fields payload is null")
void nullPayload() {
assertThatThrownBy(() -> controller.addFields(pdfFile(), null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws when fields payload is an empty list")
void emptyPayload() {
assertThatThrownBy(() -> controller.addFields(pdfFile(), "[]".getBytes()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("processes a valid new-field payload")
void validPayload() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
String json =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]";
ResponseEntity<Resource> response = controller.addFields(file, json.getBytes());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
}
// ── editFields (combined) ──────────────────────────────────────────
@Nested
@DisplayName("editFields")
class EditFields {
@Test
@DisplayName("throws when edits payload is null")
void nullPayload() {
assertThatThrownBy(() -> controller.editFields(pdfFile(), null, false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws when all sections are empty")
void emptyBatch() {
assertThatThrownBy(
() ->
controller.editFields(
pdfFile(),
"{\"add\":[],\"modify\":[],\"delete\":[]}".getBytes(),
false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("processes a combined add/delete batch")
void validBatch() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
String json =
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,\"x\":50,"
+ "\"y\":700,\"width\":200,\"height\":20}],\"modify\":[],"
+ "\"delete\":[]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
@DisplayName("refuses a field name containing a period before touching the document")
void refusesPeriodInName() throws Exception {
String json =
"{\"add\":[{\"name\":\"Customer.Name\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]}";
assertThatThrownBy(() -> controller.editFields(pdfFile(), json.getBytes(), false))
.hasMessageContaining("period");
// Rejected up front, so the document is never even loaded.
verify(pdfDocumentFactory, never()).load(any(MockMultipartFile.class));
}
@Test
@DisplayName("renaming a nested field to its own qualified name is not a rename")
void allowsUnchangedQualifiedName() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json =
"{\"modify\":[{\"targetName\":\"Customer.Name\",\"name\":\"Customer.Name\","
+ "\"x\":10,\"y\":10}]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// It must get past validation into the edit loop: the only complaint should be that
// this document has no such field, never that the name contains a period.
String encoded =
response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(encoded).isNotNull();
String report =
new String(
java.util.Base64.getDecoder().decode(encoded),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(report).contains("no field with that name exists").doesNotContain("period");
}
@Test
@DisplayName("reports a dropped edit as base64 JSON in the skipped-edits header")
void reportsSkippedEdits() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json = "{\"delete\":[\"noSuchField\"]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
String encoded =
response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(encoded).isNotNull();
String report =
new String(
java.util.Base64.getDecoder().decode(encoded),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(report).contains("noSuchField").contains("delete");
// Base64 rather than percent-encoding, so spaces survive as spaces.
assertThat(report).contains("no field with that name exists");
}
@Test
@DisplayName("omits the skipped-edits header when everything applied")
void noHeaderOnCleanBatch() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json =
"{\"add\":[{\"name\":\"clean\",\"type\":\"text\",\"pageIndex\":0,\"x\":50,"
+ "\"y\":700,\"width\":200,\"height\":20}]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER))
.isNull();
}
}
// ── buildBaseName ──────────────────────────────────────────────────
@Nested
@@ -384,4 +539,156 @@ class FormFillControllerTest {
assertThat(result).isEqualTo("document_filled");
}
}
// -- includeFields bundle ------------------------------------------
@Nested
@DisplayName("editFields ?includeFields=true")
class FieldBundle {
private byte[] editsPayload() {
return ("{\"add\":[{\"name\":\"bundled\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]}")
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private java.util.Map<String, java.util.zip.ZipEntry> entriesOf(byte[] zipBytes)
throws IOException {
java.util.Map<String, java.util.zip.ZipEntry> found = new java.util.HashMap<>();
try (java.util.zip.ZipInputStream in =
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(zipBytes))) {
for (java.util.zip.ZipEntry e; (e = in.getNextEntry()) != null; ) {
java.io.ByteArrayOutputStream data = new java.io.ByteArrayOutputStream();
in.transferTo(data);
// getMethod/getSize are only final once the entry has been fully read.
found.put(e.getName(), e);
payloads.put(e.getName(), data.toByteArray());
}
}
return found;
}
private final java.util.Map<String, byte[]> payloads = new java.util.HashMap<>();
private byte[] bundleFor(MockMultipartFile file) throws Exception {
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
ResponseEntity<Resource> response = controller.editFields(file, editsPayload(), true);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
return drainBody(response);
}
@Test
@DisplayName("returns a zip holding the pdf and the field list")
void bundlesBoth() throws Exception {
byte[] zip = bundleFor(pdfFile());
entriesOf(zip);
assertThat(payloads).containsKeys("document.pdf", "fields.json");
assertThat(new String(payloads.get("document.pdf"), 0, 5)).isEqualTo("%PDF-");
assertThat(
new String(
payloads.get("fields.json"),
java.nio.charset.StandardCharsets.UTF_8))
.contains("bundled");
}
@Test
@DisplayName("stores the pdf entry but deflates the json")
void perEntryMethods() throws Exception {
byte[] zip = bundleFor(pdfFile());
java.util.Map<String, java.util.zip.ZipEntry> entries = entriesOf(zip);
assertThat(entries.get("document.pdf").getMethod())
.as("deflating an already-compressed PDF burns CPU for almost nothing")
.isEqualTo(java.util.zip.ZipEntry.STORED);
assertThat(entries.get("fields.json").getMethod())
.as("the JSON is text and no longer gets the container's gzip")
.isEqualTo(java.util.zip.ZipEntry.DEFLATED);
}
@Test
@DisplayName("bundled fields match what a follow-up fetch would have returned")
void matchesTheSecondCallItReplaces() throws Exception {
byte[] zip = bundleFor(pdfFile());
entriesOf(zip);
byte[] bundledPdf = payloads.get("document.pdf");
// Re-ask the endpoint this feature stops re-calling, using the returned bytes.
MockMultipartFile saved =
new MockMultipartFile("file", "test.pdf", "application/pdf", bundledPdf);
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(bundledPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<
java.util.List<
stirling.software.common.model.FormFieldWithCoordinates>>
refetched = controller.listFieldsWithCoordinates(saved);
String viaRefetch = realObjectMapper.writeValueAsString(refetched.getBody());
String viaBundle =
new String(
payloads.get("fields.json"),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(viaBundle)
.as("the bundle must be interchangeable with the round trip it removes")
.isEqualTo(viaRefetch);
}
}
@Test
@DisplayName("omitting the flag still returns a bare pdf")
void defaultsToPlainPdf() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
byte[] body = drainBody(controller.editFields(file, editsPayload(), false));
assertThat(new String(body, 0, 5)).isEqualTo("%PDF-");
}
}
// -- skipped-edits header budget -----------------------------------
@Nested
@DisplayName("skipped-edits header")
class SkipHeaderBudget {
@Test
@DisplayName("stays within budget however long the reported names are")
void staysWithinBudget() throws Exception {
java.util.List<FormUtils.SkippedFieldEdit> skipped = new java.util.ArrayList<>();
String huge = "x".repeat(20000);
for (int i = 0; i < 40; i++) {
skipped.add(new FormUtils.SkippedFieldEdit("modify", huge, huge));
}
var method =
FormFillController.class.getDeclaredMethod(
"withSkippedEdits", ResponseEntity.class, java.util.List.class);
method.setAccessible(true);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> response =
(ResponseEntity<Resource>)
method.invoke(controller, streamingOk(new byte[] {1}), skipped);
String header = response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(header).isNotNull();
// Not merely short: an empty header would pass a length check while telling the
// user nothing, because the alert renders only when it has entries.
String decoded =
new String(
java.util.Base64.getDecoder().decode(header),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(decoded).startsWith("[{");
assertThat(decoded).contains("...");
// Overflowing the container's header budget turns the reply into an error page,
// which loses the edited PDF the user just saved.
assertThat(header.length()).isLessThanOrEqualTo(4096);
assertThat(
response.getHeaders()
.getFirst(FormFillController.SKIPPED_EDITS_TOTAL_HEADER))
.isEqualTo("40");
}
}
}
@@ -170,6 +170,94 @@ class FormPayloadParserTest {
}
}
// ── parseNewFieldDefinitions ───────────────────────────────────────
@Nested
@DisplayName("parseNewFieldDefinitions")
class ParseNewFieldDefinitions {
@Test
@DisplayName("returns empty list for null input")
void nullInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, null);
assertThat(result).isEmpty();
}
@Test
@DisplayName("returns empty list for blank input")
void blankInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, " ");
assertThat(result).isEmpty();
}
@Test
@DisplayName("parses a valid new-field list including geometry and flags")
void validNewFields() {
String json =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20,"
+ "\"fontSize\":14,\"readOnly\":true,\"multiline\":true}]";
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, json);
assertThat(result).hasSize(1);
FormUtils.NewFormFieldDefinition def = result.get(0);
assertThat(def.name()).isEqualTo("NewField");
assertThat(def.type()).isEqualTo("text");
assertThat(def.pageIndex()).isEqualTo(0);
assertThat(def.x()).isEqualTo(50f);
assertThat(def.y()).isEqualTo(700f);
assertThat(def.width()).isEqualTo(200f);
assertThat(def.height()).isEqualTo(20f);
assertThat(def.fontSize()).isEqualTo(14f);
assertThat(def.readOnly()).isTrue();
assertThat(def.multiline()).isTrue();
}
}
// ── parseFieldEdits ────────────────────────────────────────────────
@Nested
@DisplayName("parseFieldEdits")
class ParseFieldEdits {
@Test
@DisplayName("returns empty batch for null input")
void nullInput() {
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, null);
assertThat(batch.add()).isEmpty();
assertThat(batch.modify()).isEmpty();
assertThat(batch.delete()).isEmpty();
}
@Test
@DisplayName("parses a combined add/modify/delete batch")
void combinedBatch() {
String json =
"{\"add\":[{\"name\":\"new1\",\"type\":\"text\",\"pageIndex\":0,\"x\":1,"
+ "\"y\":2,\"width\":3,\"height\":4}],"
+ "\"modify\":[{\"targetName\":\"old1\",\"label\":\"L\"}],"
+ "\"delete\":[\"gone1\",{\"name\":\"gone2\"}]}";
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, json);
assertThat(batch.add()).hasSize(1);
assertThat(batch.add().get(0).name()).isEqualTo("new1");
assertThat(batch.modify()).hasSize(1);
assertThat(batch.modify().get(0).targetName()).isEqualTo("old1");
assertThat(batch.delete()).containsExactly("gone1", "gone2");
}
@Test
@DisplayName("tolerates missing sections")
void missingSections() {
FormUtils.FieldEditBatch batch =
FormPayloadParser.parseFieldEdits(objectMapper, "{\"delete\":[\"x\"]}");
assertThat(batch.add()).isEmpty();
assertThat(batch.modify()).isEmpty();
assertThat(batch.delete()).containsExactly("x");
}
}
// ── parseNameList ──────────────────────────────────────────────────
@Nested
@@ -485,9 +485,9 @@ class PdfJsonFontServiceMoreTest {
class DetectExtra {
@Test
@DisplayName("detectFontFlavor recognises ttcf as cff and otf via OTTO")
@DisplayName("detectFontFlavor rejects ttcf collections and recognises otf via OTTO")
void detectFlavorExtra() {
assertEquals("cff", service.detectFontFlavor(new byte[] {0x74, 0x74, 0x63, 0x66}));
assertNull(service.detectFontFlavor(new byte[] {0x74, 0x74, 0x63, 0x66}));
List<byte[]> otfVariants = List.of(new byte[] {0x4F, 0x54, 0x54, 0x4F});
for (byte[] otf : otfVariants) {
assertEquals("otf", service.detectFontFlavor(otf));
@@ -57,10 +57,9 @@ class PdfJsonFontServiceTest {
}
@Test
void detectFontFlavor_cffSignature_returnsCff() {
// 0x74746366 = "ttcf"
byte[] cff = {0x74, 0x74, 0x63, 0x66};
assertEquals("cff", service.detectFontFlavor(cff));
void detectFontFlavor_ttcSignature_returnsNull() {
byte[] ttc = {0x74, 0x74, 0x63, 0x66};
assertNull(service.detectFontFlavor(ttc));
}
@Test
@@ -94,9 +93,9 @@ class PdfJsonFontServiceTest {
}
@Test
void detectTrueTypeFormat_cffSignature_returnsCff() {
byte[] cff = {0x74, 0x74, 0x63, 0x66};
assertEquals("cff", service.detectTrueTypeFormat(cff));
void detectTrueTypeFormat_ttcSignature_returnsNull() {
byte[] ttc = {0x74, 0x74, 0x63, 0x66};
assertNull(service.detectTrueTypeFormat(ttc));
}
@Test
+18 -23
View File
@@ -1,26 +1,21 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
-----BEGIN CERTIFICATE-----
MIIDiTCCAnGgAwIBAgIUdWDUiSWDll+owMQEzypIuChp+bcwDQYJKoZIhvcNAQEL
MIIDizCCAnOgAwIBAgIUZMcjBbPlADpy5PssUsPlPM/h7dcwDQYJKoZIhvcNAQEL
BQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0yNTA4
MjYwNzQxMTBaFw0yNjA4MjYwNzQxMTBaMFQxCzAJBgNVBAYTAlVTMQswCQYDVQQI
DAJDQTELMAkGA1UEBwwCU0YxDTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3Qx
DTALBgNVBAMMBFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM
SfspXLx1WAKSo3AfDYIJAyeSrqFcTsPoNBEvT2U1b8w+SCTw4xR5sC3pNenbiEQ7
4sI60hgURtOMOAt+iKvfI0A/9N8/wYadXUyis4qGZPkM/F6H5cBF9VaYisGptY2w
ad9X8XcZgZFABYA5O50Jb5nbUM8fPwDYz2fISIejIpW36y+ApFsotJQCaISe4UWb
K7bwW4UycghYh7AqfH/1OvgR35gGeL7S+SC0F+CZqGECgansFOh/yYL6VoatoggV
oZxjIQblmuSrLtfwN1S7ngn85k3NFMBHm1ehMOHabx5G58Wg05/0mBK8bIrwjrNp
Wzomit8BQJ7eIYUikZfVAgMBAAGjUzBRMB0GA1UdDgQWBBRm6hGFGnC1dxipumf/
6ROdNE6/YDAfBgNVHSMEGDAWgBRm6hGFGnC1dxipumf/6ROdNE6/YDAPBgNVHRMB
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB66MPy5kZlSlBgsK4HtB1LSr3M
dmBWbnQQMq9rmD9AIBQV/shiIjMXGRGnt9zaB0Gg9M39iEvISE6ByMpaDQqV0Md5
9y4XJu0rg/aMXLaHOGDAWJsb7nCGDt12cWdgn1Ni2mmXUHv4SJCRXNQF7mSgIr+p
Fvd1ljyvzu/iig8qxrcuWoZvY677p3yen4dN8ocgi8Df3KjduGbsTjFAESYqqNQC
f+bvypQfhHjxdvz5W3Lpk2swUufqOvhO2b6+cshYJX98qLU8mhai/rOnYkHE7haq
WDH6XEthnVGtk2VJ4XFDbz+FID440DPzy5u/1OZw2Mcoyp6y7rZDKC/D0Uvh
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAgFw0yNTAx
MDEwMDAwMDBaGA8yMTI1MDEwMTAwMDAwMFowVDELMAkGA1UEBhMCVVMxCzAJBgNV
BAgMAkNBMQswCQYDVQQHDAJTRjENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz
dDENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AMmEw2bpAoPnrUWkydKGmvDl0bHaCMoSY9yK2b/JL1xaTRwGT4H7DOuO/0Uu6/BV
c93UEO0eHf1mt1kkYaRSPyOQLXF2QzRDkiW78c/xvqX+DgmfB7BFNrFSP+CRabdH
wvepLUFtXJ6WwvWXjyvDXn8wEAirfETMdU8OXlPaJwS6cbQawYuB6GG5Z1ulxw6k
GRi5hnq7PFJBxz1pg6Xx6pwKnaaNemW4Gp2B90St9N5yu7yV6V6XON84ZdohfXSQ
livH3UTdkrpe+MO2m3CaAA19zlIxM6OIhkuo8r5GEPxoXCykPbgGMRFWPt0GNC3/
AxZofAJg8atqy4peR8XL060CAwEAAaNTMFEwHQYDVR0OBBYEFDULr71QH24KKgNi
2fyM6W9qJnzsMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUNQuvvVAfbgoq
A2LZ/Izpb2omfOwwDQYJKoZIhvcNAQELBQADggEBAASZENsvvyxygLjE913BBLd2
73unxWNSDXakT4xssDEysf//4nGPGo57KjUGFU6M64IVUmhPRTT3aYq7PyA9pb5Q
Ijritg5PdxfUMqd+H3CT++JvJmXxmIrFtKD9fZbmBgRg2wU7yvcp7MP+w36CYmqe
MH1YF29VcBcF+GfI8k00y83qYHuoHpzPrNcL/Gu6MtcC+1Hy96bs+NYIEFH67dy8
IJrN3Vvr4VXSF9qA+Vp5RatPv+hEKZssEFK2fNpGMFPGc1r+HBQ/HEAL4M2betEo
Ne4DigJ3CkTIYAd+cZ2m4tdtzbqkeXZJ7SL+/d/5MIXKTnYITw+NrpiMZ6I72Zk=
-----END CERTIFICATE-----
+18 -23
View File
@@ -1,26 +1,21 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
-----BEGIN CERTIFICATE-----
MIIDiTCCAnGgAwIBAgIUdWDUiSWDll+owMQEzypIuChp+bcwDQYJKoZIhvcNAQEL
MIIDizCCAnOgAwIBAgIUZMcjBbPlADpy5PssUsPlPM/h7dcwDQYJKoZIhvcNAQEL
BQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0yNTA4
MjYwNzQxMTBaFw0yNjA4MjYwNzQxMTBaMFQxCzAJBgNVBAYTAlVTMQswCQYDVQQI
DAJDQTELMAkGA1UEBwwCU0YxDTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3Qx
DTALBgNVBAMMBFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM
SfspXLx1WAKSo3AfDYIJAyeSrqFcTsPoNBEvT2U1b8w+SCTw4xR5sC3pNenbiEQ7
4sI60hgURtOMOAt+iKvfI0A/9N8/wYadXUyis4qGZPkM/F6H5cBF9VaYisGptY2w
ad9X8XcZgZFABYA5O50Jb5nbUM8fPwDYz2fISIejIpW36y+ApFsotJQCaISe4UWb
K7bwW4UycghYh7AqfH/1OvgR35gGeL7S+SC0F+CZqGECgansFOh/yYL6VoatoggV
oZxjIQblmuSrLtfwN1S7ngn85k3NFMBHm1ehMOHabx5G58Wg05/0mBK8bIrwjrNp
Wzomit8BQJ7eIYUikZfVAgMBAAGjUzBRMB0GA1UdDgQWBBRm6hGFGnC1dxipumf/
6ROdNE6/YDAfBgNVHSMEGDAWgBRm6hGFGnC1dxipumf/6ROdNE6/YDAPBgNVHRMB
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB66MPy5kZlSlBgsK4HtB1LSr3M
dmBWbnQQMq9rmD9AIBQV/shiIjMXGRGnt9zaB0Gg9M39iEvISE6ByMpaDQqV0Md5
9y4XJu0rg/aMXLaHOGDAWJsb7nCGDt12cWdgn1Ni2mmXUHv4SJCRXNQF7mSgIr+p
Fvd1ljyvzu/iig8qxrcuWoZvY677p3yen4dN8ocgi8Df3KjduGbsTjFAESYqqNQC
f+bvypQfhHjxdvz5W3Lpk2swUufqOvhO2b6+cshYJX98qLU8mhai/rOnYkHE7haq
WDH6XEthnVGtk2VJ4XFDbz+FID440DPzy5u/1OZw2Mcoyp6y7rZDKC/D0Uvh
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAgFw0yNTAx
MDEwMDAwMDBaGA8yMTI1MDEwMTAwMDAwMFowVDELMAkGA1UEBhMCVVMxCzAJBgNV
BAgMAkNBMQswCQYDVQQHDAJTRjENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz
dDENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AMmEw2bpAoPnrUWkydKGmvDl0bHaCMoSY9yK2b/JL1xaTRwGT4H7DOuO/0Uu6/BV
c93UEO0eHf1mt1kkYaRSPyOQLXF2QzRDkiW78c/xvqX+DgmfB7BFNrFSP+CRabdH
wvepLUFtXJ6WwvWXjyvDXn8wEAirfETMdU8OXlPaJwS6cbQawYuB6GG5Z1ulxw6k
GRi5hnq7PFJBxz1pg6Xx6pwKnaaNemW4Gp2B90St9N5yu7yV6V6XON84ZdohfXSQ
livH3UTdkrpe+MO2m3CaAA19zlIxM6OIhkuo8r5GEPxoXCykPbgGMRFWPt0GNC3/
AxZofAJg8atqy4peR8XL060CAwEAAaNTMFEwHQYDVR0OBBYEFDULr71QH24KKgNi
2fyM6W9qJnzsMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUNQuvvVAfbgoq
A2LZ/Izpb2omfOwwDQYJKoZIhvcNAQELBQADggEBAASZENsvvyxygLjE913BBLd2
73unxWNSDXakT4xssDEysf//4nGPGo57KjUGFU6M64IVUmhPRTT3aYq7PyA9pb5Q
Ijritg5PdxfUMqd+H3CT++JvJmXxmIrFtKD9fZbmBgRg2wU7yvcp7MP+w36CYmqe
MH1YF29VcBcF+GfI8k00y83qYHuoHpzPrNcL/Gu6MtcC+1Hy96bs+NYIEFH67dy8
IJrN3Vvr4VXSF9qA+Vp5RatPv+hEKZssEFK2fNpGMFPGc1r+HBQ/HEAL4M2betEo
Ne4DigJ3CkTIYAd+cZ2m4tdtzbqkeXZJ7SL+/d/5MIXKTnYITw+NrpiMZ6I72Zk=
-----END CERTIFICATE-----
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -23
View File
@@ -1,26 +1,21 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
-----BEGIN CERTIFICATE-----
MIIDiTCCAnGgAwIBAgIUdWDUiSWDll+owMQEzypIuChp+bcwDQYJKoZIhvcNAQEL
MIIDizCCAnOgAwIBAgIUZMcjBbPlADpy5PssUsPlPM/h7dcwDQYJKoZIhvcNAQEL
BQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0yNTA4
MjYwNzQxMTBaFw0yNjA4MjYwNzQxMTBaMFQxCzAJBgNVBAYTAlVTMQswCQYDVQQI
DAJDQTELMAkGA1UEBwwCU0YxDTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3Qx
DTALBgNVBAMMBFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM
SfspXLx1WAKSo3AfDYIJAyeSrqFcTsPoNBEvT2U1b8w+SCTw4xR5sC3pNenbiEQ7
4sI60hgURtOMOAt+iKvfI0A/9N8/wYadXUyis4qGZPkM/F6H5cBF9VaYisGptY2w
ad9X8XcZgZFABYA5O50Jb5nbUM8fPwDYz2fISIejIpW36y+ApFsotJQCaISe4UWb
K7bwW4UycghYh7AqfH/1OvgR35gGeL7S+SC0F+CZqGECgansFOh/yYL6VoatoggV
oZxjIQblmuSrLtfwN1S7ngn85k3NFMBHm1ehMOHabx5G58Wg05/0mBK8bIrwjrNp
Wzomit8BQJ7eIYUikZfVAgMBAAGjUzBRMB0GA1UdDgQWBBRm6hGFGnC1dxipumf/
6ROdNE6/YDAfBgNVHSMEGDAWgBRm6hGFGnC1dxipumf/6ROdNE6/YDAPBgNVHRMB
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB66MPy5kZlSlBgsK4HtB1LSr3M
dmBWbnQQMq9rmD9AIBQV/shiIjMXGRGnt9zaB0Gg9M39iEvISE6ByMpaDQqV0Md5
9y4XJu0rg/aMXLaHOGDAWJsb7nCGDt12cWdgn1Ni2mmXUHv4SJCRXNQF7mSgIr+p
Fvd1ljyvzu/iig8qxrcuWoZvY677p3yen4dN8ocgi8Df3KjduGbsTjFAESYqqNQC
f+bvypQfhHjxdvz5W3Lpk2swUufqOvhO2b6+cshYJX98qLU8mhai/rOnYkHE7haq
WDH6XEthnVGtk2VJ4XFDbz+FID440DPzy5u/1OZw2Mcoyp6y7rZDKC/D0Uvh
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAgFw0yNTAx
MDEwMDAwMDBaGA8yMTI1MDEwMTAwMDAwMFowVDELMAkGA1UEBhMCVVMxCzAJBgNV
BAgMAkNBMQswCQYDVQQHDAJTRjENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz
dDENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AMmEw2bpAoPnrUWkydKGmvDl0bHaCMoSY9yK2b/JL1xaTRwGT4H7DOuO/0Uu6/BV
c93UEO0eHf1mt1kkYaRSPyOQLXF2QzRDkiW78c/xvqX+DgmfB7BFNrFSP+CRabdH
wvepLUFtXJ6WwvWXjyvDXn8wEAirfETMdU8OXlPaJwS6cbQawYuB6GG5Z1ulxw6k
GRi5hnq7PFJBxz1pg6Xx6pwKnaaNemW4Gp2B90St9N5yu7yV6V6XON84ZdohfXSQ
livH3UTdkrpe+MO2m3CaAA19zlIxM6OIhkuo8r5GEPxoXCykPbgGMRFWPt0GNC3/
AxZofAJg8atqy4peR8XL060CAwEAAaNTMFEwHQYDVR0OBBYEFDULr71QH24KKgNi
2fyM6W9qJnzsMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUNQuvvVAfbgoq
A2LZ/Izpb2omfOwwDQYJKoZIhvcNAQELBQADggEBAASZENsvvyxygLjE913BBLd2
73unxWNSDXakT4xssDEysf//4nGPGo57KjUGFU6M64IVUmhPRTT3aYq7PyA9pb5Q
Ijritg5PdxfUMqd+H3CT++JvJmXxmIrFtKD9fZbmBgRg2wU7yvcp7MP+w36CYmqe
MH1YF29VcBcF+GfI8k00y83qYHuoHpzPrNcL/Gu6MtcC+1Hy96bs+NYIEFH67dy8
IJrN3Vvr4VXSF9qA+Vp5RatPv+hEKZssEFK2fNpGMFPGc1r+HBQ/HEAL4M2betEo
Ne4DigJ3CkTIYAd+cZ2m4tdtzbqkeXZJ7SL+/d/5MIXKTnYITw+NrpiMZ6I72Zk=
-----END CERTIFICATE-----
Binary file not shown.
+29 -29
View File
@@ -1,34 +1,34 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
localKeyID: C0 76 69 F4 6E D7 E6 03 D1 EB AD F1 A4 66 C4 14 3A 9B CB D4
Key Attributes: <No Attributes>
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIB/3nui1td5QCAggA
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBDY04ug+QgB6t2TdOWPgdtIBIIE
0IaMRXXtpzLzSjlpyQpLMWLX9Lu+MauINVQMpan8qspC3RGkGcCQUzTkliM3Ls5Q
Pwv02iFlKAzUYg/Z5V/kONfDkuxjeZvLFjmzomtWNy6yIxp4ShZinH8AGon16J6E
s1+xlQBBLZYrRXX7WCpnHKE2OKquOoFWpYcb23py6FlD7Uq6XB0LEHR+C35tgnTQ
WkTFK/La+cbJ+zmWA11Nrnz5XzuWTrNoNB4ygVON78T9o25Hf4V8rWhSZj2N79+B
QuCAvuqZyAO12aUI9sxZZyis00JOnX7xbAeOkJk8Hhk4iQRMUUudKb5rqLrh/lcm
F9zZjpu6PxJh22ztnRik3L3LyZLdEhMJJGWk4Z/3tKO87K4EiluzwZhAfMLpqfxx
qfRKu6By97pbfJFBKqBTzmli2eeJLOwhERlovIaDiublFU8o8RE92PxUPOr7kqL7
3cx8Qx5AF2Mnu7ftcLIGgg/lN+haoxpACDkC5ZvTFCrGr7jD1DlkswSMoai9gknx
IMjID9nq6pVWyBm+wt9cALeK2wNa5RsE9fFvF/DBathV/WNmBwjnTKCeX3uPP1nw
CUE6d+zicrz79kRWRnmscE3phTTu3/O9TokCMe3rLzC0f+gOpIE7vXDSeRuek/xs
7uahAAWm94cHdz8QIBR/Ub+fFyrz/VHStAGlZhs0SoVnCl+VnZ9D9OqiyqslOihg
LMcNwH8QjEv4zRAU/Sf1OdVJItXyKfII5zSUCW/TpD/vWPlG80Ib/bc+H9uZDZsg
OADQYSyWjxA6OUThbCi6Wr+OxFUuDwVaMXxKjz1xH3HjmjpWZeTJy6BAuqe/OLDg
VxDdEyL8fgz+QaaM/uqFarVMTir2A5VYNJzTXh02rUn3mXXHbH7uZYSwSg7fJ/hU
ycSUkr/TFe9ZfqKOg1+ZKDu7Q97/tkL7gBTQbPqitUSinGvBgtMZKTHBznEn8foq
NL/VaFSR4MxTOxFyE2e+9riNJmR0tavZCSgA7LcJtcT9l62cbmwmMj8DvEw8fiSD
AYpgwovMtDoVDVQGb7ixLMz8/ta1BB7zPpr2aK8x5pVz5c+9rW/NiWQ68LCpEiAc
HxExUVR0b9thC5YvG4VepUtmZ768yTYyus9jDiDNwRH/qttmAosn4pq5gGK+IVao
oJX5jcroYaQnvXDBwve2XXXKSkIWe62r8h7Jv6mxR9yBQdVeWNtCGQ5AYNJNxI0i
ZbCmCcQJnIuMHLYddaIEmUuUBFOquQC9y/pVbMbmdWOMw5Nama+/q6bke/XGk81I
/Ov2gNN4Eu2V9N9MzlF0GiAmk1784qITj9iDIiYXPESnQfybFyhi2DaUM+KmeHpB
I2KHL2KA0EGVhBjvCd7FVAqDJL7Dy3nCiLxNiDKChCP9+DDXB2mEfZafltSWai6p
FPfGZJImQ6NO4/I/2aeXIwr4urJVFt3mr2b6w+gGRjr4qur0ZcqpvvcA3Es+tMX1
eY5Or9V8iw/wj0x+CrHvvsRBfvCTSN/yqweMr5p1xSZm3Hfz906/q8HSaHb/sNne
HCjUiKWJ6WTrjDjf9ewYnXb6Qxs3P0zjuHwSrpbq0Pr3HQveQvO5Tfrwr5+ikK1k
FyqiU4e4vjpLujkIj2dmH0CkJ6ase1j/rWU8nLr1XZSR
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQnH1/C+tgQtDL2ETF
DVH1SQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEG7VLFdF6M627msk
RRRS94wEggTQEOPfMCPRwnTb88nNFAGHr586zkrtG0MUftf4Lgfwns0D5l8qErV2
oQZqla9XWqzwc1tM6SyeCbP+86vMBLNl4NXN/F/8j+P2njyahBumx9tym0Fs8KSW
P6/GSmBESJWNJ2vT4lGAsuQyPf+iHvd+RAJbhKCtxWHXMY2OK7j2suCaTJSB5Jz1
yyPazN/PZSFtDKhMJJRWcQ1pGGsJYaRoJ1v6/05yWtPGGrYGmnDBZ2eKxVm5dncv
iYfqaIJ2HXmYZLvmDWy9AkHQSF+mNIMEN8jHXw9l1wGPx3GYtqcRr3r/cPDTZLd6
SAjNY/U2YZUBqPqxgFy8sc1kHX6dJAXgBSeR4Rb8GNB8Ry14tMgJRsdsHi1bpMQ/
hoqi2mUzYs9I/nz1ncGUB44jtwpN1OgkN9EgQN6i/pN1IJtMkFCnjQ+Ejgi/FRgQ
R4fpqDxab2NkFGNE8hWiS0nsjvRyAtnqMwf6+flYAUYumeRbUkkYMelYOQelyJVb
OxvfBUr6XBdTVwBR1B5S1MtFtHyw32i6+RCx0S5jRvA7jdX3CVfbTMnk5xLJOrP4
7vIckCJaac0NfRQUe812sYWe68LSec3bzz0E4cytyuN7c5u2s1X7i6qs5ITjE7A8
1Z2m0m+PDH1XjVvbQpzoLmbv4Spzus1fMQ7bGUjjGJw2PyfT9uD4ukEF12VI+S/n
T6ckOkbUha6t5A47KXPpN4VpCnPFvvsJ4ej/ijzVoo5UbZ358tvCBE2D4uu9/TMq
hAhWPMnM64JfYRvz96axKy2xgCRGDfYIpTSqBRvCwX3j1MyVKKfjvzIsraHCMb9g
+7ELpbBFB8rRSqV/8VRypWSxmSWhLlgTLgH1iPVd7riSzsxcnBAON2iUmgcE0IEV
fPcD2uFGTtiNiXu8iZ0xgNZ0nrhquuiUO1hmO/tBquDia7IvyXMHedaugvxdOgu7
sZ5YD0DJCGOKTPWvBAF3UZPBJ3kbv2zBl/zEQD5e2wcCo2Flubdwz1/Gf9TGehce
TLz0csUdNXjGmu1wpzwBFdBECPUQ7xoLnwc/1K2AiPcktWdLSPjzTkw6ERsYP9NA
5w1zi4KmgX2iG78mc/fqHUhppPnL0acLLGFWFKTjYK7mCnPSW5taoRl2EIW+BezK
kQYrGz1aONC5ol9e9pmK6YHt7fkHiYqPs/pE44a2tuM80EZsfsz0Mn5RKUgAIOOL
cLvK/zmaZ5pf24b8p9vD7kdlFqzEq+H2t5RGuyCGvanS5Z4LL/fDBjcsCh2E3N+i
hTsLRPZmKVqeDBIHoyBtSpe5OhzNZTitd6k1JoLFECzHckJflLVEDR7lLvPTI5ko
/xxDMxi9InTA62zoSokvFIfN95Rd2tXPqmj14gsZlrKT/3cUNmdva0YmgI2gluS0
qT7zozaKHQDDDMzTjhVRheccZOoPuXgQNvnVaXUDBDNyxRSuy3BWnt5YVQRZBzPw
HN71h6DxNar/eckRQ03inVn6tGlgwVan5w/JdS7fp1+ET0HF2N93T9f4ZzxHVbEV
aam9K+1Vn3hZvL5L06Yq5MjNlIaH/RhMY6zlh5CHR7v+vjYIC02ctbZIrbGL3k2u
JKOKDp2QMhTQQ6QQdzoR6BbRgFDGWz8bzOjtVsW2pY3ketp/7/tpfc4=
-----END ENCRYPTED PRIVATE KEY-----

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