Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 7fe67420bb build(deps): bump docker/build-push-action from 7.1.0 to 7.2.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.1.0 to 7.2.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 10:04:48 +00:00
2224 changed files with 48496 additions and 199983 deletions
@@ -1,97 +0,0 @@
---
name: feature-walkthrough
description: >-
Explain the full logic and process of the current branch end-to-end so someone
with no prior knowledge of the task can understand, review, and reproduce it.
Scopes the change from the branch diff, traces the flow across every layer it
touches (frontend tool/hook/component, Java controller/service/endpoint, Python
engine, config, i18n, tests), and produces a self-contained walkthrough document
with Mermaid diagrams (sequence/flow/architecture), annotated file map with
clickable references, before/after behavior, screenshots where a UI is involved,
a "try it locally" section, and edge cases/risks. Use when asked for a feature or
branch walkthrough, "explain what this branch does", a design/logic writeup, PR
reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML
version; --no-screens to skip screenshots.
argument-hint: "[branch-or-area] [--html] [--no-screens]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# Feature / Branch Walkthrough
Turn the current branch into a walkthrough a newcomer can follow. Audience:
**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to
try it* - not just a diff summary.
`$ARGUMENTS` may name a branch or area to focus on; default is the current branch
vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
## Process
### 1. Scope the change
- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape.
- Read the PR description / commit messages for stated intent. Do **not** invent
history or motivation that isn't evidenced (state current behavior in present tense).
- Classify touched files by layer:
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
(`public/locales/en-US`).
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
- **Engine**: `engine/src/stirling/{agents,contracts,api,services}`.
- **Config / build / docker / tests.**
### 2. Trace the flow end-to-end
Follow one real path from user action to result. For a typical PDF tool that's:
UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller →
service (PDFBox / LibreOffice / engine call) → response → review panel → download.
Read the actual files so the narrative is true to the code, and collect the exact
file:line anchors you'll cite.
### 3. Draw the diagrams (Mermaid)
Pick what fits; usually 2-3 of:
- **Sequence diagram** - request/response across frontend → backend → engine.
- **Flowchart** - the core decision/branching logic of the feature.
- **Architecture/component** - new pieces and how they wire to existing ones.
- **State** - if the feature has modes/steps.
Keep nodes labeled in plain language. Validate the Mermaid parses before shipping.
### 4. Screenshots (unless --no-screens)
If a UI is involved, capture key states with the stubbed Playwright harness
(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the
pattern) or, for before/after, capture `main` then the branch. Drop PNGs in
`walkthrough/<feature>/` and reference them from the doc. For backend-only
changes, show request/response examples (curl + JSON) instead.
### 5. Write the walkthrough
Create `walkthrough/<feature>/FEATURE-WALKTHROUGH.md` with:
1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences.
2. **Problem & approach** - what wasn't possible before; the chosen solution.
3. **Architecture diagram** + 1-paragraph orientation.
4. **End-to-end flow** - the sequence diagram + a numbered walk of each step,
each citing the real file (clickable `path:line`).
5. **Key files** - annotated map (path → one line on its role).
6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions.
7. **Behavior** - before vs after; screenshots or request/response examples.
8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to
open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test
license key). Make it copy-pasteable.
9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas.
Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and
IDEs, no build step, ideal for review.
### 6. If `--html`
Also emit `walkthrough/<feature>/walkthrough.html`: the same content with Mermaid
rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in
the file that rendering diagrams needs network, the `.md` is the offline copy) and
screenshots inline. Keep it self-contained otherwise.
### 7. Deliver
Give the doc path and a short chat summary. Offer to `SendUserFile` it.
## Principles
- **True to the code.** Every claim traces to a file you read; cite `path:line`.
No fabricated migration/version history.
- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`,
the `@app/*` layer cascade, stubbed vs live tests) on first use.
- **Show, don't assert.** Prefer a diagram + a real example over adjectives.
- Don't commit the `walkthrough/` output unless asked.
-122
View File
@@ -1,122 +0,0 @@
---
name: ui-before-after
description: >-
Analyse a branch or PR and automatically capture before/after screenshots of
every UI surface its changes touch, then pixel-diff the pairs to surface what
actually changed and assemble PR-ready before/after montage images. Generic and
diff-driven: it derives the capture targets from the diff (changed tools/routes →
URLs) instead of hand-listing screens, captures "before" from the base branch and
"after" from the head, then keeps only the views that visually differ. Each
comparison is auto-cropped to the region that actually changed (the bounding box of
differing pixels), falling back to the full page only when the change spans most of
it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the
PR description", "show what changed in the UI", or a side-by-side of UI changes.
Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch
vs its base. Flags: --scope <selector>, --base <ref|merge-base>, --theme
light|dark|both, --all (capture every route, not just changed), --no-autocrop,
--pagewide <n>, --threshold <n>.
argument-hint: "[PR# | PR-url | branch] [--scope <sel>] [--base <ref>] [--theme both] [--all] [--no-autocrop]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Before / After (generic visual diff)
Point it at a branch or PR; it figures out which UI changed, screenshots every
affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and
montages the ones that actually changed into images for the PR description.
`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base).
By default it captures the full viewport and auto-crops each comparison to the region
that changed. Flags: `--scope <css>` (narrow the *capture* to a container, e.g.
`[data-sidebar="tool-panel"]`, when you already know where the change is),
`--no-autocrop` (keep full frames), `--pagewide <fraction>` (above this share of the
page, skip cropping; default 0.6), `--base <ref|merge-base>`,
`--theme light|dark|both`, `--all` (walk every route, not just changed),
`--threshold <fraction>` (diff sensitivity, default 0.001).
Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the
stubbed-Playwright setup, worktree node_modules + `generate-icons`, the
stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers:
[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs),
[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs).
## Process
### 1. Resolve target + base
```
gh pr view <pr> --json number,title,headRefName,baseRefName,url,files # PR
# or branch: base = merge-base(main, HEAD); head = HEAD
gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
```
### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing)
Map changed frontend files to URLs generically:
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…`
toolId → URL via the repo's own rule `getToolUrlPath` in
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
id kebab-cased (`addPageNumbers``/add-page-numbers`).
- **Pages/routes**: changed `filesPage/*``/files`, etc.
- `--all`: enumerate every tool in the registry instead of just changed ones.
Write `frontend/editor/screenshots/ui-diff/targets.json` =
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
it generic - the spec never names a tool.
### 3. Capture AFTER (head) then BEFORE (base)
Copy [capture-spec.template.ts](capture-spec.template.ts) →
`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a
sample PDF so file-dependent panels render, navigates to each URL, and screenshots
the full viewport - or the `--scope` container if given). Ensure the harness is ready
(node_modules + icons).
```
# after = current head
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# before = base, in an isolated worktree (copy the spec + targets.json in)
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
```
### 4. Auto-diff (surface what changed)
```
cd frontend/editor && node <skill>/diff-shots.mjs \
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
```
Produces `diff-report.json` classifying each view `unchanged | changed | added |
removed`. For each changed view it computes the bounding box of differing pixels and
writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region
(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where
it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the
user doesn't want.
### 5. Montage the changes
Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a
state row with before/after). For changed views use the cropped `cropBefore` /
`cropAfter` from `diff-report.json` (tight on the affected region; full frame when
`pageWide`); `added`/`removed` render the "not present" placeholder. Fill
[montage-template.html](montage-template.html) (replace the `window.__BA__` data
block; base64-inline the PNGs for portability), then render one PNG per section with
[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay
as a third column.
### 6. Deliver
Output the `montage_<tab>.png` files + a short summary (N changed / added / removed,
M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image
API, so tell the user to drag the PNGs into the description. Do **not** post to the
PR.
## Gotchas
- Two installs (base worktree + head); junction main's node_modules only if its deps
match that ref, else `npm ci` (see ui-walkthrough's stale-dep note).
- A view that errors on one side (refactored/removed) → that side is missing; the
diff marks it added/removed rather than failing the run.
- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template
does); a view whose size changed is reported as "changed (dimensions differ)",
uncropped.
- Auto-crop uses a single bounding box, so two far-apart changes give one large crop
(or trip `--pagewide`); narrow with `--scope` if that happens.
- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs.
- Don't commit `screenshots/`, the throwaway spec, or the base worktree.
@@ -1,67 +0,0 @@
// Generic before/after capturer. NOT app-specific: it walks a targets.json that
// the ui-before-after skill generates from the branch/PR diff, so nothing here is
// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run
// once per (side, theme):
// PR_SHOT_SIDE=after PR_SHOT_THEME=light \
// npx playwright test --project=stubbed ui-before-after.spec.ts
//
// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress",
// "needsFile": true }]
import { test } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const SIDE = process.env.PR_SHOT_SIDE ?? "after";
const THEME = process.env.PR_SHOT_THEME ?? "light";
// Capture the full viewport by default so the affected region is in frame
// wherever it is; diff-shots.mjs crops each comparison to what actually changed.
// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container.
const SCOPE = process.env.PR_SHOT_SCOPE ?? "";
const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff");
const OUT = path.join(ROOT, SIDE);
// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture.
const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf";
type Target = { id: string; url: string; name?: string; needsFile?: boolean };
const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8"));
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true });
async function applyTheme(page: Page): Promise<void> {
if (THEME !== "dark") return;
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
async function seedFile(page: Page): Promise<void> {
if (!fs.existsSync(SAMPLE_PDF)) return;
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("files-button").click().catch(() => {});
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {});
await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {});
}
for (const t of targets) {
// One test per target so a single failure doesn't drop the rest.
test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => {
fs.mkdirSync(OUT, { recursive: true });
await applyTheme(page);
if (t.needsFile !== false) await seedFile(page);
await page.goto(t.url, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(400); // settle Mantine portals/transitions
const shot = path.join(OUT, `${t.id}__${THEME}.png`);
if (SCOPE) {
const scope = page.locator(SCOPE).first();
if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) {
await scope.screenshot({ path: shot });
return;
}
}
// Full viewport (fixed size → stable dimensions for pixel diffing).
await page.screenshot({ path: shot });
});
}
@@ -1,106 +0,0 @@
// Auto-diff before/ vs after/ screenshots, classify each as
// unchanged | changed | added | removed, and CROP each changed pair to the
// affected region (bounding box of differing pixels + padding) - unless the
// change spans most of the page, in which case the full frame is kept.
// Run from frontend/editor (so deps resolve):
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
// Env:
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
// DIFF_PAD padding px around the affected region (default 24)
// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6)
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const pm = require("pixelmatch");
const pixelmatch = pm.default || pm;
const { PNG } = require("pngjs");
const beforeDir = path.resolve(process.argv[2]);
const afterDir = path.resolve(process.argv[3]);
const outDir = path.resolve(process.argv[4] || afterDir);
const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001");
const PAD = Number(process.env.DIFF_PAD ?? "24");
const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6");
const read = (p) => PNG.sync.read(fs.readFileSync(p));
const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f);
const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []);
const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort();
fs.mkdirSync(outDir, { recursive: true });
function cropPNG(src, x, y, w, h) {
const out = new PNG({ width: w, height: h });
PNG.bitblt(src, out, x, y, w, h, 0, 0);
return out;
}
const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png));
// Bounding box of differing pixels using a diff mask (alpha>0 where changed).
function changedBBox(before, after, w, h) {
const mask = new PNG({ width: w, height: h });
pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true });
let minX = w, minY = h, maxX = -1, maxY = -1, count = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (mask.data[(y * w + x) * 4 + 3] > 0) {
count++;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
}
}
return maxX < 0 ? null : { minX, minY, maxX, maxY, count };
}
const report = [];
for (const name of names) {
const id = name.replace(/\.png$/, "");
const bp = path.join(beforeDir, name), ap = path.join(afterDir, name);
const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap);
if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; }
if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; }
const before = read(bp), after = read(ap);
if (before.width !== after.width || before.height !== after.height) {
report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap });
continue;
}
const w = after.width, h = after.height;
const overlay = new PNG({ width: w, height: h });
const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 });
const ratio = px / (w * h);
if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; }
const box = changedBBox(before, after, w, h);
// Pad + clamp the affected region.
const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD);
const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD);
const bw = x2 - x, bh = y2 - y;
const pageWide = (bw * bh) / (w * h) > PAGEWIDE;
const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide };
if (pageWide) {
// Change spans most of the page - keep the full frame, full overlay.
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay);
entry.diff = dp;
} else {
entry.bbox = { x, y, w: bw, h: bh };
const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh));
const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh));
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh));
entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp;
}
report.push(entry);
}
fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2));
const changed = report.filter((r) => r.status !== "unchanged");
console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`);
for (const r of changed) {
const tail = r.status !== "changed" ? ""
: r.pageWide ? " (page-wide → full frame)"
: ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`;
console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`);
}
@@ -1,48 +0,0 @@
"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as
stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as
data URIs so the HTML is portable. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
SHOTS = pathlib.Path(
r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30"
r"\frontend\editor\screenshots\files-page"
)
def uri(fname):
p = SHOTS / fname
return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None
data = {
"pr": "DEMO",
"title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)",
"base": "main", "head": "demo-branch",
"cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)",
"tabs": [
{"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.",
"states": [
{"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")},
{"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")},
{"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"},
]},
{"id": "move", "title": "Move-to-folder dialog",
"states": [
{"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")},
{"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"},
]},
],
}
tpl = (HERE / "montage-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )")
@@ -1,106 +0,0 @@
<!doctype html>
<!--
Before/After montage for a PR description. The ui-before-after skill replaces
the JSON in the window.__BA__ data block below with the captured manifest, then
screenshots each .tab-section (id="section-<tabId>") into a PNG to drag into the
PR description. Self-contained; images may be relative paths or data URIs.
Data shape:
{
"pr":"6552","title":"...","base":"main","head":"feat/x",
"cropSelector":"[data-sidebar=\"tool-panel\"]",
"tabs":[
{ "id":"sign","title":"Sign tool","states":[
{"name":"Initial","before":"before/sign__initial.png","after":"after/sign__initial.png"},
{"name":"Cert selected","before":null,"after":"after/sign__cert.png","note":"New in this PR"}
]}
]
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Before / After</title>
<style>
:root { --bg:#ffffff; --ink:#0b0c0e; --muted:#6b7280; --line:#e5e7eb;
--before:#6b7280; --after:#1f883d; --frame:#f3f4f6; --note:#b45309; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink);
font:14px/1.5 -apple-system,"Segoe UI",Roboto,system-ui,sans-serif; }
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
.doc-head { margin-bottom:8px; }
.doc-head h1 { font-size:18px; margin:0 0 2px; }
.doc-head .sub { color:var(--muted); font-size:12.5px; }
.legend { display:flex; gap:14px; align-items:center; margin:10px 0 4px; font-size:12px; color:var(--muted); }
.chip { font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
padding:2px 8px; border-radius:999px; color:#fff; }
.chip.before { background:var(--before); } .chip.after { background:var(--after); }
.tab-section { border:1px solid var(--line); border-radius:14px; padding:18px 18px 8px;
margin:18px 0; background:var(--bg); }
.tab-section > h2 { font-size:16px; margin:0 0 2px; }
.tab-section > .ctx { color:var(--muted); font-size:12px; margin-bottom:14px; }
.state { margin-bottom:18px; }
.state .name { font-weight:600; font-size:13.5px; margin-bottom:8px; display:flex; gap:8px; align-items:center; }
.state .name .note { font-weight:500; color:var(--note); font-size:12px; }
.pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
.cell { border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--frame); }
.cell .cap { display:flex; align-items:center; gap:8px; padding:7px 10px; border-bottom:1px solid var(--line);
background:var(--bg); }
.cell .cap .meta { color:var(--muted); font-size:11px; }
.cell img { display:block; width:100%; height:auto; background:#fff; }
.cell.empty .ph { display:flex; align-items:center; justify-content:center; height:160px; color:var(--muted);
font-size:12.5px; text-align:center; padding:0 16px; }
.single .pair { grid-template-columns:1fr; }
.empty-doc { color:var(--muted); padding:40px; text-align:center; }
@media (max-width:760px){ .pair{ grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="wrap" id="root"></div>
<script id="data">
window.__BA__ = /*__DATA__*/{"pr":"","title":"No data","base":"","head":"","cropSelector":"","tabs":[]}/*__END__*/;
</script>
<script>
(function(){
var D = window.__BA__ || { tabs: [] };
var root = document.getElementById("root");
function el(html){ var t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;"}[c];}); }
function cell(kind, src){
if (src) {
return '<div class="cell"><div class="cap"><span class="chip '+kind+'">'+kind+'</span></div>'+
'<img src="'+esc(src)+'" alt="'+kind+'"/></div>';
}
return '<div class="cell empty"><div class="cap"><span class="chip '+kind+'">'+kind+'</span>'+
'<span class="meta">not present</span></div><div class="ph">No '+kind+' screenshot for this state</div></div>';
}
var head = '<div class="doc-head"><h1>'+esc(D.title || ("PR #"+D.pr))+'</h1>'+
'<div class="sub">Before / after &nbsp;·&nbsp; base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
(D.cropSelector ? ' &nbsp;·&nbsp; cropped to <code>'+esc(D.cropSelector)+'</code>' : '')+'</div></div>'+
'<div class="legend"><span class="chip before">Before</span> base branch'+
'<span class="chip after">After</span> this PR</div>';
root.appendChild(el('<div>'+head+'</div>'));
if (!D.tabs || !D.tabs.length){ root.appendChild(el('<div class="empty-doc">No tabs captured yet.</div>')); return; }
D.tabs.forEach(function(tab){
var states = (tab.states||[]).map(function(s){
var onlyOne = (!s.before || !s.after);
return '<div class="state'+(onlyOne?' ':'')+'">'+
'<div class="name">'+esc(s.name)+(s.note?'<span class="note">'+esc(s.note)+'</span>':'')+'</div>'+
'<div class="pair">'+cell("before", s.before)+cell("after", s.after)+'</div></div>';
}).join("");
var sec = '<section class="tab-section" id="section-'+esc(tab.id)+'">'+
'<h2>'+esc(tab.title)+'</h2>'+
(tab.ctx?'<div class="ctx">'+esc(tab.ctx)+'</div>':'')+
states+'</section>';
root.appendChild(el(sec));
});
})();
</script>
</body>
</html>
@@ -1,25 +0,0 @@
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
// Run from frontend/editor (so @playwright/test resolves):
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const { chromium } = require("@playwright/test");
const htmlPath = path.resolve(process.argv[2]);
const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath));
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 });
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
await page.waitForTimeout(250); // let images/fonts paint
const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id));
if (!ids.length) { console.error("no .tab-section found"); process.exit(1); }
for (const id of ids) {
const name = id.replace(/^section-/, "");
await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) });
console.log("wrote montage_" + name + ".png");
}
await browser.close();
-120
View File
@@ -1,120 +0,0 @@
---
name: ui-walkthrough
description: >-
Full UI investigation of the current branch's feature. Enumerates every view
and state (empty, populated, loading, error, each dialog/menu/panel, responsive
breakpoints, light + dark + RTL), captures them with the stubbed Playwright
harness, assembles a single-image HTML walkthrough with a global light/dark
toggle slider, then runs two review passes: visual/consistency (alignment,
spacing, professionalism, dark/light parity, contrast, truncation) and
UX/ease-of-use (flow, discoverability, affordances, empty/error states,
expectations). Use when asked for a UI walkthrough, screenshot review, design
or QA pass, "find anywhere to make it easier/better for users", or before
merging frontend work. Pass --fix to auto-apply safe frontend fixes and
re-capture; --theme to limit themes; --no-rtl to skip RTL.
argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Walkthrough
Produce a reviewable HTML walkthrough of a feature's UI in every state and theme,
then critique it. Optionally auto-fix and re-capture.
`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the
current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
`--no-rtl`, `--breakpoints` (also capture phone/narrow widths).
## What this repo gives you (use it, don't reinvent)
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
- Report template: [report-template.html](report-template.html) - self-contained,
one big image at a time, a global light/dark slider that flips every shot,
thumbnail rail, prev/next + arrow keys, and a Findings tab.
## Process
### 1. Scope the feature
- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits.
Identify changed pages, tools (`core/components/tools/<tool>` or `core/tools/<tool>`),
dialogs, panels, and routes.
- Enumerate **every view and state** to capture, e.g.:
empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip;
each tab or step; selection + multi-select; success/result panel; and (if relevant)
permission/role variants. Write the list down before capturing - it's the report's spine.
### 2. Prepare the harness (worktree-safe)
Worktrees have no `node_modules` and no generated icons. From repo root:
```
cd frontend && npm ci # or junction main's node_modules (see memory)
cd frontend/editor && node scripts/generate-icons.js
```
Kill any stale dev server first (it serves old modules):
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
### 3. Write the capture spec
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- stub the APIs it needs, drive the UI to that state, wait on a real locator
(not a fixed sleep), `await settle(page)` for Mantine portals, then
`page.screenshot({ path: shotPath("NN_name_<theme>") })`.
- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the
`enableDarkMode` / `enableRtl` init-script pattern from the reference spec
(`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`).
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
### 4. Build the report
- Copy `report-template.html` to `screenshots/<feature>/walkthrough.html` (so the
relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it).
- Build the manifest and inject it: replace the JSON between the
`/*__DATA__*/``/*__END__*/` markers with one `views[]` entry per view
(`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll
fill in step 5. Keep `light`/`dark` as relative paths.
- The toggle slider answers the "one big image + flip light/dark for all" request:
it shows a single large screenshot, and switching the slider re-themes every view.
### 5. Review pass 1 - visual & consistency
Open each screenshot (Read the PNG) and judge against the others:
alignment & spacing rhythm, control placement, button hierarchy, typography,
**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens),
truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish.
Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`.
### 6. Review pass 2 - UX & ease of use
Walk the flow as a first-time user: discoverability, number of steps, affordance
clarity, empty-state guidance, error recovery, destructive-action confirmation,
defaults, loading feedback, mobile reachability, accessible names, and whether the
UI matches user expectations for this kind of tool. Record findings the same way.
Write both finding lists into the report's `findings.visual` / `findings.ux`,
and add short per-view `notes`. Re-inject the manifest.
### 7. If `--fix`
Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing
dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS,
mark the finding `fixed:true` with what changed, then **re-run the spec** to
re-capture the affected shots and regenerate the report. Run `task frontend:check`.
Leave anything risky or ambiguous as a finding, not a change.
### 8. Deliver
Tell the user the report path and give a tight chat summary: N views ×
themes captured, top findings by severity, and (if `--fix`) what changed.
Optionally `SendUserFile` the `walkthrough.html`.
## Gotchas
- Stale `:5173` server serves old bundles - kill it before capturing (see step 2).
- Missing `material-symbols-icons.json` → blank app → every shot times out. Run
`generate-icons.js` first.
- `await settle(page)` before shots or portals/transitions tear mid-capture.
- Don't commit the generated `screenshots/` or the throwaway spec unless asked.
@@ -1,116 +0,0 @@
"""Build a self-contained EXAMPLE.html from report-template.html with mock
light/dark screenshots, so the viewer + global theme slider can be demoed
without a real capture run. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
def svg(bg, fg, panel, accent, muted, label, kind):
"""A simple fake 'screen' SVG: title bar, sidebar, content varies by kind."""
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">',
f'<rect width="1600" height="900" fill="{bg}"/>',
# top bar
f'<rect width="1600" height="64" fill="{panel}"/>',
f'<circle cx="40" cy="32" r="12" fill="{accent}"/>',
f'<rect x="64" y="24" width="160" height="16" rx="6" fill="{muted}"/>',
f'<rect x="1430" y="20" width="130" height="24" rx="12" fill="{accent}"/>',
# left sidebar
f'<rect x="0" y="64" width="220" height="836" fill="{panel}"/>',
]
for i in range(6):
y = 100 + i * 56
parts.append(f'<rect x="24" y="{y}" width="172" height="32" rx="8" fill="{bg}"/>')
if kind == "empty":
parts += [
f'<rect x="700" y="360" width="200" height="120" rx="16" fill="none" stroke="{muted}" stroke-width="3" stroke-dasharray="10 8"/>',
f'<rect x="690" y="510" width="220" height="44" rx="10" fill="{accent}"/>',
f'<text x="800" y="600" fill="{muted}" font-family="sans-serif" font-size="26" text-anchor="middle">{label}</text>',
]
elif kind == "form":
for i in range(4):
y = 140 + i * 90
parts.append(f'<rect x="280" y="{y}" width="160" height="16" rx="6" fill="{muted}"/>')
parts.append(f'<rect x="280" y="{y+26}" width="900" height="44" rx="8" fill="{panel}" stroke="{muted}" stroke-width="1"/>')
parts.append(f'<rect x="280" y="560" width="200" height="50" rx="10" fill="{accent}"/>')
parts.append(f'<text x="800" y="850" fill="{muted}" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>')
else: # dialog
parts += [
f'<rect width="1600" height="900" fill="{fg}" opacity="0.45"/>',
f'<rect x="520" y="280" width="560" height="360" rx="18" fill="{panel}"/>',
f'<rect x="556" y="320" width="280" height="22" rx="8" fill="{fg}"/>',
f'<rect x="556" y="372" width="488" height="14" rx="6" fill="{muted}"/>',
f'<rect x="556" y="398" width="420" height="14" rx="6" fill="{muted}"/>',
f'<rect x="820" y="560" width="110" height="44" rx="9" fill="{bg}" stroke="{muted}"/>',
f'<rect x="946" y="560" width="98" height="44" rx="9" fill="{accent}"/>',
f'<text x="800" y="700" fill="#fff" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>',
]
parts.append("</svg>")
return "".join(parts)
def data_uri(s):
return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode()
LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0")
DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b")
def pair(kind, label):
return (
data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)),
data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)),
)
views = []
for idx, (kind, title, label) in enumerate([
("empty", "Empty state", "Drop a PDF to start"),
("form", "Tool options panel", "Compress options"),
("dialog", "Confirm dialog", "Replace original file?"),
], start=1):
light, dark = pair(kind, label)
views.append({
"id": f"{idx:02d}_{kind}",
"title": title,
"light": light,
"dark": dark,
"viewport": "1600x900",
"notes": ["This is mock data to demo the viewer."],
})
data = {
"feature": "EXAMPLE - Compress PDF (mock data)",
"branch": "demo",
"generated": "example",
"views": views,
"findings": {
"visual": [
{"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close",
"detail": "Cancel/Confirm have only 8px gap; easy to misclick.",
"fix": "Increase gap to var(--mantine-spacing-md)."},
{"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode",
"detail": "Muted token fails WCAG AA on the dark panel.",
"fix": "Use --mantine-color-dimmed instead of a hard-coded grey."},
],
"ux": [
{"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone",
"detail": "Users expect the action button adjacent to the dropzone.",
"fix": "Move the button directly under the dashed zone."},
],
},
}
tpl = (HERE / "report-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", (HERE / "EXAMPLE.html"))
@@ -1,298 +0,0 @@
<!doctype html>
<!--
UI Walkthrough report template (self-contained, works from file://).
The ui-walkthrough skill replaces the JSON in the window.__WALKTHROUGH__ data
block below with the captured manifest. Do not add external CDN deps - it must open offline.
Data shape:
{
"feature": "Compress PDF tool",
"branch": "claude/...",
"generated": "2026-06-21",
"views": [
{ "id": "01_empty", "title": "Empty state",
"light": "screenshots/compress/01_empty_light.png",
"dark": "screenshots/compress/01_empty_dark.png",
"viewport": "1600x900",
"notes": ["Heading is centered", "Primary CTA below the fold on mobile"] }
],
"findings": {
"visual": [ { "severity":"high", "view":"01_empty", "title":"...", "detail":"...", "fix":"..." } ],
"ux": [ { "severity":"med", "view":"03_dialog", "title":"...", "detail":"...", "fix":"..." } ]
}
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UI Walkthrough</title>
<style>
:root {
--bg: #f6f7f9; --panel: #ffffff; --panel-2: #f0f2f5; --text: #1a1b1e;
--muted: #6b7280; --border: #e2e5ea; --accent: #2f6fed; --accent-weak: #e8f0fe;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
--hi: #d92d20; --med: #d98e00; --low: #2f6fed; --stage: #0b0c0e;
}
html[data-theme="dark"] {
--bg: #0d0e10; --panel: #16181c; --panel-2: #1d2024; --text: #e6e8eb;
--muted: #9aa3ad; --border: #2a2e35; --accent: #5b8cff; --accent-weak: #1a2336;
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.4); --stage: #000;
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
background: var(--bg); color: var(--text); }
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
header h1 { font-size: 15px; margin: 0; font-weight: 650; }
header .sub { color: var(--muted); font-size: 12px; }
.spacer { flex: 1; }
.counter { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
.tabs { display: flex; gap: 4px; }
.tab { border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Light/Dark slider */
.theme-toggle { display: flex; align-items: center; gap: 9px; user-select: none; }
.theme-toggle .lbl { font-size: 12px; color: var(--muted); }
.theme-toggle .lbl.on { color: var(--text); font-weight: 600; }
.switch { position: relative; width: 52px; height: 28px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; cursor: pointer; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 999px; transition: .2s; }
.slider:before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px;
background: #fbbf24; border-radius: 50%; transition: .2s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider:before { transform: translateX(24px); background: #c7d2fe; }
main { display: grid; grid-template-columns: 240px 1fr; height: calc(100vh - 53px); }
.rail { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); padding: 8px; }
.rail .group-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); padding: 10px 8px 4px; }
.thumb { display: flex; gap: 9px; align-items: center; padding: 7px; border-radius: 8px;
cursor: pointer; border: 1px solid transparent; }
.thumb:hover { background: var(--panel-2); }
.thumb.active { background: var(--accent-weak); border-color: var(--accent); }
.thumb img { width: 64px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: var(--stage); }
.thumb .t { font-size: 12.5px; line-height: 1.3; }
.thumb .badge { font-size: 10px; color: var(--muted); }
.thumb .dot { width: 7px; height: 7px; border-radius: 50%; margin-left: auto; flex: none; }
.stagewrap { display: flex; flex-direction: column; min-width: 0; }
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
background: var(--stage); position: relative; min-height: 0; }
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;
box-shadow: 0 4px 30px rgba(0,0,0,.4); background: #fff; }
html[data-theme="dark"] .stage img { background: #16181c; }
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
border-radius: 50%; border: 1px solid var(--border); background: var(--panel);
color: var(--text); cursor: pointer; font-size: 18px; opacity: .85; }
.nav-btn:hover { opacity: 1; } .nav-btn.prev { left: 16px; } .nav-btn.next { right: 16px; }
.nav-btn:disabled { opacity: .25; cursor: default; }
.missing { color: var(--muted); font-size: 13px; text-align: center; }
.detail { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 20px;
max-height: 38vh; overflow-y: auto; }
.detail h2 { margin: 0 0 4px; font-size: 15px; }
.detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
.notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.notes li { display: flex; gap: 8px; align-items: flex-start; }
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
color: #fff; flex: none; margin-top: 1px; }
.sev.high { background: var(--hi); } .sev.med { background: var(--med); } .sev.low { background: var(--low); }
.finding .fix { color: var(--muted); font-size: 12.5px; }
.finding .fix b { color: var(--text); font-weight: 600; }
/* Summary tab */
.summary { padding: 20px 28px; overflow-y: auto; }
.summary h2 { font-size: 16px; margin: 22px 0 8px; }
.summary .empty { color: var(--muted); }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; margin-bottom: 8px; box-shadow: var(--shadow); }
.card .head { display: flex; gap: 8px; align-items: center; }
.card a { color: var(--accent); text-decoration: none; cursor: pointer; }
.hide { display: none !important; }
kbd { font: 11px ui-monospace, monospace; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 4px; padding: 1px 5px; }
</style>
</head>
<body>
<header>
<div>
<h1 id="feature-title">UI Walkthrough</h1>
<div class="sub" id="feature-sub"></div>
</div>
<div class="spacer"></div>
<div class="tabs">
<button class="tab active" data-tab="viewer">Walkthrough</button>
<button class="tab" data-tab="summary">Findings</button>
</div>
<div class="counter" id="counter"></div>
<label class="theme-toggle" title="Toggle light / dark for every screenshot">
<span class="lbl" id="lbl-light">Light</span>
<span class="switch"><input type="checkbox" id="theme-switch" /><span class="slider"></span></span>
<span class="lbl" id="lbl-dark">Dark</span>
</label>
</header>
<main id="viewer-pane">
<aside class="rail" id="rail"></aside>
<section class="stagewrap">
<div class="stage">
<button class="nav-btn prev" id="prev" aria-label="Previous">&#8249;</button>
<img id="stage-img" alt="" />
<div class="missing hide" id="missing"></div>
<button class="nav-btn next" id="next" aria-label="Next">&#8250;</button>
</div>
<div class="detail">
<h2 id="view-title"></h2>
<div class="meta" id="view-meta"></div>
<ul class="notes" id="view-notes"></ul>
</div>
</section>
</main>
<section class="summary hide" id="summary-pane"></section>
<script id="data">
window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated":"","views":[],"findings":{"visual":[],"ux":[]}}/*__END__*/;
</script>
<script>
(function () {
var D = window.__WALKTHROUGH__ || { views: [], findings: { visual: [], ux: [] } };
var views = D.views || [];
var state = { i: 0, theme: localStorage.getItem("ui-wt-theme") || "light", tab: "viewer" };
var $ = function (id) { return document.getElementById(id); };
function sevClass(s) { return s === "high" ? "high" : s === "med" || s === "medium" ? "med" : "low"; }
function applyChrome() {
document.documentElement.setAttribute("data-theme", state.theme);
$("theme-switch").checked = state.theme === "dark";
$("lbl-light").classList.toggle("on", state.theme === "light");
$("lbl-dark").classList.toggle("on", state.theme === "dark");
}
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
function findingsForView(id) {
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
return all.filter(function (f) { return f.view === id; });
}
function renderRail() {
var rail = $("rail");
rail.innerHTML = "";
if (!views.length) { rail.innerHTML = '<div class="group-label">No views captured</div>'; return; }
views.forEach(function (v, idx) {
var fs = findingsForView(v.id);
var worst = fs.some(function (f){return sevClass(f.severity)==="high";}) ? "var(--hi)"
: fs.some(function (f){return sevClass(f.severity)==="med";}) ? "var(--med)"
: fs.length ? "var(--low)" : "transparent";
var el = document.createElement("div");
el.className = "thumb" + (idx === state.i ? " active" : "");
el.innerHTML = '<img src="' + srcFor(v) + '" alt="" />' +
'<div><div class="t">' + (v.title || v.id) + '</div>' +
'<div class="badge">' + (v.viewport || "") + '</div></div>' +
'<span class="dot" style="background:' + worst + '"></span>';
el.onclick = function () { state.i = idx; render(); };
rail.appendChild(el);
});
}
function render() {
applyChrome();
if (!views.length) {
$("missing").classList.remove("hide"); $("stage-img").classList.add("hide");
$("missing").textContent = "No screenshots in this report yet.";
$("counter").textContent = ""; return;
}
var v = views[state.i];
var src = srcFor(v);
var img = $("stage-img");
if (src) {
img.classList.remove("hide"); $("missing").classList.add("hide");
img.src = src; img.alt = v.title || v.id;
} else {
img.classList.add("hide"); $("missing").classList.remove("hide");
$("missing").textContent = "No " + state.theme + " screenshot for this view.";
}
$("counter").textContent = (state.i + 1) + " / " + views.length;
$("view-title").textContent = v.title || v.id;
$("view-meta").textContent = [v.viewport, state.theme + " mode"].filter(Boolean).join(" · ");
var notes = $("view-notes"); notes.innerHTML = "";
var fs = findingsForView(v.id);
(v.notes || []).forEach(function (n) {
var li = document.createElement("li"); li.textContent = "· " + n; notes.appendChild(li);
});
fs.forEach(function (f) {
var li = document.createElement("li"); li.className = "finding";
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
notes.appendChild(li);
});
$("prev").disabled = state.i === 0;
$("next").disabled = state.i === views.length - 1;
renderRail();
}
function renderSummary() {
var pane = $("summary-pane");
function block(title, arr) {
var h = '<h2>' + title + ' (' + arr.length + ')</h2>';
if (!arr.length) return h + '<div class="empty">None found.</div>';
return h + arr.map(function (f) {
return '<div class="card"><div class="head">' +
'<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<b>' + (f.title || "") + '</b>' +
(f.view ? ' <a data-jump="' + f.view + '">' + f.view + '</a>' : '') + '</div>' +
'<div style="margin-top:6px">' + (f.detail || "") + '</div>' +
(f.fix ? '<div class="finding" style="margin-top:6px"><span class="fix"><b>Fix:</b> ' + f.fix + '</span></div>' : '') +
'</div>';
}).join("");
}
pane.innerHTML = block("Visual & consistency", (D.findings && D.findings.visual) || []) +
block("UX & ease of use", (D.findings && D.findings.ux) || []);
pane.querySelectorAll("[data-jump]").forEach(function (a) {
a.onclick = function () {
var id = a.getAttribute("data-jump");
var idx = views.findIndex(function (v) { return v.id === id; });
if (idx >= 0) { state.i = idx; setTab("viewer"); }
};
});
}
function setTab(t) {
state.tab = t;
document.querySelectorAll(".tab").forEach(function (b) { b.classList.toggle("active", b.dataset.tab === t); });
$("viewer-pane").classList.toggle("hide", t !== "viewer");
$("summary-pane").classList.toggle("hide", t !== "summary");
if (t === "viewer") $("viewer-pane").style.display = "grid";
if (t === "summary") renderSummary();
}
// wiring
$("feature-title").textContent = D.feature || "UI Walkthrough";
$("feature-sub").textContent = [D.branch, D.generated].filter(Boolean).join(" · ");
$("theme-switch").onchange = function () {
state.theme = this.checked ? "dark" : "light";
localStorage.setItem("ui-wt-theme", state.theme);
render();
};
$("prev").onclick = function () { if (state.i > 0) { state.i--; render(); } };
$("next").onclick = function () { if (state.i < views.length - 1) { state.i++; render(); } };
document.addEventListener("keydown", function (e) {
if (state.tab !== "viewer") return;
if (e.key === "ArrowLeft") $("prev").click();
if (e.key === "ArrowRight") $("next").click();
if (e.key.toLowerCase() === "t") $("theme-switch").click();
});
document.querySelectorAll(".tab").forEach(function (b) { b.onclick = function () { setTab(b.dataset.tab); }; });
render();
})();
</script>
</body>
</html>
+1
View File
@@ -27,6 +27,7 @@ node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/dist-portal/
frontend/editor/playwright-report/
.npm/
.yarn/
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.2
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.2
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+5 -20
View File
@@ -1,12 +1,12 @@
build: &build
- build.gradle
- app/(common|core|proprietary|saas)/build.gradle
- app/(common|core|proprietary)/build.gradle
- Taskfile.yml
- .taskfiles/backend.yml
openapi: &openapi
- *build
- app/(common|core|proprietary|saas)/src/main/java/**
- app/(common|core|proprietary)/src/main/java/**
docker-base: &docker-base
- docker/base/Dockerfile
@@ -23,9 +23,9 @@ docker: &docker
- *docker-base
project: &project
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
- app/(common|core|proprietary)/src/(main|test)/java/**
- *build
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- exampleYmlFiles/**
- gradle/**
- libs/**
@@ -82,26 +82,11 @@ tauri: &tauri
# tool surfaces it generates models from.
engine: &engine
- engine/**
- app/(common|core|proprietary|saas)/src/main/java/**
- app/(common|core|proprietary)/src/main/java/**
- .github/workflows/ai-engine.yml
- Taskfile.yml
- .taskfiles/engine.yml
# Files that can make the committed generated API models (frontend tool API
# types + engine tool models) go stale: the Java tool surfaces they derive from,
# the generators, the generated files themselves (to catch a hand-edit), and the
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
generated-models: &generated-models
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
- .taskfiles/frontend.yml
- .taskfiles/engine.yml
- .github/workflows/check-generated-models.yml
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
-7
View File
@@ -63,7 +63,6 @@ labels:
files:
- 'app/core/src/main/resources/static/.*'
- 'app/proprietary/src/main/resources/static/.*'
- 'app/saas/src/main/resources/static/.*'
- 'frontend/**'
- 'frontend/.*'
- 'frontend/**/.*'
@@ -84,7 +83,6 @@ labels:
- 'app/common/src/main/java/.*.java'
- 'app/proprietary/src/main/java/.*.java'
- 'app/core/src/main/java/.*.java'
- 'app/saas/src/main/java/.*.java'
- label: 'Back End'
files:
@@ -92,9 +90,6 @@ labels:
- 'app/core/src/main/java/stirling/software/SPDF/controller/.*'
- 'app/core/src/main/resources/settings.yml.template'
- 'app/core/src/main/resources/application.properties'
- 'app/proprietary/src/main/resources/application-proprietary.properties'
- 'app/saas/src/main/resources/application-dev.properties'
- 'app/saas/src/main/resources/application-saas.properties'
- 'app/core/src/main/resources/banner.txt'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
@@ -158,7 +153,6 @@ labels:
- 'app/common/src/test/.*'
- 'app/proprietary/src/test/.*'
- 'app/core/src/test/.*'
- 'app/saas/src/test/.*'
- 'testing/.*'
- '.github/workflows/scorecards.yml'
- 'exampleYmlFiles/test_cicd.yml'
@@ -177,4 +171,3 @@ labels:
- 'app/common/build.gradle'
- 'app/proprietary/build.gradle'
- 'app/core/build.gradle'
- 'app/saas/build.gradle'
+7 -109
View File
@@ -116,9 +116,6 @@ jobs:
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Single source of truth for whether this preview embeds the admin portal:
# drives the image build-arg and the deployment comment.
BUILD_PORTAL: "true"
steps:
- name: Harden Runner
@@ -249,14 +246,12 @@ jobs:
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push V2 image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -264,9 +259,7 @@ jobs:
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
@@ -297,8 +290,6 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
POLICIES_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
@@ -342,70 +333,9 @@ jobs:
# Set port for output
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
# ---- Storybook preview (only when this PR touches stories/.storybook) ----
# Runs inside the same approved-contributor-gated deploy job, so it deploys
# under the exact same access rules as the app preview.
- name: Detect Storybook changes
id: sb-changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
list-files: json
filters: |
storybook:
- 'frontend/**/*.stories.@(ts|tsx|mdx)'
- 'frontend/**/*.mdx'
- 'frontend/.storybook/**'
- name: Set up Node.js for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build and deploy Storybook
id: storybook
if: steps.sb-changes.outputs.storybook == 'true'
env:
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
VPS_USER: ${{ secrets.NEW_VPS_USERNAME }}
run: |
set -euo pipefail
# `prepare` generates the icon set stories import (not committed).
task frontend:prepare
task frontend:storybook:build
PR=${{ needs.check-pr.outputs.pr_number }}
# Served at the ROOT of its own port so Storybook's global MSW worker
# (/mockServiceWorker.js) resolves. Port = PR + 20000 (bijective, offset
# from the app preview's bare-PR-number port).
SB_PORT=$((PR + 20000))
DIR=/stirling/SB-PR-$PR
tar czf storybook.tgz -C frontend/storybook-static .
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
storybook.tgz "$VPS_USER@$VPS_HOST:/tmp/storybook-$PR.tgz"
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T \
"$VPS_USER@$VPS_HOST" << ENDSSH
set -e
rm -rf "$DIR" && mkdir -p "$DIR"
tar xzf /tmp/storybook-$PR.tgz -C "$DIR"
rm -f /tmp/storybook-$PR.tgz
docker rm -f storybook-pr-$PR 2>/dev/null || true
docker run -d --name storybook-pr-$PR --restart unless-stopped \
-p $SB_PORT:80 -v "$DIR":/usr/share/nginx/html:ro nginx:alpine
ENDSSH
echo "url=http://$VPS_HOST:$SB_PORT/" >> "$GITHUB_OUTPUT"
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SB_URL: ${{ steps.storybook.outputs.url }}
SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }}
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -429,40 +359,12 @@ jobs:
}
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
// Only mention the portal when this image actually embeds it.
// Use the direct IP URL - the SSL hostname isn't supported yet.
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
const portalNote = withPortal
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
: ``;
// Storybook preview: only present when this PR changed stories/config.
const sbUrl = process.env.SB_URL;
let storybookNote = "";
if (sbUrl) {
const files = JSON.parse(process.env.SB_FILES || "[]");
const stories = files.filter((f) => /\.stories\.(ts|tsx|mdx)$/.test(f));
const config = files.filter((f) => f.startsWith("frontend/.storybook/"));
const shorten = (f) =>
f.replace(/^frontend\/editor\/src\//, "").replace(/^frontend\//, "");
const storyList = stories.map((f) => `- \`${shorten(f)}\``).join("\n");
const configList = config.map((f) => `- \`${shorten(f)}\``).join("\n");
const summary =
`${stories.length} stor${stories.length === 1 ? "y" : "ies"} changed` +
(config.length ? ` (+${config.length} config file${config.length === 1 ? "" : "s"})` : "");
storybookNote =
`📚 **Storybook:** [${sbUrl}](${sbUrl})\n\n` +
`<details>\n<summary>${summary}</summary>\n\n` +
(storyList ? `**Stories**\n${storyList}\n\n` : "") +
(configList ? `**Config**\n${configList}\n` : "") +
`</details>\n\n`;
}
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with embedded architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
portalNote +
storybookNote +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** for approved V2 contributors.`;
@@ -558,11 +460,7 @@ jobs:
else
echo "V2 PR directory not found, nothing to clean up"
fi
# Remove this PR's Storybook preview (container + files), if any.
docker rm -f storybook-pr-${{ github.event.pull_request.number }} 2>/dev/null || true
rm -rf /stirling/SB-PR-${{ github.event.pull_request.number }}
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
@@ -270,7 +270,7 @@ jobs:
- name: Build and push PR-specific image (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -296,7 +296,7 @@ jobs:
- name: Build and push engine image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: ./engine
file: ./engine/Dockerfile
+99 -4
View File
@@ -1,9 +1,9 @@
name: AI Engine CI
# Runs the engine quality gate (lint, type-check, format-check, tests). Called
# from build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net. Freshness of the generated tool_models.py is checked
# by the shared check-generated-models workflow.
# Validates the Python AI engine: regenerates tool models and runs the
# engine quality gate (lint, type-check, format-check, tests). Called from
# build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net.
on:
workflow_call:
push:
@@ -34,9 +34,104 @@ jobs:
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Regenerate tool models
run: task engine:tool-models
- name: Verify tool models are up to date
id: tool-models-check
continue-on-error: true
run: git diff --exit-code engine/src/stirling/models/tool_models.py
- name: Comment on tool models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const body = [
marker,
'### Tool Models Check Failed',
'',
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
'',
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if tool models check failed
if: steps.tool-models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Tool Models Check Failed"
echo "============================================"
echo ""
echo "The generated engine/src/stirling/models/tool_models.py"
echo "is out of date with the Java OpenAPI spec and will"
echo "need to be regenerated before it can be merged in."
echo ""
echo "Run 'task engine:tool-models' to regenerate, then"
echo "commit the updated file."
echo "============================================"
exit 1
- name: Remove tool models check comment on success
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
- name: Quality-check engine
id: engine-check
run: task engine:check
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+4 -25
View File
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
# Enterprise Playwright suite — exercises premium-key gated features (audit,
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
# stacks under testing/compose. Slow and secret-gated, so it runs in four
# stacks under testing/compose. Slow and secret-gated, so it runs in three
# situations:
#
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
@@ -12,6 +12,8 @@ name: Enterprise E2E (Playwright)
# - on a nightly cron schedule (catches Keycloak image drift, license
# expiry, upstream proprietary changes),
# - manual workflow_dispatch.
#
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
on:
workflow_call:
@@ -50,10 +52,6 @@ jobs:
playwright-e2e-enterprise:
needs: pick
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
# header comment. GitHub reports the skipped reusable workflow as success.
if: needs.pick.outputs.is_fork != 'true'
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 45
env:
@@ -167,8 +165,6 @@ jobs:
wait_for_backend
- name: Run enterprise OAuth Playwright tests
id: oauth-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
run: task e2e:enterprise -- --grep "OAuth"
- name: Stop backend + tear down OAuth Keycloak
if: always()
@@ -242,8 +238,6 @@ jobs:
wait_for_backend
- name: Run enterprise SAML Playwright tests
id: saml-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
run: task e2e:enterprise -- --grep "SAML"
- name: Stop backend + tear down SAML Keycloak
if: always()
@@ -274,8 +268,6 @@ jobs:
wait_for_backend
- name: Run enterprise feature Playwright tests
id: feature-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
run: task e2e:enterprise -- --grep "Enterprise license"
- name: Print backend log on failure
if: failure()
@@ -288,23 +280,10 @@ jobs:
run: |
source /tmp/helpers.sh
stop_backend
- name: Flag flaky tests
# Runs regardless of the test outcomes: a flaky test (passed on retry)
# leaves its step green, so this is the only place it surfaces. Merges
# all three phase reports (some may be absent if an earlier phase hard-
# failed and skipped the rest). Emits ::warning:: annotations + a job
# summary; never fails the job.
if: always()
working-directory: frontend
run: >
npx tsx editor/scripts/report-flaky-tests.mts
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/playwright-report/
path: frontend/editor/playwright-report/
retention-days: 7
-20
View File
@@ -43,7 +43,6 @@ jobs:
docker-base: ${{ steps.changes.outputs.docker-base }}
tauri: ${{ steps.changes.outputs.tauri }}
engine: ${{ steps.changes.outputs.engine }}
generated-models: ${{ steps.changes.outputs.generated-models }}
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
@@ -172,20 +171,6 @@ jobs:
uses: ./.github/workflows/ai-engine.yml
secrets: inherit
# The generated frontend types and engine tool models are both derived from
# the Java OpenAPI spec. This job regenerates and diffs them; it boots the
# backend, so it is gated on the narrow generated-models filter (spec source,
# generators, generated files, generation tasks) rather than the broad
# frontend filter, so a CSS-only PR does not pay for a backend build.
generated-models:
if: needs.files-changed.outputs.generated-models == 'true'
needs: [files-changed]
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
pre-commit:
needs: [files-changed]
permissions:
@@ -217,9 +202,6 @@ jobs:
contents: read
uses: ./.github/workflows/coverage-aggregate.yml
secrets: inherit
with:
frontend-validation-result: ${{ needs.frontend-validation.result }}
playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }}
# Single status check that branch protection should mark as required.
# Succeeds when every upstream job is either `success` or `skipped` (path-
@@ -243,7 +225,6 @@ jobs:
- test-build-docker-images
- tauri-build
- ai-engine
- generated-models
- pre-commit
- dependency-review
runs-on: ubuntu-latest
@@ -269,7 +250,6 @@ jobs:
test-build-docker-images=${{ needs.test-build-docker-images.result }}
tauri-build=${{ needs.tauri-build.result }}
ai-engine=${{ needs.ai-engine.result }}
generated-models=${{ needs.generated-models.result }}
pre-commit=${{ needs.pre-commit.result }}
dependency-review=${{ needs.dependency-review.result }}
run: |
@@ -1,148 +0,0 @@
name: Check generated models
# Verifies the committed generated API models are still in sync with the Java
# OpenAPI spec: the frontend tool API types
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
# single top-level `task tool-models` and fails if either committed file is
# out of date. Called from build.yml when the backend Java, frontend, or engine
# changes; also runs on push to main as a post-merge safety net.
on:
workflow_call:
push:
branches: [main]
permissions:
contents: read
jobs:
generated-models:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
# frontend types and the engine tool models from it.
- name: Regenerate generated models
run: task tool-models
- name: Verify generated models are up to date
id: models-check
continue-on-error: true
run: |
git diff --exit-code \
frontend/editor/src/core/types/toolApiTypes.ts \
engine/src/stirling/models/tool_models.py
- name: Comment on generated models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const body = [
marker,
'### Generated Models Check Failed',
'',
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
'',
'Run `task tool-models` to regenerate both, then commit the updated files.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if generated models check failed
if: steps.models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Generated Models Check Failed"
echo "============================================"
echo ""
echo "The generated frontend API types and/or engine tool"
echo "models are out of date with the Java OpenAPI spec and"
echo "will need to be regenerated before they can be merged in."
echo ""
echo "Run 'task tool-models' to regenerate both, then"
echo "commit the updated files."
echo "============================================"
exit 1
- name: Remove generated models check comment on success
if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+8 -19
View File
@@ -13,17 +13,6 @@ name: Aggregate backend coverage
# producers themselves
on:
workflow_call:
inputs:
frontend-validation-result:
description: Result of the frontend-validation producer job
required: false
type: string
default: skipped
playwright-e2e-live-result:
description: Result of the playwright-e2e-live producer job
required: false
type: string
default: skipped
permissions:
contents: read
@@ -51,7 +40,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
@@ -207,9 +196,9 @@ jobs:
# --------------------------------------------------------------
- name: Download vitest coverage artifact
# frontend-validation uploads as `frontend-coverage`. Tolerate
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
# absence so a backend-only PR still produces the matrix with
# just backend rows populated.
if: always()
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: frontend-coverage
@@ -217,12 +206,12 @@ jobs:
continue-on-error: true
- name: Download Playwright frontend coverage artifact
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
# Same tolerance as vitest - matrix script handles missing inputs.
if: always()
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: playwright-frontend-coverage
name: playwright-frontend-coverage-${{ github.run_id }}
path: matrix-inputs/playwright/
continue-on-error: true
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
distribution: temurin
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+2 -2
View File
@@ -121,7 +121,7 @@ jobs:
- name: Build and push frontend image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./docker/frontend/Dockerfile
@@ -150,7 +150,7 @@ jobs:
- name: Build and push backend image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./docker/backend/Dockerfile
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+1 -11
View File
@@ -62,17 +62,7 @@ jobs:
# .test-state/playwright/coverage-pw/ for the post-process step
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:live
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
@@ -179,7 +169,7 @@ jobs:
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-frontend-coverage
name: playwright-frontend-coverage-${{ github.run_id }}
path: |
.test-state/playwright/coverage-pw-summary/
.test-state/playwright/coverage-pw/
+1 -12
View File
@@ -44,22 +44,11 @@ jobs:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
path: frontend/playwright-report/
path: frontend/editor/playwright-report/
retention-days: 7
@@ -98,13 +98,6 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
env:
PR_IS_FORK: "false"
run: task frontend:licenses:generate
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
env:
@@ -360,7 +353,6 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses and generate report
id: license-check
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
+7 -24
View File
@@ -61,7 +61,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
@@ -510,7 +510,6 @@ jobs:
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
timeout-minutes: 15
shell: pwsh
run: |
$allSigned = $true
@@ -532,26 +531,11 @@ jobs:
# Extract MSI and verify the inner exe (the file that actually gets installed).
# This is the critical check - AV flags the installed exe at runtime.
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
# _MSIExecute mutex and hangs forever on hosted runners when another
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
$msi = $msiFiles[0].FullName
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
choco install lessmsi -y --no-progress --limit-output | Out-Null
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
if (-not $proc.WaitForExit(120000)) {
try { $proc.Kill() } catch {}
Write-Host "[ERROR] MSI extraction timed out after 120s"
$allSigned = $false
} elseif ($proc.ExitCode -ne 0) {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
} else {
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
if ($proc.ExitCode -eq 0) {
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
if ($innerExe) {
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
@@ -564,6 +548,9 @@ jobs:
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
$allSigned = $false
}
} else {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
}
if (-not $allSigned) {
@@ -813,11 +800,7 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
# Don't regenerate/append notes on re-runs, and don't force this into the
# "Latest" slot - leave the release body and latest marker as they are.
generate_release_notes: false
append_body: false
make_latest: false
generate_release_notes: true
fail_on_unmatched_files: true
# Installers + updater payloads + manifest. .sig contents are embedded
# in latest.json so the .sig files themselves are not uploaded.
+2 -2
View File
@@ -53,8 +53,8 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-nightly-${{ github.run_id }}
path: frontend/playwright-report/
name: playwright-nightly-${{ github.run_id }}
path: frontend/editor/playwright-report/
retention-days: 14
# Builds all desktop platforms on a schedule so the Rust dependency cache is
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
- name: Build and push base image
id: build-push-base
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: docker/base
+5 -5
View File
@@ -66,7 +66,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
@@ -145,7 +145,7 @@ jobs:
id: build-push-latest
# Empty-tag guard: build-push-action errors when asked to push with no tags.
if: env.RUN_MAIN_APP == 'true' && steps.meta.outputs.tags != ''
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -192,7 +192,7 @@ jobs:
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-fat.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
@@ -236,7 +236,7 @@ jobs:
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-lite.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
@@ -365,7 +365,7 @@ jobs:
- name: Build and push unoserver image
id: build-push-unoserver
if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode != 'skip'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
+11 -30
View File
@@ -356,24 +356,25 @@ jobs:
exit 1
fi
- name: Build Tauri app (signed)
if: inputs.sign
- name: Build Tauri app
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Apple signing/notarization env is blanked when sign is false so the
# cache warmer compiles an unsigned bundle and skips notarization.
APPLE_CERTIFICATE: ${{ inputs.sign && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ inputs.sign && env.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ inputs.sign && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ inputs.sign && secrets.APPLE_ID_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.sign && secrets.APPLE_TEAM_ID || '' }}
# AppImage signing — three env vars work together:
# SIGN=1 tells linuxdeploy-plugin-appimage to forward --sign to appimagetool
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
# SIGN_KEY appimagetool picks the key matching this fingerprint
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
# Mirror the Windows/macOS gate: only sign when secret is present AND ref is main (skips PRs from forks/Dependabot).
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
# Mirror the Windows/macOS gate: only sign when enabled AND secret is present AND ref is main (skips PRs from forks/Dependabot and the cache warmer).
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -391,26 +392,6 @@ jobs:
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
- name: Build Tauri app (Linux AppImage)
+3 -3
View File
@@ -95,7 +95,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
@@ -186,7 +186,7 @@ jobs:
# (multi-platform + gha cache) against the published base image.
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -259,7 +259,7 @@ jobs:
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
- name: Build and push test image (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./docker/embedded/Dockerfile
+2 -10
View File
@@ -15,15 +15,7 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
-5
View File
@@ -1,5 +0,0 @@
{
"ignoredFiles": [
"frontend/editor/src-tauri/icons/icon.png"
]
}
-4
View File
@@ -30,10 +30,6 @@ tasks:
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`. Local overrides in
# .env.proprietary.local win over the committed .env.proprietary defaults.
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
+10 -26
View File
@@ -149,32 +149,16 @@ tasks:
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
#
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [windows]
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [linux, darwin]
- |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
+77 -28
View File
@@ -80,12 +80,6 @@ tasks:
OPEN: '{{.OPEN | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
# full path, hostname, or user. Consumed at dev-serve time by vite.config
# and dropped from production builds.
STIRLING_DEV_LABEL:
sh: basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
@@ -134,6 +128,37 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:portal:
desc: "Start developer portal dev server"
ignore_error: true
deps: [install]
vars:
PORT: '{{.PORT | default "5173"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
OPEN: '{{.OPEN | default ""}}'
SUBPATH: '{{.SUBPATH | default ""}}'
MOCKS: '{{.MOCKS | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
dev:portal:proxy:serve:
internal: true
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
cmds:
- npx tsx scripts/dev-origin-proxy.ts
# ============================================================
# Build
# ============================================================
@@ -180,6 +205,29 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
build:portal:
desc: "Build developer portal"
deps: [install]
vars:
SUBPATH: '{{.SUBPATH | default ""}}'
cmds:
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
preview:portal:proxy:
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
deps: [prepare]
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- task: build:proprietary
vars: { PREVIEW: '1' }
- task: build:portal
vars: { SUBPATH: portal }
- npx tsx scripts/dev-origin-proxy.ts
storybook:
desc: "Start Storybook dev server"
@@ -215,8 +263,8 @@ tasks:
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
# shell-agnostic. Covers editor, portal, and the shared design system.
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -297,6 +345,8 @@ tasks:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: scripts/tsconfig.json }
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
@@ -312,7 +362,14 @@ tasks:
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/portal/tsconfig.json }
vars: { PROJECT: portal/tsconfig.json }
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: shared/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -325,6 +382,7 @@ tasks:
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- task: typecheck:shared
# ============================================================
# Quality Gate
@@ -353,6 +411,7 @@ tasks:
- task: lint
- task: format:check
- task: build
- task: build:portal
- task: test
- task: storybook:build
@@ -364,6 +423,7 @@ tasks:
desc: "Run tests"
cmds:
- task: test:editor
- task: test:portal
test:editor:
desc: "Run editor tests"
@@ -371,6 +431,12 @@ tasks:
cmds:
- npx vitest run --root editor
test:portal:
desc: "Run portal tests"
deps: [prepare]
cmds:
- npx vitest run --root portal
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
@@ -402,23 +468,6 @@ tasks:
# Code Generation
# ============================================================
tool-models:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
licenses:generate:
desc: "Generate frontend license report"
deps: [install]
@@ -432,7 +481,7 @@ tasks:
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
platforms: [linux, darwin]
+1 -3
View File
@@ -139,8 +139,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate committed env file:
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/editor/.env` — core, proprietary, and shared vars
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
- These files are committed to Git and must not contain private keys
@@ -453,7 +452,6 @@ The frontend is organized with a clear separation of concerns:
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
- Translation files are located in `frontend/editor/public/locales/`
- After changing any translation file, run `task pre-commit:fix`
## Important Notes
+3 -3
View File
@@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
6. **Frontend Setup (Required for Stirling 2.0)**
5. **Frontend Setup (Required for Stirling 2.0)**
Navigate to the frontend directory and install dependencies using npm.
### Verify Setup
@@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project:
@@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
## 7. Testing
+2 -2
View File
@@ -20,8 +20,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+2 -2
View File
@@ -53,8 +53,8 @@ For full installation options (including desktop and Kubernetes), see our [Docum
## Support
- **Community**: [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
## Contributing
+79 -13
View File
@@ -79,12 +79,59 @@ tasks:
OPEN: "true"
dev:portal:
desc: "Start backend + editor; the portal is an admin route at /portal"
desc: "Start backend + developer portal concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:portal:all:
desc: "Start backend + developer portal + editor concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# Point the portal's "Editor" app switcher at the editor we spawn here.
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:proxy:
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
deps:
- task: backend:dev
vars:
@@ -95,7 +142,18 @@ tasks:
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
SUBPATH: portal
MOCKS: 'false'
- task: frontend:dev:portal:proxy:serve
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
@@ -143,6 +201,24 @@ tasks:
- task: backend:build
- task: frontend:build
preview:portal:proxy:
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# ============================================================
# Test
# ============================================================
@@ -185,16 +261,6 @@ tasks:
- task: frontend:format:check
- task: engine:format:check
# ============================================================
# Code generation
# ============================================================
tool-models:
desc: "Generate all API models from the Java OpenAPI spec"
cmds:
- task: frontend:tool-models
- task: engine:tool-models
# ============================================================
# Quality Gate
# ============================================================
-8
View File
@@ -1,8 +0,0 @@
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
# Flip to true in app/.env.proprietary.local to test linking locally.
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
# SaaS base URL the linked instance calls (register + entitlement).
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
-1
View File
@@ -1,4 +1,3 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
!.env.proprietary
-32
View File
@@ -80,18 +80,10 @@
"moduleName": ".*",
"moduleLicense": "Apache License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "The Apache License, Version 2.0"
@@ -116,10 +108,6 @@
"moduleName": ".*",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "CDDL+GPL License"
@@ -184,14 +172,6 @@
"moduleName": ".*",
"moduleLicense": "Eclipse Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "EPL-2.0"
},
{
"moduleName": ".*",
"moduleLicense": "LGPL-2.1-only"
},
{
"moduleName": ".*",
"moduleLicense": "Ubuntu Font Licence 1.0"
@@ -208,18 +188,6 @@
"moduleName": ".*",
"moduleLicense": "The W3C License"
},
{
"moduleName": "com.google.re2j:re2j",
"moduleLicense": "Go License"
},
{
"moduleName": "com.hubspot:algebra",
"moduleLicense": null
},
{
"moduleName": "com.hubspot.immutables:immutables-exceptions",
"moduleLicense": null
},
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
@@ -132,7 +132,7 @@ public class AppConfig {
return true;
}
Path mountInfo = Path.of("/proc/1/mountinfo");
// this should always exist, if not some unknown use case
// this should always exist, if not some unknown usecase
if (!Files.exists(mountInfo)) {
return true;
}
@@ -246,14 +246,6 @@ public class ApplicationProperties {
* and paused runs are kept regardless of age.
*/
private int runExpiryMinutes = 30;
/**
* Whether a policy S3 source's custom endpoint may resolve to a loopback, link-local, or
* private address. Off by default so a user-supplied endpoint cannot be pointed at internal
* services (e.g. the cloud metadata address); enable for a self-hosted MinIO or other
* in-network object store.
*/
private boolean allowPrivateS3Endpoints = false;
}
@Data
@@ -603,7 +595,7 @@ public class ApplicationProperties {
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = true;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@@ -680,7 +672,7 @@ public class ApplicationProperties {
private String issuer;
private String clientId;
@ToString.Exclude private String clientSecret;
private Boolean autoCreateUser = true;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String useAsUsername;
private Collection<String> scopes = new ArrayList<>();
@@ -751,6 +743,7 @@ public class ApplicationProperties {
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
/**
@@ -854,8 +847,8 @@ public class ApplicationProperties {
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = true;
private boolean useMozillaBundle = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@@ -898,10 +891,10 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide = true;
private Boolean enableDesktopInstallSlide;
private Datasource datasource;
private boolean disableSanitize;
private int maxDPI = 500;
private int maxDPI;
private boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
@@ -915,9 +908,8 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
@Data
public static class MobileScannerSettings {
@@ -927,16 +919,6 @@ public class ApplicationProperties {
private boolean stretchToFit = false; // Whether to stretch image to fill page
}
@Data
public static class ServerCertificate {
private boolean enabled =
true; // Enable server-side "Sign with Stirling-PDF" certificate
private String organizationName = "Stirling PDF Inc";
private int validity = 365; // Certificate validity in days
private boolean regenerateOnStartup =
false; // Generate a new certificate on each startup
}
public boolean isAnalyticsEnabled() {
return this.enableAnalytics != null && this.enableAnalytics;
}
@@ -1021,7 +1003,7 @@ public class ApplicationProperties {
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = true;
private boolean linkEnabled = false;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@@ -1195,7 +1177,7 @@ public class ApplicationProperties {
@Data
public static class Metrics {
private boolean enabled = true;
private boolean enabled;
}
@Data
@@ -1247,7 +1229,7 @@ public class ApplicationProperties {
private boolean enableInvites = false;
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
private String host;
private int port = 587;
private int port;
private String username;
@ToString.Exclude private String password;
private String from;
@@ -1274,10 +1256,10 @@ public class ApplicationProperties {
@ToString.Exclude private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private Boolean customFolderSuffix = true;
private Boolean enableAllowUserIDs = true;
private Boolean customFolderSuffix = false;
private Boolean enableAllowUserIDs = false;
private List<Long> allowUserIDs = new ArrayList<>();
private Boolean enableAllowChannelIDs = true;
private Boolean enableAllowChannelIDs = false;
private List<Long> allowChannelIDs = new ArrayList<>();
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
@@ -1,56 +0,0 @@
package stirling.software.common.service;
/**
* Thread-scoped correlation id for one automation run a single pipeline, policy, or AI-workflow
* execution over its input file(s).
*
* <p>Automations dispatch each tool step as a separate internal loopback POST via {@link
* InternalApiClient}. The orchestrator opens a run scope around its dispatch loop; {@code
* InternalApiClient} reads {@link #current()} and stamps it on every sub-step request as {@link
* #RUN_ID_HEADER}. The SaaS PAYG interceptor uses that header so all sub-steps of ONE run group
* into a single charge, while two <em>separate</em> runs that happen to touch identical bytes stay
* distinct charges (the old content+time-window grouping merged them).
*
* <p>Sub-steps dispatch synchronously on the orchestrator's own thread (loopback {@code
* RestTemplate}), so this ThreadLocal is visible to {@code InternalApiClient}. The id then crosses
* to the receiving request thread via the HTTP header never via this ThreadLocal.
*
* <p>No-op when the id is absent (a standalone tool call): the interceptor treats a missing run id
* as "its own charge", which is exactly what a one-off call should be.
*/
public final class AutomationRunContext {
/** Header carrying the run id on internal sub-step dispatches. */
public static final String RUN_ID_HEADER = "X-Stirling-Run-Id";
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
private AutomationRunContext() {}
/**
* Opens a run scope on the current thread. Returns an {@link AutoCloseable} that restores the
* previously-active id (nesting-safe) use in try-with-resources around the dispatch loop.
*/
public static Scope open(String runId) {
String previous = CURRENT.get();
CURRENT.set(runId);
return () -> {
if (previous == null) {
CURRENT.remove();
} else {
CURRENT.set(previous);
}
};
}
/** The run id active on this thread, or {@code null} when not inside a run scope. */
public static String current() {
return CURRENT.get();
}
/** AutoCloseable whose {@link #close()} declares no checked exception. */
public interface Scope extends AutoCloseable {
@Override
void close();
}
}
@@ -8,7 +8,6 @@ import java.nio.file.Files;
import java.time.Duration;
import java.util.regex.Pattern;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.FileSystemResource;
@@ -61,17 +60,6 @@ public class InternalApiClient {
*/
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
/**
* Header carrying the parent policy's name onto each sub-step dispatch, read from MDC key
* {@link #POLICY_NAME_MDC_KEY} (set by the policy runner on the worker thread). Lets the audit
* layer attribute a tool step to the policy that ran it, instead of showing it as a bare direct
* call.
*/
public static final String POLICY_NAME_HEADER = "X-Stirling-Policy-Name";
/** MDC key the policy runner stamps with the running policy's name; forwarded as a header. */
public static final String POLICY_NAME_MDC_KEY = "auditPolicyName";
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final TempFileManager tempFileManager;
@@ -123,27 +111,6 @@ public class InternalApiClient {
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
// every caller of this dispatcher is an automation surface by design.
headers.add(AUTOMATION_HEADER, "true");
// Propagate the current automation run id (set by the orchestrator around its dispatch
// loop) so the PAYG interceptor groups every sub-step of this one run into a single charge,
// and never merges two separate runs that happen to touch identical bytes. Absent the
// receiving call is treated as standalone. See AutomationRunContext.
String runId = AutomationRunContext.current();
if (runId != null && !runId.isEmpty()) {
headers.add(AutomationRunContext.RUN_ID_HEADER, runId);
}
// Forward the parent policy name (set in MDC by the policy runner) so the audited sub-step
// ties back to its policy. Single-line, length-capped: it becomes an HTTP header value.
String policyName = MDC.get(POLICY_NAME_MDC_KEY);
if (policyName != null && !policyName.isBlank()) {
String safe = policyName.replaceAll("[\\r\\n]", " ").trim();
if (safe.length() > 200) {
safe = safe.substring(0, 200);
}
if (!safe.isEmpty()) {
headers.add(POLICY_NAME_HEADER, safe);
}
}
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
@@ -7,7 +7,6 @@ import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@@ -18,9 +17,6 @@ import stirling.software.common.model.PdfMetadata;
@Service
public class PdfMetadataService {
/** ({@code {labels}}). Written by the classify-and-label tool. */
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
private final ApplicationProperties applicationProperties;
private final String stirlingPDFLabel;
private final UserServiceInterface userService;
@@ -181,14 +177,4 @@ public class PdfMetadataService {
}
pdf.getDocumentInformation().setAuthor(author);
}
/**
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
*/
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
PDDocumentInformation info = pdf.getDocumentInformation();
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
pdf.setDocumentInformation(info);
}
}
@@ -144,10 +144,8 @@ public class TempFileCleanupService {
int directoriesDeletedCount = 0;
for (Path directory : registry.getTempDirectories()) {
try {
if (Files.exists(directory)
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
if (Files.exists(directory)) {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
directoriesDeletedCount++;
log.debug("Cleaned up temporary directory: {}", directory);
}
@@ -277,21 +275,6 @@ public class TempFileCleanupService {
return totalDeletedCount.get();
}
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
if (maxAgeMillis <= 0) {
return true;
}
try {
long currentTime = System.currentTimeMillis();
long lastModified = Files.getLastModifiedTime(directory).toMillis();
return (currentTime - lastModified) > maxAgeMillis;
} catch (IOException e) {
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
return false;
}
}
/** Get the system temp directory path based on configuration or system property. */
private Path getSystemTempPath() {
String systemTempDir =
@@ -57,16 +57,6 @@ public class RequestUriUtils {
return true;
}
// Admin portal SPA shell (mounted at /processor must match the frontend
// PORTAL_BASENAME). Served publicly like the editor root so a direct nav /
// refresh to /processor loads the app (the JWT lives in localStorage, not a
// cookie, so the server can't authenticate the navigation itself). The
// portal gates access via its own auth gate + RequirePortalAccess, and its
// data APIs stay protected, so serving the shell pre-auth is safe.
if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -155,7 +155,6 @@ public class TempFileManager {
if (directory != null && Files.isDirectory(directory)) {
try {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
log.debug("Deleted temp directory: {}", directory.toString());
} catch (IOException e) {
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
@@ -85,18 +85,6 @@ public class TempFileRegistry {
return directory;
}
/**
* Unregister a temporary directory from the registry.
*
* @param directory The directory to unregister
*/
public void unregisterDirectory(Path directory) {
if (directory != null) {
tempDirectories.remove(directory);
log.debug("Unregistered temp directory: {}", directory.toString());
}
}
/**
* Register a third-party temporary file that requires special handling.
*
@@ -176,13 +176,11 @@ class TempFileCleanupServiceMoreTest {
class ScheduledCleanup {
@Test
@DisplayName("deletes stale registered temp directories and reports counts")
@DisplayName("deletes registered temp directories and reports counts")
void deletesRegisteredDirectories() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Files.setLastModifiedTime(
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
@@ -195,22 +193,6 @@ class TempFileCleanupServiceMoreTest {
verify(tempFileManager).cleanupOldTempFiles(anyLong());
}
@Test
@DisplayName("keeps a fresh registered temp directory")
void keepsFreshRegisteredDirectory() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
withIsolatedUserHome(cleanupService::scheduledCleanup);
assertThat(Files.exists(regDir)).isTrue();
}
@Test
@DisplayName("skips a registered directory that no longer exists")
void skipsMissingRegisteredDirectory() {
@@ -73,14 +73,6 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
}
@Test
void testIsStaticResource_portalShell() {
// The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable.
assertTrue(RequestUriUtils.isStaticResource("/processor"));
assertTrue(RequestUriUtils.isStaticResource("/processor/users"));
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/processor"));
}
// --- isFrontendRoute tests ---
@Test
+1 -11
View File
@@ -175,14 +175,6 @@ springBoot {
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
// The admin portal ships as a lazy route inside the editor bundle (see
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
// the portal or AI layers change. Building the portal implies building the editor.
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
if (buildWithPortal) {
buildWithFrontend = true
}
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
// level deeper under frontend/editor/.
@@ -305,11 +297,9 @@ tasks.register('npmBuild', Exec) {
// Override VITE_API_BASE_URL to use relative paths for production builds
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
environment 'VITE_API_BASE_URL', '/'
// Include the admin portal's lazy route/chunk in the editor build when requested.
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
doFirst {
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
}
}
@@ -24,14 +24,12 @@ import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.cert.jcajce.JcaCertStore;
@@ -52,13 +50,6 @@ public abstract class CreateSignatureBase implements SignatureInterface {
@Getter private Certificate[] certificateChain;
@Setter private String tsaUrl;
/**
* Provider that must service the signing operation. Set for hardware-held keys (SunPKCS11 for
* USB tokens, SunMSCAPI for the Windows store) so the {@link java.security.Signature} runs on
* the token. Left {@code null} for software keystores, which use the default provider.
*/
@Setter private Provider signingProvider;
/**
* Specifies whether the external signing scenario should be used. If set to {@code true},
* external signing will be performed and {@link SignatureInterface} will be used for signing.
@@ -89,48 +80,25 @@ public abstract class CreateSignatureBase implements SignatureInterface {
NoSuchAlgorithmException,
IOException,
CertificateException {
this(keystore, pin, null);
}
/**
* Initialize the signature creator, optionally selecting a specific certificate by alias. A
* hardware token / the Windows store can hold several certificates, so the caller picks one;
* when {@code requestedAlias} is null the first usable entry is used (software keystore
* behaviour).
*
* @param keystore the keystore (software, PKCS#11 or Windows-MY)
* @param pin the keystore / token PIN, may be null for the Windows store
* @param requestedAlias the alias to sign with, or null to pick the first usable entry
*/
public CreateSignatureBase(KeyStore keystore, char[] pin, String requestedAlias)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
if (requestedAlias != null
&& !requestedAlias.isBlank()
&& keystore.containsAlias(requestedAlias)) {
privateKey = (PrivateKey) keystore.getKey(requestedAlias, pin);
certificateChain = resolveChain(keystore, requestedAlias);
if (certificateChain == null) {
throw new IOException("Could not find certificate for alias " + requestedAlias);
}
checkValidity(certificateChain[0]);
return;
}
// grabs the first alias from the keystore and gets the private key.
// grabs the first alias from the keystore and get the private key. An
// alternative method or constructor could be used for setting a specific
// alias that should be used.
Enumeration<String> aliases = keystore.aliases();
String alias;
Certificate cert = null;
while (cert == null && aliases.hasMoreElements()) {
String alias = aliases.nextElement();
alias = aliases.nextElement();
privateKey = (PrivateKey) keystore.getKey(alias, pin);
Certificate[] certChain = resolveChain(keystore, alias);
Certificate[] certChain = keystore.getCertificateChain(alias);
if (certChain != null) {
certificateChain = certChain;
cert = certChain[0];
checkValidity(cert);
if (cert instanceof X509Certificate) {
// avoid expired certificate
((X509Certificate) cert).checkValidity();
//// SigUtils.checkCertificateUsage((X509Certificate) cert);
}
}
}
@@ -139,27 +107,6 @@ public abstract class CreateSignatureBase implements SignatureInterface {
}
}
/**
* Resolve the certificate chain for an alias. PKCS#11 tokens and the Windows store frequently
* expose only the leaf certificate (a null chain), so fall back to the single certificate.
*/
private static Certificate[] resolveChain(KeyStore keystore, String alias)
throws KeyStoreException {
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain != null && chain.length > 0) {
return chain;
}
Certificate single = keystore.getCertificate(alias);
return single != null ? new Certificate[] {single} : null;
}
private static void checkValidity(Certificate cert) throws CertificateException {
if (cert instanceof X509Certificate x509Cert) {
// avoid expired certificate
x509Cert.checkValidity();
}
}
public final void setPrivateKey(PrivateKey privateKey) {
this.privateKey = privateKey;
}
@@ -189,18 +136,12 @@ public abstract class CreateSignatureBase implements SignatureInterface {
try {
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
X509Certificate cert = (X509Certificate) certificateChain[0];
JcaContentSignerBuilder signerBuilder =
new JcaContentSignerBuilder(resolveSignatureAlgorithm(privateKey, cert));
// Hardware keys (PKCS#11 / Windows store) must sign on their own provider so the
// operation runs on the token; software keys use the default provider.
if (signingProvider != null) {
signerBuilder.setProvider(signingProvider);
}
ContentSigner signer = signerBuilder.build(privateKey);
ContentSigner sha1Signer =
new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey);
gen.addSignerInfoGenerator(
new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().build())
.build(signer, cert));
.build(sha1Signer, cert));
gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));
CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
CMSSignedData signedData = gen.generate(msg, false);
@@ -216,26 +157,4 @@ public abstract class CreateSignatureBase implements SignatureInterface {
throw new IOException(e);
}
}
/**
* Pick a SHA-256 signature algorithm that matches the key type. RSA keeps the historical
* default; EC / EdDSA tokens are common, so they are handled too.
*/
private static String resolveSignatureAlgorithm(PrivateKey key, X509Certificate cert) {
String alg = key.getAlgorithm();
if (alg == null || alg.isBlank()) {
alg = cert.getPublicKey().getAlgorithm();
}
alg = alg == null ? "" : alg.toUpperCase(Locale.ROOT);
if (alg.contains("ED25519") || alg.contains("EDDSA")) {
return "Ed25519";
}
if (alg.contains("EC")) { // EC, ECDSA
return "SHA256withECDSA";
}
if (alg.contains("DSA")) {
return "SHA256withDSA";
}
return "SHA256withRSA";
}
}
@@ -85,6 +85,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
"/pdfjs-legacy/**",
@@ -3,12 +3,9 @@ package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
@@ -264,19 +261,10 @@ public class RearrangePagesPDFController {
log.info("newPageOrder = {}", newPageOrder);
log.info("totalPages = {}", totalPages);
// Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE)
// so each slot is a distinct node, not one PDPage under multiple /Kids.
// Snapshot the desired pages before mutating the source document's page tree.
List<PDPage> newPages = new ArrayList<>(newPageOrder.size());
Set<Integer> seenIndices = new HashSet<>();
for (Integer idx : newPageOrder) {
PDPage page = document.getPage(idx);
if (!seenIndices.add(idx)) {
// Duplicate index: distinct page node sharing content/resources.
COSDictionary clonedDict = new COSDictionary();
clonedDict.addAll(page.getCOSObject());
page = new PDPage(clonedDict);
}
newPages.add(page);
newPages.add(document.getPage(idx));
}
// Rearrange in-place on the source document rather than copying pages into a
@@ -350,18 +350,6 @@ public class ConfigController {
"serverCertificateEnabled",
serverCertificateService != null && serverCertificateService.isEnabled());
// Hardware-backed signing (Windows store / USB PKCS#11 tokens) is only viable on the
// desktop bundle, where the backend runs locally in the user's session. The Tauri
// bundle signals this via STIRLING_PDF_TAURI_MODE (machineType is Server-jar there);
// the bare-jar desktop launcher signals it via a Client-* machineType.
boolean hardwareSigningAvailable =
Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
if (!hardwareSigningAvailable && applicationContext.containsBean("machineType")) {
String mt = applicationContext.getBean("machineType", String.class);
hardwareSigningAvailable = mt != null && mt.startsWith("Client-");
}
configData.put("hardwareSigningAvailable", hardwareSigningAvailable);
// Legal settings
configData.put(
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
@@ -10,7 +10,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.UUID;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
@@ -28,7 +27,6 @@ import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.service.AutomationRunContext;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ZipExtractionUtils;
@@ -73,17 +71,6 @@ public class PipelineProcessor {
PipelineResult runPipelineAgainstFiles(List<Resource> outputFiles, PipelineConfig config)
throws Exception {
// One pipeline execution = one automation run. Scope a run id so every tool sub-step
// dispatched via InternalApiClient groups into a single charge on the SaaS billing side
// (see AutomationRunContext); pipeline steps run synchronously on this thread.
try (AutomationRunContext.Scope ignored =
AutomationRunContext.open(UUID.randomUUID().toString())) {
return runPipelineAgainstFilesInternal(outputFiles, config);
}
}
private PipelineResult runPipelineAgainstFilesInternal(
List<Resource> outputFiles, PipelineConfig config) throws Exception {
PipelineResult result = new PipelineResult();
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
@@ -70,13 +70,10 @@ import io.micrometer.common.util.StringUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -112,17 +109,14 @@ public class CertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ServerCertificateServiceInterface serverCertificateService;
private final TempFileManager tempFileManager;
private final HardwareKeyStoreService hardwareKeyStoreService;
public CertSignController(
CustomPDFDocumentFactory pdfDocumentFactory,
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
TempFileManager tempFileManager,
HardwareKeyStoreService hardwareKeyStoreService) {
TempFileManager tempFileManager) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.serverCertificateService = serverCertificateService;
this.tempFileManager = tempFileManager;
this.hardwareKeyStoreService = hardwareKeyStoreService;
}
public static void sign(
@@ -176,8 +170,7 @@ public class CertSignController {
"This endpoint accepts a PDF file, a digital certificate and related"
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> signPDFWithCert(
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
public ResponseEntity<Resource> signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request)
throws Exception {
MultipartFile pdf = request.getFileInput();
String certType = request.getCertType();
@@ -203,8 +196,6 @@ public class CertSignController {
KeyStore ks = null;
String keystorePassword = password;
Provider signingProvider = null;
HardwareKeyStoreService.Pkcs11Session pkcs11Session = null;
switch (certType) {
case "PEM":
@@ -254,31 +245,6 @@ public class CertSignController {
ks = serverCertificateService.getServerKeyStore();
keystorePassword = serverCertificateService.getServerCertificatePassword();
break;
case "WINDOWS_STORE":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
ks = hardwareKeyStoreService.loadWindowsKeyStore();
signingProvider = hardwareKeyStoreService.windowsProvider();
// PIN is prompted by the Windows CSP / token middleware, not passed here.
keystorePassword = password;
break;
case "PKCS11":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
char[] pkcs11Pin = password != null ? password.toCharArray() : null;
try {
pkcs11Session =
hardwareKeyStoreService.openPkcs11(
request.getPkcs11LibraryPath(),
request.getPkcs11Slot(),
pkcs11Pin);
} finally {
if (pkcs11Pin != null) {
java.util.Arrays.fill(pkcs11Pin, '\0');
}
}
ks = pkcs11Session.keyStore();
signingProvider = pkcs11Session.provider();
keystorePassword = password;
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
@@ -286,9 +252,7 @@ public class CertSignController {
"certificate type: " + certType);
}
char[] pin = keystorePassword != null ? keystorePassword.toCharArray() : null;
CreateSignature createSignature =
new CreateSignature(ks, pin, request.getAlias(), signingProvider);
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
TempFile signedOut = tempFileManager.createManagedTempFile(".pdf");
try (OutputStream os = new FileOutputStream(signedOut.getFile())) {
sign(
@@ -305,14 +269,6 @@ public class CertSignController {
} catch (IOException e) {
signedOut.close();
throw e;
} finally {
// Clear the PIN copy and log out the token session once signing is done.
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
if (pkcs11Session != null) {
pkcs11Session.close();
}
}
// Return the signed PDF
return WebResponseUtils.pdfFileToWebResponse(
@@ -368,22 +324,7 @@ public class CertSignController {
NoSuchAlgorithmException,
IOException,
CertificateException {
this(keystore, pin, null, null);
}
public CreateSignature(
KeyStore keystore, char[] pin, String alias, Provider signingProvider)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin, alias);
setSigningProvider(signingProvider);
loadLogo();
}
private void loadLogo() throws IOException {
super(keystore, pin);
ClassPathResource resource = new ClassPathResource("static/images/signature.png");
try (InputStream is = resource.getInputStream()) {
logoFile = Files.createTempFile("signature", ".png").toFile();
@@ -305,23 +305,6 @@ public class GetInfoOnPDF {
}
}
/**
* Info-dictionary keys exposed above via typed getters; any other key in the dictionary is
* surfaced as custom metadata (e.g. the classification policy's StirlingPDFClassification
* entry).
*/
private static final java.util.Set<String> STANDARD_INFO_KEYS =
java.util.Set.of(
"Title",
"Author",
"Subject",
"Keywords",
"Producer",
"Creator",
"CreationDate",
"ModDate",
"Trapped");
private static ObjectNode extractMetadata(PDDocument document) {
ObjectNode metadata = objectMapper.createObjectNode();
@@ -352,18 +335,6 @@ public class GetInfoOnPDF {
if (modificationDate != null) {
metadata.put("ModificationDate", modificationDate);
}
// Surface custom Info-dictionary entries (anything beyond the
// standard fields above) e.g. StirlingPDFClassification
for (String key : info.getMetadataKeys()) {
if (STANDARD_INFO_KEYS.contains(key)) {
continue;
}
String value = info.getCustomMetadataValue(key);
if (value != null && !value.isBlank()) {
metadata.put(key, value);
}
}
}
} catch (Exception e) {
log.error("Error extracting metadata: {}", e.getMessage());
@@ -1,85 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
import stirling.software.SPDF.model.api.security.Pkcs11CertificatesRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
/**
* Lets the desktop frontend discover which hardware-backed signing options the local backend can
* reach (Windows certificate store, plugged-in USB / PKCS#11 tokens) and enumerate the certificates
* available to sign with. Enumeration endpoints are restricted to the desktop bundle, reached over
* loopback - see {@link HardwareKeyStoreService#assertLocalDesktop}.
*/
@RestController
@RequestMapping("/api/v1/security/cert-sign/hardware")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Security", description = "Security APIs")
public class HardwareSigningController {
private final HardwareKeyStoreService hardwareKeyStoreService;
@GetMapping("/capabilities")
@Operation(
summary = "Hardware signing capabilities",
description =
"Reports whether hardware-backed signing is available on this device and which"
+ " PKCS#11 driver libraries were detected. Returns desktop=false when"
+ " not running as the desktop app.")
public ResponseEntity<HardwareSigningCapabilities> getCapabilities() {
return ResponseEntity.ok(hardwareKeyStoreService.capabilities());
}
@GetMapping("/windows-certificates")
@Operation(
summary = "List Windows certificate store signing certificates",
description =
"Enumerates certificates with a usable private key from the current user's"
+ " Windows certificate store. Desktop-only, loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getWindowsCertificates(
HttpServletRequest request) throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
return ResponseEntity.ok(hardwareKeyStoreService.listWindowsCertificates());
}
@PostMapping("/pkcs11-certificates")
@Operation(
summary = "List PKCS#11 token signing certificates",
description =
"Logs into a PKCS#11 token with the supplied PIN and enumerates its signing"
+ " certificates. The PIN is used only for this call. Desktop-only,"
+ " loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getPkcs11Certificates(
HttpServletRequest request, @RequestBody Pkcs11CertificatesRequest body)
throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
char[] pin = body.pin() != null ? body.pin().toCharArray() : null;
try {
return ResponseEntity.ok(
hardwareKeyStoreService.listPkcs11Certificates(
body.libraryPath(), body.slot(), pin));
} finally {
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
}
}
}
@@ -136,17 +136,11 @@ public class RedactController {
+ "Users can provide text patterns to redact, with options for regex and whole word matching. "
+ "Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
String rawListOfText = request.getListOfText();
String[] listOfText = request.getListOfText().split("\n");
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
if (rawListOfText == null || rawListOfText.trim().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.redaction.no.patterns", "No text patterns provided for redaction");
}
String[] listOfText = rawListOfText.split("\n");
if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) {
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
throw ExceptionUtils.createIllegalArgumentException(
"error.redaction.no.patterns", "No text patterns provided for redaction");
}
@@ -102,27 +102,8 @@ public class ValidateSignatureController {
try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
List<PDSignature> signatures = document.getSignatureDictionaries();
// Detect content appended outside every signature's ByteRange (added after signing). A
// properly signed document has its last signature cover all the way to EOF; if the
// furthest any signature reaches stops short of the file length, the tail is unsigned.
// Taking the max across all signatures avoids false positives on legitimately
// multi-signed PDFs, where an earlier signature intentionally omits later revisions.
long fileLength = file.getSize();
long maxCovered = 0;
for (PDSignature sig : signatures) {
int[] byteRange = sig.getByteRange();
if (byteRange != null && byteRange.length == 4) {
long end = (long) byteRange[2] + byteRange[3];
if (end > maxCovered) {
maxCovered = end;
}
}
}
boolean documentCovered = maxCovered <= 0 || maxCovered >= fileLength;
for (PDSignature sig : signatures) {
SignatureValidationResult result = new SignatureValidationResult();
result.setCoversEntireDocument(documentCovered);
try {
byte[] signedContent = sig.getSignedContent(file.getInputStream());
@@ -1,30 +0,0 @@
package stirling.software.SPDF.controller.web;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.common.model.ApplicationProperties;
/**
* Serves /robots.txt dynamically so the system.googlevisibility flag actually controls
* search-engine indexing. 'true' returns an allow-all policy; 'false' returns a disallow-all policy
* to keep the instance out of search engines (useful for embedded/internal deployments).
*/
@RestController
public class RobotsController {
private final ApplicationProperties applicationProperties;
public RobotsController(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
public String robotsTxt() {
boolean allowIndexing = applicationProperties.getSystem().isGooglevisibility();
return "User-agent: *\n" + (allowIndexing ? "Allow: /\n" : "Disallow: /\n");
}
}
@@ -1,7 +1,5 @@
package stirling.software.SPDF.model.api.general;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -19,8 +17,20 @@ public class PosterPdfRequest extends PDFFile {
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
private String pageSize = "A4";
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int xFactor = 2;
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int yFactor = 2;
@Schema(
@@ -28,36 +38,4 @@ public class PosterPdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean rightToLeft = false;
@JsonProperty("xFactor")
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
public int getXFactor() {
return xFactor;
}
@JsonProperty("xFactor")
public void setXFactor(int xFactor) {
this.xFactor = xFactor;
}
@JsonProperty("yFactor")
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
public int getYFactor() {
return yFactor;
}
@JsonProperty("yFactor")
public void setYFactor(int yFactor) {
this.yFactor = yFactor;
}
}
@@ -29,8 +29,7 @@ public class AddPasswordRequest extends PDFFile {
description = "The length of the encryption key",
type = "integer",
allowableValues = {"40", "128", "256"},
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "256")
requiredMode = Schema.RequiredMode.REQUIRED)
private int keyLength = 256;
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
@@ -1,21 +0,0 @@
package stirling.software.SPDF.model.api.security;
/**
* Metadata for a single signing certificate discovered on a hardware source (Windows certificate
* store or a PKCS#11 token). Returned to the desktop frontend so the user can pick which
* certificate to sign with. Never carries private key material - signing always happens on the
* token / OS.
*/
public record HardwareCertificateInfo(
String alias,
String source,
String subject,
String issuer,
String subjectCommonName,
String issuerCommonName,
String serialNumber,
String keyAlgorithm,
String notBefore,
String notAfter,
boolean expired,
boolean notYetValid) {}
@@ -1,19 +0,0 @@
package stirling.software.SPDF.model.api.security;
import java.util.List;
/**
* Describes what hardware-backed signing the local backend can offer. Only meaningful on the
* desktop bundle, where the backend runs as a local sidecar in the signed-in user's session and can
* reach the Windows certificate store / a plugged-in USB PKCS#11 token.
*/
public record HardwareSigningCapabilities(
boolean desktop,
String osName,
boolean windowsStoreSupported,
boolean pkcs11Supported,
List<Pkcs11LibraryInfo> detectedLibraries) {
/** A PKCS#11 driver library detected on disk (or supplied via configuration). */
public record Pkcs11LibraryInfo(String name, String path) {}
}
@@ -1,7 +0,0 @@
package stirling.software.SPDF.model.api.security;
/**
* Request body for enumerating the certificates on a PKCS#11 token. The PIN is required to log into
* the token; it is used only for the duration of the call and never stored.
*/
public record Pkcs11CertificatesRequest(String libraryPath, Integer slot, String pin) {}
@@ -14,10 +14,8 @@ import stirling.software.common.model.api.PDFFile;
public class SignPDFWithCertRequest extends PDFFile {
@Schema(
description =
"The type of the digital certificate. WINDOWS_STORE and PKCS11 are"
+ " hardware-backed and only available in the desktop app.",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER", "WINDOWS_STORE", "PKCS11"},
description = "The type of the digital certificate",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String certType;
@@ -41,31 +39,9 @@ public class SignPDFWithCertRequest extends PDFFile {
@Schema(description = "The JKS keystore file (Java Key Store)")
private MultipartFile jksFile;
@Schema(
description =
"The password for the keystore / private key, or the token PIN for PKCS11",
format = "password")
@Schema(description = "The password for the keystore or the private key", format = "password")
private String password;
@Schema(
description =
"The alias of the certificate to sign with. Required for WINDOWS_STORE and"
+ " recommended for PKCS11 tokens holding multiple certificates.")
private String alias;
@Schema(
description =
"Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must"
+ " be an allowed driver - a detected one or configured via"
+ " STIRLING_PKCS11_LIBRARIES.")
private String pkcs11LibraryPath;
@Schema(
description =
"Optional PKCS#11 slot index. When omitted the first slot with a token is"
+ " used.")
private Integer pkcs11Slot;
@Schema(
description = "Whether to visually show the signature in the PDF file",
defaultValue = "false",
@@ -18,11 +18,6 @@ public class SignatureValidationResult {
// Time validation
private boolean notExpired;
// Whether the document's signatures cover all of its bytes. False when content was appended
// outside every signature's ByteRange (i.e. added after signing), which the signature can't
// attest to even though the signed bytes themselves remain cryptographically intact.
private boolean coversEntireDocument = true;
// Revocation validation
private boolean revocationChecked; // true if PKIX revocation was enabled
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
@@ -115,8 +115,7 @@ public class CertificateValidationService {
log.info("Enabled AIA certificate fetching and revocation checking");
}
// Trust only what we explicitly opt into. Desktop follows the same flags as the server -
// our own signing cert is trusted via serverAsAnchor, not by force-loading every system CA.
// Trust only what we explicitly opt into:
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
@@ -1,483 +0,0 @@
package stirling.software.SPDF.service;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.Provider;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.security.auth.x500.X500Principal;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities.Pkcs11LibraryInfo;
import stirling.software.common.util.ExceptionUtils;
/**
* Bridges PDF signing to hardware-held keys: the Windows certificate store (via the JDK SunMSCAPI
* provider) and USB / smart-card PKCS#11 tokens (via SunPKCS11). The private key never leaves the
* token - the JCA routes the actual signing operation onto the hardware.
*
* <p>These code paths are gated to the desktop bundle. On a hosted server the backend cannot reach
* a remote user's USB token anyway, and loading an arbitrary PKCS#11 driver library is effectively
* native code execution, so PKCS#11 libraries are additionally restricted to an allowlist of
* detected / configured driver paths.
*/
@Service
@Slf4j
public class HardwareKeyStoreService {
public static final String SOURCE_WINDOWS_STORE = "WINDOWS_STORE";
public static final String SOURCE_PKCS11 = "PKCS11";
private static final String WINDOWS_KEYSTORE_TYPE = "Windows-MY";
private static final String MSCAPI_PROVIDER = "SunMSCAPI";
private static final String PKCS11_BASE_PROVIDER = "SunPKCS11";
/** Extra PKCS#11 driver libraries, absolute paths, comma/`File.pathSeparator` separated. */
private static final String PKCS11_LIBRARIES_ENV = "STIRLING_PKCS11_LIBRARIES";
/** Same as {@link #PKCS11_LIBRARIES_ENV} but as a JVM system property. */
private static final String PKCS11_LIBRARIES_PROP = "stirling.pkcs11.libraries";
private final String machineType;
public HardwareKeyStoreService(
@Autowired(required = false) @Qualifier("machineType") String machineType) {
this.machineType = machineType;
}
// ---------------------------------------------------------------------
// Gating
// ---------------------------------------------------------------------
/**
* True when running as the desktop bundle (local sidecar in the user's session). The Tauri
* bundle sets {@code STIRLING_PDF_TAURI_MODE=true} (with {@code BROWSER_OPEN=false}, so
* machineType is {@code Server-jar} there); the bare-jar desktop launcher instead yields a
* {@code Client-*} machineType. Accept either.
*/
public boolean isDesktop() {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
return true;
}
return machineType != null && machineType.startsWith("Client-");
}
public boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
}
private boolean windowsStoreSupported() {
return isWindows() && Security.getProvider(MSCAPI_PROVIDER) != null;
}
private boolean pkcs11Supported() {
return Security.getProvider(PKCS11_BASE_PROVIDER) != null;
}
/** Reject anything that is not the desktop bundle reached over loopback. */
public void assertLocalDesktop(HttpServletRequest request) {
if (!isDesktop()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.hardwareSigningDesktopOnly",
"Hardware-backed signing is only available in the Stirling PDF desktop app");
}
if (request != null && !isLocalRequest(request.getRemoteAddr())) {
throw ExceptionUtils.createIllegalArgumentException(
"error.hardwareSigningLocalOnly",
"Hardware-backed signing can only be used from this device");
}
}
/**
* True when the request originates from this machine. Loopback (incl. IPv4-mapped IPv6 like
* {@code ::ffff:127.0.0.1}) counts, as does any address bound to a local interface - so it
* works whether the desktop app reaches the sidecar over {@code localhost} or a LAN IP, while
* still rejecting other machines on the network.
*/
static boolean isLocalRequest(String remoteAddr) {
if (remoteAddr == null || remoteAddr.isBlank()) {
return false;
}
try {
InetAddress addr = InetAddress.getByName(remoteAddr);
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) {
return true;
}
return NetworkInterface.networkInterfaces()
.anyMatch(nif -> nif.inetAddresses().anyMatch(local -> local.equals(addr)));
} catch (Exception e) {
return false;
}
}
// ---------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------
public HardwareSigningCapabilities capabilities() {
boolean desktop = isDesktop();
if (!desktop) {
return new HardwareSigningCapabilities(false, "", false, false, List.of());
}
return new HardwareSigningCapabilities(
true,
System.getProperty("os.name", ""),
windowsStoreSupported(),
pkcs11Supported(),
detectPkcs11Libraries());
}
/**
* Known driver install locations plus any paths configured via {@code
* STIRLING_PKCS11_LIBRARIES}.
*/
public List<Pkcs11LibraryInfo> detectPkcs11Libraries() {
Map<String, List<String>> candidates = new LinkedHashMap<>();
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (os.contains("win")) {
candidates.put(
"OpenSC",
List.of(
"C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
candidates.put(
"YubiKey (ykcs11)",
List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll"));
candidates.put("SafeNet eToken", List.of("C:\\Windows\\System32\\eTPKCS11.dll"));
candidates.put(
"Thales/Gemalto IDPrime", List.of("C:\\Windows\\System32\\IDPrimePKCS11.dll"));
candidates.put(
"SoftHSM2",
List.of(
"C:\\Program Files\\SoftHSM2\\lib\\softhsm2-x64.dll",
"C:\\SoftHSM2\\lib\\softhsm2-x64.dll"));
} else if (os.contains("mac")) {
candidates.put(
"OpenSC",
List.of(
"/Library/OpenSC/lib/opensc-pkcs11.so",
"/usr/local/lib/opensc-pkcs11.so"));
candidates.put(
"YubiKey (ykcs11)",
List.of("/usr/local/lib/libykcs11.dylib", "/opt/homebrew/lib/libykcs11.dylib"));
candidates.put(
"SoftHSM2",
List.of(
"/usr/local/lib/softhsm/libsofthsm2.so",
"/opt/homebrew/lib/softhsm/libsofthsm2.so"));
} else {
candidates.put(
"OpenSC",
List.of(
"/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
"/usr/lib/opensc-pkcs11.so",
"/usr/lib64/opensc-pkcs11.so"));
candidates.put(
"YubiKey (ykcs11)",
List.of(
"/usr/lib/x86_64-linux-gnu/libykcs11.so",
"/usr/local/lib/libykcs11.so"));
candidates.put(
"SoftHSM2",
List.of(
"/usr/lib/softhsm/libsofthsm2.so",
"/usr/lib64/softhsm/libsofthsm2.so",
"/usr/local/lib/softhsm/libsofthsm2.so"));
}
List<Pkcs11LibraryInfo> result = new ArrayList<>();
candidates.forEach(
(name, paths) ->
paths.stream()
.filter(p -> Files.exists(Path.of(p)))
.findFirst()
.ifPresent(p -> result.add(new Pkcs11LibraryInfo(name, p))));
for (String configured : configuredLibraries()) {
if (Files.exists(Path.of(configured))
&& result.stream().noneMatch(l -> sameFile(l.path(), configured))) {
result.add(new Pkcs11LibraryInfo(fileName(configured), configured));
}
}
return result;
}
private static List<String> configuredLibraries() {
String env = System.getenv(PKCS11_LIBRARIES_ENV);
String prop = System.getProperty(PKCS11_LIBRARIES_PROP);
StringBuilder combined = new StringBuilder();
if (env != null && !env.isBlank()) {
combined.append(env);
}
if (prop != null && !prop.isBlank()) {
if (combined.length() > 0) {
combined.append(java.io.File.pathSeparator);
}
combined.append(prop);
}
if (combined.length() == 0) {
return List.of();
}
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
// ---------------------------------------------------------------------
// Windows certificate store
// ---------------------------------------------------------------------
public KeyStore loadWindowsKeyStore() throws Exception {
if (!windowsStoreSupported()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.windowsStoreUnavailable",
"The Windows certificate store is not available on this platform");
}
KeyStore ks = KeyStore.getInstance(WINDOWS_KEYSTORE_TYPE, MSCAPI_PROVIDER);
ks.load(null, null);
return ks;
}
public Provider windowsProvider() {
return Security.getProvider(MSCAPI_PROVIDER);
}
public List<HardwareCertificateInfo> listWindowsCertificates() throws Exception {
return listSigningCertificates(loadWindowsKeyStore(), SOURCE_WINDOWS_STORE);
}
// ---------------------------------------------------------------------
// PKCS#11 tokens
// ---------------------------------------------------------------------
/**
* A configured, logged-in PKCS#11 keystore plus the provider that must service signing. Closing
* logs the session out so the PIN-authenticated session does not outlive the request. The
* provider stays cached (logout is C_Logout, not C_Finalize) so the next call reuses the same
* C_Initialize. Single-user desktop model - logout is best-effort.
*/
public record Pkcs11Session(KeyStore keyStore, Provider provider) implements AutoCloseable {
@Override
public void close() {
if (provider instanceof java.security.AuthProvider authProvider) {
try {
authProvider.logout();
} catch (Exception e) {
// Not logged in / already logged out - nothing to clear.
}
}
}
}
// One SunPKCS11 provider per driver+slot, reused across enumerate + sign. A PKCS#11 module
// typically allows C_Initialize only once per process, so configuring a fresh provider on every
// call races with the previous (not-yet-GC'd) one - the cause of "first sign fails, second
// works". Reusing the provider keeps a single C_Initialize alive for the session.
private final java.util.concurrent.ConcurrentHashMap<String, Provider> pkcs11Providers =
new java.util.concurrent.ConcurrentHashMap<>();
public Pkcs11Session openPkcs11(String libraryPath, Integer slot, char[] pin) throws Exception {
validateLibraryAllowed(libraryPath);
if (!pkcs11Supported()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11Unavailable", "PKCS#11 support is not available in this runtime");
}
String cacheKey = libraryPath + "|" + slot;
Provider provider =
pkcs11Providers.computeIfAbsent(
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
try {
KeyStore ks = KeyStore.getInstance("PKCS11", provider);
ks.load(null, pin);
return new Pkcs11Session(ks, provider);
} catch (Exception e) {
// A wrong PIN must not be retried: a second C_Login would burn the token's retry
// counter twice per attempt and can lock the token. Only rebuild on provider/init
// failures (e.g. token removed/re-inserted leaving a stale provider).
if (isAuthFailure(e)) {
throw e;
}
pkcs11Providers.remove(cacheKey, provider);
Provider fresh =
pkcs11Providers.computeIfAbsent(
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
KeyStore ks = KeyStore.getInstance("PKCS11", fresh);
ks.load(null, pin);
return new Pkcs11Session(ks, fresh);
}
}
/** True when the failure is a bad/locked PIN rather than a provider/init/device problem. */
private static boolean isAuthFailure(Throwable t) {
while (t != null) {
if (t instanceof javax.security.auth.login.FailedLoginException) {
return true;
}
String msg = t.getMessage();
if (msg != null && msg.toUpperCase(Locale.ROOT).contains("CKR_PIN")) {
return true; // CKR_PIN_INCORRECT / CKR_PIN_LOCKED / CKR_PIN_INVALID / ...
}
t = t.getCause();
}
return false;
}
private Provider buildPkcs11Provider(String libraryPath, Integer slot) {
StringBuilder config = new StringBuilder();
config.append("--name=").append(providerName(libraryPath)).append('\n');
config.append("library=").append(libraryPath).append('\n');
if (slot != null) {
config.append("slot=").append(slot).append('\n');
}
try {
return Security.getProvider(PKCS11_BASE_PROVIDER).configure(config.toString());
} catch (Exception e) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11ConfigFailed",
"Failed to initialise the PKCS#11 driver: {0}",
e.getMessage());
}
}
public List<HardwareCertificateInfo> listPkcs11Certificates(
String libraryPath, Integer slot, char[] pin) throws Exception {
try (Pkcs11Session session = openPkcs11(libraryPath, slot, pin)) {
return listSigningCertificates(session.keyStore(), SOURCE_PKCS11);
}
}
/**
* Reject driver paths that are not detected on disk / configured - blocks arbitrary DLL loads.
*/
public void validateLibraryAllowed(String libraryPath) {
if (libraryPath == null || libraryPath.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11LibraryRequired", "A PKCS#11 driver library path is required");
}
Set<String> allowed =
detectPkcs11Libraries().stream()
.map(Pkcs11LibraryInfo::path)
.collect(Collectors.toSet());
boolean ok = allowed.stream().anyMatch(p -> sameFile(p, libraryPath));
if (!ok) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11LibraryNotAllowed",
"PKCS#11 driver is not in the allowed list. Add it via the"
+ " STIRLING_PKCS11_LIBRARIES setting: {0}",
libraryPath);
}
}
// ---------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------
private List<HardwareCertificateInfo> listSigningCertificates(KeyStore ks, String source)
throws Exception {
List<HardwareCertificateInfo> certs = new ArrayList<>();
Enumeration<String> aliases = ks.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (!ks.isKeyEntry(alias)) {
continue; // only entries we can sign with
}
Certificate cert = ks.getCertificate(alias);
if (cert instanceof X509Certificate x509) {
certs.add(toInfo(alias, x509, source));
}
}
return certs;
}
private static HardwareCertificateInfo toInfo(
String alias, X509Certificate cert, String source) {
java.util.Date now = new java.util.Date();
return new HardwareCertificateInfo(
alias,
source,
cert.getSubjectX500Principal().getName(),
cert.getIssuerX500Principal().getName(),
commonName(cert.getSubjectX500Principal()),
commonName(cert.getIssuerX500Principal()),
cert.getSerialNumber().toString(16),
cert.getPublicKey().getAlgorithm(),
cert.getNotBefore().toInstant().toString(),
cert.getNotAfter().toInstant().toString(),
now.after(cert.getNotAfter()),
now.before(cert.getNotBefore()));
}
private static String commonName(X500Principal principal) {
try {
X500Name x500Name = new X500Name(principal.getName());
RDN[] rdns = x500Name.getRDNs(BCStyle.CN);
if (rdns.length > 0) {
return IETFUtils.valueToString(rdns[0].getFirst().getValue());
}
} catch (Exception e) {
log.debug("Could not parse common name from {}", principal.getName());
}
return principal.getName();
}
private static String providerName(String libraryPath) {
String base = fileName(libraryPath).replaceAll("[^a-zA-Z0-9]", "");
if (base.isEmpty()) {
base = "token";
}
return "StirlingHW" + base;
}
private static String fileName(String path) {
try {
return Path.of(path).getFileName().toString();
} catch (Exception e) {
return path;
}
}
private static boolean sameFile(String a, String b) {
if (a == null || b == null) {
return false;
}
try {
Path pa = Path.of(a);
Path pb = Path.of(b);
if (Files.exists(pa) && Files.exists(pb)) {
return Files.isSameFile(pa, pb);
}
return pa.toAbsolutePath().normalize().equals(pb.toAbsolutePath().normalize());
} catch (Exception e) {
return a.equalsIgnoreCase(b);
}
}
}
@@ -62,6 +62,8 @@ security:
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
@@ -139,10 +141,10 @@ telegram:
botUsername: "" # Telegram bot username (without @)
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs. NOTE: only takes effect when allowUserIDs is non-empty; with an empty list every user is still allowed even when this is 'true'
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users (the enableAllowUserIDs toggle has no effect until this list is populated).
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs. NOTE: only takes effect when allowChannelIDs is non-empty; with an empty list every channel is still allowed even when this is 'true'
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels (the enableAllowChannelIDs toggle has no effect until this list is populated).
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
feedback:
@@ -170,7 +172,7 @@ legal:
system:
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
googlevisibility: false # 'true' serves an allow-all /robots.txt; 'false' serves a disallow-all /robots.txt to keep the instance out of search engines
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: true # see when a new update is available
showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
@@ -184,7 +186,7 @@ system:
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
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.
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
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.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
@@ -195,7 +197,7 @@ system:
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling PDF Inc # Organization name for generated certificates
organizationName: Stirling-PDF # Organization name for generated certificates
validity: 365 # Certificate validity in days
regenerateOnStartup: false # Generate new certificate on each startup
html:
@@ -302,7 +304,7 @@ autoPipeline:
allowedExtensions: [] # Optional extension allow-list (case-insensitive, without the leading dot). Empty list = accept all extensions. Example: ["pdf", "tiff"]
ui:
appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF"
appNameNavbar: "" # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
@@ -179,13 +179,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.code.gson:gson",
"moduleUrl": "https://github.com/google/gson",
"moduleVersion": "2.14.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
@@ -193,13 +186,6 @@
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
"moduleVersion": "2.48.0",
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.guava:failureaccess",
"moduleUrl": "https://github.com/google/guava/",
@@ -643,6 +629,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
"moduleVersion": "2.21.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -1 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24"><symbol id="icon-redact-auto" viewBox="0 0 24 24"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect width="24" height="24" style="fill:none"/><g><path d="M17.541,15.64258a.91793.91793,0,0,1,.55469-.18555h1.1084a.91586.91586,0,0,1,.55469.18555,1.30889,1.30889,0,0,1,.40429.499,1.57206,1.57206,0,0,1,.15039.68457v5.47754H19.2041V20.21094H18.0957v2.09277H16.9873V16.82617a1.55843,1.55843,0,0,1,.15039-.68457A1.2979,1.2979,0,0,1,17.541,15.64258Zm1.66309,1.10547H18.0957v2.17187h1.1084Z" style="fill:currentColor"/><path d="M5.68653,22.30351a2.00588,2.00588,0,0,1-2-2v-16A1.92585,1.92585,0,0,1,4.274,2.891a1.92585,1.92585,0,0,1,1.4125-.5875h8l6,6v5.66931h-2V9.30351h-5v-5h-7v16h9.74021v2Z" style="fill:currentColor"/><rect width="4.338" height=".795" x="7.698" y="10.432" style="fill:currentColor"/><rect width="7.312" height="1.213" x="7.698" y="12.169" style="fill:currentColor"/><rect width="7.312" height="1.213" x="7.698" y="17.146" style="fill:currentColor"/><rect width="7.312" height=".575" x="7.698" y="14.324" style="fill:currentColor"/><rect width="5.256" height=".448" x="7.698" y="15.798" style="fill:currentColor"/></g></g></g></symbol></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
<symbol id="icon-redact-auto" viewBox="0 0 24 24"> <g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<rect width="24" height="24" style="fill: none"/>
<g>
<path d="M17.541,15.64258a.91793.91793,0,0,1,.55469-.18555h1.1084a.91586.91586,0,0,1,.55469.18555,1.30889,1.30889,0,0,1,.40429.499,1.57206,1.57206,0,0,1,.15039.68457v5.47754H19.2041V20.21094H18.0957v2.09277H16.9873V16.82617a1.55843,1.55843,0,0,1,.15039-.68457A1.2979,1.2979,0,0,1,17.541,15.64258Zm1.66309,1.10547H18.0957v2.17187h1.1084Z" style="fill: currentColor"/>
<path d="M5.68653,22.30351a2.00588,2.00588,0,0,1-2-2v-16A1.92585,1.92585,0,0,1,4.274,2.891a1.92585,1.92585,0,0,1,1.4125-.5875h8l6,6v5.66931h-2V9.30351h-5v-5h-7v16h9.74021v2Z" style="fill: currentColor"/>
<rect x="7.69809" y="10.43189" width="4.33778" height="0.79501" style="fill: currentColor"/>
<rect x="7.69809" y="12.16889" width="7.31192" height="1.21288" style="fill: currentColor"/>
<rect x="7.69809" y="17.14555" width="7.31192" height="1.21288" style="fill: currentColor"/>
<rect x="7.69809" y="14.32375" width="7.31192" height="0.57517" style="fill: currentColor"/>
<rect x="7.69809" y="15.79848" width="5.25578" height="0.4475" style="fill: currentColor"/>
</g>
</g>
</g>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="800" version="1.1" viewBox="0 0 512 512"><title>rename</title><symbol id="icon-rename" viewBox="0 0 512 512"><g id="Page-1" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="Combined-Shape" fill="currentColor"><path d="M362.666667,1.42108547e-14 L362.666667,21.3333333 L320,21.333 L320,362.666 L362.666667,362.666667 L362.666667,384 L320,383.999 L320,384 L298.666667,384 L298.666,383.999 L256,384 L256,362.666667 L298.666,362.666 L298.666,21.333 L256,21.3333333 L256,1.42108547e-14 L362.666667,1.42108547e-14 Z M426.666667,64 L426.666667,320 L341.333333,320 L341.333333,277.333333 L384,277.333333 L384,106.666667 L341.333333,106.666667 L341.333333,64 L426.666667,64 Z M277.333333,64 L277.333333,320 L3.55271368e-14,320 L3.55271368e-14,64 L277.333333,64 Z M179.2,89.6 L149.333333,89.6 L149.333333,234.666667 C149.333333,248 148.5,256.333333 147.875,264.354167 L147.792993,265.422171 L147.792993,265.422171 L147.714003,266.48894 C147.417695,270.579012 147.2,274.696296 147.2,279.466667 L147.2,279.466667 L177.066667,279.466667 L177.066667,260.266667 C184.941497,273.926888 199.708077,282.130544 215.466667,281.6 C229.540046,281.805757 242.921593,275.508559 251.733333,264.533333 C263.162478,248.989677 269.832496,230.461848 270.933333,211.2 C270.933333,170.666667 249.6,142.933333 217.6,142.933333 C202.507405,142.999748 188.308689,150.099106 179.2,162.133333 L179.2,162.133333 L179.2,89.6 Z M119.466667,162.133333 C107.961824,149.843793 91.4322333,143.546807 74.6666667,145.066667 C57.6785115,144.485924 40.8138255,148.15216 25.6,155.733333 L25.6,155.733333 L34.1333333,177.066667 C45.3979052,171.147831 57.7246848,167.522308 70.4,166.4 C78.5613135,165.511423 86.6853595,168.371259 92.4903835,174.176283 C98.2954074,179.981307 101.155244,188.105353 100.266667,196.266667 L100.266667,196.266667 L100.266667,198.4 L78.9333333,198.4 C65.8181975,197.679203 52.705771,199.864608 40.5333333,204.8 C26.2806563,210.950309 17.6507691,225.621117 19.2,241.066667 C19.0625857,252.057651 23.6679763,262.574827 31.8381493,269.927982 C40.0083223,277.281138 50.9508304,280.757072 61.8666667,279.466667 C77.2795695,280.291768 92.2192911,274.001359 102.4,262.4 L102.4,262.4 L102.4,277.333333 L130.133333,277.333333 C128.292479,266.054406 127.577851,254.620365 128,243.2 L128,243.2 L128,204.8 C129.999138,190.023932 126.995128,175.003882 119.466667,162.133333 Z M98.1333333,213.333333 L98.1333333,238.933333 C92.082572,249.988391 80.836024,257.218314 68.2666667,258.133333 C63.0655139,258.520242 57.9538681,256.621996 54.2659359,252.934064 C50.5780036,249.246132 48.6797582,244.134486 49.0666667,238.933333 C49.0666667,224 59.7333333,215.466667 85.3333333,213.333333 L85.3333333,213.333333 L98.1333333,213.333333 Z M209.066667,166.4 C226.133333,166.4 238.933333,183.466667 238.933333,211.2 C238.933333,238.933333 228.266667,256 211.2,256 C197.298049,255.69869 184.825037,247.383349 179.2,234.666667 L179.2,234.666667 L179.2,187.733333 C185.154203,176.240507 196.263981,168.304951 209.066667,166.4 Z" transform="translate(42.666667, 64.000000)"/></g></g></symbol></svg>
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>rename</title>
<symbol id="icon-rename" viewBox="0 0 512 512">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Combined-Shape" fill="currentColor" transform="translate(42.666667, 64.000000)">
<path d="M362.666667,1.42108547e-14 L362.666667,21.3333333 L320,21.333 L320,362.666 L362.666667,362.666667 L362.666667,384 L320,383.999 L320,384 L298.666667,384 L298.666,383.999 L256,384 L256,362.666667 L298.666,362.666 L298.666,21.333 L256,21.3333333 L256,1.42108547e-14 L362.666667,1.42108547e-14 Z M426.666667,64 L426.666667,320 L341.333333,320 L341.333333,277.333333 L384,277.333333 L384,106.666667 L341.333333,106.666667 L341.333333,64 L426.666667,64 Z M277.333333,64 L277.333333,320 L3.55271368e-14,320 L3.55271368e-14,64 L277.333333,64 Z M179.2,89.6 L149.333333,89.6 L149.333333,234.666667 C149.333333,248 148.5,256.333333 147.875,264.354167 L147.792993,265.422171 L147.792993,265.422171 L147.714003,266.48894 C147.417695,270.579012 147.2,274.696296 147.2,279.466667 L147.2,279.466667 L177.066667,279.466667 L177.066667,260.266667 C184.941497,273.926888 199.708077,282.130544 215.466667,281.6 C229.540046,281.805757 242.921593,275.508559 251.733333,264.533333 C263.162478,248.989677 269.832496,230.461848 270.933333,211.2 C270.933333,170.666667 249.6,142.933333 217.6,142.933333 C202.507405,142.999748 188.308689,150.099106 179.2,162.133333 L179.2,162.133333 L179.2,89.6 Z M119.466667,162.133333 C107.961824,149.843793 91.4322333,143.546807 74.6666667,145.066667 C57.6785115,144.485924 40.8138255,148.15216 25.6,155.733333 L25.6,155.733333 L34.1333333,177.066667 C45.3979052,171.147831 57.7246848,167.522308 70.4,166.4 C78.5613135,165.511423 86.6853595,168.371259 92.4903835,174.176283 C98.2954074,179.981307 101.155244,188.105353 100.266667,196.266667 L100.266667,196.266667 L100.266667,198.4 L78.9333333,198.4 C65.8181975,197.679203 52.705771,199.864608 40.5333333,204.8 C26.2806563,210.950309 17.6507691,225.621117 19.2,241.066667 C19.0625857,252.057651 23.6679763,262.574827 31.8381493,269.927982 C40.0083223,277.281138 50.9508304,280.757072 61.8666667,279.466667 C77.2795695,280.291768 92.2192911,274.001359 102.4,262.4 L102.4,262.4 L102.4,277.333333 L130.133333,277.333333 C128.292479,266.054406 127.577851,254.620365 128,243.2 L128,243.2 L128,204.8 C129.999138,190.023932 126.995128,175.003882 119.466667,162.133333 Z M98.1333333,213.333333 L98.1333333,238.933333 C92.082572,249.988391 80.836024,257.218314 68.2666667,258.133333 C63.0655139,258.520242 57.9538681,256.621996 54.2659359,252.934064 C50.5780036,249.246132 48.6797582,244.134486 49.0666667,238.933333 C49.0666667,224 59.7333333,215.466667 85.3333333,213.333333 L85.3333333,213.333333 L98.1333333,213.333333 Z M209.066667,166.4 C226.133333,166.4 238.933333,183.466667 238.933333,211.2 C238.933333,238.933333 228.266667,256 211.2,256 C197.298049,255.69869 184.825037,247.383349 179.2,234.666667 L179.2,234.666667 L179.2,187.733333 C185.154203,176.240507 196.263981,168.304951 209.066667,166.4 Z">
</path>
</g>
</g>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -9,7 +9,6 @@ import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import org.apache.pdfbox.Loader;
@@ -303,11 +302,6 @@ class RearrangePagesPDFControllerTest {
assertNotNull(response);
// 2 pages * 3 duplicates = 6 final pages
assertEquals(6, realDoc.getNumberOfPages());
// Each duplicate must be a distinct page node in the saved output; a shared
// node under multiple /Kids is an invalid tree readers reject as cyclic.
List<Object> savedPages = reloadAndSnapshot(response);
assertEquals(6, savedPages.size());
assertEquals(6, new HashSet<>(savedPages).size());
}
}
@@ -329,29 +323,4 @@ class RearrangePagesPDFControllerTest {
assertEquals(4, realDoc.getNumberOfPages());
}
}
@Test
void testRearrangePages_SideStitchBooklet_RepeatedPaddingPagesAreDistinctNodes()
throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("SIDE_STITCH_BOOKLET_SORT");
// 6 pages is not a multiple of 4, so booklet padding repeats the last page index
// several times; each repeat must be a distinct page node, not one shared node.
try (PDDocument realDoc = buildRealPdf(6)) {
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
ResponseEntity<Resource> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertEquals(8, realDoc.getNumberOfPages());
List<Object> savedPages = reloadAndSnapshot(response);
assertEquals(8, savedPages.size());
assertEquals(8, new HashSet<>(savedPages).size());
}
}
}
@@ -30,10 +30,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -54,8 +51,6 @@ class CertSignControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private HardwareKeyStoreService hardwareKeyStoreService;
@Mock private HttpServletRequest httpRequest;
@InjectMocks private CertSignController certSignController;
@@ -174,8 +169,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -201,8 +195,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -228,7 +221,7 @@ class CertSignControllerTest {
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> certSignController.signPDFWithCert(request, httpRequest));
() -> certSignController.signPDFWithCert(request));
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
}
@@ -254,8 +247,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -286,8 +278,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -318,8 +309,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -350,8 +340,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -382,8 +371,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -299,18 +299,6 @@ class RedactControllerMoreTest {
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
}
@Test
@DisplayName("null listOfText throws an illegal-argument error before any load")
void nullPatternsThrows() throws Exception {
RedactPdfRequest request = new RedactPdfRequest();
request.setFileInput(pdfFile(new byte[] {1, 2, 3}));
request.setListOfText(null);
assertThatThrownBy(() -> controller.redactPdf(request))
.isInstanceOf(RuntimeException.class);
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
}
@Test
@DisplayName("null file input is reported as a failure")
void nullFileThrows() {
@@ -89,13 +89,9 @@ class CertificateValidationServiceMoreTest {
}
private static ApplicationProperties defaultProps() {
// Test baseline: every trust source explicitly off so each test enables only what it
// exercises (the shipped POJO defaults now enable system + Mozilla trust).
// Real POJO defaults: trust all off, revocation "none".
ApplicationProperties props = new ApplicationProperties();
var trust = props.getSecurity().getValidation().getTrust();
trust.setServerAsAnchor(false);
trust.setUseSystemTrust(false);
trust.setUseMozillaBundle(false);
props.getSecurity().getValidation().getTrust().setServerAsAnchor(false);
return props;
}
@@ -1,146 +0,0 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
/** Unit tests for the gating / allowlist logic that protects the hardware signing paths. */
class HardwareKeyStoreServiceTest {
private static final String PKCS11_PROP = "stirling.pkcs11.libraries";
private HardwareKeyStoreService service(String machineType) {
return new HardwareKeyStoreService(machineType);
}
@Test
void isDesktop_trueOnlyForClientMachineTypes() {
assertTrue(service("Client-windows").isDesktop());
assertTrue(service("Client-mac").isDesktop());
assertTrue(service("Client-unix").isDesktop());
assertFalse(service("Server-jar").isDesktop());
assertFalse(service("Docker").isDesktop());
assertFalse(service(null).isDesktop());
}
@Test
void isDesktop_trueInTauriModeEvenWithoutClientMachineType() {
// The Tauri bundle sets STIRLING_PDF_TAURI_MODE=true while machineType stays Server-jar.
String previous = System.getProperty("STIRLING_PDF_TAURI_MODE");
try {
System.setProperty("STIRLING_PDF_TAURI_MODE", "true");
assertTrue(service("Server-jar").isDesktop());
assertTrue(service(null).isDesktop());
} finally {
if (previous == null) {
System.clearProperty("STIRLING_PDF_TAURI_MODE");
} else {
System.setProperty("STIRLING_PDF_TAURI_MODE", previous);
}
}
}
@Test
void capabilities_notDesktop_reportsUnavailable() {
HardwareSigningCapabilities caps = service("Server-jar").capabilities();
assertFalse(caps.desktop());
assertFalse(caps.windowsStoreSupported());
assertFalse(caps.pkcs11Supported());
assertTrue(caps.detectedLibraries().isEmpty());
}
@Test
void capabilities_desktop_reportsOsName() {
HardwareSigningCapabilities caps = service("Client-windows").capabilities();
assertTrue(caps.desktop());
assertFalse(caps.osName().isBlank());
}
@Test
void assertLocalDesktop_rejectsNonDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
assertThrows(
IllegalArgumentException.class,
() -> service("Server-jar").assertLocalDesktop(request));
}
@Test
void assertLocalDesktop_rejectsRemoteCallerEvenOnDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
// 203.0.113.0/24 is TEST-NET-3 (RFC 5737) - never a real local interface address.
when(request.getRemoteAddr()).thenReturn("203.0.113.5");
assertThrows(
IllegalArgumentException.class,
() -> service("Client-windows").assertLocalDesktop(request));
}
@Test
void assertLocalDesktop_allowsLoopbackOnDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(request));
// No servlet context (e.g. internal call) is also allowed.
assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(null));
}
@Test
void isLocalRequest_acceptsLoopbackForms_rejectsRemote() {
assertTrue(HardwareKeyStoreService.isLocalRequest("127.0.0.1"));
assertTrue(HardwareKeyStoreService.isLocalRequest("::1"));
assertTrue(HardwareKeyStoreService.isLocalRequest("0:0:0:0:0:0:0:1"));
// IPv4-mapped IPv6 loopback - what Tomcat reports for the desktop webview.
assertTrue(HardwareKeyStoreService.isLocalRequest("::ffff:127.0.0.1"));
assertFalse(HardwareKeyStoreService.isLocalRequest("203.0.113.5"));
assertFalse(HardwareKeyStoreService.isLocalRequest(null));
}
@Test
void validateLibraryAllowed_blankPath_throws() {
assertThrows(
IllegalArgumentException.class,
() -> service("Client-windows").validateLibraryAllowed(" "));
}
@Test
void validateLibraryAllowed_unknownPath_throws() {
assertThrows(
IllegalArgumentException.class,
() ->
service("Client-windows")
.validateLibraryAllowed("/definitely/not/a/real/driver.so"));
}
@Test
void validateLibraryAllowed_configuredPath_isAllowed(@TempDir Path tempDir) throws Exception {
Path fakeDriver = Files.createFile(tempDir.resolve("fake-pkcs11.so"));
String previous = System.getProperty(PKCS11_PROP);
try {
System.setProperty(PKCS11_PROP, fakeDriver.toString());
HardwareKeyStoreService service = service("Client-windows");
assertDoesNotThrow(() -> service.validateLibraryAllowed(fakeDriver.toString()));
assertTrue(
service.detectPkcs11Libraries().stream()
.anyMatch(l -> l.path().equals(fakeDriver.toString())));
} finally {
if (previous == null) {
System.clearProperty(PKCS11_PROP);
} else {
System.setProperty(PKCS11_PROP, previous);
}
}
}
}
-18
View File
@@ -66,20 +66,6 @@ dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
// version directly so it is selected consistently (root build.gradle pins are the fallback).
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
// Compile-time-only annotation artifacts (class-retention annotations, not needed at
// runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
exclude group: 'com.google.code.findbugs', module: 'annotations'
exclude group: 'org.derive4j', module: 'derive4j-annotation'
exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
}
api 'io.micrometer:micrometer-registry-prometheus'
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
@@ -94,10 +80,6 @@ dependencies {
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
// @DataJpaTest slice (Boot 4 ships test slices as separate starters, like webmvc-test at the
// root) so policy.source repositories can be exercised against embedded H2.
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
// Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without
// manually-started instances. Tests skip cleanly when Docker is unavailable.
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
@@ -1,29 +0,0 @@
package stirling.software.proprietary.access.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import stirling.software.proprietary.access.service.DefaultPrincipalResolver;
import stirling.software.proprietary.access.service.DefaultTeamLeadLookup;
import stirling.software.proprietary.access.service.PrincipalResolver;
import stirling.software.proprietary.access.service.TeamLeadLookup;
/** Access-layer bean wiring. */
@Configuration
public class AccessConfig {
/** No-op {@link TeamLeadLookup} unless another bean is defined. */
@Bean
@ConditionalOnMissingBean(TeamLeadLookup.class)
TeamLeadLookup defaultTeamLeadLookup() {
return new DefaultTeamLeadLookup();
}
/** USER/TEAM projection unless another bean is defined (e.g. the saas resolver). */
@Bean
@ConditionalOnMissingBean(PrincipalResolver.class)
PrincipalResolver defaultPrincipalResolver() {
return new DefaultPrincipalResolver();
}
}
@@ -1,130 +0,0 @@
package stirling.software.proprietary.access.controller;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
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.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.access.model.AccessPermission;
import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.ResourceAccessService;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */
@RestController
@RequestMapping("/api/v1/admin/access")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)")
public class ResourceGrantController {
private final ResourceAccessService accessService;
private final UserRepository userRepository;
private final TeamRepository teamRepository;
@GetMapping("/grants")
public ResponseEntity<?> list(
@RequestParam ResourceType resourceType,
@RequestParam(required = false, defaultValue = "") String resourceId) {
List<ResourceGrant> grants = accessService.listGrants(resourceType, resourceId);
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
}
@GetMapping("/grants/by-principal")
public ResponseEntity<?> listByPrincipal(
@RequestParam PrincipalType principalType, @RequestParam Long principalId) {
List<ResourceGrant> grants =
accessService.listGrantsForPrincipal(principalType, principalId);
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
}
@PostMapping("/grants")
public ResponseEntity<?> create(
@RequestBody GrantRequest request, @AuthenticationPrincipal User admin) {
if (request.resourceType() == null
|| request.principalType() == null
|| request.principalId() == null) {
return ResponseEntity.badRequest()
.body(
Map.of(
"error",
"resourceType, principalType and principalId are required"));
}
// PORTAL is a singleton (empty resourceId); every other type must name a resource.
boolean portal = request.resourceType() == ResourceType.PORTAL;
if (!portal && (request.resourceId() == null || request.resourceId().isBlank())) {
return ResponseEntity.badRequest()
.body(Map.of("error", "resourceId is required for " + request.resourceType()));
}
Long principalId = request.principalId();
String principalError = validatePrincipalExists(request.principalType(), principalId);
if (principalError != null) {
return ResponseEntity.badRequest().body(Map.of("error", principalError));
}
AccessPermission permission =
request.permission() == null ? AccessPermission.USE : request.permission();
String resourceId = portal ? "" : request.resourceId();
ResourceGrant grant =
accessService.grant(
request.resourceType(),
resourceId,
request.principalType(),
principalId,
permission,
admin);
return ResponseEntity.ok(toDto(grant));
}
@DeleteMapping("/grants/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
accessService.revoke(id);
return ResponseEntity.ok(Map.of("message", "Grant revoked"));
}
// Rejects grants to nonexistent principals (dead rows otherwise).
private String validatePrincipalExists(PrincipalType type, Long id) {
return switch (type) {
case USER -> userRepository.existsById(id) ? null : "User " + id + " does not exist";
case TEAM -> teamRepository.existsById(id) ? null : "Team " + id + " does not exist";
};
}
private Map<String, Object> toDto(ResourceGrant g) {
Map<String, Object> m = new HashMap<>();
m.put("id", g.getId());
m.put("resourceType", g.getResourceType());
m.put("resourceId", g.getResourceId());
m.put("principalType", g.getPrincipalType());
m.put("principalId", g.getPrincipalId());
m.put("permission", g.getPermission());
m.put("createdAt", g.getCreatedAt());
return m;
}
/** Request body for creating a grant. */
public record GrantRequest(
ResourceType resourceType,
String resourceId,
PrincipalType principalType,
Long principalId,
AccessPermission permission) {}
}
@@ -1,7 +0,0 @@
package stirling.software.proprietary.access.model;
/** Permission level a grant confers. MANAGE implies USE. */
public enum AccessPermission {
USE,
MANAGE
}
@@ -1,14 +0,0 @@
package stirling.software.proprietary.access.model;
/**
* Fallback policy applied when no explicit {@link ResourceGrant} matches. Admins (org owners)
* always pass regardless of this policy.
*/
public enum DefaultAccessPolicy {
// Every authenticated user in the deployment (org) may use the resource.
ORG_ALL,
// Only org admins and team leaders. This is the default for the portal.
ADMINS_AND_TEAM_LEADS,
// Nobody but the owner, admins, and explicit grantees.
EXPLICIT_ONLY
}
@@ -1,68 +0,0 @@
package stirling.software.proprietary.access.model;
import jakarta.persistence.Column;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
/** Base for a resource owned by a user, a team, or the server, with grant-based access. */
@MappedSuperclass
@Getter
@Setter
public abstract class OwnedResource {
@Enumerated(EnumType.STRING)
@Column(name = "scope", nullable = false, length = 32)
private OwnerScope scope;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_user_id")
private User ownerUser;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_team_id")
private Team ownerTeam;
@Column(name = "enabled", nullable = false)
private boolean enabled = true;
// Server resource that users cannot override with their own of the same kind.
@Column(name = "locked", nullable = false)
private boolean locked = false;
// Who, besides owner/admin/grantees, may use this resource.
@Enumerated(EnumType.STRING)
@Column(name = "default_access", nullable = false, length = 32)
private DefaultAccessPolicy defaultAccess = DefaultAccessPolicy.EXPLICIT_ONLY;
/** Subclass primary key. */
public abstract Long getId();
public Long getOwnerUserId() {
return ownerUser != null ? ownerUser.getId() : null;
}
public Long getOwnerTeamId() {
return ownerTeam != null ? ownerTeam.getId() : null;
}
/** Owner as a principal ref; null when server-owned (admin-only ownership). */
public PrincipalRef getOwnerRef() {
if (getOwnerUserId() != null) {
return PrincipalRef.user(getOwnerUserId());
}
if (getOwnerTeamId() != null) {
return PrincipalRef.team(getOwnerTeamId());
}
return null;
}
}

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