Compare commits

...
Author SHA1 Message Date
Anthony Stirling 2b0a4fc731 chore(forms): drop decorative divider comments 2026-08-30 11:33:26 +01:00
Anthony Stirling 1e8c427f6a fix(forms): harden XFDF import and fix FDF parser defects 2026-08-30 10:41:06 +01:00
Anthony Stirling 04d3d903cb Merge remote-tracking branch 'origin/main' into feature/xfdf-form-data 2026-08-30 10:06:51 +01: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
Anthony Stirling 45925a3111 Merge remote-tracking branch 'origin/main' into sweep/pr7520
# Conflicts:
#	frontend/editor/public/locales/en-GB/translation.toml
2026-08-26 08:13:40 +01: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
Anthony Stirling 32a425ac0d Merge remote-tracking branch 'origin/main' into sweep/pr7520
# Conflicts:
#	frontend/editor/public/locales/en-GB/translation.toml
#	frontend/editor/public/locales/en-US/translation.toml
#	frontend/editor/src/core/tools/formFill/FormFill.tsx
2026-08-26 07:23:13 +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
Anthony Stirling b961925de2 Import and export XFDF and FDF form data in Fill Form 2026-08-13 20:48:47 +01:00
639 changed files with 38847 additions and 4479 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
}
]
}
]
}
}
-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
+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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.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
View File
@@ -34,7 +34,6 @@ 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
@@ -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
+5 -1
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
- 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
View File
@@ -59,7 +59,6 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: sync-files
- name: Install Python dependencies
run: |
+9 -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/
+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) {
@@ -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
+14 -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',
@@ -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()) {
@@ -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");
@@ -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();
+6 -4
View File
@@ -16,8 +16,9 @@
<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>
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>7</maxHistory>
<totalSizeCap>64MB</totalSizeCap>
</rollingPolicy>
</appender>
@@ -28,8 +29,9 @@
<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>
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>7</maxHistory>
<totalSizeCap>256MB</totalSizeCap>
</rollingPolicy>
</appender>
@@ -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"
},
@@ -1064,21 +1064,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 +1083,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 +1096,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",
@@ -2304,7 +2304,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 +2317,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 +2343,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",
@@ -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
+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-----
+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-----
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIXl98lJJ1MUsCAggA
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBAcT6pXTGm0w+LUzlVH0GpJBIIE
0NfOk8+haqEuGskrV8+JJVQLgqpKiOmXBjkiSHGReF4UTocKiUAwrHbvLj+j1VLM
TNM/G68+SzGuWxI7gxpzA9u7p4Is5+2Sji9KsMuAh2CQlEuzkFsVaD9KXF2rje7g
0G+4+ExZtsjlt/UqG2plFuWzJwji4J82Cy5dir1MQOOAweq5zG5/nzVpMmNoc1lo
B9PO18R3SpY6qIp8Q0+d1QJC8zsXi/KKQ3ODiS83x5BL4KkQfjYDK/Lfr9yk5a3t
JN8wE5jkDyGCLGGWgwy7Xq5N7m+kvcdeIEqKP9g5k5uZ7LppsDFe9dpHVymTHZGu
tGrB74vi4D28YNhuG5qkTjp6CEehSjMwgWEo0Y6ZGu4WQvoTmkne88zly5vUFNrw
JFM57YqE8U0Gzy7c/zeGtPq8U7y/Pd4z3muZe9sLpFoFAC7Aoq5yw662mPEBZRVb
MDw8fK1OY9fnj9qHwQbYAD5AT9GmpwEP4tWkB6qNiDJBR8Jn3VmQ1uwR7oH+BiwX
Y0xWjgl39JcpMORhzJim7K788FEjDrxR1ptepowC4EKjSeq92BGpO+Flf+lY/xYS
3QR64h/wJEx7M3FrD7qxSHguW3h8rSMPHQg3YThyBUYsCc1tNpgmhQXNHXlE6G7o
vdlDawf0Oybq6KzhdU25/kJyTaM7suiDkwyZf8SIElSD8R2VdYmL2AeowJsi26Qc
0f7l/cL/Pws0j4vxYY+6DD5uw+bCBvsjE5Y8Fw6t0xgYwnMCALjfKr2p3CW/Ifa/
uynI7Hd548orqkddc834DO6gcPuXMUgZ75RFYglpnD+DDvOzvqh7mrgDiCURZuXd
eZkF3sr4Wfn4YsQfM0XdfB0/dmzLnGGIzbW9cuB4VQUswDZ9KCnZVMZOC8AMKvSQ
eZn8VEYSr+qT5m8yKSmeUUQga6G/jN6yHj2mV8ura3o1NHvQpy82lHX3M+2d+cs1
PWTcYM3AwPpHAM2HyisPYOeNNiEKvo3mtyw2SgV4P6kavdNXFk/xA7mzDWr0QnNX
/j4ZZFynhUz46joCC6bew0yyRfL1Jqy+XDvtEOmjhy96nJvUDb5IqsMY5ZHRmGkc
yO3uVQu7kexLcA8mYA5OK1llWuyHxffTyGuL5C0q7+8mBvPrkCakUjsLGAgIWYTE
ftJ6q8u8xyDghXhRM0lvcoVLjzzjCIDaGVqeXl6HtgJ4grUaNCjESIfsURFylVxk
3jNFojsxHPtv+zYAG0otqedSKjZaG0uNivjBt/v21luSs+lqEKbv4122yzC8H6pG
zrS6OGkKb8fIqz3D5nAezMFuMjd+ORiGf/IUJToCeluqVGwXMXExdDSCDf0hFJny
6y/eKmA88lu6uHYe4TB7ZR2wPyIGl1HPN3xj7Dc/T3wEhCDycKLN4/fY9ZNw5U6E
F5yVnZFdcaA6qHiY99xvtOPX/EmxibcV6C84QV3HDmdXgjEIH52I9oK0WEjRb2hd
U2lCnZDNqthn3zn0DZ/aSe4HDe5SfLnzFFGyD1wvCTRcM25901Op4kgVD/BPwWH+
4E7KiBh91UueWn7m5h1B8cEnpsHwpQLxq2ZdNYzp3ZFyzvzSUXe3QvPveehAgr0M
lEXzn1/fJpmRPP5hvt6uYqZ+y90BkiT6UlANFHpoA6x0
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-----
@@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
* cumulative units and returns the refreshed entitlement.
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
* /api/v1/instance/revoke-self}).
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
*/
@Slf4j
@Service
@@ -72,13 +56,7 @@ public class AccountLinkClient {
this.httpClient = httpClient;
}
/** The device credential a successful {@link #register} returns. */
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
/**
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
* map auth failures (401/403) through rather than masking everything as a 502.
*/
/** A non-2xx reply from the SaaS account-link API. */
public static class UpstreamException extends IOException {
private final int status;
@@ -92,11 +70,7 @@ public class AccountLinkClient {
}
}
/**
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
*/
/** Authoritative deny (401/403) — the device credential is revoked or invalid. */
public static final class RevokedException extends RuntimeException {
private final int status;
@@ -110,46 +84,142 @@ public class AccountLinkClient {
}
}
/** What the SaaS side hands back when it records a connect handshake. */
public record ConnectRequestResult(
String requestId, int expiresInSeconds, String authorizeUrl) {}
public enum ConnectClaimOutcome {
/** Approved and collected; the credential fields are populated. */
GRANTED,
/** A re-authentication was approved. */
CONFIRMED,
/** No human decision yet. */
PENDING,
/** Declined, expired or already used. */
REJECTED,
/** SaaS unreachable or erroring. */
UNAVAILABLE
}
public record ConnectClaimResult(
ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
static ConnectClaimResult of(ConnectClaimOutcome outcome) {
return new ConnectClaimResult(outcome, null, null, null);
}
}
/** Opens a connect handshake. */
public ConnectRequestResult connectRequest(
String name, String callbackUrl, String nonce, String claimSecret) throws IOException {
return connectRequest(name, callbackUrl, nonce, claimSecret, null);
}
/**
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
* credential.
*
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
* admin).
* As {@link #connectRequest}, but presenting an existing device credential so the SaaS side
* treats this as a re-authentication and pins the handshake to the team we already belong to.
*/
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
String body =
instanceName == null || instanceName.isBlank()
? "{}"
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
HttpRequest request =
public ConnectRequestResult connectRequest(
String name,
String callbackUrl,
String nonce,
String claimSecret,
DeviceCredential credential)
throws IOException {
ObjectNode root = mapper.createObjectNode();
if (name != null && !name.isBlank()) {
root.put("name", name);
}
root.put("callbackUrl", callbackUrl);
root.put("nonce", nonce);
root.put("claimSecret", claimSecret);
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/register"))
.header("Authorization", "Bearer " + supabaseJwt)
.uri(uri("/api/v1/account-link/connect/request"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root)));
if (credential != null) {
builder.header(HEADER_DEVICE_ID, credential.getDeviceId())
.header(HEADER_DEVICE_SECRET, credential.getDeviceSecret());
}
HttpResponse<String> response = send(request);
HttpResponse<String> response = send(builder.build());
if (response.statusCode() / 100 != 2) {
throw new UpstreamException(response.statusCode(), response.body());
}
JsonNode root = mapper.readTree(response.body());
String deviceId = text(root, "deviceId");
String deviceSecret = text(root, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
throw new IOException("SaaS register response missing deviceId/deviceSecret");
JsonNode body = mapper.readTree(response.body());
String requestId = text(body, "requestId");
if (requestId == null) {
throw new IOException("SaaS connect response missing requestId");
}
String authorizeUrl = text(body, "authorizeUrl");
if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) {
throw new IOException("SaaS connect response carried no usable authorizeUrl");
}
return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl);
}
/**
* Collects the device credential for an approved handshake, proving possession of the claim
* secret.
*/
public ConnectClaimResult connectClaim(String requestId, String claimSecret) {
HttpResponse<String> response;
try {
ObjectNode root = mapper.createObjectNode();
root.put("requestId", requestId);
root.put("claimSecret", claimSecret);
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/connect/claim"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(
HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(root)))
.build();
response = send(request);
} catch (Exception e) {
log.debug("Connect claim failed (transport): {}", e.getMessage());
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
}
int status = response.statusCode();
if (status == 202) {
return ConnectClaimResult.of(ConnectClaimOutcome.PENDING);
}
if (status >= 500 && status <= 599) {
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
}
if (status < 200 || status > 299) {
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
try {
JsonNode body = mapper.readTree(response.body());
Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null;
// A re-authentication says so explicitly and carries no credential, so an absent
// credential is only an error when we were expecting one.
if ("confirmed".equals(text(body, "status"))) {
return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId);
}
String deviceId = text(body, "deviceId");
String deviceSecret = text(body, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
log.warn("Connect claim succeeded but the reply carried no credential");
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
return new ConnectClaimResult(
ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId);
} catch (RuntimeException e) {
log.debug("Connect claim parse failed: {}", e.getMessage());
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
return new RegisterResult(deviceId, deviceSecret, teamId);
}
/**
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
@@ -174,17 +244,7 @@ public class AccountLinkClient {
}
}
/**
* Fetches the current entitlement using the stored device credential. Three outcomes:
*
* <ul>
* <li>2xx → the parsed snapshot.
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
* the caller must BLOCK, not fail open.
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
* ("unknown" — the caller fails open).
* </ul>
*/
/** Fetches the current entitlement using the stored device credential. */
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
HttpResponse<String> response;
try {
@@ -224,9 +284,6 @@ public class AccountLinkClient {
/**
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
* not advance its last-synced markers so the usage retries next sync.
*/
public InstanceEntitlement reportUsage(
String deviceId,
@@ -360,4 +417,19 @@ public class AccountLinkClient {
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.get(field).asText() : null;
}
/** Absolute http(s) with a host. */
static boolean isAbsoluteHttpUrl(String candidate) {
try {
URI uri = URI.create(candidate.strip());
String scheme = uri.getScheme();
return uri.isAbsolute()
&& scheme != null
&& ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
&& uri.getHost() != null
&& !uri.getHost().isBlank();
} catch (IllegalArgumentException e) {
return false;
}
}
}
@@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
/**
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
*
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
* / test aid).
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
*/
/** Same-origin account-link surface on the self-hosted instance (combined billing). */
@Slf4j
@Hidden
@RestController
@@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j;
public class AccountLinkController {
private final AccountLinkService service;
private final ConnectService connectService;
private final LocalUsageService localUsageService;
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
private final ObjectProvider<UsageSyncService> syncServiceProvider;
public AccountLinkController(
AccountLinkService service,
ConnectService connectService,
LocalUsageService localUsageService,
ObjectProvider<UsageSyncService> syncServiceProvider) {
this.service = service;
this.connectService = connectService;
this.localUsageService = localUsageService;
this.syncServiceProvider = syncServiceProvider;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
public record LinkRequest(String supabaseJwt, String name) {}
/** {@code callbackUrl} is the portal telling us where its own callback route lives. */
public record ConnectStartRequest(String name, String callbackUrl) {}
@PostMapping("/link")
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
return ResponseEntity.badRequest()
.body(java.util.Map.of("error", "supabaseJwt is required"));
}
/** {@code nonce} comes from the callback fragment the approval page redirected to. */
public record ConnectCompleteRequest(String nonce) {}
/**
* Opens a browser-mediated link handshake and returns the approval URL to send the admin to.
*/
@PostMapping("/connect/start")
public ResponseEntity<?> connectStart(
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
try {
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
return ResponseEntity.ok(
connectService.start(req != null ? req.name() : null, callbackHint(req, http)));
} catch (AccountLinkClient.UpstreamException e) {
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
// the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
// raw upstream body back to the browser.
HttpStatus status =
e.status() == HttpStatus.UNAUTHORIZED.value()
|| e.status() == HttpStatus.FORBIDDEN.value()
? HttpStatus.valueOf(e.status())
: HttpStatus.BAD_GATEWAY;
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
} catch (IOException e) {
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
// configured SaaS host/IP. Log it server-side; return the same opaque body the
// UpstreamException branch does.
log.warn("Account-link failed (transport): {}", e.getMessage());
log.warn("Account-link connect rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "LINK_FAILED"));
.body(java.util.Map.of("error", "CONNECT_FAILED"));
} catch (IOException e) {
// Same reasoning as /link: a transport message can carry the configured SaaS host.
log.warn("Account-link connect failed (transport): {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
}
}
/** Re-establishes the admin's SaaS session for a server that is already linked. */
@PostMapping("/connect/reauth")
public ResponseEntity<?> connectReauth(
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
try {
return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http)));
} catch (AccountLinkClient.UpstreamException e) {
log.warn("Account-link reauth rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
} catch (IOException e) {
log.warn("Account-link reauth failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
}
}
/** Called by the callback page with the nonce it found in the fragment. */
@PostMapping("/connect/complete")
public ResponseEntity<ConnectService.ConnectStatus> connectComplete(
@RequestBody(required = false) ConnectCompleteRequest req) {
return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null));
}
/** Everything we know about where the admin's browser is, for the callback. */
private static ConnectService.CallbackHint callbackHint(
ConnectStartRequest req, HttpServletRequest http) {
return new ConnectService.CallbackHint(
req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http));
}
/**
* This instance's base URL as the browser reached it, including any context path so a subpath
* deployment builds a callback that actually resolves.
*/
private static String baseUrlOf(HttpServletRequest request) {
String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto"));
String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host"));
String scheme = forwardedProto != null ? forwardedProto : request.getScheme();
String hostPort;
if (forwardedHost != null) {
hostPort = forwardedHost;
} else {
int port = request.getServerPort();
boolean defaultPort =
("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port;
}
String context = request.getContextPath() == null ? "" : request.getContextPath();
return scheme + "://" + hostPort + context;
}
private static String firstHop(String headerValue) {
if (headerValue == null || headerValue.isBlank()) {
return null;
}
String first = headerValue.split(",")[0].strip();
return first.isEmpty() ? null : first;
}
@GetMapping("/status")
public ResponseEntity<AccountLinkService.LinkStatus> status() {
return ResponseEntity.ok(service.status());
@@ -106,12 +155,7 @@ public class AccountLinkController {
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
}
/**
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
* {@code 409} when metering is off (the sync bean is absent).
*/
/** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */
@PostMapping("/sync-now")
public ResponseEntity<Void> syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
@@ -8,29 +8,17 @@ import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/**
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
*
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
*/
/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "stirling.billing.account-link")
public class AccountLinkProperties {
/** Master switch. When {@code false} (default) the feature is fully inert. */
/** Master switch. */
private boolean enabled = false;
/**
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
*
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
*/
/** Base URL of the SaaS backend this instance links to (register + entitlement live there). */
private String saasBaseUrl = "https://stirling.com/app";
/** Cached entitlement is reused for this long before a refresh is attempted. */
@@ -39,20 +27,18 @@ public class AccountLinkProperties {
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
/** Phase 2 usage metering + daily sync. */
private final Metering metering = new Metering();
/**
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
* enforcement. Both default off; metering requires the master flag too. This is the production
* safety key — flipping it on is what actually bills linked instances.
* Separate from {@link #enabled} so linking can be exercised without billing anything. Both
* default off, and metering needs the master flag as well.
*/
@Getter
@Setter
public static class Metering {
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
/** Turns on usage metering, the daily sync, and cap enforcement. */
private boolean enabled = false;
/**
@@ -65,12 +51,7 @@ public class AccountLinkProperties {
*/
private int graceDays = 3;
/**
* Dedup window for identical input sets. A re-run of the same inputs within this window is
* treated as workflow chaining and not re-charged; the same inputs run again after it are
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
* costs the same on the instance and in the cloud.
*/
/** Dedup window for identical input sets. */
private Duration workflowWindow = Duration.ofMinutes(5);
}
}
@@ -1,6 +1,5 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -9,13 +8,7 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
*
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
* The credential — not the JWT — authenticates all later unattended entitlement calls.
*/
/** Linking orchestrator (self-hosted side of combined billing). */
@Slf4j
@Service
@Profile("!saas")
@@ -38,24 +31,9 @@ public class AccountLinkService {
/** Status of this instance's link, for the portal's "Account link" card. */
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
/**
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
* credential.
*
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
*/
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
entitlementCache.invalidate();
log.info("Account-link: instance linked to team {}", result.teamId());
return status();
}
/**
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
* revoked_at} set), then clears locally regardless.
*/
public void unlink() {
credentialStore
@@ -12,7 +12,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
* Singleton row holding this instance's daily-sync bookkeeping (combined billing).
*
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
@@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
@@ -0,0 +1,276 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Locale;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/** Browser-mediated account linking, instance side. */
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class ConnectService {
/** Frontend route that consumes the callback fragment. */
static final String CALLBACK_PATH = "/account-link/callback";
private static final int SECRET_BYTES = 32;
private final AccountLinkClient client;
private final ConnectStateRepository stateRepo;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
private final ApplicationProperties applicationProperties;
private final SecureRandom random = new SecureRandom();
public ConnectService(
AccountLinkClient client,
ConnectStateRepository stateRepo,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache,
ApplicationProperties applicationProperties) {
this.client = client;
this.stateRepo = stateRepo;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
this.applicationProperties = applicationProperties;
}
public enum Phase {
/** Nothing in flight and not linked. */
NONE,
/** A handshake is open, waiting for a leader to approve it on the SaaS site. */
PENDING,
/** Linked. */
LINKED,
/** The handshake outlived its window; start a new one. */
EXPIRED,
/** Declined or already used; start a new one. */
REJECTED,
/** SaaS could not be reached; the handshake is still valid and can be retried. */
UNAVAILABLE
}
/** What the portal renders. */
public record ConnectStatus(
Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) {
static ConnectStatus of(Phase phase) {
return new ConnectStatus(phase, null, null, null);
}
}
/** Everything we know about where the admin's browser actually is, in decreasing authority. */
public record CallbackHint(
String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {}
/** Opens a handshake and returns where to send the admin. */
@Transactional
public ConnectStatus start(String name, CallbackHint hint) throws IOException {
if (credentialStore.isLinked()) {
return status();
}
return open(name, hint, null);
}
/**
* Opens a handshake that only re-establishes the admin's browser session, for an instance that
* is already linked.
*/
@Transactional
public ConnectStatus startReauth(CallbackHint hint) throws IOException {
DeviceCredential credential =
credentialStore
.get()
.orElseThrow(
() ->
new IOException(
"This server is not linked, so there is no session"
+ " to re-establish"));
return open(credential.getDeviceId(), hint, credential);
}
private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential)
throws IOException {
String callbackUrl = resolveCallbackUrl(hint);
if (callbackUrl == null) {
throw new IOException(
"Cannot determine where to send the admin back to; set system.frontendUrl");
}
String nonce = randomSecret();
String claimSecret = randomSecret();
AccountLinkClient.ConnectRequestResult created =
client.connectRequest(name, callbackUrl, nonce, claimSecret, credential);
LocalDateTime now = LocalDateTime.now();
ConnectState state = new ConnectState();
state.setId(ConnectState.SINGLETON_ID);
state.setRequestId(created.requestId());
state.setNonce(nonce);
state.setClaimSecret(claimSecret);
state.setCallbackUrl(callbackUrl);
state.setAuthorizeUrl(created.authorizeUrl());
state.setCreatedAt(now);
state.setExpiresAt(
now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900));
stateRepo.save(state);
log.info("Account-link connect: handshake {} opened", created.requestId());
return pendingStatus(state, now);
}
/** Finishes a handshake from the callback the approval page redirected to. */
@Transactional
public ConnectStatus complete(String nonce) {
Optional<ConnectState> found = stateRepo.findById(ConnectState.SINGLETON_ID);
if (found.isEmpty()) {
// Already finished (a double-submitted callback) or never started.
return status();
}
ConnectState state = found.get();
if (state.isExpired(LocalDateTime.now())) {
stateRepo.delete(state);
return ConnectStatus.of(Phase.EXPIRED);
}
if (nonce == null || !nonceMatches(nonce, state.getNonce())) {
log.warn(
"Account-link connect: callback for handshake {} had a bad nonce",
state.getRequestId());
return ConnectStatus.of(Phase.REJECTED);
}
AccountLinkClient.ConnectClaimResult claim =
client.connectClaim(state.getRequestId(), state.getClaimSecret());
return switch (claim.outcome()) {
case GRANTED -> {
credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId());
entitlementCache.invalidate();
stateRepo.delete(state);
log.info("Account-link connect: linked to team {}", claim.teamId());
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
}
case CONFIRMED -> {
stateRepo.delete(state);
log.info(
"Account-link connect: session re-established for team {}", claim.teamId());
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
}
case PENDING ->
// The admin reached the callback before the approval committed. The row stays,
// so a retry finishes it.
ConnectStatus.of(Phase.PENDING);
case REJECTED -> {
stateRepo.delete(state);
yield ConnectStatus.of(Phase.REJECTED);
}
case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE);
};
}
@Transactional(readOnly = true)
public ConnectStatus status() {
Optional<DeviceCredential> credential = credentialStore.get();
if (credential.isPresent()) {
return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId());
}
Optional<ConnectState> state = stateRepo.findById(ConnectState.SINGLETON_ID);
if (state.isEmpty()) {
return ConnectStatus.of(Phase.NONE);
}
LocalDateTime now = LocalDateTime.now();
if (state.get().isExpired(now)) {
return ConnectStatus.of(Phase.EXPIRED);
}
return pendingStatus(state.get(), now);
}
private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) {
long remaining = Duration.between(now, state.getExpiresAt()).toSeconds();
return new ConnectStatus(
Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null);
}
/** Decides the callback, preferring knowledge over inference. */
String resolveCallbackUrl(CallbackHint hint) {
String configured = applicationProperties.getSystem().getFrontendUrl();
if (configured != null && !configured.isBlank()) {
return trimTrailingSlash(configured.strip()) + CALLBACK_PATH;
}
String browserOrigin = originOf(hint.browserOrigin());
if (browserOrigin != null) {
String requested = hint.requestedCallbackUrl();
if (requested != null && browserOrigin.equals(originOf(requested))) {
return requested.strip();
}
return browserOrigin + CALLBACK_PATH;
}
return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank()
? null
: trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH;
}
/** Scheme, host and port of an absolute http(s) URL; null if it is not one. */
private static String originOf(String candidate) {
if (candidate == null || candidate.isBlank()) {
return null;
}
URI uri;
try {
uri = new URI(candidate.strip());
} catch (URISyntaxException e) {
return null;
}
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"https".equals(scheme)) {
return null;
}
int port = uri.getPort();
boolean defaultPort =
port == -1
|| ("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
return defaultPort
? scheme + "://" + uri.getHost()
: scheme + "://" + uri.getHost() + ":" + port;
}
private static String trimTrailingSlash(String value) {
return value.replaceAll("/+$", "");
}
private String randomSecret() {
byte[] buf = new byte[SECRET_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** Constant-time so a caller cannot probe the nonce a character at a time. */
private static boolean nonceMatches(String candidate, String expected) {
if (expected == null) {
return false;
}
return MessageDigest.isEqual(
candidate.getBytes(StandardCharsets.UTF_8),
expected.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,60 @@
package stirling.software.proprietary.accountlink;
import java.io.Serializable;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** The one in-flight "connect this server" handshake, instance side. */
@Entity
@Table(name = "account_link_connect_state")
@NoArgsConstructor
@Getter
@Setter
public class ConnectState implements Serializable {
private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@Id
@Column(name = "id")
private Long id = SINGLETON_ID;
/** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */
@Column(name = "request_id", nullable = false, length = 64)
private String requestId;
/** Correlator we minted. */
@Column(name = "nonce", nullable = false, length = 128)
private String nonce;
/** Secret we minted and sent to SaaS server to server. */
@Column(name = "claim_secret", nullable = false, length = 128)
private String claimSecret;
/** Where we asked the approval page to send the admin back to. */
@Column(name = "callback_url", nullable = false, length = 2048)
private String callbackUrl;
/** The approval URL handed to the browser, so a reload can offer it again. */
@Column(name = "authorize_url", nullable = false, length = 2048)
private String authorizeUrl;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
public boolean isExpired(LocalDateTime now) {
return expiresAt != null && expiresAt.isBefore(now);
}
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Data access for the singleton {@link ConnectState} row. */
public interface ConnectStateRepository extends JpaRepository<ConnectState, Long> {}
@@ -13,8 +13,8 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* The device credential this self-hosted instance received when it linked a SaaS account
* (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
* The device credential this self-hosted instance received when it linked a SaaS account (combined
* billing). Singleton — one instance links to exactly one SaaS team.
*
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
@@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
/**
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
* Decides whether a request may proceed under combined billing on a self-hosted instance.
*
* <p>Rules (in order):
*
@@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
* Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI /
* automation) work when the instance is unlinked or over its limit; manual tools pass through.
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
*
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
@@ -16,11 +16,11 @@ import lombok.NoArgsConstructor;
/**
* The last time the instance metered a given input set this period — the local equivalent of the
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
* window</b>: an identical input set re-submitted within the window (see {@link
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
* window so the same operation costs the same on the instance and in the cloud.
* cloud's lineage join (combined billing). The meter dedups on a rolling <b>workflow window</b>: an
* identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering})
* is treated as workflow chaining and not re-charged, while the same inputs run again after the
* window are billed afresh — matching the cloud's 5-minute open-job window so the same operation
* costs the same on the instance and in the cloud.
*
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
@@ -5,7 +5,7 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
/** Persistence for the per-period metered input-set signatures (combined billing). */
public interface MeteredInputSignatureRepository
extends JpaRepository<MeteredInputSignature, Long> {
@@ -17,10 +17,10 @@ import lombok.NoArgsConstructor;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* Durable per-(billing period, category) cumulative usage counter for combined billing. Each
* successful billable op increments its row; the daily sync reports the cumulative totals and SaaS
* bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing)
* and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
*/
@Entity
@@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
/** Persistence for the per-period/per-category usage counters (combined billing). */
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
/**
@@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
* usage to SaaS, which bills the delta against its own last-seen totals.
* Daily usage sender for combined billing. Reports each period's cumulative per-category usage to
* SaaS, which bills the delta against its own last-seen totals.
*
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
@@ -59,7 +59,7 @@ public enum AuditLevel {
*/
public static AuditLevel fromInt(int level) {
// Ensure level is within valid bounds
int boundedLevel = Math.min(Math.max(level, 0), 3);
int boundedLevel = Math.clamp(level, 0, 3);
for (AuditLevel auditLevel : values()) {
if (auditLevel.level == boundedLevel) {
@@ -11,9 +11,9 @@ import java.util.HexFormat;
/**
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
* bytes the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
* of file size), hardware-accelerated by the JVM where available.
* meter (combined billing), so both derive an <em>identical</em> signature for the same bytes the
* basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file
* size), hardware-accelerated by the JVM where available.
*
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
@@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId.
*
@@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore {
private static final String FILE_INDEX_PREFIX = "stirling:file2job:";
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<List<String>> LIST_STRING = new TypeReference<>() {};
private static final TypeReference<Map<String, String>> MAP_STRING = new TypeReference<>() {};
private static final TypeReference<List<String>> LIST_STRING =
new TypeReference<List<String>>() {};
private static final TypeReference<Map<String, String>> MAP_STRING =
new TypeReference<Map<String, String>>() {};
private final StringRedisTemplate template;
@@ -265,7 +267,7 @@ public class ValkeyJobStore implements JobStore {
}
try {
return MAPPER.readValue(v.toString(), MAP_STRING);
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
log.warn(
"JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty",
key,
@@ -277,7 +279,7 @@ public class ValkeyJobStore implements JobStore {
private static String writeJson(Object value) {
try {
return MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
throw new IllegalStateException("Failed to JSON-serialize JobStore field", e);
}
}
@@ -286,7 +288,7 @@ public class ValkeyJobStore implements JobStore {
try {
List<String> parsed = MAPPER.readValue(json, LIST_STRING);
return parsed == null ? new ArrayList<>() : parsed;
} catch (JsonProcessingException e) {
} catch (JacksonException e) {
log.warn(
"JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty",
key,
@@ -35,7 +35,7 @@ public class AuditConfigurationProperties {
// Ensure level is within valid bounds (0-3)
int configLevel = auditConfig.getLevel();
this.level = Math.min(Math.max(configLevel, 0), 3);
this.level = Math.clamp(configLevel, 0, 3);
// Retention days (0 means infinite)
this.retentionDays = auditConfig.getRetentionDays();
@@ -48,7 +48,7 @@ public class UsageRestController {
@RequestParam(value = "dataType", defaultValue = "all") String dataType,
@RequestParam(value = "days", defaultValue = "30") Integer days) {
int lookbackDays = Math.max(1, Math.min(days, 365));
int lookbackDays = Math.clamp(days, 1, 365);
// Get audit events filtered by type
List<PersistentAuditEvent> events = getEventsByDataType(dataType, lookbackDays);
@@ -1,20 +1,17 @@
package stirling.software.proprietary.failure;
import org.springframework.http.HttpStatus;
import lombok.Getter;
/**
* Why an action could not be dispatched. Carries a {@link Reason} rather than an HTTP status, so
* the service stays web-agnostic and the controller owns the mapping.
*/
/** Carries a {@link Reason} rather than an HTTP status, so the service stays web-agnostic. */
@Getter
public class FailureActionException extends RuntimeException {
public enum Reason {
/**
* No such event, it belongs to another team, or the caller's team did not resolve. One
* reason for all three, so the response does not vary with which it was. Unrelated to
* {@link FailureKind#UNKNOWN}, which is an unclassified failure rather than a refused
* action.
* No such event, another team's, or an unresolved team: one reason, so the answer cannot
* vary.
*/
EVENT_NOT_FOUND,
@@ -22,15 +19,13 @@ public class FailureActionException extends RuntimeException {
ACTION_NOT_RECOGNISED,
/**
* The action exists but this kind does not declare it, so an incoherent pairing (releasing
* a document whose destination is what failed) cannot be dispatched even by hand.
*
* <p>Unreachable today: both kinds declare both actions, so no request can trip this guard
* until a kind ships with a restricted action set. Declared now because the guard must
* exist before that kind does, not after.
* The action exists but this kind does not offer it, so it cannot be dispatched by hand.
*/
ACTION_NOT_DECLARED,
/** Offered, but the client is what runs it, so refused rather than half-performed. */
ACTION_NOT_DISPATCHABLE,
/** The event is already closed, so no further transition is possible. */
ALREADY_CLOSED
}
@@ -41,9 +36,21 @@ public class FailureActionException extends RuntimeException {
this(reason, message, null);
}
/** For a refusal that follows from a lower-level failure, so its stack is not dropped. */
public FailureActionException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
/**
* Lives with the reasons it maps, so every surface that dispatches an action answers alike. A
* closed row is a conflict, not a bad request: it was well-formed and valid a moment earlier.
*/
public static HttpStatus statusOf(Reason reason) {
return switch (reason) {
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED, ACTION_NOT_DISPATCHABLE ->
HttpStatus.BAD_REQUEST;
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
};
}
}
@@ -1,11 +1,52 @@
package stirling.software.proprietary.failure;
import lombok.Getter;
/**
* The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how
* the event is shown and touch nothing else, which is what makes them valid for every kind
* including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet.
* The actions a {@link FailureKind} may declare. Client actions are declared here rather than
* invented per client, so the server keeps deciding what a kind offers, in what order and labelled
* how.
*/
@Getter
public enum FailureActionId {
ACKNOWLEDGE,
DISMISS
/**
* Kept in the vocabulary for as long as any persisted row is {@code ACKNOWLEDGED}: such rows
* must stay readable and closable whether or not any kind currently offers this.
*/
ACKNOWLEDGE(Execution.SERVER, "Acknowledge"),
DISMISS(Execution.SERVER, "Dismiss"),
/** Open the document behind the incident, in whichever client can resolve its id. */
VIEW_FILE(Execution.CLIENT, "View file"),
VIEW_IN_PROCESSOR(Execution.CLIENT, "View in processor");
/** Dispatch refuses a {@code CLIENT} id, so this is enforced rather than merely documented. */
public enum Execution {
/** {@link FailureActionRegistry} requires a {@link FailureAction} bean for these. */
SERVER,
/**
* Declared and rendered, never dispatched: the server has neither the file nor the tool.
*/
CLIENT
}
private final Execution execution;
/** English fallback, for a client with no translation for the label key. */
private final String defaultLabel;
FailureActionId(Execution execution, String defaultLabel) {
this.execution = execution;
this.defaultLabel = defaultLabel;
}
/** Also whether it can be dispatched. */
public boolean runsOnServer() {
return execution == Execution.SERVER;
}
}
@@ -16,6 +16,9 @@ import lombok.extern.slf4j.Slf4j;
* Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the
* point: because kinds declare action ids as data, one could name an action nobody implements,
* which would otherwise show up as a button that 400s rather than as a failed boot.
*
* <p>Only {@link FailureActionId.Execution#SERVER} ids belong here: a bean for a client action is
* refused, because dispatch could never reach it.
*/
@Slf4j
@Service
@@ -25,6 +28,14 @@ public class FailureActionRegistry {
public FailureActionRegistry(List<FailureAction> actions) {
for (FailureAction action : actions) {
if (!action.id().runsOnServer()) {
throw new IllegalStateException(
"Action "
+ action.id()
+ " is run by the client, so "
+ action.getClass().getName()
+ " could never be dispatched");
}
FailureAction clash = byId.put(action.id(), action);
if (clash != null) {
throw new IllegalStateException(
@@ -38,10 +49,7 @@ public class FailureActionRegistry {
}
}
/**
* Fail fast if any kind declares an action with no handler, naming every gap rather than the
* first, so one boot tells you everything that is missing.
*/
/** Names every gap rather than the first, so one boot tells you everything that is missing. */
@PostConstruct
void verifyEveryDeclaredActionHasAHandler() {
List<String> gaps =
@@ -49,6 +57,7 @@ public class FailureActionRegistry {
.flatMap(
kind ->
kind.getActions().stream()
.filter(FailureActionId::runsOnServer)
.filter(action -> !byId.containsKey(action))
.map(action -> kind.getId() + " -> " + action))
.toList();
@@ -0,0 +1,15 @@
package stirling.software.proprietary.failure;
/**
* Who an offered action is for, the read scope having already decided they may see the incident.
* The distinction is possession, not seniority: a reviewer cannot reach a document only its owner
* holds.
*/
public enum FailureAudience {
OWNER,
/** Anyone who triages the team's incidents, whoever hit them. */
TEAM_REVIEWER,
ANYONE_WHO_SEES
}
@@ -1,7 +1,11 @@
package stirling.software.proprietary.failure;
import static stirling.software.proprietary.failure.FailureActionId.ACKNOWLEDGE;
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
import static stirling.software.proprietary.failure.FailureActionId.VIEW_FILE;
import static stirling.software.proprietary.failure.FailureActionId.VIEW_IN_PROCESSOR;
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
import java.util.Arrays;
import java.util.HashMap;
@@ -20,13 +24,8 @@ import lombok.Getter;
* The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback
* like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs.
*
* <p>Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a
* new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every
* failed run a record, and kinds get promoted out of it as production shows what occurs.
*
* <p>A kind offers an acknowledgement only where there is something to acknowledge <em>doing</em>.
* With nothing to fix, "seen it" and "clear it" are the same decision, so the row offers only the
* one that clears it.
* <p>A new kind ships as a registry entry plus copy. Each offer also says who it is for, since one
* incident is read both by whoever hit it and by whoever reviews after them.
*/
@Getter
public enum FailureKind {
@@ -37,8 +36,9 @@ public enum FailureKind {
FailureScope.FILE,
errorCodes("E004"),
fallback("This document is password-protected, so the pipeline could not read it."),
offer(ACKNOWLEDGE),
offer(DISMISS, "dismissSkipFile")),
offer(VIEW_FILE, OWNER),
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
offer(DISMISS, ANYONE_WHO_SEES)),
UNKNOWN(
FailureStage.INTERNAL,
@@ -47,7 +47,11 @@ public enum FailureKind {
FailureScope.RUN,
noErrorCodes(),
fallback("This run failed for a reason Stirling does not yet recognise."),
offer(DISMISS));
// Same order as every other kind: declaration order is display order, so the document
// leads wherever it is offered rather than moving between failures.
offer(VIEW_FILE, OWNER),
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
offer(DISMISS, ANYONE_WHO_SEES));
private static final String KEY_PREFIX = "portal.failures.kind.";
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
@@ -95,22 +99,26 @@ public enum FailureKind {
}
/**
* One action this kind offers, with the key to label it by. One ordered list rather than ids
* plus a parallel map of overrides, which could disagree with each other.
* One ordered list rather than ids plus parallel maps of audiences and labels, which could
* disagree with each other.
*
* @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic
* label
*/
private record Offer(FailureActionId id, String labelKeySuffix) {}
private record Offer(FailureActionId id, FailureAudience audience, String labelKeySuffix) {}
/** An action labelled by this kind's own wording, where the generic label reads badly. */
private static Offer offer(FailureActionId id, String labelKeySuffix) {
return new Offer(id, labelKeySuffix);
/** Declaration order is display order. */
private static Offer offer(FailureActionId id, FailureAudience audience) {
return new Offer(id, audience, null);
}
/** An action labelled by the shared wording for that action. */
private static Offer offer(FailureActionId id) {
return new Offer(id, null);
/**
* As {@link #offer(FailureActionId, FailureAudience)}, but labelled by this kind's own wording
* where the shared one reads badly.
*/
private static Offer offer(
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
return new Offer(id, audience, labelKeySuffix);
}
/**
@@ -149,6 +157,22 @@ public enum FailureKind {
return offers.stream().map(Offer::id).toList();
}
/**
* What this kind offers, in declaration order, each with its label resolved. What a review
* surface reads, so it never has to ask two separate questions about one offer.
*/
public List<OfferedAction> getOfferedActions() {
return offers.stream()
.map(
offer ->
new OfferedAction(
offer.id(), labelKeyFor(offer.id()), offer.audience()))
.toList();
}
/** One action as a kind declares it: what to call it and who it is for. */
public record OfferedAction(FailureActionId id, String labelKey, FailureAudience audience) {}
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
public boolean declares(FailureActionId action) {
return offers.stream().anyMatch(offer -> offer.id() == action);
@@ -74,8 +74,10 @@ public class FileRunEventController {
@Operation(
summary = "Apply an action to a recorded failure",
description =
"Rejected with 400 if the failure's kind does not declare the action, so an"
+ " action that makes no sense for a given failure cannot be applied.")
"Rejected with 400 if the failure's kind does not declare the action, or if the"
+ " action is one the client runs rather than the server, so neither an"
+ " action that makes no sense for a given failure nor one the server"
+ " cannot perform can be applied.")
public FileRunEventView act(
@PathVariable String eventId,
@PathVariable String actionId,
@@ -87,7 +89,8 @@ public class FileRunEventController {
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
return FileRunEventView.of(updated, service.availableActions(updated));
} catch (FailureActionException e) {
throw new ResponseStatusException(statusFor(e.getReason()), e.getMessage(), e);
throw new ResponseStatusException(
FailureActionException.statusOf(e.getReason()), e.getMessage(), e);
}
}
@@ -147,18 +150,6 @@ public class FileRunEventController {
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
}
/**
* A closed row is a conflict rather than a bad request: the request was well-formed and would
* have been valid a moment earlier.
*/
private static HttpStatus statusFor(FailureActionException.Reason reason) {
return switch (reason) {
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED -> HttpStatus.BAD_REQUEST;
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
};
}
/** Wrapped rather than a bare array so pagination can be added without breaking clients. */
public record FileRunEventsResponse(List<FileRunEventView> events) {}
@@ -178,10 +169,13 @@ public class FileRunEventController {
}
}
/** Inputs an action declared it needs. Empty for both actions that exist today. */
/**
* Inputs an action declared it needs. Empty for every action the server runs today: the one
* that needs a password is run by the client, which never sends it here.
*/
public record ActionRequest(Map<String, String> inputs) {
Map<String, String> safeInputs() {
public Map<String, String> safeInputs() {
return inputs == null ? Map.of() : inputs;
}
}
@@ -98,20 +98,18 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
* Close the incidents about documents their owner deleted from the editor: the queue is what
* needs attention, and a document that no longer exists needs none.
*
* <p>Restricted to that owner's own editor rows. File ids are minted by the client, so scoping
* on team alone would let one caller close a colleague's incidents by naming ids. Processor
* rows are excluded outright: nothing was deleted from an editor there.
* <p>Scoped by the absence of a source rather than by origin: a source-fed run's {@code fileId}
* is a hash no client can name. Narrowed to the owner's own rows, since clients mint the ids.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set e.status ="
+ " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED,"
+ " e.statusActor = :actor, e.statusAt = :now where e.origin ="
+ " stirling.software.proprietary.failure.FailureOrigin.TOOL and ((:teamId is"
+ " null and e.teamId is null) or e.teamId = :teamId) and ((:actor is null and"
+ " e.actor is null) or e.actor = :actor) and e.fileId in :fileIds and e.status in"
+ " :allowedFrom")
+ " e.statusActor = :actor, e.statusAt = :now where e.sourceId is null and"
+ " ((:teamId is null and e.teamId is null) or e.teamId = :teamId) and"
+ " ((:actor is null and e.actor is null) or e.actor = :actor) and e.fileId in"
+ " :fileIds and e.status in :allowedFrom")
int markFilesRemoved(
@Param("teamId") Long teamId,
@Param("actor") String actor,
@@ -21,12 +21,22 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
* team always comes from the authenticated principal, and scoping applies only when login is
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
* reads nothing; see {@link #readScope()}.
*
* <p>The read scope decides who sees an incident; {@link #availableActions} decides who may act.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileRunEventService {
/** Why an offered action came back disabled. Copy lives under {@code portal.failures}. */
private static final String CLOSED_REASON_KEY = "portal.failures.disabled.closed";
private static final String UNATTENDED_REASON_KEY = "portal.failures.disabled.unattended";
/** The row never named a document, so unlike the unattended case no client can find one. */
private static final String DOCUMENTLESS_REASON_KEY = "portal.failures.disabled.noDocument";
private final FileRunEventStore store;
private final FailureActionRegistry actionRegistry;
private final PolicyManagementAuthority policyManagementAuthority;
@@ -116,36 +126,17 @@ public class FileRunEventService {
* Dispatch an action against one event.
*
* @throws FailureActionException if the event is not the caller's, the action is unknown, the
* event's kind does not declare the action, or the event is already closed
* event's kind does not declare the action, the client is what runs the action, or the
* event is already closed
*/
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
// Whoever can see it can close it: a leader for the whole team, everyone else for the
// failures they caused. Someone who fixes their own problem should not have to ask a leader
// to clear the row.
//
// Closing the row is all this covers. Acting on the document behind it, such as supplying a
// password for a retry, would need its own permission, and no such action exists yet.
ReadScope scope = readScope();
if (!scope.permitted()) {
// Reported as "no such event", the same as an id from another team, so the response
// does
// not depend on whether the id happens to exist.
throw new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
}
FileRunEvent event =
store.find(eventId, scope.teamId())
// Reported as "no such event" rather than a refusal, so a member cannot
// learn that a colleague's incident exists by trying to close it.
.filter(
found ->
scope.actor() == null
|| scope.actor().equals(found.actor()))
.orElseThrow(
() ->
new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND,
"No such event: " + eventId));
// Audience decides what is offered, not what may be dispatched, so this scope is the whole
// gate. A server action aimed at OWNER alone would need its own guard here.
FileRunEvent event = requireVisible(eventId);
FailureActionId resolvedId = parseActionId(actionId);
@@ -156,6 +147,12 @@ public class FileRunEventService {
FailureActionException.Reason.ACTION_NOT_DECLARED,
"Kind " + event.kind().getId() + " does not offer action " + resolvedId);
}
// Without this a client could post VIEW_FILE and be answered as though something happened.
if (!resolvedId.runsOnServer()) {
throw new FailureActionException(
FailureActionException.Reason.ACTION_NOT_DISPATCHABLE,
"Action " + resolvedId + " is run by the client, not the server");
}
if (event.status().terminal()) {
throw new FailureActionException(
FailureActionException.Reason.ALREADY_CLOSED,
@@ -174,23 +171,91 @@ public class FileRunEventService {
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
}
/** "No such event" rather than a refusal, so trying does not confirm a colleague's exists. */
private FileRunEvent requireVisible(String eventId) {
ReadScope scope = readScope();
if (!scope.permitted()) {
return notFound(eventId);
}
return store.find(eventId, scope.teamId())
.filter(found -> scope.actor() == null || scope.actor().equals(found.actor()))
.orElseGet(() -> notFound(eventId));
}
private FileRunEvent notFound(String eventId) {
throw new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
}
public Ownership ownershipOf(FileRunEvent event) {
if (event.actor() == null) {
return Ownership.UNOWNED;
}
String caller = currentActor();
return event.actor().equals(caller) ? Ownership.MINE : Ownership.THEIRS;
}
/**
* Which of an event's declared actions are usable right now. Decided per row, so the client
* never renders a button that would be refused.
* Offers resolved for one caller, so no client renders a button that would be refused. Outside
* their audience is dropped, not disabled: greyed out would read as a permission problem.
*/
public List<AvailableAction> availableActions(FileRunEvent event) {
Ownership ownership = ownershipOf(event);
boolean reviewsTeam = reviewsTeam();
boolean closed = event.status().terminal();
return event.kind().getActions().stream()
.map(
action ->
new AvailableAction(
action,
event.kind().labelKeyFor(action),
!closed,
closed ? "portal.failures.disabled.closed" : null))
// Login disabled is excluded: its rows are unowned only for want of users, and its one
// operator owns everything they can see.
boolean unattended = enforced() && ownership == Ownership.UNOWNED;
// Answered here, or the client reports "not on this device" about a document the row never
// identified in the first place.
boolean documentless = event.fileId() == null || event.fileId().isBlank();
return event.kind().getOfferedActions().stream()
.filter(offer -> offeredTo(offer.audience(), ownership, reviewsTeam))
.map(offer -> availability(offer, closed, unattended, documentless))
.toList();
}
/** Enabled is derived from the reason, so a disabled button always has one to show. */
private static AvailableAction availability(
FailureKind.OfferedAction offer,
boolean closed,
boolean unattended,
boolean documentless) {
String reason = disabledReasonFor(offer.audience(), closed, unattended, documentless);
return new AvailableAction(offer.id(), offer.labelKey(), reason == null, reason);
}
/** Closed wins over everything, then the owner-only reasons, most specific first. */
private static String disabledReasonFor(
FailureAudience audience, boolean closed, boolean unattended, boolean documentless) {
if (closed) {
return CLOSED_REASON_KEY;
}
if (audience != FailureAudience.OWNER) {
return null;
}
if (unattended) {
return UNATTENDED_REASON_KEY;
}
return documentless ? DOCUMENTLESS_REASON_KEY : null;
}
/** An unattended incident has no owner, so its reviewer inherits the owner's actions. */
private static boolean offeredTo(
FailureAudience audience, Ownership ownership, boolean reviewsTeam) {
return switch (audience) {
case OWNER ->
ownership == Ownership.MINE || (ownership == Ownership.UNOWNED && reviewsTeam);
case TEAM_REVIEWER -> reviewsTeam;
case ANYONE_WHO_SEES -> true;
};
}
/** Login disabled has no roles, so its one operator triages everything. */
private boolean reviewsTeam() {
return !enforced() || policyManagementAuthority.canEditPolicies();
}
private FailureActionId parseActionId(String actionId) {
for (FailureActionId candidate : FailureActionId.values()) {
if (candidate.name().equals(actionId)) {
@@ -261,7 +326,6 @@ public class FileRunEventService {
return applicationProperties.getSecurity().isEnableLogin();
}
/** One action as offered for a specific event, with its resolved availability. */
public record AvailableAction(
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
}
@@ -60,14 +60,24 @@ public record FileRunEventView(
event.lastSeenAt() == null ? 0L : event.lastSeenAt().toEpochMilli());
}
/** One button, as offered for this specific row. */
/**
* {@code defaultLabel} and {@code execution} let a client render and route an action it was
* never built with. Declaration order is display order.
*/
public record ActionView(
String id, String labelKey, boolean enabled, String disabledReasonKey) {
String id,
String labelKey,
String defaultLabel,
FailureActionId.Execution execution,
boolean enabled,
String disabledReasonKey) {
static ActionView of(FileRunEventService.AvailableAction action) {
public static ActionView of(FileRunEventService.AvailableAction action) {
return new ActionView(
action.id().name(),
action.labelKey(),
action.id().getDefaultLabel(),
action.id().getExecution(),
action.enabled(),
action.disabledReasonKey());
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.failure;
/**
* Whose incident this is, from the reader's point of view. Derived on read, never persisted: one
* row is {@code MINE} to whoever hit it and {@code THEIRS} to the leader reviewing after them.
*/
public enum Ownership {
MINE,
/** A colleague's, visible because the caller reviews the team. */
THEIRS,
/**
* An unattended run: a folder, bucket or webhook is its only attribution, so there is no owner.
*/
UNOWNED
}
@@ -1,5 +1,6 @@
package stirling.software.proprietary.model;
import java.io.Serial;
import java.io.Serializable;
import jakarta.persistence.*;
@@ -19,7 +20,7 @@ import lombok.*;
@ToString
public class UserLicenseSettings implements Serializable {
private static final long serialVersionUID = 1L;
@Serial private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@@ -0,0 +1,48 @@
package stirling.software.proprietary.notification;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
/**
* Open to any authenticated user, unlike the failure endpoints it draws on: each source scopes its
* own rows. Read-only, because every action a notification offers runs on the client's own device.
*/
@RestController
@RequestMapping("/api/v1/notifications")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Notifications", description = "Things worth telling the caller about")
public class NotificationController {
/** How many notifications one read returns when the caller does not say: one panelful. */
private static final int DEFAULT_LIMIT = 20;
/** The most one read may return however large a limit the caller asks for. */
private static final int MAX_LIMIT = 100;
private final NotificationService notifications;
@GetMapping
@Operation(
summary = "List the caller's notifications",
description =
"Newest first. Derived from the sources that produce them, so there is nothing"
+ " to mark read here yet: the client tracks what it has shown.")
public NotificationsResponse list(@RequestParam(required = false) Integer limit) {
int capped = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
return new NotificationsResponse(notifications.list(capped));
}
/** Wrapped so paging or a total can be added without breaking clients. */
public record NotificationsResponse(List<NotificationView> notifications) {}
}
@@ -0,0 +1,53 @@
package stirling.software.proprietary.notification;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.failure.FileRunEvent;
import stirling.software.proprietary.failure.FileRunEventService;
import stirling.software.proprietary.failure.FileRunEventView;
/**
* Derived on read rather than stored: one source today, and a table would need a write path,
* retention and a per-user read model first. Each source scopes its own rows, so this cannot widen.
*/
@Service
@RequiredArgsConstructor
public class NotificationService {
private final FileRunEventService fileRunEvents;
/** Newest first, and only open failures: one already dealt with is not news. */
public List<NotificationView> list(int limit) {
return fileRunEvents.list(null, null, limit).stream().map(this::fromFailure).toList();
}
/** Prefixes the row id on the way out, so it is never sent bare. */
private NotificationView fromFailure(FileRunEvent event) {
return new NotificationView(
NotificationSource.FAILURE.qualify(event.id()),
NotificationSource.FAILURE,
event.kind().getId(),
event.origin(),
fileRunEvents.ownershipOf(event),
event.severity(),
event.status(),
event.kind().getTitleKey(),
event.kind().getDefaultTitle(),
event.detail(),
event.fileId(),
event.sourceId(),
event.policyId(),
event.occurrences(),
event.createdAt(),
event.lastSeenAt(),
// A disposition such as Dismiss belongs to the review surface, not the bell.
fileRunEvents.availableActions(event).stream()
.filter(action -> !action.id().runsOnServer())
.map(FileRunEventView.ActionView::of)
.toList());
}
}

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