mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
135
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c600c0d89 | ||
|
|
5ef2947c01 | ||
|
|
5bbe26072f | ||
|
|
efc6b91648 | ||
|
|
0661f90267 | ||
|
|
02a7db0f1e | ||
|
|
2533baaeb3 | ||
|
|
ced8e5e52c | ||
|
|
e4c9b0410c | ||
|
|
53086a12c2 | ||
|
|
4d6c8341c8 | ||
|
|
113a5c7372 | ||
|
|
5f2001c708 | ||
|
|
18fb663b4d | ||
|
|
73668bcae8 | ||
|
|
80501e599c | ||
|
|
a2ff8f8c8c | ||
|
|
547dce4cf3 | ||
|
|
aba9275ea7 | ||
|
|
71957d5da5 | ||
|
|
536a9ebaa7 | ||
|
|
c8db0a912e | ||
|
|
88012c6861 | ||
|
|
e76a22e520 | ||
|
|
e565251991 | ||
|
|
47c68af068 | ||
|
|
5412dd4d4f | ||
|
|
d8c6af2e7a | ||
|
|
9d473fb435 | ||
|
|
041b6a4b5f | ||
|
|
8990f55e50 | ||
|
|
d5e3c3e6e1 | ||
|
|
6094040e0a | ||
|
|
6933d11bce | ||
|
|
46865cd154 | ||
|
|
3bee6d212e | ||
|
|
9d01866c83 | ||
|
|
ca3b87e7fd | ||
|
|
7435b4f7ed | ||
|
|
b35329c8f5 | ||
|
|
4dc0927104 | ||
|
|
a4f1696aba | ||
|
|
b4a264239c | ||
|
|
bbd4d2c3ac | ||
|
|
4d207f0c3f | ||
|
|
8a5470dd01 | ||
|
|
999b5e5995 | ||
|
|
66b80a80c0 | ||
|
|
e05b7f12da | ||
|
|
dff3101ca7 | ||
|
|
af1acb68d5 | ||
|
|
9eb8e2b7fd | ||
|
|
08d670cfb3 | ||
|
|
22ec0947c9 | ||
|
|
ba404d3f90 | ||
|
|
a9c50f2501 | ||
|
|
a380a82234 | ||
|
|
0b953f26b3 | ||
|
|
6b4e20b705 | ||
|
|
4e73d62530 | ||
|
|
8f50e373c4 | ||
|
|
3813ca360e | ||
|
|
1681b5d298 | ||
|
|
831bd4fe94 | ||
|
|
4fbb2fe885 | ||
|
|
54bf32485f | ||
|
|
a1b1f974a0 | ||
|
|
29002d0b82 | ||
|
|
1e2895a79f | ||
|
|
67e10138b5 | ||
|
|
8de94ff152 | ||
|
|
b3875d3149 | ||
|
|
357eb77f94 | ||
|
|
718277a934 | ||
|
|
3bf0019d7c | ||
|
|
7e76097ac1 | ||
|
|
ce7f74a3c1 | ||
|
|
50c0f2bcb5 | ||
|
|
fab00f3fe2 | ||
|
|
b5b9cc443f | ||
|
|
a4ac034a18 | ||
|
|
e24a30828b | ||
|
|
e2ea720fc8 | ||
|
|
a4ffdc7831 | ||
|
|
621731bda1 | ||
|
|
5e3e89ccb2 | ||
|
|
f60a75c253 | ||
|
|
401894a065 | ||
|
|
15423c6479 | ||
|
|
f4560cccb9 | ||
|
|
43a6ff3066 | ||
|
|
14def378bf | ||
|
|
49cc3f88f9 | ||
|
|
79dc7d5615 | ||
|
|
bda9cebc5c | ||
|
|
0fc2958daa | ||
|
|
c19d56de22 | ||
|
|
1954d20910 | ||
|
|
b6b5077467 | ||
|
|
a63ab2db7b | ||
|
|
1ba9092999 | ||
|
|
99887f77a6 | ||
|
|
1b3e757686 | ||
|
|
bb830e5711 | ||
|
|
f4d760b139 | ||
|
|
4cfe29139a | ||
|
|
0be0c0839b | ||
|
|
c6e84a2124 | ||
|
|
72e8ba2951 | ||
|
|
4230d2902b | ||
|
|
2c1a666889 | ||
|
|
47fe4a1d06 | ||
|
|
b509fae0c8 | ||
|
|
8b179fbc55 | ||
|
|
ecddce9944 | ||
|
|
398f473316 | ||
|
|
bc96eed040 | ||
|
|
d271b8f357 | ||
|
|
6b2ab5a743 | ||
|
|
275d70242e | ||
|
|
1835e6fe6b | ||
|
|
939afef14f | ||
|
|
2118de0bb7 | ||
|
|
963fe6c0cf | ||
|
|
bef83d80a1 | ||
|
|
8ed1ff152b | ||
|
|
d2c8bbfcb2 | ||
|
|
da1be650ea | ||
|
|
5cb5a866ca | ||
|
|
83e5319661 | ||
|
|
ed58d90ab8 | ||
|
|
4ee54243f3 | ||
|
|
350c0b796e | ||
|
|
663bb32b2c | ||
|
|
b86e963c9d |
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: pr-quiz
|
||||
description: >-
|
||||
Quiz the PR author on their own branch before they request review, to prove they
|
||||
actually understand the change - especially code an AI wrote for them. Scopes the
|
||||
branch diff vs its base, reads the changed code, then asks graded questions about
|
||||
what changed, why, how it works, what it could break, and which edge cases it must
|
||||
handle. Presents all questions first, waits for the author's answers, then grades
|
||||
each honestly against the real code (Correct / Partial / Incorrect with the true
|
||||
answer and file:line), scores it, and gives a readiness verdict that names the
|
||||
areas to re-study before asking humans to review. Use when asked to quiz me on my
|
||||
PR/branch, "test my understanding before review", a self-check gate before opening
|
||||
a PR, or before requesting reviewers. Administered as an interactive
|
||||
multiple-choice quiz (clickable options) by default; pass --free-text for
|
||||
written answers, --questions N to set count, --save to write a scorecard.
|
||||
argument-hint: "[branch-or-base-ref] [--questions N] [--free-text] [--save]"
|
||||
allowed-tools: Bash, Read, Grep, Glob, Write, AskUserQuestion
|
||||
---
|
||||
|
||||
# PR Quiz
|
||||
|
||||
Test whether the **author** genuinely understands their own branch before they ask
|
||||
other people to spend time reviewing it. This is a self-check gate: the point is to
|
||||
catch changes - often AI-written - that the author would not be able to explain or
|
||||
defend in review. Be a fair but honest examiner, not a pushover.
|
||||
|
||||
`$ARGUMENTS` may name a base ref or branch to diff against; default is this branch
|
||||
vs where it forked from the main line. Flags:
|
||||
- `--questions N` - target N questions (else scale to diff size, see below).
|
||||
- `--free-text` - administer as a written numbered list instead of the default
|
||||
interactive multiple-choice.
|
||||
- `--save` - also write a scorecard file after grading.
|
||||
|
||||
## Integrity rules (read first - the whole skill depends on these)
|
||||
|
||||
1. **Present every question before revealing any answer.** Ask, then wait. Never
|
||||
show the answer key alongside the questions.
|
||||
2. **Do not give hints or the answer while the quiz is open.** If the author asks
|
||||
"what's the answer?" or "is it X?" before committing, decline warmly and tell
|
||||
them to give their best answer first - guessing is part of the signal.
|
||||
3. **Grade truthfully.** Vague, hand-wavy, or "the AI did it" non-answers are
|
||||
Partial or Incorrect, not Correct. Do not inflate the score to be nice; a false
|
||||
pass defeats the entire purpose.
|
||||
4. **Ground everything in code you actually read.** Every question and every model
|
||||
answer must trace to a real line in the diff. Cite `path:line`. No trivia
|
||||
("how many lines?"), no invented behavior.
|
||||
5. **Credit real understanding.** If the author explains it correctly in their own
|
||||
words, mark it Correct even if worded differently than your key.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the change (silently)
|
||||
- Find the base. Prefer the fork point off the main line so the quiz covers only
|
||||
this branch's work:
|
||||
```bash
|
||||
git fetch -q origin 2>/dev/null; \
|
||||
BASE=$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main); \
|
||||
git diff --stat "$BASE"...HEAD
|
||||
```
|
||||
If `$ARGUMENTS` names a ref, diff against that instead.
|
||||
- If the diff is empty, stop and say there's nothing to quiz on.
|
||||
- Read commit messages / PR description for the *stated* intent, but verify it
|
||||
against the actual diff - a mismatch is itself a good question.
|
||||
|
||||
### 2. Understand the code well enough to examine on it
|
||||
Read the full diff plus enough surrounding context and related files to answer
|
||||
every question you plan to ask. You cannot grade understanding you don't have.
|
||||
Note the non-obvious parts: the design decisions, the risky lines, the edge cases,
|
||||
the cross-file ripples, and anything that violates or upholds repo conventions
|
||||
(for this repo e.g. `@app/*` import layering, all file ops via FileContext,
|
||||
Jackson 3 / Spring Boot 4 APIs, engine typed-contract boundaries).
|
||||
|
||||
### 3. Build the question set
|
||||
Scale count to the change unless `--questions N` is given:
|
||||
small (< ~50 changed lines) 3-4, medium 5-8, large 9-12. Cap at 12.
|
||||
Draw from these categories - weight toward the ones the diff actually exercises:
|
||||
- **Intent** - what problem this solves; why it was needed now.
|
||||
- **Mechanism** - how a specific non-trivial piece actually works ("walk me
|
||||
through what `foo()` does when called with X").
|
||||
- **Decisions & alternatives** - why this approach over an obvious alternative;
|
||||
what a reviewer would reasonably push back on.
|
||||
- **Blast radius** - what else this touches or could break; what you'd retest.
|
||||
- **Edge cases** - inputs/states the change must handle (null, empty, large,
|
||||
concurrent, error paths).
|
||||
- **Conventions & correctness** - does it follow the repo's rules; is there a
|
||||
latent bug the author should be able to spot.
|
||||
Prefer questions the author can only answer if they read and understood the code.
|
||||
Keep a private answer key with `path:line` for each - do **not** show it yet.
|
||||
|
||||
### 4. Administer the quiz
|
||||
- **Default (multiple choice):** use the `AskUserQuestion` tool. Per question write
|
||||
3-4 options where **every** option is independently plausible - each distractor a
|
||||
real-but-wrong reading of the code, not filler. Two hard rules so the answer
|
||||
can't be spotted by shape rather than knowledge:
|
||||
- **Randomise the correct option's position** across questions - never default
|
||||
it to first. Spread it roughly evenly over the slots.
|
||||
- **Keep all options the same depth and length.** Do not describe the correct
|
||||
one more fully than the distractors - a longer or more-detailed option is a
|
||||
dead giveaway. Trim the right answer or flesh out the wrong ones until a
|
||||
reader can't tell them apart by size.
|
||||
The tool caps a call at 4 questions, so ask in batches of 4 - but run them as
|
||||
one continuous flow: fire the next batch immediately after the previous
|
||||
returns, with no narration ("Round 2 of 3") and no grading between batches.
|
||||
The author always has an "Other" free-text escape, which is fine.
|
||||
- **`--free-text`:** present all questions in one numbered list, then say
|
||||
"Answer in one reply; number your answers. I won't grade until you're done."
|
||||
Wait for the author's answers.
|
||||
- Do not proceed to grading until every answer is in.
|
||||
|
||||
### 5. Grade
|
||||
For each question, in order:
|
||||
- Verdict: **Correct** / **Partial** / **Incorrect**.
|
||||
- The model answer in one or two sentences, citing the real `path:line`.
|
||||
- One line on the gap when Partial/Incorrect - what they missed and where to look.
|
||||
Then a **Score** (e.g. 6/8, counting Partial as half) and a one-line summary of
|
||||
the pattern (e.g. "solid on intent, shaky on the error paths").
|
||||
|
||||
### 6. Readiness verdict
|
||||
End with a clear call:
|
||||
- **Ready for review** - understanding is sound; note anything to mention to
|
||||
reviewers proactively.
|
||||
- **Study first** - list the specific files/concepts to re-read before requesting
|
||||
review, each as a clickable `path:line`. Be concrete: "re-read the null handling
|
||||
in X before you send this out."
|
||||
Keep it honest - if they'd get grilled in review on something, say so now.
|
||||
|
||||
### 7. If `--save`
|
||||
Write `pr-quiz/<branch>-scorecard.md`: the questions, their answers, your grades
|
||||
and model answers, the score, and the verdict. Don't commit it unless asked.
|
||||
|
||||
## Principles
|
||||
- **The author is the examinee, not the collaborator.** During the quiz you withhold
|
||||
answers; you're measuring them, not helping them pass.
|
||||
- **A failed quiz is a successful outcome** - it caught a gap before a human's time
|
||||
was spent. Frame it that way, not as a scolding.
|
||||
- **True to the code.** Every question, answer, and grade traces to a line you read.
|
||||
- **Terse and direct** in chat - the questions and the verdict, minimal preamble.
|
||||
@@ -1,16 +1,35 @@
|
||||
# CI routing infra. Editing the top-level router (build.yml) or this filter
|
||||
# config re-runs every area's jobs, so every job-gating filter below includes
|
||||
# *ci. That makes a change to how jobs are dispatched actually exercise those
|
||||
# jobs (self-testing), instead of a router edit only matching the project filter.
|
||||
ci: &ci
|
||||
- .github/workflows/build.yml
|
||||
- .github/config/.files.yaml
|
||||
|
||||
build: &build
|
||||
- *ci
|
||||
- build.gradle
|
||||
- gradle/spotless.gradle
|
||||
- app/(common|core|proprietary|saas)/build.gradle
|
||||
- Taskfile.yml
|
||||
- .taskfiles/backend.yml
|
||||
- .github/workflows/check-licence.yml
|
||||
|
||||
openapi: &openapi
|
||||
- *ci
|
||||
- *build
|
||||
- app/(common|core|proprietary|saas)/src/main/java/**
|
||||
- .github/workflows/check-openapi.yml
|
||||
|
||||
docker-base: &docker-base
|
||||
- docker/base/Dockerfile
|
||||
|
||||
# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
|
||||
# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
|
||||
# Dockerfile itself changes, not on every code PR.
|
||||
dockerfiles: &dockerfiles
|
||||
- docker/**/Dockerfile*
|
||||
|
||||
docker: &docker
|
||||
- docker/embedded/Dockerfile
|
||||
- docker/embedded/Dockerfile.fat
|
||||
@@ -23,13 +42,11 @@ docker: &docker
|
||||
- *docker-base
|
||||
|
||||
project: &project
|
||||
- *ci
|
||||
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
|
||||
- *build
|
||||
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
- "testing/**/!(requirements*.txt|requirements*.in)*"
|
||||
- *docker
|
||||
- *docker-base
|
||||
- gradle.properties
|
||||
@@ -45,8 +62,11 @@ project: &project
|
||||
- .taskfiles/docker.yml
|
||||
- scripts/db-migration/**
|
||||
- .github/workflows/db-migration-test.yml
|
||||
- .github/workflows/docker-compose-tests.yml
|
||||
- .github/workflows/test-build-docker.yml
|
||||
|
||||
frontend: &frontend
|
||||
- *ci
|
||||
- frontend/**
|
||||
- .github/workflows/testdriver.yml
|
||||
- testing/**
|
||||
@@ -63,10 +83,15 @@ frontend: &frontend
|
||||
- Taskfile.yml
|
||||
- .taskfiles/frontend.yml
|
||||
- .taskfiles/e2e.yml
|
||||
- .github/workflows/frontend-validation.yml
|
||||
- .github/workflows/frontend-a11y.yml
|
||||
- .github/workflows/e2e-stubbed.yml
|
||||
- .github/workflows/e2e-live.yml
|
||||
|
||||
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
|
||||
# job on changes to any of these.
|
||||
tauri: &tauri
|
||||
- *ci
|
||||
- frontend/editor/src-tauri/**
|
||||
- frontend/editor/src/desktop/**
|
||||
- frontend/editor/tsconfig.desktop.vite.json
|
||||
@@ -81,6 +106,7 @@ tauri: &tauri
|
||||
# the engine validation job on changes to engine sources or to the Java
|
||||
# tool surfaces it generates models from.
|
||||
engine: &engine
|
||||
- *ci
|
||||
- engine/**
|
||||
- app/(common|core|proprietary|saas)/src/main/java/**
|
||||
- .github/workflows/ai-engine.yml
|
||||
@@ -93,6 +119,7 @@ engine: &engine
|
||||
# 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
|
||||
- *ci
|
||||
- *openapi
|
||||
- frontend/editor/scripts/generate-tool-api-types.mts
|
||||
- frontend/editor/src/core/types/toolApiTypes.ts
|
||||
@@ -115,6 +142,7 @@ licenses-backend: &licenses-backend
|
||||
# Files that can affect premium / enterprise behaviour. Gate the enterprise
|
||||
# Playwright job on changes to any of these on PRs.
|
||||
proprietary: &proprietary
|
||||
- *ci
|
||||
- app/proprietary/**
|
||||
- frontend/editor/src/proprietary/**
|
||||
- frontend/editor/src/core/tests/enterprise/**
|
||||
@@ -129,4 +157,5 @@ proprietary: &proprietary
|
||||
- configs/settings.yml.template
|
||||
- build.gradle
|
||||
- app/proprietary/build.gradle
|
||||
- gradle/spotless.gradle
|
||||
- .github/workflows/build-enterprise.yml
|
||||
|
||||
@@ -163,7 +163,7 @@ labels:
|
||||
- '.github/workflows/scorecards.yml'
|
||||
- 'exampleYmlFiles/test_cicd.yml'
|
||||
|
||||
- label: 'Github'
|
||||
- label: 'GitHub'
|
||||
files:
|
||||
- '.github/.*'
|
||||
|
||||
|
||||
+11
-7
@@ -5,6 +5,7 @@
|
||||
# the GitHub Action https://github.com/marketplace/actions/github-labeler.
|
||||
- name: "Licenses"
|
||||
color: "EDEDED"
|
||||
description: "Issues or pull requests related to licenses"
|
||||
from_name: "licenses"
|
||||
- name: "Back End"
|
||||
color: "20CE6C"
|
||||
@@ -146,21 +147,21 @@
|
||||
description: "Changes that do not affect the meaning of the code (formatting, etc.)"
|
||||
- name: "admin"
|
||||
color: "195055"
|
||||
- name: "codex"
|
||||
color: "ededed"
|
||||
description: null
|
||||
- name: "Github"
|
||||
- name: "GitHub"
|
||||
color: "0052CC"
|
||||
- name: "github_actions"
|
||||
color: "000000"
|
||||
description: "Pull requests that update GitHub Actions code"
|
||||
description: "Issues or pull requests related to GitHub configuration and integrations"
|
||||
from_name: "Github"
|
||||
- name: "needs-changes"
|
||||
color: "A65A86"
|
||||
description: "Pull requests that require changes before they can be merged"
|
||||
- name: "on-hold"
|
||||
color: "2526F9"
|
||||
- name: "python"
|
||||
color: "2b67c6"
|
||||
description: "Pull requests that update Python code"
|
||||
- name: "engine"
|
||||
color: "2b67c6"
|
||||
description: "Issues or pull requests related to the engine"
|
||||
- name: "size:L"
|
||||
color: "eb9500"
|
||||
description: "This PR changes 100-499 lines ignoring generated files."
|
||||
@@ -201,3 +202,6 @@
|
||||
- name: "license-review-required"
|
||||
color: "EDEDED"
|
||||
description: "This PR requires a license review"
|
||||
- name: "has conflicts"
|
||||
color: "D93F0B"
|
||||
description: "Pull request has merge conflicts with the base branch"
|
||||
|
||||
@@ -1,101 +1,116 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# This file is autogenerated by pip-compile with Python 3.13
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in'
|
||||
#
|
||||
# WARNING: pip install will require the following package to be hashed.
|
||||
# Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip
|
||||
# CVE-2025-6176 mitigation: pin brotli to a specific commit
|
||||
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
|
||||
# via
|
||||
# -r .github/scripts/requirements_dev.in
|
||||
# fonttools
|
||||
cffi==2.0.0 \
|
||||
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
|
||||
--hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
|
||||
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
|
||||
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
|
||||
--hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
|
||||
--hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
|
||||
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
|
||||
--hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
|
||||
--hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
|
||||
--hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
|
||||
--hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
|
||||
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
|
||||
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
|
||||
--hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
|
||||
--hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
|
||||
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
|
||||
--hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
|
||||
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
|
||||
--hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
|
||||
--hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
|
||||
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
|
||||
--hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
|
||||
--hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
|
||||
--hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
|
||||
--hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
|
||||
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
|
||||
--hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
|
||||
--hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
|
||||
--hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
|
||||
--hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
|
||||
--hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
|
||||
--hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
|
||||
--hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
|
||||
--hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
|
||||
--hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
|
||||
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
|
||||
--hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
|
||||
--hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
|
||||
--hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
|
||||
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
|
||||
--hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
|
||||
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
|
||||
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
|
||||
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
|
||||
--hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
|
||||
--hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
|
||||
--hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
|
||||
--hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
|
||||
--hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
|
||||
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
|
||||
--hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
|
||||
--hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
|
||||
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
|
||||
--hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
|
||||
--hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
|
||||
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
|
||||
--hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
|
||||
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
|
||||
--hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
|
||||
--hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
|
||||
--hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
|
||||
--hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
|
||||
--hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
|
||||
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
|
||||
--hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
|
||||
--hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
|
||||
--hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
|
||||
--hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
|
||||
--hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
|
||||
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
|
||||
--hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
|
||||
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
|
||||
--hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
|
||||
--hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
|
||||
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
|
||||
--hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
|
||||
--hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
|
||||
--hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
|
||||
--hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
|
||||
--hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
|
||||
--hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
|
||||
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
|
||||
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
|
||||
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
|
||||
cffi==2.1.0 \
|
||||
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
|
||||
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
|
||||
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
|
||||
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
|
||||
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
|
||||
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
|
||||
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
|
||||
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
|
||||
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
|
||||
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
|
||||
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
|
||||
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
|
||||
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
|
||||
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
|
||||
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
|
||||
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
|
||||
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
|
||||
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
|
||||
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
|
||||
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
|
||||
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
|
||||
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
|
||||
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
|
||||
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
|
||||
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
|
||||
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
|
||||
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
|
||||
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
|
||||
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
|
||||
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
|
||||
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
|
||||
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
|
||||
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
|
||||
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
|
||||
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
|
||||
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
|
||||
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
|
||||
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
|
||||
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
|
||||
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
|
||||
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
|
||||
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
|
||||
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
|
||||
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
|
||||
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
|
||||
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
|
||||
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
|
||||
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
|
||||
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
|
||||
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
|
||||
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
|
||||
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
|
||||
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
|
||||
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
|
||||
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
|
||||
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
|
||||
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
|
||||
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
|
||||
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
|
||||
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
|
||||
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
|
||||
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
|
||||
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
|
||||
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
|
||||
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
|
||||
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
|
||||
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
|
||||
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
|
||||
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
|
||||
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
|
||||
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
|
||||
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
|
||||
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
|
||||
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
|
||||
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
|
||||
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
|
||||
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
|
||||
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
|
||||
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
|
||||
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
|
||||
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
|
||||
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
|
||||
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
|
||||
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
|
||||
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
|
||||
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
|
||||
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
|
||||
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
|
||||
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
|
||||
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
|
||||
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
|
||||
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
|
||||
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
|
||||
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
|
||||
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
|
||||
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
|
||||
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
|
||||
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
|
||||
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
|
||||
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
|
||||
# via weasyprint
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
@@ -105,67 +120,67 @@ cssselect2==0.9.0 \
|
||||
--hash=sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 \
|
||||
--hash=sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb
|
||||
# via weasyprint
|
||||
distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
distlib==0.4.3 \
|
||||
--hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \
|
||||
--hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed
|
||||
# via virtualenv
|
||||
filelock==3.29.0 \
|
||||
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
|
||||
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
|
||||
filelock==3.30.0 \
|
||||
--hash=sha256:1774e682dbe443bd60f9609162fc596e2c80dc84ffc2957068953406d0520090 \
|
||||
--hash=sha256:40632998f0772e64183bb819f086a1b9def6be1090cf1dcb9d45f46806ef279b
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
fonttools==4.62.1 \
|
||||
--hash=sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04 \
|
||||
--hash=sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a \
|
||||
--hash=sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9 \
|
||||
--hash=sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392 \
|
||||
--hash=sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82 \
|
||||
--hash=sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d \
|
||||
--hash=sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b \
|
||||
--hash=sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e \
|
||||
--hash=sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416 \
|
||||
--hash=sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae \
|
||||
--hash=sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069 \
|
||||
--hash=sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9 \
|
||||
--hash=sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7 \
|
||||
--hash=sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed \
|
||||
--hash=sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800 \
|
||||
--hash=sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e \
|
||||
--hash=sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9 \
|
||||
--hash=sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b \
|
||||
--hash=sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1 \
|
||||
--hash=sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe \
|
||||
--hash=sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7 \
|
||||
--hash=sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd \
|
||||
--hash=sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056 \
|
||||
--hash=sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23 \
|
||||
--hash=sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae \
|
||||
--hash=sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260 \
|
||||
--hash=sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974 \
|
||||
--hash=sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87 \
|
||||
--hash=sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24 \
|
||||
--hash=sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53 \
|
||||
--hash=sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936 \
|
||||
--hash=sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14 \
|
||||
--hash=sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42 \
|
||||
--hash=sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c \
|
||||
--hash=sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1 \
|
||||
--hash=sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca \
|
||||
--hash=sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c \
|
||||
--hash=sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d \
|
||||
--hash=sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a \
|
||||
--hash=sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782 \
|
||||
--hash=sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c \
|
||||
--hash=sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a \
|
||||
--hash=sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79 \
|
||||
--hash=sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3 \
|
||||
--hash=sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7 \
|
||||
--hash=sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d \
|
||||
--hash=sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2 \
|
||||
--hash=sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4 \
|
||||
--hash=sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68 \
|
||||
--hash=sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca
|
||||
fonttools==4.63.0 \
|
||||
--hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \
|
||||
--hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \
|
||||
--hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \
|
||||
--hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \
|
||||
--hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \
|
||||
--hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \
|
||||
--hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \
|
||||
--hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \
|
||||
--hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \
|
||||
--hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \
|
||||
--hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \
|
||||
--hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \
|
||||
--hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \
|
||||
--hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \
|
||||
--hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \
|
||||
--hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \
|
||||
--hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \
|
||||
--hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \
|
||||
--hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \
|
||||
--hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \
|
||||
--hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \
|
||||
--hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \
|
||||
--hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \
|
||||
--hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \
|
||||
--hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \
|
||||
--hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \
|
||||
--hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \
|
||||
--hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \
|
||||
--hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \
|
||||
--hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \
|
||||
--hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \
|
||||
--hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \
|
||||
--hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \
|
||||
--hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \
|
||||
--hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \
|
||||
--hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \
|
||||
--hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \
|
||||
--hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \
|
||||
--hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \
|
||||
--hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \
|
||||
--hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \
|
||||
--hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \
|
||||
--hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \
|
||||
--hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \
|
||||
--hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \
|
||||
--hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \
|
||||
--hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \
|
||||
--hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \
|
||||
--hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \
|
||||
--hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745
|
||||
# via weasyprint
|
||||
identify==2.6.19 \
|
||||
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
|
||||
@@ -175,193 +190,190 @@ nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
# via pre-commit
|
||||
numpy==2.4.4 \
|
||||
--hash=sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed \
|
||||
--hash=sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50 \
|
||||
--hash=sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959 \
|
||||
--hash=sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827 \
|
||||
--hash=sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd \
|
||||
--hash=sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233 \
|
||||
--hash=sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc \
|
||||
--hash=sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b \
|
||||
--hash=sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7 \
|
||||
--hash=sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e \
|
||||
--hash=sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a \
|
||||
--hash=sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d \
|
||||
--hash=sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3 \
|
||||
--hash=sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e \
|
||||
--hash=sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb \
|
||||
--hash=sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a \
|
||||
--hash=sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0 \
|
||||
--hash=sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e \
|
||||
--hash=sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113 \
|
||||
--hash=sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103 \
|
||||
--hash=sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93 \
|
||||
--hash=sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af \
|
||||
--hash=sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5 \
|
||||
--hash=sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7 \
|
||||
--hash=sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392 \
|
||||
--hash=sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c \
|
||||
--hash=sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4 \
|
||||
--hash=sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40 \
|
||||
--hash=sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf \
|
||||
--hash=sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44 \
|
||||
--hash=sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b \
|
||||
--hash=sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5 \
|
||||
--hash=sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e \
|
||||
--hash=sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74 \
|
||||
--hash=sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0 \
|
||||
--hash=sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e \
|
||||
--hash=sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec \
|
||||
--hash=sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015 \
|
||||
--hash=sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d \
|
||||
--hash=sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d \
|
||||
--hash=sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842 \
|
||||
--hash=sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150 \
|
||||
--hash=sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8 \
|
||||
--hash=sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a \
|
||||
--hash=sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed \
|
||||
--hash=sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f \
|
||||
--hash=sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008 \
|
||||
--hash=sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e \
|
||||
--hash=sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0 \
|
||||
--hash=sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e \
|
||||
--hash=sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f \
|
||||
--hash=sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a \
|
||||
--hash=sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40 \
|
||||
--hash=sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7 \
|
||||
--hash=sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83 \
|
||||
--hash=sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d \
|
||||
--hash=sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c \
|
||||
--hash=sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871 \
|
||||
--hash=sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502 \
|
||||
--hash=sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252 \
|
||||
--hash=sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8 \
|
||||
--hash=sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115 \
|
||||
--hash=sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f \
|
||||
--hash=sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e \
|
||||
--hash=sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d \
|
||||
--hash=sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0 \
|
||||
--hash=sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119 \
|
||||
--hash=sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e \
|
||||
--hash=sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db \
|
||||
--hash=sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121 \
|
||||
--hash=sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d \
|
||||
--hash=sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
|
||||
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
|
||||
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
|
||||
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
|
||||
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
|
||||
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
|
||||
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
|
||||
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
|
||||
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
|
||||
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
|
||||
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
|
||||
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
|
||||
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
|
||||
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
|
||||
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
|
||||
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
|
||||
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
|
||||
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
|
||||
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
|
||||
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
|
||||
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
|
||||
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
|
||||
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
|
||||
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
|
||||
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
|
||||
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
|
||||
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
|
||||
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
|
||||
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
|
||||
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
|
||||
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
|
||||
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
|
||||
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
|
||||
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
|
||||
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
|
||||
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
|
||||
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
|
||||
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
|
||||
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
|
||||
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
|
||||
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
|
||||
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
|
||||
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
|
||||
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
|
||||
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
|
||||
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
|
||||
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
|
||||
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
|
||||
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
|
||||
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
|
||||
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
|
||||
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
|
||||
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
|
||||
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
|
||||
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
|
||||
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
|
||||
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
|
||||
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
|
||||
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
|
||||
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
|
||||
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
|
||||
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
|
||||
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
|
||||
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
|
||||
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
|
||||
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
|
||||
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
|
||||
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
|
||||
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
|
||||
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
|
||||
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
|
||||
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
|
||||
# via opencv-python-headless
|
||||
opencv-python-headless==4.13.0.92 \
|
||||
--hash=sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22 \
|
||||
--hash=sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e \
|
||||
--hash=sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209 \
|
||||
--hash=sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c \
|
||||
--hash=sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb \
|
||||
--hash=sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6 \
|
||||
--hash=sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b \
|
||||
--hash=sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d
|
||||
opencv-python-headless==5.0.0.93 \
|
||||
--hash=sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f \
|
||||
--hash=sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4 \
|
||||
--hash=sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f \
|
||||
--hash=sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00 \
|
||||
--hash=sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e \
|
||||
--hash=sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4 \
|
||||
--hash=sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c \
|
||||
--hash=sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9 \
|
||||
--hash=sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pdf2image==1.17.0 \
|
||||
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
|
||||
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pillow==12.2.0 \
|
||||
--hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \
|
||||
--hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \
|
||||
--hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \
|
||||
--hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \
|
||||
--hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \
|
||||
--hash=sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f \
|
||||
--hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \
|
||||
--hash=sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e \
|
||||
--hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \
|
||||
--hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \
|
||||
--hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \
|
||||
--hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \
|
||||
--hash=sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1 \
|
||||
--hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \
|
||||
--hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \
|
||||
--hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \
|
||||
--hash=sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97 \
|
||||
--hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \
|
||||
--hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \
|
||||
--hash=sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa \
|
||||
--hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \
|
||||
--hash=sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d \
|
||||
--hash=sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705 \
|
||||
--hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \
|
||||
--hash=sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024 \
|
||||
--hash=sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0 \
|
||||
--hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \
|
||||
--hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \
|
||||
--hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \
|
||||
--hash=sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3 \
|
||||
--hash=sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b \
|
||||
--hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \
|
||||
--hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \
|
||||
--hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \
|
||||
--hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \
|
||||
--hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \
|
||||
--hash=sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f \
|
||||
--hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \
|
||||
--hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \
|
||||
--hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \
|
||||
--hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \
|
||||
--hash=sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5 \
|
||||
--hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \
|
||||
--hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \
|
||||
--hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \
|
||||
--hash=sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60 \
|
||||
--hash=sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65 \
|
||||
--hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \
|
||||
--hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \
|
||||
--hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \
|
||||
--hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \
|
||||
--hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \
|
||||
--hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \
|
||||
--hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \
|
||||
--hash=sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c \
|
||||
--hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \
|
||||
--hash=sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e \
|
||||
--hash=sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab \
|
||||
--hash=sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808 \
|
||||
--hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \
|
||||
--hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \
|
||||
--hash=sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909 \
|
||||
--hash=sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec \
|
||||
--hash=sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe \
|
||||
--hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \
|
||||
--hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \
|
||||
--hash=sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f \
|
||||
--hash=sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff \
|
||||
--hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \
|
||||
--hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \
|
||||
--hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \
|
||||
--hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \
|
||||
--hash=sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032 \
|
||||
--hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \
|
||||
--hash=sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136 \
|
||||
--hash=sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e \
|
||||
--hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \
|
||||
--hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \
|
||||
--hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \
|
||||
--hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \
|
||||
--hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \
|
||||
--hash=sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176 \
|
||||
--hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \
|
||||
--hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \
|
||||
--hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \
|
||||
--hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \
|
||||
--hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \
|
||||
--hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \
|
||||
--hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \
|
||||
--hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \
|
||||
--hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5
|
||||
pillow==12.3.0 \
|
||||
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
|
||||
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
|
||||
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
|
||||
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
|
||||
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
|
||||
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
|
||||
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
|
||||
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
|
||||
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
|
||||
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
|
||||
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
|
||||
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
|
||||
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
|
||||
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
|
||||
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
|
||||
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
|
||||
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
|
||||
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
|
||||
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
|
||||
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
|
||||
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
|
||||
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
|
||||
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
|
||||
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
|
||||
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
|
||||
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
|
||||
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
|
||||
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
|
||||
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
|
||||
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
|
||||
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
|
||||
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
|
||||
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
|
||||
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
|
||||
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
|
||||
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
|
||||
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
|
||||
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
|
||||
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
|
||||
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
|
||||
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
|
||||
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
|
||||
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
|
||||
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
|
||||
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
|
||||
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
|
||||
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
|
||||
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
|
||||
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
|
||||
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
|
||||
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
|
||||
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
|
||||
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
|
||||
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
|
||||
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
|
||||
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
|
||||
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
|
||||
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
|
||||
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
|
||||
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
|
||||
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
|
||||
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
|
||||
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
|
||||
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
|
||||
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
|
||||
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
|
||||
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
|
||||
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
|
||||
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
|
||||
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
|
||||
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
|
||||
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
|
||||
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
|
||||
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
|
||||
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
|
||||
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
|
||||
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
|
||||
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
|
||||
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
|
||||
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
|
||||
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
|
||||
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
|
||||
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
|
||||
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
|
||||
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
|
||||
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
|
||||
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
|
||||
# via
|
||||
# -r .github/scripts/requirements_dev.in
|
||||
# pdf2image
|
||||
# weasyprint
|
||||
platformdirs==4.9.6 \
|
||||
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
|
||||
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
|
||||
platformdirs==4.10.0 \
|
||||
--hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
|
||||
--hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
@@ -381,9 +393,9 @@ pyphen==0.17.2 \
|
||||
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
|
||||
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
|
||||
# via weasyprint
|
||||
python-discovery==1.2.2 \
|
||||
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
|
||||
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
|
||||
python-discovery==1.4.4 \
|
||||
--hash=sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3 \
|
||||
--hash=sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe
|
||||
# via virtualenv
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
@@ -470,17 +482,17 @@ tinyhtml5==2.1.0 \
|
||||
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
|
||||
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
|
||||
# via weasyprint
|
||||
unoserver==3.6 \
|
||||
--hash=sha256:25c360fa194396a89cb79b4edd2735f8e4f0fd8531e59db3952114585bd7df05 \
|
||||
--hash=sha256:e446bcb3638c51880f002aaeecab1cf74dfa9df81035f027f7ff2e081b6d7015
|
||||
unoserver==3.7 \
|
||||
--hash=sha256:b05f9578506ac7374ae1b314c3a79528636c542ac78220a9ce99110584ca424b \
|
||||
--hash=sha256:fc44e6808071c9d2957e705ecf1742cea8a582aa5d5cc23babf36bb332ec6e8e
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
virtualenv==21.2.4 \
|
||||
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
|
||||
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
|
||||
virtualenv==21.6.1 \
|
||||
--hash=sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128 \
|
||||
--hash=sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b
|
||||
# via pre-commit
|
||||
weasyprint==68.1 \
|
||||
--hash=sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be \
|
||||
--hash=sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e
|
||||
weasyprint==69.0 \
|
||||
--hash=sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6 \
|
||||
--hash=sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
webencodings==0.5.1 \
|
||||
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
|
||||
@@ -489,27 +501,28 @@ webencodings==0.5.1 \
|
||||
# cssselect2
|
||||
# tinycss2
|
||||
# tinyhtml5
|
||||
zopfli==0.4.1 \
|
||||
--hash=sha256:02086247dd12fda929f9bfe8b3962b6bcdbfc8c82e99255aebcf367867cf0760 \
|
||||
--hash=sha256:07a5cdc5d1aaa6c288c5d9f5a5383042ba743641abf8e2fd898dcad622d8a38e \
|
||||
--hash=sha256:27823dc1161a4031d1c25925fd45d9868ec0cbc7692341830a7dcfa25063662c \
|
||||
--hash=sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707 \
|
||||
--hash=sha256:4238d4d746d1095e29c9125490985e0c12ffd3654f54a24af551e2391e936d54 \
|
||||
--hash=sha256:5a4c22b6161f47f5bd34637dbaee6735abd287cd64e0d1ce28ef1871bf625f4b \
|
||||
--hash=sha256:84a31ba9edc921b1d3a4449929394a993888f32d70de3a3617800c428a947b9b \
|
||||
--hash=sha256:a899eca405662a23ae75054affa3517a060362eae1185d3d791c86a50153c4dd \
|
||||
--hash=sha256:a93c2ecafff372de6c0aa2212eff18a75f6c71a100372fee7b4b129cc0b6f9a7 \
|
||||
--hash=sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7 \
|
||||
--hash=sha256:d7bcee1b189d64ec33d1e05cfa1b6a1268c29329c382f6ca1bd6245b04925c57 \
|
||||
--hash=sha256:fdfb7ce9f5de37a5b2f75dd2642fd7717956ef2a72e0387302a36d382440db07
|
||||
zopfli==0.4.3 \
|
||||
--hash=sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d \
|
||||
--hash=sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef \
|
||||
--hash=sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3 \
|
||||
--hash=sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6 \
|
||||
--hash=sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc \
|
||||
--hash=sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42 \
|
||||
--hash=sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e \
|
||||
--hash=sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073 \
|
||||
--hash=sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357 \
|
||||
--hash=sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe \
|
||||
--hash=sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94 \
|
||||
--hash=sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206 \
|
||||
--hash=sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e
|
||||
# via fonttools
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
pip==26.0.1 \
|
||||
--hash=sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b \
|
||||
--hash=sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8
|
||||
pip==26.1.2 \
|
||||
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
|
||||
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
setuptools==82.0.1 \
|
||||
--hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \
|
||||
--hash=sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb
|
||||
setuptools==83.0.0 \
|
||||
--hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \
|
||||
--hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# This file is autogenerated by pip-compile with Python 3.13
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
|
||||
@@ -8,7 +8,7 @@ tomli-w==1.2.0 \
|
||||
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
|
||||
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
tomlkit==0.14.0 \
|
||||
--hash=sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680 \
|
||||
--hash=sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064
|
||||
tomlkit==0.15.0 \
|
||||
--hash=sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 \
|
||||
--hash=sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
|
||||
@@ -23,13 +23,9 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-pr:
|
||||
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_deploy: ${{ steps.decide.outputs.should_deploy }}
|
||||
is_fork: ${{ steps.resolve.outputs.is_fork }}
|
||||
@@ -39,7 +35,7 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -101,8 +97,8 @@ jobs:
|
||||
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy-v2-pr:
|
||||
needs: [pick, check-pr]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: check-pr
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
|
||||
# Concurrency control - only one deployment per PR at a time
|
||||
concurrency:
|
||||
@@ -112,17 +108,14 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
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
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -190,13 +183,8 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -205,7 +193,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -240,22 +228,8 @@ jobs:
|
||||
echo "Image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Build and push V2 image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-image.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
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 }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
|
||||
- name: Build and push V2 image
|
||||
if: steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
@@ -297,7 +271,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 }}"
|
||||
@@ -347,7 +320,7 @@ jobs:
|
||||
# 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
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
with:
|
||||
list-files: json
|
||||
filters: |
|
||||
@@ -475,8 +448,7 @@ jobs:
|
||||
|
||||
cleanup-v2-deployment:
|
||||
if: github.event.action == 'closed'
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
@@ -484,7 +456,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -34,12 +34,8 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-comment:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
if: |
|
||||
@@ -74,7 +70,7 @@ jobs:
|
||||
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -179,19 +175,15 @@ jobs:
|
||||
}
|
||||
|
||||
deploy-pr:
|
||||
needs: [pick, check-comment]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: check-comment
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -220,9 +212,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -240,36 +232,16 @@ jobs:
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push PR-specific image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: |
|
||||
VERSION_TAG=alpha
|
||||
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push PR-specific image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
- name: Build and push PR-specific image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
@@ -283,19 +255,8 @@ jobs:
|
||||
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push engine image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push engine image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
- name: Build and push engine image
|
||||
if: needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./engine
|
||||
@@ -510,11 +471,10 @@ jobs:
|
||||
|
||||
handle-label-commands:
|
||||
if: ${{ github.event.issue.pull_request != null }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ name: _runner-pick
|
||||
|
||||
# Tiny reusable workflow that classifies the trigger as either a "fork PR
|
||||
# from an untrusted contributor" or a "trusted commit" so downstream jobs
|
||||
# can pick a runner class without each one duplicating the 200-char gate
|
||||
# expression in their own `runs-on:`.
|
||||
# can trust-gate (skip secret-dependent jobs on forks) without each one
|
||||
# duplicating the gate expression.
|
||||
#
|
||||
# Caller pattern:
|
||||
#
|
||||
@@ -13,12 +13,12 @@ name: _runner-pick
|
||||
#
|
||||
# real-work:
|
||||
# needs: pick
|
||||
# runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
# if: needs.pick.outputs.is_fork != 'true'
|
||||
# steps: [...]
|
||||
#
|
||||
# Output:
|
||||
# is_fork: "true" when the trigger is a pull_request from a fork or an
|
||||
# untrusted author_association, "false" otherwise.
|
||||
# Outputs:
|
||||
# is_fork: "true" when the trigger is a pull_request from a fork or an
|
||||
# untrusted author_association, "false" otherwise.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
is_fork: ${{ steps.decide.outputs.is_fork }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -50,21 +50,18 @@ jobs:
|
||||
AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
if [ -z "${PR_NUMBER:-}" ]; then
|
||||
# Not a pull_request event at all (push, schedule, workflow_dispatch,
|
||||
# workflow_call from a non-PR trigger) -> trusted by default.
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
is_fork=false
|
||||
elif [ "${HEAD_REPO_FORK}" = "true" ]; then
|
||||
is_fork=true
|
||||
else
|
||||
case "${AUTHOR_ASSOC}" in
|
||||
OWNER|MEMBER|COLLABORATOR) is_fork=false ;;
|
||||
*) is_fork=true ;;
|
||||
esac
|
||||
fi
|
||||
if [ "${HEAD_REPO_FORK}" = "true" ]; then
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
case "${AUTHOR_ASSOC}" in
|
||||
OWNER|MEMBER|COLLABORATOR)
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -18,11 +18,9 @@ jobs:
|
||||
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
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -33,6 +31,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-suffix: ai-engine
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,14 +19,8 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
build:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -34,7 +28,7 @@ jobs:
|
||||
flavor: [core, proprietary, saas]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -56,9 +50,9 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
@@ -247,7 +241,7 @@ jobs:
|
||||
# so skip it for merge_group runs and workflow_dispatch.
|
||||
if: github.event_name == 'pull_request'
|
||||
id: jacoco
|
||||
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
|
||||
uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0
|
||||
with:
|
||||
paths: |
|
||||
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
|
||||
@@ -15,23 +15,11 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
# No `concurrency:` block here on purpose. When this workflow is called via
|
||||
# workflow_call from build.yml, ${{ github.workflow }}/event_name/pr_number
|
||||
@@ -50,20 +38,19 @@ 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.
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
|
||||
# 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') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
PREMIUM_ENABLED: "true"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -308,3 +295,70 @@ jobs:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
|
||||
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
|
||||
multinode-e2e:
|
||||
needs: [pick, playwright-e2e-enterprise]
|
||||
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
|
||||
if: >-
|
||||
always() && needs.pick.outputs.is_fork != 'true'
|
||||
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
PREMIUM_ENABLED: "true"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
MN_COMPOSE: docker-compose-multinode.yml
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
- name: Install behave test deps
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
- name: Build the multi-node image
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" build
|
||||
- name: Bring up the cluster and wait for both nodes healthy
|
||||
working-directory: testing/compose
|
||||
run: |
|
||||
docker compose -f "$MN_COMPOSE" up -d
|
||||
for i in $(seq 1 90); do
|
||||
h1=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null || echo starting)
|
||||
h2=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-2 2>/dev/null || echo starting)
|
||||
if [ "$h1" = healthy ] && [ "$h2" = healthy ]; then echo "both nodes healthy"; exit 0; fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::nodes did not become healthy"
|
||||
docker compose -f "$MN_COMPOSE" logs --tail=200 stirling-1 stirling-2
|
||||
exit 1
|
||||
- name: Seed the cluster (teams, users, S3 connection, policy)
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" --profile seed run --rm seed
|
||||
- name: Run multi-node regression (implemented guarantees)
|
||||
working-directory: testing/cucumber
|
||||
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
|
||||
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
|
||||
- name: Run multi-node failover (destructive)
|
||||
working-directory: testing/cucumber
|
||||
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
|
||||
- name: Dump node logs on failure
|
||||
if: failure()
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" logs --tail=400 stirling-1 stirling-2
|
||||
- name: Tear down
|
||||
if: always()
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" --profile seed down -v --remove-orphans
|
||||
|
||||
@@ -41,20 +41,21 @@ jobs:
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
docker-base: ${{ steps.changes.outputs.docker-base }}
|
||||
dockerfiles: ${{ steps.changes.outputs.dockerfiles }}
|
||||
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)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -98,6 +99,17 @@ jobs:
|
||||
uses: ./.github/workflows/frontend-validation.yml
|
||||
secrets: inherit
|
||||
|
||||
# Advisory: deliberately NOT in all-checks-passed. It reports on the stories a
|
||||
# branch touches so a regression is visible in review, but a browser scan is
|
||||
# too new here to block merges on. Promote it once its pass/fail proves stable.
|
||||
frontend-a11y:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/frontend-a11y.yml
|
||||
secrets: inherit
|
||||
|
||||
playwright-e2e:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [files-changed]
|
||||
@@ -148,11 +160,11 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/test-build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
|
||||
dockerfiles-changed: ${{ needs.files-changed.outputs.dockerfiles }}
|
||||
|
||||
tauri-build:
|
||||
if: needs.files-changed.outputs.tauri == 'true'
|
||||
@@ -162,6 +174,12 @@ jobs:
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/tauri-build.yml
|
||||
secrets: inherit
|
||||
# PR smoke build: macOS + Windows (the platforms our developers use).
|
||||
# The full signed multi-OS matrix runs on release;
|
||||
# nightly still warms the Rust cache with all-OS defaults.
|
||||
with:
|
||||
platform: windows-macos
|
||||
sign: false
|
||||
|
||||
ai-engine:
|
||||
if: needs.files-changed.outputs.engine == 'true'
|
||||
@@ -249,7 +267,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -21,11 +21,9 @@ jobs:
|
||||
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
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -36,6 +34,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-suffix: generated-models
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -44,7 +43,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
|
||||
|
||||
@@ -11,11 +11,9 @@ permissions:
|
||||
jobs:
|
||||
check-licence:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -38,9 +36,9 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -10,17 +10,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-generate-openapi-docs:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -43,9 +37,9 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -29,16 +29,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
aggregate:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -60,13 +56,13 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -12,18 +12,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
migration-test:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,9 +40,9 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
# No `-PnoSpotless` here yet because the upstream cache layer matches the
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -10,38 +10,23 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
deploy-v2-on-push:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy-v2-push-V2
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
@@ -100,27 +85,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push frontend image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-frontend.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push frontend image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false'
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
@@ -134,22 +105,8 @@ jobs:
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push backend image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-backend.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push backend image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false'
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -11,32 +11,21 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 4 because bench showed 16 was within noise of 4."
|
||||
required: false
|
||||
type: string
|
||||
default: "4"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
docker-compose-tests:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -59,9 +48,9 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
# When the PR changes the base image, test.sh builds it locally
|
||||
@@ -72,7 +61,7 @@ jobs:
|
||||
# runtime token isn't exposed) since the docker driver can't use it.
|
||||
- name: Set up Docker Buildx
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
@@ -85,7 +74,7 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
@@ -5,27 +5,17 @@ name: Playwright E2E (live backend)
|
||||
# server.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e-live:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -94,7 +84,7 @@ jobs:
|
||||
fi
|
||||
- name: Set up Python for coverage summary
|
||||
if: always() && steps.live-coverage.outputs.report == 'true'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install defusedxml for coverage summary
|
||||
@@ -134,7 +124,7 @@ jobs:
|
||||
# a summary even on backend failure, as long as some Playwright
|
||||
# tests ran far enough to dump V8 coverage.
|
||||
if: always()
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -5,26 +5,16 @@ name: Playwright E2E (stubbed)
|
||||
# mocks API responses in the browser.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 8 to match the other playwright workflows; bench showed flat scaling above 8."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Frontend a11y regression gate
|
||||
|
||||
# Reusable workflow called from build.yml when frontend sources change.
|
||||
#
|
||||
# Scans the stories this branch touches in real Chromium and runs axe against
|
||||
# each. Existing violations are grandfathered in .storybook/a11y-baseline.json;
|
||||
# the check fails on a NEW violation — a story breaking a rule it wasn't already
|
||||
# breaking — or on a story that fails to render at all.
|
||||
#
|
||||
# Only changed stories, because a full sweep is ~30 minutes: far too slow to sit
|
||||
# in front of every merge. The whole suite is scanned nightly instead
|
||||
# (nightly.yml), which catches anything a branch didn't touch.
|
||||
#
|
||||
# Advisory for now: this is not in build.yml's all-checks-passed list, so a
|
||||
# failure reports without blocking. Promote it once a few weeks of runs show the
|
||||
# pass/fail is stable.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
frontend-a11y:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Need the base branch too, to diff against it.
|
||||
fetch-depth: 0
|
||||
- name: Set up Node.js
|
||||
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
|
||||
- name: a11y gate (changed stories)
|
||||
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
|
||||
- name: Upload scan reports
|
||||
# The reports carry the offending selector and help text for each
|
||||
# violation; without them a red run can only be understood by
|
||||
# reproducing the whole scan locally.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: a11y-scan-${{ github.run_id }}
|
||||
path: frontend/.a11y-scan/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
# The reports live in a dot-directory, which upload-artifact treats as
|
||||
# hidden and silently skips by default.
|
||||
include-hidden-files: true
|
||||
@@ -19,20 +19,16 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
files-changed:
|
||||
name: detect what files changed
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -40,7 +36,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -48,15 +44,15 @@ jobs:
|
||||
generate-frontend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: [pick, files-changed]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -299,7 +295,10 @@ jobs:
|
||||
base: main
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
labels: |
|
||||
Licenses
|
||||
github-actions
|
||||
Front End
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
@@ -318,18 +317,16 @@ jobs:
|
||||
|
||||
generate-backend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: [pick, files-changed]
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -354,9 +351,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -520,7 +517,10 @@ jobs:
|
||||
base: main
|
||||
title: "Update Backend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,backend
|
||||
labels: |
|
||||
Licenses
|
||||
github-actions
|
||||
Back End
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
|
||||
@@ -11,15 +11,11 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
frontend-validation:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -121,7 +117,7 @@ jobs:
|
||||
run: task frontend:test:coverage
|
||||
- name: Set up Python for coverage summary
|
||||
if: always()
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install defusedxml for coverage summary
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -36,19 +36,15 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
determine-matrix:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -71,9 +67,9 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -112,10 +108,8 @@ jobs:
|
||||
fi
|
||||
|
||||
build-jars:
|
||||
needs: [pick, determine-matrix]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant:
|
||||
@@ -133,7 +127,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -146,9 +140,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -195,10 +189,9 @@ jobs:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
@@ -250,9 +243,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -638,13 +631,13 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
collect-and-release:
|
||||
needs: [pick, determine-matrix, build, build-jars]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: [determine-matrix, build, build-jars]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -13,16 +13,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-all-browsers:
|
||||
name: Playwright (chromium + firefox + webkit)
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -57,6 +53,49 @@ jobs:
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Whole-suite accessibility sweep. Pull requests only scan the stories they
|
||||
# touch (frontend-a11y.yml) because a full pass takes ~30 minutes; this covers
|
||||
# everything else, so a violation introduced by a change somewhere other than
|
||||
# the story itself — a shared component, a theme token — still surfaces within
|
||||
# a day.
|
||||
a11y-all-stories:
|
||||
name: a11y (every story)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
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
|
||||
|
||||
- name: a11y gate (every story)
|
||||
run: task frontend:storybook:a11y
|
||||
|
||||
- name: Upload scan reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: a11y-scan-nightly-${{ github.run_id }}
|
||||
path: frontend/.a11y-scan/
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
# The reports live in a dot-directory, which upload-artifact treats as
|
||||
# hidden and silently skips by default.
|
||||
include-hidden-files: true
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
# written on main, where PR and merge-queue tauri builds can restore it.
|
||||
warm-tauri-cache:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
name: PR conflict labeler
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- edited
|
||||
- ready_for_review
|
||||
schedule:
|
||||
- cron: "17 */6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pr-conflict-labeler-${{ github.event.pull_request.number || 'all-open-prs' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CONFLICT_LABEL: "has conflicts"
|
||||
|
||||
jobs:
|
||||
label-conflicts:
|
||||
name: Label conflicted PRs
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up stirling-bot token
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Apply conflict label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const conflictLabel = process.env.CONFLICT_LABEL;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const eventPullRequest = context.payload.pull_request;
|
||||
|
||||
async function sleep(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getPullRequestWithMergeableState(pullNumber) {
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
|
||||
if (pull.mergeable !== null) {
|
||||
return pull;
|
||||
}
|
||||
|
||||
core.info(`PR #${pullNumber}: mergeable is not ready yet (attempt ${attempt}/6).`);
|
||||
await sleep(5000);
|
||||
}
|
||||
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
return pull;
|
||||
}
|
||||
|
||||
async function ensureConflictLabel() {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: conflictLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: conflictLabel,
|
||||
color: 'D93F0B',
|
||||
description: 'Pull request has merge conflicts with the base branch',
|
||||
});
|
||||
core.info(`Created '${conflictLabel}' label.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function labelPullRequest(pull) {
|
||||
const existingLabels = pull.labels.map((label) => label.name);
|
||||
const hasConflictLabel = existingLabels.includes(conflictLabel);
|
||||
const hasConflicts = pull.mergeable === false && pull.mergeable_state === 'dirty';
|
||||
|
||||
if (hasConflicts && !hasConflictLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull.number,
|
||||
labels: [conflictLabel],
|
||||
});
|
||||
core.info(`Added '${conflictLabel}' to PR #${pull.number}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasConflicts && hasConflictLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull.number,
|
||||
name: conflictLabel,
|
||||
});
|
||||
core.info(`Removed '${conflictLabel}' from PR #${pull.number}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`PR #${pull.number}: no label change needed (mergeable=${pull.mergeable}, mergeable_state=${pull.mergeable_state}).`);
|
||||
}
|
||||
|
||||
await ensureConflictLabel();
|
||||
|
||||
let pullNumbers;
|
||||
if (eventPullRequest) {
|
||||
pullNumbers = [eventPullRequest.number];
|
||||
} else {
|
||||
const pulls = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
pullNumbers = pulls.map((pull) => pull.number);
|
||||
core.info(`Checking ${pullNumbers.length} open PR(s).`);
|
||||
}
|
||||
|
||||
for (const pullNumber of pullNumbers) {
|
||||
const pull = await getPullRequestWithMergeableState(pullNumber);
|
||||
await labelPullRequest(pull);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-suffix: pre-commit
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -76,13 +76,13 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -107,13 +107,13 @@ jobs:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -139,7 +139,6 @@ jobs:
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,13 +26,13 @@ jobs:
|
||||
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -22,18 +22,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,9 +40,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Sync Portal Docs
|
||||
|
||||
# Regenerates the portal Developer Docs manifest from the Stirling docs repo and
|
||||
# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs
|
||||
# repo fires a `docs-updated` repository_dispatch.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Docs repo ref (branch or tag) to sync from"
|
||||
required: false
|
||||
default: "main"
|
||||
repository_dispatch:
|
||||
types: [docs-updated]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync docs manifest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Regenerate docs manifest
|
||||
working-directory: frontend
|
||||
env:
|
||||
DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }}
|
||||
GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
run: npm run docs:sync
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Sync portal docs from docs repo"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync-portal-docs
|
||||
base: main
|
||||
title: "Sync portal docs from docs repo"
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
|
||||
|
||||
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
|
||||
from the Stirling docs repo via `npm run docs:sync`.
|
||||
labels: |
|
||||
Documentation
|
||||
github-actions
|
||||
Front End
|
||||
add-paths: frontend/editor/src/portal/generated/docsManifest.json
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- "app/common/build.gradle"
|
||||
- "app/core/build.gradle"
|
||||
- "app/proprietary/build.gradle"
|
||||
- "gradle/spotless.gradle"
|
||||
- "README.md"
|
||||
- "frontend/editor/public/locales/*/translation.toml"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
@@ -35,7 +36,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -51,7 +52,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
@@ -64,6 +65,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-suffix: sync-files
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)."
|
||||
description: "Platform to build (windows, macos, linux, windows-macos, or all)."
|
||||
required: false
|
||||
type: string
|
||||
default: "all"
|
||||
@@ -21,10 +21,15 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
minimal:
|
||||
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass. Used by PR builds."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
description: "Platform to build (windows, macos, linux, windows-macos, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
@@ -33,11 +38,17 @@ on:
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
- windows-macos
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
minimal:
|
||||
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -51,7 +62,7 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -66,10 +77,11 @@ jobs:
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
|
||||
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
@@ -96,10 +108,9 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -158,9 +169,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -386,10 +397,10 @@ jobs:
|
||||
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 }}
|
||||
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
|
||||
# AppImage runs in its own continue-on-error step below so its
|
||||
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
|
||||
|
||||
- name: Build Tauri app (unsigned)
|
||||
if: ${{ !inputs.sign }}
|
||||
@@ -406,15 +417,18 @@ jobs:
|
||||
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 }}
|
||||
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
|
||||
# AppImage runs in its own continue-on-error step below so its
|
||||
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
|
||||
args: >-
|
||||
${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
|
||||
--config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
# AppImage is decoupled so its linuxdeploy run gets a fresh process
|
||||
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
|
||||
# Skipped on minimal smoke builds (flaky + slow, deb is enough to verify).
|
||||
- name: Build Tauri app (Linux AppImage)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
|
||||
continue-on-error: true
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
@@ -601,12 +615,19 @@ jobs:
|
||||
pr-comment:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && needs.build.result == 'success'
|
||||
# Fork and Dependabot pull_request runs receive a read-only GITHUB_TOKEN,
|
||||
# so the API cannot create or update PR comments there. The artifacts are
|
||||
# still uploaded and remain available from the Actions run page.
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.build.result == 'success' &&
|
||||
!github.event.pull_request.head.repo.fork &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -697,7 +718,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -12,19 +12,16 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
dockerfiles-changed:
|
||||
description: "Whether any Dockerfile changed (forwarded from files-changed). Gates the slow arm64 build leg."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
# TODO: extract a pre-matrix `prepare` job that runs once and produces
|
||||
# shared artifacts for the three matrix entries below to consume:
|
||||
# 1. `task backend:build` — currently runs 3× in parallel with
|
||||
@@ -40,14 +37,7 @@ jobs:
|
||||
# spring-security=true matrix entry if `task backend:build` and
|
||||
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
|
||||
test-build-docker-images:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -63,7 +53,7 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -71,7 +61,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -104,9 +94,9 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
@@ -120,18 +110,12 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Build base image locally (PR base change only)
|
||||
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
|
||||
@@ -146,13 +130,22 @@ jobs:
|
||||
# GITHUB_EVENT_NAME is already provided by the runner.
|
||||
env:
|
||||
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
|
||||
DOCKERFILES_CHANGED: ${{ inputs.dockerfiles-changed }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then
|
||||
# Base Dockerfile changed: build against the locally-built base,
|
||||
# which only exists for amd64.
|
||||
echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
elif [ "$DOCKERFILES_CHANGED" = "true" ]; then
|
||||
# A Dockerfile changed: also verify the arm64 build (slow QEMU leg).
|
||||
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# No Dockerfile change: amd64 only. arm64 is exercised on the base
|
||||
# image publish and on release, not on every code PR.
|
||||
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Base-changed PRs build the embedded image with the local docker driver
|
||||
@@ -168,24 +161,10 @@ jobs:
|
||||
--tag stirling-pdf-embedded:pr-test \
|
||||
.
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }} (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
platforms: ${{ steps.build-params.outputs.platforms }}
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# Fork PRs that did NOT change the base use the buildx container builder
|
||||
# PRs that did NOT change the base use the buildx container builder
|
||||
# (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'
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
@@ -213,52 +192,24 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
test-build-unoserver-image:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/unoserver/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
tags: stirling-unoserver:pr-test
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
|
||||
@@ -20,22 +20,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
deploy:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -49,9 +39,9 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.6.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -61,13 +51,8 @@ jobs:
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -76,25 +61,12 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push test image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push test image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
- name: Build and push test image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
@@ -153,32 +125,31 @@ jobs:
|
||||
files-changed:
|
||||
if: always()
|
||||
name: detect what files changed
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: ".github/config/.files.yaml"
|
||||
|
||||
test:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [pick, deploy, files-changed]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: [deploy, files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -208,13 +179,13 @@ jobs:
|
||||
FORCE_COLOR: "3"
|
||||
|
||||
cleanup:
|
||||
needs: [pick, deploy, test]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
needs: [deploy, test]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start backend dev server in proprietary mode"
|
||||
@@ -41,13 +40,12 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
|
||||
+98
-6
@@ -85,7 +85,8 @@ tasks:
|
||||
# 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)"
|
||||
sh: >-
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
|
||||
cmds:
|
||||
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
@@ -183,16 +184,74 @@ tasks:
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
deps: [install]
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx storybook dev -p 6006 {{.CLI_ARGS}}
|
||||
|
||||
storybook:build:
|
||||
desc: "Build static Storybook"
|
||||
deps: [install]
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx storybook build {{.CLI_ARGS}}
|
||||
|
||||
storybook:browser:
|
||||
internal: true
|
||||
desc: "Install the Chromium build the story scan runs in"
|
||||
run: once
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx playwright install chromium
|
||||
|
||||
storybook:test:
|
||||
desc: "Scan every story in real Chromium: it must render and pass axe"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
# Runs each story as a browser test. Pass a filter through, e.g.
|
||||
# task frontend:storybook:test -- Button
|
||||
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
|
||||
|
||||
storybook:a11y:
|
||||
desc: "a11y regression gate over every story: fail only on NEW axe violations"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
|
||||
storybook:a11y:changed:
|
||||
desc: "a11y gate over the stories this branch affects (default base origin/main)"
|
||||
summary: |
|
||||
Scans the stories a branch affects, which is what pull requests run — a
|
||||
full scan takes ~30 minutes, far too long to sit in front of every merge.
|
||||
A story is affected if its file changed, or if a same-named sibling
|
||||
source file changed (editing Button.tsx or Button.css re-scans
|
||||
Button.stories.tsx — the story renders the live component, so a component
|
||||
edit changes what the story shows without touching the story file).
|
||||
Changes that ripple further than a component's own stories are covered by
|
||||
the nightly full sweep.
|
||||
|
||||
Pass a base ref through CLI_ARGS, e.g.
|
||||
task frontend:storybook:a11y:changed -- origin/release
|
||||
deps: [prepare, storybook:browser]
|
||||
vars:
|
||||
BASE: '{{.CLI_ARGS | default "origin/main"}}'
|
||||
CHANGED:
|
||||
sh: node .storybook/a11y-changed.mjs {{.CLI_ARGS | default "origin/main"}}
|
||||
cmds:
|
||||
- cmd: |
|
||||
if [ -z '{{.CHANGED}}' ]; then
|
||||
echo "a11y: no story files affected vs {{.BASE}} — nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
node .storybook/a11y-scan.mjs {{.CHANGED}}
|
||||
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
|
||||
storybook:a11y:record:
|
||||
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
|
||||
|
||||
# ============================================================
|
||||
# Code quality
|
||||
# ============================================================
|
||||
@@ -203,6 +262,33 @@ tasks:
|
||||
cmds:
|
||||
- task: lint:eslint
|
||||
- task: lint:dpdm
|
||||
- task: lint:colors
|
||||
- task: lint:css
|
||||
|
||||
lint:css:
|
||||
desc: "Lint stylesheets for duplicate selectors"
|
||||
deps: [install]
|
||||
cmds:
|
||||
# Covers the whole editor tree, including the portal/processor layer and
|
||||
# public/css. Vendored CSS and build output are excluded via ignoreFiles
|
||||
# in stylelint.config.mjs.
|
||||
- npx stylelint "editor/**/*.css"
|
||||
|
||||
lint:colors:
|
||||
desc: "Enforce theme tokens — no hardcoded colours or raw primitives in components"
|
||||
aliases: [lint:colours]
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/lint/theme-lint.mjs
|
||||
- node editor/scripts/lint/theme-lint.mjs css-colors
|
||||
- node editor/scripts/lint/theme-lint.mjs code-colors
|
||||
- node editor/scripts/lint/theme-lint.mjs no-primitives
|
||||
|
||||
contrast:
|
||||
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/lint/theme-lint.mjs contrast
|
||||
|
||||
lint:eslint:
|
||||
desc: "Run ESLint linting"
|
||||
@@ -249,10 +335,8 @@ tasks:
|
||||
|
||||
typecheck:_run:
|
||||
internal: true
|
||||
env:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
cmds:
|
||||
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
|
||||
- 'npx tsc --noEmit --project {{.PROJECT}}'
|
||||
|
||||
typecheck:core:
|
||||
desc: "Typecheck core build variant"
|
||||
@@ -314,6 +398,13 @@ tasks:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/portal/tsconfig.json }
|
||||
|
||||
typecheck:storybook:
|
||||
desc: "Typecheck Storybook config and stories"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: .storybook/tsconfig.json }
|
||||
|
||||
typecheck:all:
|
||||
desc: "Typecheck all build variants"
|
||||
cmds:
|
||||
@@ -325,6 +416,7 @@ tasks:
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
- task: typecheck:storybook
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
|
||||
@@ -73,7 +73,7 @@ tasks:
|
||||
- task: gitleaks
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)"
|
||||
desc: "Install the pinned pre-commit Python tools"
|
||||
run: once
|
||||
cmds:
|
||||
- uv sync --project scripts/pre-commit --locked
|
||||
@@ -112,7 +112,7 @@ tasks:
|
||||
toml-sort:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}}
|
||||
- uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
|
||||
|
||||
whitespace:
|
||||
cmds:
|
||||
|
||||
@@ -155,6 +155,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
|
||||
|
||||
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
|
||||
@@ -22,6 +22,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/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/editor/src/portal-saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal-saas/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
|
||||
+1
-27
@@ -2,32 +2,6 @@
|
||||
bootRun {
|
||||
enabled = false
|
||||
}
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/java/org/apache/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
// google-java-format 1.28.0 bundles Guava 32.x which crashes Spotless lint on JDK 24/25
|
||||
suppressLintsFor { setStep('google-java-format') }
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
yaml {
|
||||
target '**/*.yml', '**/*.yaml'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
format 'gradle', {
|
||||
target '**/gradle/*.gradle', '**/*.gradle'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api "com.google.guava:guava:${guavaVersion}"
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
@@ -42,7 +16,7 @@ dependencies {
|
||||
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.10' // RAR archive support for CBR files
|
||||
api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
|
||||
@@ -338,6 +338,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("PageOps", "split-pages");
|
||||
addEndpointToGroup("PageOps", "rearrange-pages");
|
||||
addEndpointToGroup("PageOps", "rotate-pdf");
|
||||
addEndpointToGroup("PageOps", "auto-rotate-pdf");
|
||||
addEndpointToGroup("PageOps", "multi-page-layout");
|
||||
addEndpointToGroup("PageOps", "booklet-imposition");
|
||||
addEndpointToGroup("PageOps", "scale-pages");
|
||||
|
||||
+137
-8
@@ -206,16 +206,12 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Policies {
|
||||
/**
|
||||
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Absolute directories that policy folder input sources and output sinks may read from or
|
||||
* write to. Empty (the default) disables folder access entirely, so a policy can never be
|
||||
* pointed at an arbitrary server path. Stirling's own config directory is always
|
||||
* off-limits, and folder access is always disabled in SaaS mode regardless of this list.
|
||||
* write to. Empty (the default) disables folder access except to implicitly defined
|
||||
* folders, such as server storage folders (if enabled) and the pipeline watched folders.
|
||||
* Stirling's own config directory is always off-limits, and folder access is always
|
||||
* disabled in SaaS mode regardless of this list.
|
||||
*/
|
||||
private List<String> allowedFolderRoots = new java.util.ArrayList<>();
|
||||
|
||||
@@ -254,6 +250,29 @@ public class ApplicationProperties {
|
||||
* in-network object store.
|
||||
*/
|
||||
private boolean allowPrivateS3Endpoints = false;
|
||||
|
||||
/**
|
||||
* Whether an API/Purview/ConsignO integration's base URL may resolve to a loopback,
|
||||
* link-local, or private address. Off by default: unlike S3 connections, any user may
|
||||
* create one of these, so without this gate a user could point a connection at the cloud
|
||||
* metadata address and have the server fetch it for them. Enable only when integrations
|
||||
* genuinely live inside the network (e.g. an on-prem ConsignO or an internal API gateway).
|
||||
*/
|
||||
private boolean allowPrivateApiEndpoints = false;
|
||||
|
||||
/**
|
||||
* Whether administrators may define their own API integrations - a free-form base URL,
|
||||
* path, body and headers - as opposed to only using the built-in vendor presets (Purview,
|
||||
* ConsignO, S3). On by default, and admin-only regardless: a custom integration can point
|
||||
* the server at any host, so it is authoring power, not self-serve.
|
||||
*
|
||||
* <p>Turning this off stops new custom integrations being created or edited. Ones that
|
||||
* already exist keep running, because a policy that silently stopped calling out would be a
|
||||
* worse surprise than one that keeps working; disable the connection itself to stop it.
|
||||
*/
|
||||
private boolean allowCustomApiIntegrations = true;
|
||||
|
||||
private long webhookMaxBytes = 104857600L;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -308,6 +327,102 @@ public class ApplicationProperties {
|
||||
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
|
||||
*/
|
||||
private int longRunningTimeoutSeconds = 600;
|
||||
|
||||
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
|
||||
private int streamTimeoutSeconds = 1800;
|
||||
|
||||
/**
|
||||
* Whether the processor pushes settings-derived AI config to the engine on startup/save.
|
||||
* Pin false for env-driven deployments (SaaS) to keep the engine env-controlled.
|
||||
*/
|
||||
private boolean pushConfigToEngine = true;
|
||||
|
||||
/** Model + provider selection, forwarded to the engine per-request. */
|
||||
private Models models = new Models();
|
||||
|
||||
/** Retrieval-augmented-generation (RAG) knobs, forwarded to the engine per-request. */
|
||||
private Rag rag = new Rag();
|
||||
|
||||
/** Request size / cost guardrails. */
|
||||
private Limits limits = new Limits();
|
||||
|
||||
/** Per-capability on/off switches so an admin can disable individual AI tools. */
|
||||
private Features features = new Features();
|
||||
|
||||
@Data
|
||||
public static class Models {
|
||||
/** Provider driving the model strings: 'anthropic', 'openai', 'ollama', or 'custom'. */
|
||||
private String provider = "anthropic";
|
||||
|
||||
/** High-quality tier model name (without provider prefix), e.g. 'claude-haiku-4-5'. */
|
||||
private String smartModel = "claude-haiku-4-5";
|
||||
|
||||
/** Cheap/fast tier model name (without provider prefix). */
|
||||
private String fastModel = "claude-haiku-4-5";
|
||||
|
||||
private int smartMaxTokens = 8192;
|
||||
private int fastMaxTokens = 2048;
|
||||
|
||||
/**
|
||||
* API key for the selected provider (secret; masked). Empty means the engine uses its
|
||||
* own env credential (e.g. ANTHROPIC_API_KEY).
|
||||
*/
|
||||
private String apiKey = "";
|
||||
|
||||
/**
|
||||
* OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g.
|
||||
* http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only.
|
||||
*/
|
||||
private String baseUrl = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Rag {
|
||||
/**
|
||||
* Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible).
|
||||
*/
|
||||
private String embeddingProvider = "voyageai";
|
||||
|
||||
/** Embedding model name (without provider prefix), e.g. 'voyage-4'. */
|
||||
private String embeddingModel = "voyage-4";
|
||||
|
||||
/**
|
||||
* Secret API key for the embedding provider; masked + env-overridable like
|
||||
* models.apiKey.
|
||||
*/
|
||||
private String embeddingApiKey = "";
|
||||
|
||||
/**
|
||||
* OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g.
|
||||
* http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only.
|
||||
*/
|
||||
private String embeddingBaseUrl = "";
|
||||
|
||||
/** How many chunks retrieval returns per search. */
|
||||
private int topK = 20;
|
||||
|
||||
/** Per-run cap on knowledge-search tool calls before the agent must answer. */
|
||||
private int maxSearches = 5;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Limits {
|
||||
private int maxPages = 200;
|
||||
private int maxCharacters = 200000;
|
||||
|
||||
/** Process-wide cap on concurrent model API calls (engine restart to apply). */
|
||||
private int modelMaxConcurrency = 32;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Features {
|
||||
private boolean chat = true;
|
||||
private boolean documentQuestions = true;
|
||||
private boolean createPdf = true;
|
||||
private boolean mathAuditor = true;
|
||||
private boolean pdfComment = true;
|
||||
private boolean classify = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -961,6 +1076,20 @@ public class ApplicationProperties {
|
||||
private Quotas quotas = new Quotas();
|
||||
private Sharing sharing = new Sharing();
|
||||
private Signing signing = new Signing();
|
||||
private Encryption encryption = new Encryption();
|
||||
|
||||
/**
|
||||
* Encryption at rest for stored files (Pro/Enterprise). Enabling encrypts new writes;
|
||||
* disabling later only stops encrypting new writes — existing encrypted files keep
|
||||
* decrypting as long as the key material is present. The master key is resolved like the
|
||||
* credential key: {@code stirling.security.fileEncryptionKey} property, {@code
|
||||
* STIRLING_FILE_ENCRYPTION_KEY} env var, or an auto-generated {@code file-encryption.key}
|
||||
* in the config directory.
|
||||
*/
|
||||
@Data
|
||||
public static class Encryption {
|
||||
private boolean enabled = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Local {
|
||||
|
||||
@@ -4,6 +4,8 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
@@ -47,6 +49,9 @@ public class JobResult {
|
||||
*/
|
||||
private final List<String> notes = new CopyOnWriteArrayList<>();
|
||||
|
||||
/** Key/value metadata that survives the write-through into the shared job store. */
|
||||
private final Map<String, String> metadata = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Create a new JobResult with the given job ID
|
||||
*
|
||||
@@ -161,4 +166,16 @@ public class JobResult {
|
||||
public List<String> getNotes() {
|
||||
return Collections.unmodifiableList(notes);
|
||||
}
|
||||
|
||||
/** Attach a metadata value, e.g. a policy id so cluster peers can identify a policy run. */
|
||||
public void putMetadata(String key, String value) {
|
||||
if (key != null && value != null) {
|
||||
this.metadata.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/** An unmodifiable view of this job's metadata. */
|
||||
public Map<String, String> getMetadata() {
|
||||
return Collections.unmodifiableMap(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,14 @@ public class InternalApiClient {
|
||||
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
|
||||
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
|
||||
// intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
|
||||
//
|
||||
// `/api/v1/integration/*` holds third-party steps (external API call, Purview labelling,
|
||||
// ConsignO). They reach outside the JVM, so the namespace is deliberately kept to tools that
|
||||
// dereference an admin-owned connection rather than a caller-supplied host — see
|
||||
// ApiConnectionResolver.
|
||||
private static final Pattern ALLOWED_ENDPOINT_PATH =
|
||||
Pattern.compile(
|
||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||
"^/api/v1/(general|misc|security|convert|filter|integration)(/[A-Za-z0-9_-]+)+$"
|
||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
/**
|
||||
|
||||
@@ -230,6 +230,18 @@ public class TaskManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Attach metadata to a job and write it through to the shared store for cluster peers. */
|
||||
public boolean putMetadata(String jobId, String key, String value) {
|
||||
JobResult jobResult = jobResults.get(jobId);
|
||||
if (jobResult != null) {
|
||||
jobResult.putMetadata(key, value);
|
||||
writeThrough(jobId, jobResult);
|
||||
return true;
|
||||
}
|
||||
log.warn("Attempted to set metadata on non-existent job ID: {}", jobId);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about all jobs in the system
|
||||
*
|
||||
@@ -378,7 +390,7 @@ public class TaskManager {
|
||||
fileIds.add(rf.getFileId());
|
||||
}
|
||||
}
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
Map<String, String> meta = new HashMap<>(result.getMetadata());
|
||||
if (result.getNotes() != null && !result.getNotes().isEmpty()) {
|
||||
meta.put("notesCount", Integer.toString(result.getNotes().size()));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
@@ -28,6 +29,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Component
|
||||
public class PdfTextLocator {
|
||||
|
||||
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^A-Za-z0-9]");
|
||||
|
||||
/** One found line of text with its user-space bounding box. */
|
||||
public record MatchedBox(float x, float y, float width, float height) {}
|
||||
|
||||
@@ -82,7 +85,7 @@ public class PdfTextLocator {
|
||||
|
||||
/** Strip everything non-alphanumeric and lowercase for tolerant matching. */
|
||||
private static String normalize(String s) {
|
||||
return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
return NON_ALPHANUMERIC_PATTERN.matcher(s).replaceAll("").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class CapturedLine {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class RequestUriUtils {
|
||||
|
||||
private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$");
|
||||
|
||||
public static boolean isStaticResource(String requestURI) {
|
||||
return isStaticResource("", requestURI);
|
||||
}
|
||||
@@ -198,11 +202,12 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/readiness")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/api/v1/webhooks/")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
// Workflow participant endpoints - access controlled by share tokens, not login
|
||||
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
|
||||
// Share-link SPA bootstrap; data APIs remain protected
|
||||
|| trimmedUri.matches("^/share/[^/]+/?$");
|
||||
|| SHARE_LINK_PATTERN.matcher(trimmedUri).matches();
|
||||
}
|
||||
|
||||
private static String stripContextPath(String contextPath, String requestURI) {
|
||||
|
||||
@@ -176,6 +176,13 @@ class RequestUriUtilsTest {
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_webhookReceiver() {
|
||||
// The webhook source receiver authenticates each delivery by HMAC signature, not a session.
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/whk_abc123", ""));
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/api/v1/webhooks/whk_abc123", "/app"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_withContextPath() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app"));
|
||||
|
||||
+1
-30
@@ -9,35 +9,6 @@ configurations {
|
||||
}
|
||||
}
|
||||
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/resources/static/**', 'src/main/java/org/apache/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
// google-java-format 1.28.0 bundles Guava 32.x which crashes Spotless lint on JDK 24/25
|
||||
suppressLintsFor { setStep('google-java-format') }
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
yaml {
|
||||
target '**/*.yml', '**/*.yaml'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
format 'gradle', {
|
||||
target '**/gradle/*.gradle', '**/*.gradle'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (!gradle.ext.disableAdditional) {
|
||||
implementation project(':proprietary')
|
||||
@@ -79,7 +50,7 @@ dependencies {
|
||||
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
|
||||
implementation 'org.verapdf:validation-model:1.28.2'
|
||||
implementation 'org.verapdf:validation-model:1.30.2'
|
||||
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
|
||||
implementation "org.mozilla:rhino:${rhinoVersion}"
|
||||
|
||||
|
||||
+8
-8
@@ -117,8 +117,8 @@ final class FormPayloadParser {
|
||||
names.add(single);
|
||||
}
|
||||
}
|
||||
} else if (root.isTextual()) {
|
||||
final String single = trimToNull(root.asText(""));
|
||||
} else if (root.isString()) {
|
||||
final String single = trimToNull(root.asString(""));
|
||||
if (single != null) {
|
||||
names.add(single);
|
||||
}
|
||||
@@ -197,8 +197,8 @@ final class FormPayloadParser {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return trimToEmpty(node.asText(""));
|
||||
if (node.isString()) {
|
||||
return trimToEmpty(node.asString(""));
|
||||
}
|
||||
if (node.isNumber()) {
|
||||
return node.numberValue().toString();
|
||||
@@ -207,7 +207,7 @@ final class FormPayloadParser {
|
||||
return Boolean.toString(node.booleanValue());
|
||||
}
|
||||
// Fallback for other scalar-like nodes
|
||||
return trimToEmpty(node.asText(""));
|
||||
return trimToEmpty(node.asString(""));
|
||||
}
|
||||
|
||||
private static void collectNames(JsonNode arrayNode, Set<String> sink) {
|
||||
@@ -227,8 +227,8 @@ final class FormPayloadParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.isTextual()) {
|
||||
return trimToNull(node.asText(""));
|
||||
if (node.isString()) {
|
||||
return trimToNull(node.asString(""));
|
||||
}
|
||||
|
||||
if (node.isObject()) {
|
||||
@@ -269,7 +269,7 @@ final class FormPayloadParser {
|
||||
final JsonNode v = objectNode.get(key);
|
||||
if (v == null || v.isNull()) {
|
||||
result.put(key, null);
|
||||
} else if (v.isTextual() || v.isNumber() || v.isBoolean()) {
|
||||
} else if (v.isString() || v.isNumber() || v.isBoolean()) {
|
||||
result.put(key, coerceScalarToString(v));
|
||||
} else {
|
||||
result.put(key, v.toString());
|
||||
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.AutoRotateAnalysisResult;
|
||||
import stirling.software.SPDF.model.api.misc.AutoRotateAnalysisResult.PageResult;
|
||||
import stirling.software.SPDF.model.api.misc.AutoRotatePdfRequest;
|
||||
import stirling.software.SPDF.model.api.misc.PageRotation;
|
||||
import stirling.software.SPDF.utils.AutoRotateDetection;
|
||||
import stirling.software.SPDF.utils.AutoRotateDetection.OsdResult;
|
||||
import stirling.software.SPDF.utils.AutoRotateDetection.TextDirection;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempDirectory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@MiscApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AutoRotateController {
|
||||
|
||||
// OSD decides orientation from script shape, not character identity, so it does not need
|
||||
// OCR-grade resolution. 150 DPI is ample for that and a quarter of the pixels of 300.
|
||||
private static final int OSD_RENDER_DPI = 150;
|
||||
|
||||
private static final String METHOD_TEXT = "text";
|
||||
private static final String METHOD_OSD = "osd";
|
||||
private static final String METHOD_INFERRED = "inferred";
|
||||
private static final String METHOD_NONE = "none";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/auto-rotate-pdf",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Detect and fix the orientation of every page",
|
||||
description =
|
||||
"Detects each page's orientation (embedded-text direction first, Tesseract OSD"
|
||||
+ " for scanned pages) and sets the page rotation so the content"
|
||||
+ " displays upright. With dryRun=true, returns a JSON per-page report"
|
||||
+ " instead of the PDF. With pageRotations set, applies the given"
|
||||
+ " corrections without running detection."
|
||||
+ " Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<?> autoRotatePdf(@Valid @ModelAttribute AutoRotatePdfRequest request)
|
||||
throws IOException, InterruptedException {
|
||||
String mode =
|
||||
request.getDetectionMode() == null
|
||||
? "auto"
|
||||
: request.getDetectionMode().toLowerCase(Locale.ROOT);
|
||||
if (!"auto".equals(mode) && !"text".equals(mode) && !"osd".equals(mode)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
ExceptionUtils.ErrorCode.INVALID_ARGUMENT,
|
||||
"detectionMode",
|
||||
"must be one of auto, text, osd");
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(request)) {
|
||||
if (request.getPageRotations() != null && !request.getPageRotations().isEmpty()) {
|
||||
applyExplicitRotations(document, request.getPageRotations());
|
||||
return pdfResponse(document, request);
|
||||
}
|
||||
|
||||
AutoRotateAnalysisResult analysis = analyse(document, mode, request);
|
||||
if (request.isDryRun()) {
|
||||
return ResponseEntity.ok(analysis);
|
||||
}
|
||||
for (PageResult pageResult : analysis.getPages()) {
|
||||
if (pageResult.isApply()) {
|
||||
PDPage page = document.getPage(pageResult.getPageNumber() - 1);
|
||||
page.setRotation(
|
||||
Math.floorMod(page.getRotation() + pageResult.getCorrection(), 360));
|
||||
}
|
||||
}
|
||||
return pdfResponse(document, request);
|
||||
}
|
||||
}
|
||||
|
||||
private AutoRotateAnalysisResult analyse(
|
||||
PDDocument document, String mode, AutoRotatePdfRequest request)
|
||||
throws IOException, InterruptedException {
|
||||
double threshold =
|
||||
request.getConfidenceThreshold() == null ? 14.0 : request.getConfidenceThreshold();
|
||||
boolean tesseractAvailable = endpointConfiguration.isGroupEnabled("tesseract");
|
||||
boolean useText = !"osd".equals(mode);
|
||||
boolean useOsd = !"text".equals(mode);
|
||||
|
||||
List<PageResult> results = new ArrayList<>();
|
||||
List<Integer> osdCandidates = new ArrayList<>();
|
||||
|
||||
int pageCount = document.getNumberOfPages();
|
||||
// One walk of the document for all pages, rather than one walk per page.
|
||||
List<TextDirection> textDirections =
|
||||
useText ? AutoRotateDetection.detectTextDirections(document) : List.of();
|
||||
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
int currentRotation = Math.floorMod(document.getPage(i).getRotation(), 360);
|
||||
PageResult result =
|
||||
PageResult.builder()
|
||||
.pageNumber(i + 1)
|
||||
.currentRotation(currentRotation)
|
||||
.method(METHOD_NONE)
|
||||
.build();
|
||||
|
||||
if (useText) {
|
||||
TextDirection direction = textDirections.get(i);
|
||||
if (direction.isConclusive()) {
|
||||
int correction =
|
||||
AutoRotateDetection.correctionFromTextDirection(
|
||||
direction.dominantDirection(), currentRotation);
|
||||
result.setMethod(METHOD_TEXT);
|
||||
result.setCorrection(correction);
|
||||
result.setConfidence(direction.dominance() * 100);
|
||||
result.setApply(correction != 0);
|
||||
} else if (!useOsd) {
|
||||
result.setNote(
|
||||
direction.glyphCount() < AutoRotateDetection.MIN_GLYPHS
|
||||
? "tooFewGlyphs"
|
||||
: "noDominantDirection");
|
||||
}
|
||||
}
|
||||
|
||||
if (useOsd && METHOD_NONE.equals(result.getMethod())) {
|
||||
if (tesseractAvailable) {
|
||||
osdCandidates.add(i);
|
||||
} else {
|
||||
result.setNote("tesseractUnavailable");
|
||||
}
|
||||
}
|
||||
results.add(result);
|
||||
}
|
||||
|
||||
if (!osdCandidates.isEmpty()) {
|
||||
runOsdOnPages(document, osdCandidates, results, threshold);
|
||||
}
|
||||
|
||||
if (request.isInferUndetected()) {
|
||||
inferUndetectedPages(results);
|
||||
}
|
||||
|
||||
return summarise(results, pageCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in pages that no signal could decide, using the pages that could. When every decided
|
||||
* page sharing an undecided page's current rotation agrees on one correction, that correction
|
||||
* is the document's consensus for that rotation and is applied to the undecided page. This is
|
||||
* the common "whole document rotated uniformly, but a cover or near-blank page has too little
|
||||
* text to detect on its own" case. If decided pages disagree, nothing is inferred.
|
||||
*/
|
||||
private void inferUndetectedPages(List<PageResult> results) {
|
||||
// rotation -> the single agreed correction, or null once a conflict is seen
|
||||
Map<Integer, Integer> consensus = new HashMap<>();
|
||||
Set<Integer> conflicted = new HashSet<>();
|
||||
for (PageResult result : results) {
|
||||
if (METHOD_NONE.equals(result.getMethod())) {
|
||||
continue;
|
||||
}
|
||||
int rotation = result.getCurrentRotation();
|
||||
if (conflicted.contains(rotation)) {
|
||||
continue;
|
||||
}
|
||||
Integer existing = consensus.get(rotation);
|
||||
if (existing == null) {
|
||||
consensus.put(rotation, result.getCorrection());
|
||||
} else if (existing != result.getCorrection()) {
|
||||
conflicted.add(rotation);
|
||||
consensus.remove(rotation);
|
||||
}
|
||||
}
|
||||
|
||||
for (PageResult result : results) {
|
||||
if (!METHOD_NONE.equals(result.getMethod())) {
|
||||
continue;
|
||||
}
|
||||
Integer correction = consensus.get(result.getCurrentRotation());
|
||||
if (correction == null) {
|
||||
continue;
|
||||
}
|
||||
result.setMethod(METHOD_INFERRED);
|
||||
result.setCorrection(correction);
|
||||
result.setConfidence(null);
|
||||
result.setApply(correction != 0);
|
||||
result.setNote("inferredFromDocument");
|
||||
}
|
||||
}
|
||||
|
||||
private void runOsdOnPages(
|
||||
PDDocument document,
|
||||
List<Integer> pageIndexes,
|
||||
List<PageResult> results,
|
||||
double threshold)
|
||||
throws IOException, InterruptedException {
|
||||
String tessDataPath = runtimePathConfig.getTessDataPath();
|
||||
boolean haveOsdData =
|
||||
tessDataPath != null && new File(tessDataPath, "osd.traineddata").exists();
|
||||
|
||||
int dpi = OSD_RENDER_DPI;
|
||||
if (applicationProperties != null && applicationProperties.getSystem() != null) {
|
||||
dpi = Math.min(OSD_RENDER_DPI, applicationProperties.getSystem().getMaxDPI());
|
||||
}
|
||||
final int renderDpi = dpi;
|
||||
|
||||
try (TempDirectory tempDir = new TempDirectory(tempFileManager)) {
|
||||
PDFRenderer renderer = new PDFRenderer(document);
|
||||
renderer.setSubsamplingAllowed(true);
|
||||
// One reused path, deleted after every page: the images are throwaway input for
|
||||
// Tesseract, so a long document must not accumulate one file per page on disk.
|
||||
File imageFile = new File(tempDir.getPath().toFile(), "osd-page.bmp");
|
||||
|
||||
for (int pageIndex : pageIndexes) {
|
||||
PageResult result = results.get(pageIndex);
|
||||
try {
|
||||
// Rendering honours the page's current /Rotate, so OSD sees the page exactly
|
||||
// as a viewer would and its verdict is always an additive correction.
|
||||
BufferedImage image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
renderDpi,
|
||||
() ->
|
||||
renderer.renderImageWithDPI(
|
||||
pageIndex, renderDpi, ImageType.GRAY));
|
||||
|
||||
if (AutoRotateDetection.isBlankRender(image)) {
|
||||
// Nothing for OSD to read; skip the process spawn entirely.
|
||||
result.setNote("blankPage");
|
||||
continue;
|
||||
}
|
||||
|
||||
// BMP, not PNG: the file is deleted straight after Tesseract reads it, so
|
||||
// paying for compression only to discard the result is wasted work.
|
||||
ImageIO.write(image, "bmp", imageFile);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("tesseract");
|
||||
command.add(imageFile.getAbsolutePath());
|
||||
command.add("stdout");
|
||||
command.add("--psm");
|
||||
command.add("0");
|
||||
if (haveOsdData) {
|
||||
command.add("--tessdata-dir");
|
||||
command.add(tessDataPath);
|
||||
}
|
||||
|
||||
ProcessExecutorResult processResult =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.TESSERACT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
Optional<OsdResult> osd =
|
||||
AutoRotateDetection.parseOsd(processResult.getMessages());
|
||||
if (osd.isEmpty()) {
|
||||
result.setNote("osdNoVerdict");
|
||||
continue;
|
||||
}
|
||||
result.setConfidence(osd.get().confidence());
|
||||
result.setCorrection(osd.get().rotate());
|
||||
if (osd.get().confidence() >= threshold) {
|
||||
result.setMethod(METHOD_OSD);
|
||||
result.setApply(osd.get().rotate() != 0);
|
||||
} else {
|
||||
result.setNote("belowThreshold");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Textless pages make Tesseract exit non-zero; skip, never guess.
|
||||
log.debug("OSD failed for page {}: {}", pageIndex + 1, e.getMessage());
|
||||
result.setNote("osdFailed");
|
||||
} finally {
|
||||
Files.deleteIfExists(imageFile.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyExplicitRotations(PDDocument document, List<PageRotation> rotations) {
|
||||
int pageCount = document.getNumberOfPages();
|
||||
Set<Integer> seen = new HashSet<>();
|
||||
for (PageRotation entry : rotations) {
|
||||
Integer pageNumber = entry.getPageNumber();
|
||||
Integer angle = entry.getRotation();
|
||||
if (pageNumber == null
|
||||
|| angle == null
|
||||
|| pageNumber < 1
|
||||
|| pageNumber > pageCount
|
||||
|| angle % 90 != 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
ExceptionUtils.ErrorCode.INVALID_ARGUMENT,
|
||||
"pageRotations",
|
||||
"page numbers must exist and rotations must be multiples of 90");
|
||||
}
|
||||
// Rotations are additive, so a repeated page would be turned twice; reject rather
|
||||
// than silently pick a winner.
|
||||
if (!seen.add(pageNumber)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
ExceptionUtils.ErrorCode.INVALID_ARGUMENT,
|
||||
"pageRotations",
|
||||
"page " + pageNumber + " is listed more than once");
|
||||
}
|
||||
PDPage page = document.getPage(pageNumber - 1);
|
||||
page.setRotation(Math.floorMod(page.getRotation() + angle, 360));
|
||||
}
|
||||
}
|
||||
|
||||
private AutoRotateAnalysisResult summarise(List<PageResult> results, int pageCount) {
|
||||
int toRotate = 0;
|
||||
int byText = 0;
|
||||
int byOsd = 0;
|
||||
int byInference = 0;
|
||||
int undetected = 0;
|
||||
for (PageResult result : results) {
|
||||
if (result.isApply()) {
|
||||
toRotate++;
|
||||
}
|
||||
switch (result.getMethod()) {
|
||||
case METHOD_TEXT -> byText++;
|
||||
case METHOD_OSD -> byOsd++;
|
||||
case METHOD_INFERRED -> byInference++;
|
||||
default -> undetected++;
|
||||
}
|
||||
}
|
||||
return AutoRotateAnalysisResult.builder()
|
||||
.pages(results)
|
||||
.totalPages(pageCount)
|
||||
.pagesToRotate(toRotate)
|
||||
.detectedByText(byText)
|
||||
.detectedByOsd(byOsd)
|
||||
.inferred(byInference)
|
||||
.undetected(undetected)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ResponseEntity<?> pdfResponse(PDDocument document, AutoRotatePdfRequest request)
|
||||
throws IOException {
|
||||
String originalName =
|
||||
request.getFileInput() != null
|
||||
? request.getFileInput().getOriginalFilename()
|
||||
: "document.pdf";
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(originalName, "_auto_rotated.pdf"),
|
||||
tempFileManager);
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -336,7 +336,19 @@ public class ConfigController {
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// AI Engine settings
|
||||
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
|
||||
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
|
||||
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
|
||||
// Per-capability flags let the UI hide individual AI tools an admin has turned off.
|
||||
ApplicationProperties.AiEngine.Features aiFeatures = aiEngineConfig.getFeatures();
|
||||
configData.put(
|
||||
"aiFeatures",
|
||||
Map.ofEntries(
|
||||
Map.entry("chat", aiFeatures.isChat()),
|
||||
Map.entry("documentQuestions", aiFeatures.isDocumentQuestions()),
|
||||
Map.entry("createPdf", aiFeatures.isCreatePdf()),
|
||||
Map.entry("mathAuditor", aiFeatures.isMathAuditor()),
|
||||
Map.entry("pdfComment", aiFeatures.isPdfComment()),
|
||||
Map.entry("classify", aiFeatures.isClassify())));
|
||||
|
||||
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||
ApplicationProperties.Security.Timestamp tsConfig =
|
||||
|
||||
+70
-6
@@ -23,6 +23,9 @@ import org.bouncycastle.cms.CMSSignedData;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.bouncycastle.cms.SignerInformationStore;
|
||||
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
import org.bouncycastle.tsp.TimeStampToken;
|
||||
import org.bouncycastle.tsp.TimeStampTokenInfo;
|
||||
import org.bouncycastle.util.Store;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -54,6 +57,9 @@ public class ValidateSignatureController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final CertificateValidationService certValidationService;
|
||||
|
||||
/** PDF sub-filter identifying an RFC 3161 document timestamp (PAdES-LTV). */
|
||||
private static final String SUBFILTER_RFC3161 = "ETSI.RFC3161";
|
||||
|
||||
@InitBinder
|
||||
public void initBinder(WebDataBinder binder) {
|
||||
binder.registerCustomEditor(
|
||||
@@ -128,8 +134,39 @@ public class ValidateSignatureController {
|
||||
byte[] signedContent = sig.getSignedContent(file.getInputStream());
|
||||
byte[] signatureBytes = sig.getContents(file.getInputStream());
|
||||
|
||||
CMSProcessable content = new CMSProcessableByteArray(signedContent);
|
||||
CMSSignedData signedData = new CMSSignedData(content, signatureBytes);
|
||||
// An RFC 3161 document timestamp (PAdES-LTV) carries its signed content
|
||||
// *inside* the CMS - a TSTInfo - rather than being detached over the document.
|
||||
// Building it as detached digests the ByteRange against an attribute that
|
||||
// covers the TSTInfo, which can never match.
|
||||
boolean isDocTimeStamp = SUBFILTER_RFC3161.equals(sig.getSubFilter());
|
||||
CMSSignedData signedData;
|
||||
// Parse from a stream: /Contents is zero-padded to its reserved length and the
|
||||
// byte[] constructors reject those trailing bytes since BC 1.85.
|
||||
if (isDocTimeStamp) {
|
||||
signedData = new CMSSignedData(new ByteArrayInputStream(signatureBytes));
|
||||
} else {
|
||||
CMSProcessable content = new CMSProcessableByteArray(signedContent);
|
||||
signedData =
|
||||
new CMSSignedData(
|
||||
content, new ByteArrayInputStream(signatureBytes));
|
||||
}
|
||||
|
||||
// What actually binds a timestamp to this document: the TSTInfo's message
|
||||
// imprint must equal the digest of the signed byte range. Without this check a
|
||||
// valid timestamp token for some *other* document would verify happily here.
|
||||
Date timeStampGenTime = null;
|
||||
if (isDocTimeStamp) {
|
||||
TimeStampToken token = new TimeStampToken(signedData);
|
||||
TimeStampTokenInfo info = token.getTimeStampInfo();
|
||||
timeStampGenTime = info.getGenTime();
|
||||
if (!timestampCoversContent(info, signedContent)) {
|
||||
result.setValid(false);
|
||||
result.setErrorMessage(
|
||||
"Timestamp message imprint does not match the document");
|
||||
results.add(result);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Store<X509CertificateHolder> certStore = signedData.getCertificates();
|
||||
SignerInformationStore signerStore = signedData.getSignerInfos();
|
||||
@@ -162,7 +199,15 @@ public class ValidateSignatureController {
|
||||
CertificateValidationService.ValidationTime validationTimeResult =
|
||||
certValidationService.extractValidationTime(signerInfo);
|
||||
Date validationTime;
|
||||
if (validationTimeResult == null) {
|
||||
if (timeStampGenTime != null) {
|
||||
// The TSA's own asserted time is the authoritative one here, and is
|
||||
// exactly what makes the signature verifiable after the cert expires.
|
||||
validationTime = timeStampGenTime;
|
||||
// Distinct from "timestamp", which CertificateValidationService already
|
||||
// uses for a signature countersigned by a TSA. Both are RFC 3161, but
|
||||
// one attests a signature and the other attests the whole document.
|
||||
result.setValidationTimeSource("document-timestamp");
|
||||
} else if (validationTimeResult == null) {
|
||||
validationTime = new Date();
|
||||
result.setValidationTimeSource("current");
|
||||
} else {
|
||||
@@ -235,10 +280,13 @@ public class ValidateSignatureController {
|
||||
|
||||
// Set basic signature info
|
||||
result.setSignerName(sig.getName());
|
||||
// A DocTimeStamp has no /M entry; its date is the TSA's genTime.
|
||||
result.setSignatureDate(
|
||||
sig.getSignDate() != null
|
||||
? sig.getSignDate().getTime().toString()
|
||||
: null);
|
||||
timeStampGenTime != null
|
||||
? timeStampGenTime.toString()
|
||||
: sig.getSignDate() != null
|
||||
? sig.getSignDate().getTime().toString()
|
||||
: null);
|
||||
result.setReason(sig.getReason());
|
||||
result.setLocation(sig.getLocation());
|
||||
|
||||
@@ -301,4 +349,20 @@ public class ValidateSignatureController {
|
||||
|
||||
return ResponseEntity.ok(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the timestamp token was issued over exactly these bytes.
|
||||
*
|
||||
* <p>The digest algorithm is taken from the token rather than assumed, because a TSA chooses it
|
||||
* - assuming SHA-256 would silently fail against any TSA that uses something else.
|
||||
*/
|
||||
private static boolean timestampCoversContent(TimeStampTokenInfo info, byte[] signedContent)
|
||||
throws Exception {
|
||||
org.bouncycastle.operator.DigestCalculator digest =
|
||||
new JcaDigestCalculatorProviderBuilder().build().get(info.getHashAlgorithm());
|
||||
try (java.io.OutputStream out = digest.getOutputStream()) {
|
||||
out.write(signedContent);
|
||||
}
|
||||
return java.util.Arrays.equals(digest.getDigest(), info.getMessageImprintDigest());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ public class ApiEndpoint {
|
||||
postNode.path("parameters")
|
||||
.forEach(
|
||||
paramNode -> {
|
||||
String paramName = paramNode.path("name").asText("");
|
||||
String paramName = paramNode.path("name").asString("");
|
||||
parameters.put(paramName, paramNode);
|
||||
});
|
||||
this.description = postNode.path("description").asText("");
|
||||
this.description = postNode.path("description").asString("");
|
||||
}
|
||||
|
||||
public boolean areParametersValid(Map<String, Object> providedParams) {
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Per-page orientation report returned by auto-rotate-pdf when dryRun is set. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AutoRotateAnalysisResult {
|
||||
|
||||
private List<PageResult> pages;
|
||||
|
||||
private int totalPages;
|
||||
|
||||
@Schema(description = "Number of pages a correction would be applied to")
|
||||
private int pagesToRotate;
|
||||
|
||||
private int detectedByText;
|
||||
|
||||
private int detectedByOsd;
|
||||
|
||||
@Schema(description = "Pages whose correction was inherited from the document consensus")
|
||||
private int inferred;
|
||||
|
||||
private int undetected;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PageResult {
|
||||
|
||||
@Schema(description = "1-based page number")
|
||||
private int pageNumber;
|
||||
|
||||
@Schema(description = "The page's current /Rotate value, normalised to 0-270")
|
||||
private int currentRotation;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Detected additional clockwise rotation that would make the page upright."
|
||||
+ " Reported for diagnostics even when it is not used (for example"
|
||||
+ " an OSD verdict below the confidence threshold); 'apply' is the"
|
||||
+ " authority on whether it is actually applied")
|
||||
private int correction;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Detection confidence: percentage of glyphs sharing the dominant direction"
|
||||
+ " for method 'text', Tesseract orientation confidence for method"
|
||||
+ " 'osd', absent when nothing was detected")
|
||||
private Double confidence;
|
||||
|
||||
@Schema(
|
||||
description = "How the orientation was determined",
|
||||
allowableValues = {"text", "osd", "inferred", "none"})
|
||||
private String method;
|
||||
|
||||
@Schema(description = "Whether the correction will be (or was) applied")
|
||||
private boolean apply;
|
||||
|
||||
@Schema(description = "Machine-readable reason when no correction is applied")
|
||||
private String note;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AutoRotatePdfRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Detection method. 'auto' tries embedded-text direction first and falls back"
|
||||
+ " to Tesseract OSD for pages without usable text; 'text' uses only"
|
||||
+ " embedded-text direction; 'osd' forces Tesseract OSD for every page",
|
||||
allowableValues = {"auto", "text", "osd"},
|
||||
defaultValue = "auto")
|
||||
private String detectionMode = "auto";
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Minimum Tesseract OSD orientation confidence required before a correction is"
|
||||
+ " applied. Matches OCRmyPDF's --rotate-pages-threshold scale",
|
||||
minimum = "0",
|
||||
defaultValue = "14.0")
|
||||
@Min(value = 0, message = "Confidence threshold must be non-negative")
|
||||
private Double confidenceThreshold = 14.0;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"If true, no rotation is applied; returns a JSON report of the per-page"
|
||||
+ " detection results instead of a PDF")
|
||||
private boolean dryRun;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"When a page cannot be decided on its own but the pages that could be decided"
|
||||
+ " agree on a single correction for that same current rotation, apply"
|
||||
+ " that shared correction to the undecided page. Handles documents"
|
||||
+ " rotated uniformly where some pages are too sparse to detect alone",
|
||||
defaultValue = "true")
|
||||
private boolean inferUndetected = true;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Optional pre-computed corrections to apply without running detection. Pages"
|
||||
+ " not listed are left unchanged, and a page may only appear once")
|
||||
@Valid
|
||||
private List<PageRotation> pageRotations;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** A pre-computed rotation for one page, used by auto-rotate-pdf's apply-only path. */
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PageRotation {
|
||||
|
||||
@Schema(
|
||||
description = "1-based page number to rotate",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
example = "1")
|
||||
private Integer pageNumber;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Additional clockwise rotation to add to the page's current rotation, in"
|
||||
+ " degrees. Must be a multiple of 90",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
example = "90")
|
||||
private Integer rotation;
|
||||
}
|
||||
@@ -200,8 +200,8 @@ public class VeraPDFService {
|
||||
|
||||
try (PDFAParser detectionParser =
|
||||
Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdfBytes))) {
|
||||
declaredFlavour = detectionParser.getFlavour();
|
||||
detectedFlavours = detectionParser.getFlavours();
|
||||
detectedFlavours = detectedFlavours(detectionParser);
|
||||
declaredFlavour = firstFlavour(detectedFlavours);
|
||||
}
|
||||
|
||||
// For PDF/A flavours, we need to validate first to check if PDF/A identification exists in
|
||||
@@ -299,7 +299,7 @@ public class VeraPDFService {
|
||||
Foundries.defaultInstance()
|
||||
.createParser(new ByteArrayInputStream(pdfBytes), flavour)) {
|
||||
|
||||
PDFAFlavour parserDeclared = parser.getFlavour();
|
||||
PDFAFlavour parserDeclared = firstFlavour(detectedFlavours(parser));
|
||||
PDFAValidator validator =
|
||||
Foundries.defaultInstance().createValidator(flavour, false);
|
||||
ValidationResult result = validator.validate(parser);
|
||||
@@ -322,7 +322,19 @@ public class VeraPDFService {
|
||||
}
|
||||
|
||||
private static boolean isPdfaFlavour(PDFAFlavour flavour) {
|
||||
return PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A);
|
||||
return flavour != null
|
||||
&& PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A);
|
||||
}
|
||||
|
||||
// veraPDF 1.30+ returns an empty flavour list for non-PDF/A files, where getFlavour() throws
|
||||
private static List<PDFAFlavour> detectedFlavours(PDFAParser parser) {
|
||||
List<PDFAFlavour> flavours = parser.getFlavours();
|
||||
return flavours != null ? flavours : List.of();
|
||||
}
|
||||
|
||||
// null means "no PDF/A flavour detected" rather than an error
|
||||
private static PDFAFlavour firstFlavour(List<PDFAFlavour> flavours) {
|
||||
return flavours.isEmpty() ? null : flavours.get(0);
|
||||
}
|
||||
|
||||
private static String formatStandardDisplay(
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package stirling.software.SPDF.utils;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
|
||||
/**
|
||||
* Page-orientation detection primitives for the auto-rotate tool.
|
||||
*
|
||||
* <p>Two independent signals are supported: the dominant direction of a page's embedded text
|
||||
* (cheap, digital PDFs only) and Tesseract's orientation-and-script-detection output (works on
|
||||
* scans, requires the external binary). Both express their result as the additional clockwise
|
||||
* /Rotate correction that makes the page display upright.
|
||||
*/
|
||||
public final class AutoRotateDetection {
|
||||
|
||||
private AutoRotateDetection() {}
|
||||
|
||||
/** Glyphs needed to trust the text signal at the ordinary dominance bar. */
|
||||
public static final int MIN_GLYPHS = 30;
|
||||
|
||||
/** Fraction of glyphs that must share one direction at the ordinary bar. */
|
||||
public static final double MIN_DOMINANCE = 0.95;
|
||||
|
||||
/**
|
||||
* Glyphs needed to trust the text signal when the glyphs are near-unanimous. Lets sparse pages
|
||||
* (a header, a single line, a rotated URL) be decided from their own text instead of falling
|
||||
* through to OSD, as long as effectively every glyph agrees on the direction.
|
||||
*/
|
||||
public static final int MIN_GLYPHS_UNANIMOUS = 8;
|
||||
|
||||
/** Dominance required for the sparse-page path — essentially total agreement. */
|
||||
public static final double UNANIMOUS_DOMINANCE = 0.99;
|
||||
|
||||
/**
|
||||
* Dominant embedded-text direction of one page.
|
||||
*
|
||||
* @param dominantDirection glyph direction in page space, degrees CCW (0/90/180/270)
|
||||
* @param dominance fraction of counted glyphs sharing the dominant direction (0..1)
|
||||
* @param glyphCount number of non-whitespace glyphs counted
|
||||
*/
|
||||
public record TextDirection(int dominantDirection, double dominance, int glyphCount) {
|
||||
|
||||
public boolean isConclusive() {
|
||||
if (glyphCount >= MIN_GLYPHS && dominance >= MIN_DOMINANCE) {
|
||||
return true;
|
||||
}
|
||||
return glyphCount >= MIN_GLYPHS_UNANIMOUS && dominance >= UNANIMOUS_DOMINANCE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed Tesseract OSD verdict.
|
||||
*
|
||||
* @param rotate clockwise degrees to rotate the rendered page so text is upright
|
||||
* @param confidence Tesseract's orientation confidence (same scale OCRmyPDF thresholds on)
|
||||
*/
|
||||
public record OsdResult(int rotate, double confidence) {}
|
||||
|
||||
private static final Pattern OSD_ROTATE =
|
||||
Pattern.compile("^Rotate:\\s*(\\d+)", Pattern.MULTILINE);
|
||||
private static final Pattern OSD_CONFIDENCE =
|
||||
Pattern.compile("^Orientation confidence:\\s*([0-9.]+)", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* Counts non-whitespace glyph directions for every page in one pass. A stripper per page would
|
||||
* re-walk the document once per page, which is quadratic on long documents; this walks it once
|
||||
* and buckets glyphs by the page being processed.
|
||||
*
|
||||
* @return one entry per page, in page order
|
||||
*/
|
||||
public static List<TextDirection> detectTextDirections(PDDocument document) throws IOException {
|
||||
int pageCount = document.getNumberOfPages();
|
||||
DirectionCountingStripper stripper = new DirectionCountingStripper(pageCount);
|
||||
stripper.setStartPage(1);
|
||||
stripper.setEndPage(pageCount);
|
||||
stripper.getText(document);
|
||||
|
||||
List<TextDirection> directions = new ArrayList<>(pageCount);
|
||||
for (int page = 0; page < pageCount; page++) {
|
||||
int[] counts = stripper.counts[page];
|
||||
int total = 0;
|
||||
int bestIndex = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
total += counts[i];
|
||||
if (counts[i] > counts[bestIndex]) {
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
double dominance = total == 0 ? 0 : (double) counts[bestIndex] / total;
|
||||
directions.add(new TextDirection(bestIndex * 90, dominance, total));
|
||||
}
|
||||
return directions;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a rendered page carries no ink worth analysing. Checked after rendering but before
|
||||
* spawning Tesseract, since the process spawn costs far more than the pixel scan and this
|
||||
* catches both empty generated pages and scanned blanks (the back of a duplex sheet).
|
||||
*/
|
||||
public static boolean isBlankRender(BufferedImage image) {
|
||||
final int darkThreshold = 200; // 8-bit grey; anything lighter counts as paper
|
||||
final int step = 4; // subsample: blank pages are uniform, no need for every pixel
|
||||
long sampled = 0;
|
||||
long dark = 0;
|
||||
for (int y = 0; y < image.getHeight(); y += step) {
|
||||
for (int x = 0; x < image.getWidth(); x += step) {
|
||||
sampled++;
|
||||
if ((image.getRGB(x, y) & 0xFF) < darkThreshold) {
|
||||
dark++;
|
||||
// A page needs a meaningful amount of ink before OSD can do anything;
|
||||
// bail out as soon as we know there is enough.
|
||||
if (dark > sampled / 1000 + 20) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clockwise /Rotate correction for a page whose dominant glyph direction (page space, CCW) is
|
||||
* {@code dominantDirection} and whose current /Rotate is {@code pageRotation}. Derivation: the
|
||||
* on-screen text angle is (direction - rotation) CCW, and adding d to /Rotate turns the display
|
||||
* a further d clockwise, so the correction that zeroes the screen angle is their difference.
|
||||
*/
|
||||
public static int correctionFromTextDirection(int dominantDirection, int pageRotation) {
|
||||
return Math.floorMod(dominantDirection - pageRotation, 360);
|
||||
}
|
||||
|
||||
/** Extracts rotation and confidence from `tesseract <img> stdout --psm 0` output. */
|
||||
public static Optional<OsdResult> parseOsd(String tesseractOutput) {
|
||||
if (tesseractOutput == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Matcher rotate = OSD_ROTATE.matcher(tesseractOutput);
|
||||
Matcher confidence = OSD_CONFIDENCE.matcher(tesseractOutput);
|
||||
if (!rotate.find() || !confidence.find()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(
|
||||
new OsdResult(
|
||||
Integer.parseInt(rotate.group(1)),
|
||||
Double.parseDouble(confidence.group(1))));
|
||||
} catch (NumberFormatException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static class DirectionCountingStripper extends PDFTextStripper {
|
||||
|
||||
// counts[page][i] holds glyphs on that page whose direction is i * 90 degrees
|
||||
final int[][] counts;
|
||||
|
||||
DirectionCountingStripper(int pageCount) throws IOException {
|
||||
this.counts = new int[pageCount][4];
|
||||
}
|
||||
|
||||
/**
|
||||
* PDFBox snaps glyph direction to a quadrant, so getDir() only ever yields 0/90/180/270 —
|
||||
* obliquely drawn text (30, 45, 135 degrees) is reported as 0 rather than as its true
|
||||
* angle. Skew is therefore invisible to this signal by construction, which is consistent
|
||||
* with skew being out of scope here: only 90-degree orientation is corrected.
|
||||
*/
|
||||
@Override
|
||||
protected void processTextPosition(TextPosition text) {
|
||||
String unicode = text.getUnicode();
|
||||
if (unicode == null || unicode.isBlank()) {
|
||||
return;
|
||||
}
|
||||
int page = getCurrentPageNo() - 1;
|
||||
if (page < 0 || page >= counts.length) {
|
||||
return;
|
||||
}
|
||||
counts[page][Math.floorMod(Math.round(text.getDir()), 360) / 90]++;
|
||||
// super is intentionally not called: we only count, no text assembly needed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,8 @@ spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
spring.h2.console.enabled=false
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
# Batch associations into IN() loads so list endpoints don't N+1 as tables grow.
|
||||
spring.jpa.properties.hibernate.default_batch_fetch_size=100
|
||||
# Defer datasource initialization to ensure that the database is fully set up
|
||||
# before Hibernate attempts to access it. This is particularly useful when
|
||||
# using database initialization scripts or tools.
|
||||
@@ -96,7 +98,8 @@ spring.main.allow-bean-definition-overriding=true
|
||||
# spring-data-redis is on the classpath only for the optional Valkey backplane (which wires its own
|
||||
# factory); exclude Spring Boot's stock Redis auto-config so a default install doesn't create a dead
|
||||
# localhost:6379 factory that flips /actuator/health to DOWN.
|
||||
spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration,org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration
|
||||
# Also exclude the repositories auto-config: in cluster mode it needs a redisTemplate bean we don't define.
|
||||
spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration,org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration,org.springframework.boot.data.redis.autoconfigure.DataRedisRepositoriesAutoConfiguration
|
||||
|
||||
# Set up a consistent temporary directory location
|
||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||
|
||||
@@ -292,6 +292,26 @@ storage:
|
||||
linkExpirationDays: 3 # Number of days before share links expire
|
||||
signing:
|
||||
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
|
||||
# ====================================================================================
|
||||
# ENCRYPTION AT REST - PRO / ENTERPRISE LICENSE REQUIRED TO ENABLE
|
||||
# ====================================================================================
|
||||
# Encrypts stored files (AES-256 envelope encryption, per-team keys). The master key is
|
||||
# resolved in this order:
|
||||
# 1. stirling.security.fileEncryptionKey property
|
||||
# 2. STIRLING_FILE_ENCRYPTION_KEY environment variable
|
||||
# 3. an auto-generated configs/file-encryption.key (single-node only; cluster mode
|
||||
# requires an explicitly shared key on every node)
|
||||
# Generate a key with: openssl rand -base64 32
|
||||
#
|
||||
# *** BACK UP THE MASTER KEY. Losing it makes every encrypted stored file ***
|
||||
# *** permanently unrecoverable. Verify backups against the key fingerprint logged ***
|
||||
# *** at startup. ***
|
||||
#
|
||||
# Enabling encrypts new writes only (existing files stay readable as plaintext).
|
||||
# Disabling later only stops encrypting new writes - existing encrypted files remain
|
||||
# readable as long as the key material is present.
|
||||
encryption:
|
||||
enabled: false # set to 'true' to encrypt stored files at rest
|
||||
userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only.
|
||||
autoPipeline:
|
||||
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
|
||||
@@ -366,12 +386,43 @@ aiEngine:
|
||||
enabled: false # Set to 'true' to enable the AI engine integration
|
||||
url: http://localhost:5001 # URL of the Python AI engine
|
||||
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
|
||||
longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents
|
||||
streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs
|
||||
pushConfigToEngine: true # Push admin AI config to the engine on startup + save; false = engine stays fully env-controlled
|
||||
models:
|
||||
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
|
||||
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
|
||||
fastModel: claude-haiku-4-5 # Cheap/fast tier model name (no provider prefix)
|
||||
smartMaxTokens: 8192 # Max output tokens for the smart tier
|
||||
fastMaxTokens: 2048 # Max output tokens for the fast tier
|
||||
apiKey: "" # API key for the selected provider (secret). Empty = engine uses its native env credentials (e.g. ANTHROPIC_API_KEY)
|
||||
baseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' providers (e.g. http://ollama:11434/v1). Ignored for anthropic/openai
|
||||
rag:
|
||||
embeddingProvider: voyageai # Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
|
||||
embeddingModel: voyage-4 # Embedding model name (no provider prefix)
|
||||
embeddingApiKey: "" # Secret API key for the embedding provider. Empty = engine uses its native env credentials (e.g. VOYAGE_API_KEY)
|
||||
embeddingBaseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' embedding providers (e.g. http://ollama:11434/v1). Ignored for voyageai/openai
|
||||
topK: 20 # Number of chunks retrieval returns per search
|
||||
maxSearches: 5 # Per-run cap on knowledge-search tool calls before the agent must answer
|
||||
limits:
|
||||
maxPages: 200 # Upper bound on PDF pages the engine will process per request
|
||||
maxCharacters: 200000 # Upper bound on characters of extracted text per request
|
||||
modelMaxConcurrency: 32 # Process-wide cap on concurrent model API calls (engine restart to apply)
|
||||
features: # Per-capability switches; turn an individual AI tool off without disabling the whole engine
|
||||
chat: true # Assistant chat
|
||||
documentQuestions: true # Ask-questions-about-a-PDF
|
||||
createPdf: true # Generate a PDF from a natural-language spec
|
||||
mathAuditor: true # Numerical/formula contradiction auditing
|
||||
pdfComment: true # AI-authored PDF comments/annotations
|
||||
classify: true # Automatic document classification/labelling
|
||||
|
||||
policies:
|
||||
# Folder automations can read from and write to the directories you allow here, so treat this as a
|
||||
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs
|
||||
# entirely; list absolute directories to permit folder access only within them. Stirling's own
|
||||
# config directory is always off-limits, and folder access is always disabled in SaaS mode.
|
||||
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs,
|
||||
# other than from directories that are always permitted like server file-storage and watched folders.
|
||||
# List absolute directories to permit folder access within them.
|
||||
# Stirling's own config directory is always off-limits, and folder access is always
|
||||
# disabled in SaaS mode.
|
||||
allowedFolderRoots: [] # e.g. ["/data/inbox", "/data/outbox"]
|
||||
scheduleSweepSeconds: 60 # How often (seconds) scheduled policies are checked for being due
|
||||
watchReconcileSeconds: 300 # How often (seconds) folder-watch re-syncs watches and re-runs as a safety net for missed events
|
||||
@@ -385,6 +436,8 @@ policies:
|
||||
mcp:
|
||||
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
|
||||
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
|
||||
maxRequestBytes: 10485760 # Max size (bytes) of an incoming MCP tool request payload (default 10 MB)
|
||||
maxInlineResponseBytes: 10485760 # Max size (bytes) of an MCP tool response returned inline before it is rejected (default 10 MB)
|
||||
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
|
||||
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
|
||||
auth:
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.AutoRotateAnalysisResult;
|
||||
import stirling.software.SPDF.model.api.misc.AutoRotatePdfRequest;
|
||||
import stirling.software.SPDF.model.api.misc.PageRotation;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AutoRotateControllerTest {
|
||||
|
||||
private static final String SAMPLE_TEXT =
|
||||
"The quick brown fox jumps over the lazy dog again and again";
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
|
||||
@InjectMocks private AutoRotateController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
lenient().when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
|
||||
}
|
||||
|
||||
private static PDDocument docWithUprightText(int... pageRotations) throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
for (int rotation : pageRotations) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
content.setTextMatrix(Matrix.getTranslateInstance(72, 400));
|
||||
content.showText(SAMPLE_TEXT);
|
||||
content.endText();
|
||||
}
|
||||
page.setRotation(rotation);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private AutoRotatePdfRequest request(PDDocument document) throws IOException {
|
||||
AutoRotatePdfRequest request = new AutoRotatePdfRequest();
|
||||
request.setFileInput(
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"test.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
new byte[] {1, 2, 3}));
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(document);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static PDDocument reload(ResponseEntity<?> response) throws IOException {
|
||||
Resource resource = (Resource) response.getBody();
|
||||
return Loader.loadPDF(resource.getContentAsByteArray());
|
||||
}
|
||||
|
||||
private static PDDocument docWithTextAt(int textAngleDegrees, int pageRotation)
|
||||
throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
content.setTextMatrix(
|
||||
Matrix.getRotateInstance(Math.toRadians(textAngleDegrees), 300, 400));
|
||||
content.showText(SAMPLE_TEXT);
|
||||
content.endText();
|
||||
}
|
||||
page.setRotation(pageRotation);
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the dominant glyph direction straight from a document, independently of the production
|
||||
* detection code, so the round-trip assertion below validates the result rather than restating
|
||||
* the formula under test.
|
||||
*/
|
||||
private static int dominantGlyphDirection(PDDocument document) throws IOException {
|
||||
int[] counts = new int[4];
|
||||
PDFTextStripper stripper =
|
||||
new PDFTextStripper() {
|
||||
@Override
|
||||
protected void processTextPosition(TextPosition text) {
|
||||
if (!text.getUnicode().isBlank()) {
|
||||
counts[Math.floorMod(Math.round(text.getDir()), 360) / 90]++;
|
||||
}
|
||||
}
|
||||
};
|
||||
stripper.setStartPage(1);
|
||||
stripper.setEndPage(1);
|
||||
stripper.getText(document);
|
||||
int best = 0;
|
||||
for (int i = 1; i < 4; i++) {
|
||||
if (counts[i] > counts[best]) {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best * 90;
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end round trip: build a page whose text is drawn at a known angle under a known
|
||||
* /Rotate, run the real controller, then assert the output actually displays upright. Upright
|
||||
* means the glyph direction and the page rotation cancel — computed here in the test, not via
|
||||
* the production helper.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"0, 0",
|
||||
"0, 90",
|
||||
"0, 180",
|
||||
"0, 270",
|
||||
"90, 0",
|
||||
"90, 90",
|
||||
"180, 0",
|
||||
"180, 270",
|
||||
"270, 90",
|
||||
})
|
||||
void roundTripLeavesPageUpright(int textAngle, int pageRotation) throws Exception {
|
||||
AutoRotatePdfRequest request = request(docWithTextAt(textAngle, pageRotation));
|
||||
request.setDetectionMode("text");
|
||||
|
||||
ResponseEntity<?> response = controller.autoRotatePdf(request);
|
||||
|
||||
try (PDDocument corrected = reload(response)) {
|
||||
int glyphDirection = dominantGlyphDirection(corrected);
|
||||
int finalRotation = Math.floorMod(corrected.getPage(0).getRotation(), 360);
|
||||
assertThat(Math.floorMod(glyphDirection - finalRotation, 360))
|
||||
.as(
|
||||
"text drawn at %d under /Rotate %d should display upright, got glyph"
|
||||
+ " direction %d with /Rotate %d",
|
||||
textAngle, pageRotation, glyphDirection, finalRotation)
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dryRunReportsTextDetection() throws Exception {
|
||||
AutoRotatePdfRequest request = request(docWithUprightText(90, 0));
|
||||
request.setDryRun(true);
|
||||
request.setDetectionMode("text");
|
||||
|
||||
ResponseEntity<?> response = controller.autoRotatePdf(request);
|
||||
|
||||
AutoRotateAnalysisResult result = (AutoRotateAnalysisResult) response.getBody();
|
||||
assertThat(result.getTotalPages()).isEqualTo(2);
|
||||
assertThat(result.getPagesToRotate()).isEqualTo(1);
|
||||
assertThat(result.getDetectedByText()).isEqualTo(2);
|
||||
|
||||
AutoRotateAnalysisResult.PageResult first = result.getPages().get(0);
|
||||
assertThat(first.getMethod()).isEqualTo("text");
|
||||
assertThat(first.getCorrection()).isEqualTo(270);
|
||||
assertThat(first.isApply()).isTrue();
|
||||
assertThat(first.getConfidence()).isEqualTo(100.0);
|
||||
|
||||
AutoRotateAnalysisResult.PageResult second = result.getPages().get(1);
|
||||
assertThat(second.getCorrection()).isZero();
|
||||
assertThat(second.isApply()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesDetectedCorrections() throws Exception {
|
||||
AutoRotatePdfRequest request = request(docWithUprightText(90, 0));
|
||||
request.setDetectionMode("text");
|
||||
|
||||
ResponseEntity<?> response = controller.autoRotatePdf(request);
|
||||
|
||||
try (PDDocument corrected = reload(response)) {
|
||||
assertThat(corrected.getPage(0).getRotation()).isZero();
|
||||
assertThat(corrected.getPage(1).getRotation()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesExplicitPageRotations() throws Exception {
|
||||
AutoRotatePdfRequest request = request(docWithUprightText(0, 0));
|
||||
request.setPageRotations(List.of(new PageRotation(1, 90)));
|
||||
|
||||
ResponseEntity<?> response = controller.autoRotatePdf(request);
|
||||
|
||||
try (PDDocument corrected = reload(response)) {
|
||||
assertThat(corrected.getPage(0).getRotation()).isEqualTo(90);
|
||||
assertThat(corrected.getPage(1).getRotation()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTesseractUnavailableForTextlessPages() throws Exception {
|
||||
PDDocument document = new PDDocument();
|
||||
document.addPage(new PDPage(PDRectangle.LETTER));
|
||||
AutoRotatePdfRequest request = request(document);
|
||||
request.setDryRun(true);
|
||||
|
||||
ResponseEntity<?> response = controller.autoRotatePdf(request);
|
||||
|
||||
AutoRotateAnalysisResult result = (AutoRotateAnalysisResult) response.getBody();
|
||||
AutoRotateAnalysisResult.PageResult page = result.getPages().get(0);
|
||||
assertThat(page.getMethod()).isEqualTo("none");
|
||||
assertThat(page.getNote()).isEqualTo("tesseractUnavailable");
|
||||
assertThat(result.getUndetected()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void infersUndetectedPageFromDocumentConsensus() throws Exception {
|
||||
// Page 1 has body text and is rotated 90 (-> 270 correction); page 2 is blank and shares
|
||||
// the same rotation. With OSD unavailable, page 2 can't be detected on its own, so it
|
||||
// should inherit page 1's 270 correction.
|
||||
PDDocument document = docWithUprightText(90);
|
||||
PDPage blank = new PDPage(PDRectangle.LETTER);
|
||||
blank.setRotation(90);
|
||||
document.addPage(blank);
|
||||
AutoRotatePdfRequest request = request(document);
|
||||
request.setDryRun(true);
|
||||
request.setDetectionMode("text");
|
||||
|
||||
ResponseEntity<?> response = controller.autoRotatePdf(request);
|
||||
|
||||
AutoRotateAnalysisResult result = (AutoRotateAnalysisResult) response.getBody();
|
||||
AutoRotateAnalysisResult.PageResult page2 = result.getPages().get(1);
|
||||
assertThat(page2.getMethod()).isEqualTo("inferred");
|
||||
assertThat(page2.getCorrection()).isEqualTo(270);
|
||||
assertThat(page2.isApply()).isTrue();
|
||||
assertThat(page2.getNote()).isEqualTo("inferredFromDocument");
|
||||
assertThat(result.getInferred()).isEqualTo(1);
|
||||
assertThat(result.getPagesToRotate()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotInferWhenDisabled() throws Exception {
|
||||
PDDocument document = docWithUprightText(90);
|
||||
PDPage blank = new PDPage(PDRectangle.LETTER);
|
||||
blank.setRotation(90);
|
||||
document.addPage(blank);
|
||||
AutoRotatePdfRequest request = request(document);
|
||||
request.setDryRun(true);
|
||||
request.setDetectionMode("text");
|
||||
request.setInferUndetected(false);
|
||||
|
||||
ResponseEntity<?> response = controller.autoRotatePdf(request);
|
||||
|
||||
AutoRotateAnalysisResult result = (AutoRotateAnalysisResult) response.getBody();
|
||||
AutoRotateAnalysisResult.PageResult page2 = result.getPages().get(1);
|
||||
assertThat(page2.getMethod()).isEqualTo("none");
|
||||
assertThat(page2.isApply()).isFalse();
|
||||
assertThat(result.getInferred()).isZero();
|
||||
assertThat(result.getUndetected()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidDetectionMode() {
|
||||
AutoRotatePdfRequest request = new AutoRotatePdfRequest();
|
||||
request.setDetectionMode("magic");
|
||||
|
||||
assertThatThrownBy(() -> controller.autoRotatePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRotationThatIsNotAMultipleOf90() throws Exception {
|
||||
AutoRotatePdfRequest request = request(docWithUprightText(0));
|
||||
request.setPageRotations(List.of(new PageRotation(1, 45)));
|
||||
|
||||
assertThatThrownBy(() -> controller.autoRotatePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPageRotationOutsideTheDocument() throws Exception {
|
||||
AutoRotatePdfRequest request = request(docWithUprightText(0));
|
||||
request.setPageRotations(List.of(new PageRotation(5, 90)));
|
||||
|
||||
assertThatThrownBy(() -> controller.autoRotatePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicatePageInRotations() throws Exception {
|
||||
// Rotations are additive, so applying the same page twice would over-rotate it.
|
||||
AutoRotatePdfRequest request = request(docWithUprightText(0, 0));
|
||||
request.setPageRotations(List.of(new PageRotation(1, 90), new PageRotation(1, 90)));
|
||||
|
||||
assertThatThrownBy(() -> controller.autoRotatePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
|
||||
import stirling.software.SPDF.model.api.security.SignatureValidationResult;
|
||||
import stirling.software.SPDF.service.CertificateValidationService;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
/**
|
||||
* Validation of RFC 3161 document timestamps (PAdES-LTV).
|
||||
*
|
||||
* <p>These fixtures are a real PDF stamped by a real public TSA (freetsa.org). Before this was
|
||||
* handled explicitly, every such timestamp was reported invalid: a DocTimeStamp's CMS encapsulates
|
||||
* a TSTInfo rather than being detached over the document, so digesting the byte range compared
|
||||
* against the wrong thing and always mismatched. That made the timestamp feature look broken to
|
||||
* anyone who checked their own output with our validator.
|
||||
*/
|
||||
class DocumentTimestampValidationTest {
|
||||
|
||||
private ValidateSignatureController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
CertificateValidationService certValidationService =
|
||||
new CertificateValidationService(null, new ApplicationProperties());
|
||||
CustomPDFDocumentFactory factory = org.mockito.Mockito.mock(CustomPDFDocumentFactory.class);
|
||||
// Delegate to the real loader so the signature dictionary is parsed as in production.
|
||||
when(factory.load(any(InputStream.class)))
|
||||
.thenAnswer(
|
||||
invocation ->
|
||||
Loader.loadPDF(
|
||||
((InputStream) invocation.getArgument(0)).readAllBytes()));
|
||||
controller = new ValidateSignatureController(factory, certValidationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGenuineDocumentTimestampValidates() throws Exception {
|
||||
SignatureValidationResult result = validate("timestamp/doc-timestamped.pdf");
|
||||
|
||||
assertThat(result.isValid()).isTrue();
|
||||
assertThat(result.getErrorMessage()).isNull();
|
||||
// The TSA's asserted time is what keeps the signature verifiable once the signing
|
||||
// certificate expires, so it must be the time we validate against.
|
||||
// Deliberately not "timestamp" - that value already means "signature countersigned by a
|
||||
// TSA", which is a different assertion about a different thing.
|
||||
assertThat(result.getValidationTimeSource()).isEqualTo("document-timestamp");
|
||||
assertThat(result.getSignatureDate()).isNotNull();
|
||||
assertThat(result.getSubjectDN()).contains("freetsa.org");
|
||||
assertThat(result.isCoversEntireDocument()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTamperedDocumentFailsTheMessageImprintCheck() throws Exception {
|
||||
// Same file with a single byte flipped inside the signed range. Without the imprint check
|
||||
// the CMS signature over the TSTInfo would still verify happily - the token is untouched -
|
||||
// and a modified document would be reported as validly timestamped.
|
||||
SignatureValidationResult result = validate("timestamp/doc-timestamped-tampered.pdf");
|
||||
|
||||
assertThat(result.isValid()).isFalse();
|
||||
assertThat(result.getErrorMessage())
|
||||
.isEqualTo("Timestamp message imprint does not match the document");
|
||||
}
|
||||
|
||||
private SignatureValidationResult validate(String resource) throws IOException {
|
||||
byte[] bytes;
|
||||
try (InputStream in = new ClassPathResource(resource).getInputStream()) {
|
||||
bytes = in.readAllBytes();
|
||||
}
|
||||
SignatureValidationRequest request = new SignatureValidationRequest();
|
||||
request.setFileInput(
|
||||
new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", bytes));
|
||||
|
||||
List<SignatureValidationResult> results = controller.validateSignature(request).getBody();
|
||||
assertThat(results).hasSize(1);
|
||||
return results.get(0);
|
||||
}
|
||||
}
|
||||
+22
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -291,6 +292,27 @@ class ValidateSignatureControllerMoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Padded signature contents")
|
||||
class PaddedContentsTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Zero-padded /Contents still parses as CMS")
|
||||
void zeroPaddedContentsParsesAsCms() throws Exception {
|
||||
byte[] contents;
|
||||
try (PDDocument doc = Loader.loadPDF(signedPdfBytes)) {
|
||||
PDSignature sig = doc.getSignatureDictionaries().get(0);
|
||||
contents = sig.getContents(new ByteArrayInputStream(signedPdfBytes));
|
||||
}
|
||||
|
||||
// /Contents is zero-filled up to its reserved length. Since BC 1.85 the byte[]
|
||||
// constructors reject that tail ("Extra data detected in stream"), streams do not.
|
||||
assertThat(contents[contents.length - 1]).isZero();
|
||||
CMSSignedData parsed = new CMSSignedData(new ByteArrayInputStream(contents));
|
||||
assertThat(parsed.getSignerInfos().size()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error and edge handling")
|
||||
class ErrorHandlingTests {
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.verapdf.gf.foundry.VeraGreenfieldFoundryProvider;
|
||||
import org.verapdf.pdfa.Foundries;
|
||||
import org.verapdf.pdfa.PDFAParser;
|
||||
import org.verapdf.pdfa.PDFAValidator;
|
||||
import org.verapdf.pdfa.flavours.PDFAFlavour;
|
||||
import org.verapdf.pdfa.results.ValidationResult;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
|
||||
/**
|
||||
* Exercises {@link VeraPDFService} against real PDF/A files. Fixtures were produced by Ghostscript
|
||||
* with the same flags as ConvertPDFToPDFA and independently confirmed conformant by veraPDF.
|
||||
*/
|
||||
class VeraPDFServicePdfaFixtureTest {
|
||||
|
||||
private static final String VALID_1B = "valid-pdfa-1b.pdf";
|
||||
private static final String VALID_2B = "valid-pdfa-2b.pdf";
|
||||
private static final String DECLARED_BUT_INVALID_1B = "declared-pdfa-1b-no-outputintent.pdf";
|
||||
|
||||
private VeraPDFService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new VeraPDFService();
|
||||
service.initialize();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixtures_areGenuinePdfaAccordingToVeraPdfItself() throws Exception {
|
||||
assertVeraPdfVerdict(VALID_1B, PDFAFlavour.PDFA_1_B, true);
|
||||
assertVeraPdfVerdict(VALID_2B, PDFAFlavour.PDFA_2_B, true);
|
||||
assertVeraPdfVerdict(DECLARED_BUT_INVALID_1B, PDFAFlavour.PDFA_1_B, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePDF_realPdfa1b_reportsCompliantPdfa1b() throws Exception {
|
||||
PDFVerificationResult result = onlyResult(fixture(VALID_1B));
|
||||
|
||||
assertEquals("1b", result.getStandard());
|
||||
assertEquals("1b", result.getValidationProfile());
|
||||
assertTrue(result.isDeclaredPdfa(), "Genuine PDF/A-1b must be reported as declared PDF/A");
|
||||
assertTrue(result.isCompliant(), "Genuine PDF/A-1b must validate as compliant");
|
||||
assertEquals(
|
||||
0, result.getTotalFailures(), () -> "Unexpected failures: " + messages(result));
|
||||
assertTrue(
|
||||
result.getStandardName().startsWith("PDF/A-"),
|
||||
"Display name should name the PDF/A standard, got: " + result.getStandardName());
|
||||
assertTrue(
|
||||
result.getStandardName().endsWith(" compliant"),
|
||||
"Display name should read as compliant, got: " + result.getStandardName());
|
||||
assertEquals(result.getStandardName(), result.getComplianceSummary());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePDF_realPdfa2b_reportsPdfa2bAndNotPdfa1b() throws Exception {
|
||||
PDFVerificationResult result = onlyResult(fixture(VALID_2B));
|
||||
|
||||
// Proves firstFlavour() returns the flavour actually declared, not just a non-null one
|
||||
assertEquals("2b", result.getStandard());
|
||||
assertEquals("2b", result.getValidationProfile());
|
||||
assertNotEquals("1b", result.getStandard());
|
||||
assertTrue(result.isDeclaredPdfa());
|
||||
assertTrue(result.isCompliant(), () -> "Unexpected failures: " + messages(result));
|
||||
assertEquals(0, result.getTotalFailures());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePDF_plainPdf_reportsNotPdfaAndDoesNotThrowIndexOutOfBounds() throws Exception {
|
||||
byte[] pdfBytes = createSimplePdf();
|
||||
|
||||
// veraPDF 1.30 returns an empty flavour list here where 1.28 returned [1b]; get(0) threw
|
||||
List<PDFVerificationResult> results =
|
||||
assertDoesNotThrow(
|
||||
() -> service.validatePDF(new ByteArrayInputStream(pdfBytes)),
|
||||
"Empty veraPDF flavour list must not surface as IndexOutOfBoundsException");
|
||||
|
||||
assertEquals(1, results.size());
|
||||
PDFVerificationResult result = results.get(0);
|
||||
assertEquals("not-pdfa", result.getStandard());
|
||||
assertFalse(result.isDeclaredPdfa());
|
||||
assertFalse(result.isCompliant());
|
||||
assertEquals("Not PDF/A (no PDF/A identification metadata)", result.getStandardName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePDF_declaresPdfaButNotConformant_reportsFlavourWithFailures() throws Exception {
|
||||
PDFVerificationResult result = onlyResult(fixture(DECLARED_BUT_INVALID_1B));
|
||||
|
||||
// "declares PDF/A but broken" must stay distinct from "not PDF/A at all"
|
||||
assertEquals("1b", result.getStandard());
|
||||
assertNotEquals("not-pdfa", result.getStandard());
|
||||
assertTrue(result.isDeclaredPdfa(), "XMP still declares pdfaid:part=1");
|
||||
assertFalse(result.isCompliant(), "Stripped OutputIntent must fail conformance");
|
||||
assertTrue(result.getTotalFailures() > 0, "Non-conformance must be reported as issues");
|
||||
assertTrue(
|
||||
result.getStandardName().endsWith(" with errors"),
|
||||
"Display name should flag errors, got: " + result.getStandardName());
|
||||
assertTrue(
|
||||
messages(result).contains("OutputIntent"),
|
||||
"Expected the missing OutputIntent to be reported, got: " + messages(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstFlavour_withEmptyList_returnsNullInsteadOfThrowing() throws Exception {
|
||||
Method method = VeraPDFService.class.getDeclaredMethod("firstFlavour", List.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
assertNull(method.invoke(null, List.of()));
|
||||
assertEquals(PDFAFlavour.PDFA_2_B, method.invoke(null, List.of(PDFAFlavour.PDFA_2_B)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectedFlavours_withNullFlavourList_returnsEmptyList() throws Exception {
|
||||
Method method =
|
||||
VeraPDFService.class.getDeclaredMethod("detectedFlavours", PDFAParser.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
PDFAParser parser = mock(PDFAParser.class);
|
||||
when(parser.getFlavours()).thenReturn(null);
|
||||
|
||||
assertEquals(List.of(), method.invoke(null, parser));
|
||||
}
|
||||
|
||||
private static void assertVeraPdfVerdict(
|
||||
String fixtureName, PDFAFlavour expectedFlavour, boolean expectedCompliant)
|
||||
throws Exception {
|
||||
VeraGreenfieldFoundryProvider.initialise();
|
||||
byte[] bytes = fixture(fixtureName);
|
||||
|
||||
List<PDFAFlavour> flavours;
|
||||
try (PDFAParser parser =
|
||||
Foundries.defaultInstance().createParser(new ByteArrayInputStream(bytes))) {
|
||||
flavours = parser.getFlavours();
|
||||
}
|
||||
assertEquals(List.of(expectedFlavour), flavours, fixtureName + " declared flavours");
|
||||
|
||||
try (PDFAParser parser =
|
||||
Foundries.defaultInstance()
|
||||
.createParser(new ByteArrayInputStream(bytes), expectedFlavour)) {
|
||||
PDFAValidator validator =
|
||||
Foundries.defaultInstance().createValidator(expectedFlavour, false);
|
||||
ValidationResult result = validator.validate(parser);
|
||||
assertEquals(
|
||||
expectedCompliant, result.isCompliant(), fixtureName + " veraPDF compliance");
|
||||
}
|
||||
}
|
||||
|
||||
private PDFVerificationResult onlyResult(byte[] pdfBytes) throws Exception {
|
||||
List<PDFVerificationResult> results =
|
||||
service.validatePDF(new ByteArrayInputStream(pdfBytes));
|
||||
|
||||
assertNotNull(results);
|
||||
assertEquals(1, results.size(), () -> "Expected a single result, got: " + results);
|
||||
return results.get(0);
|
||||
}
|
||||
|
||||
private static String messages(PDFVerificationResult result) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (PDFVerificationResult.ValidationIssue issue : result.getFailures()) {
|
||||
builder.append(issue.getMessage()).append(" | ");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static byte[] fixture(String name) throws IOException {
|
||||
try (InputStream in =
|
||||
VeraPDFServicePdfaFixtureTest.class.getResourceAsStream("/pdfa/" + name)) {
|
||||
assertNotNull(in, "Missing test fixture /pdfa/" + name);
|
||||
return in.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createSimplePdf() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new PDPage());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package stirling.software.SPDF.utils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
import stirling.software.SPDF.utils.AutoRotateDetection.OsdResult;
|
||||
import stirling.software.SPDF.utils.AutoRotateDetection.TextDirection;
|
||||
|
||||
class AutoRotateDetectionTest {
|
||||
|
||||
private static final String SAMPLE_TEXT =
|
||||
"The quick brown fox jumps over the lazy dog again and again";
|
||||
|
||||
private PDDocument docWithText(int textAngleDegrees, int pageRotation) throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
content.setTextMatrix(
|
||||
Matrix.getRotateInstance(Math.toRadians(textAngleDegrees), 300, 400));
|
||||
content.showText(SAMPLE_TEXT);
|
||||
content.endText();
|
||||
}
|
||||
page.setRotation(pageRotation);
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground truth per the PDF spec: /Rotate R displays the page R degrees clockwise, so upright
|
||||
* page-space text under /Rotate R needs a further (360 - R) % 360 to display upright again;
|
||||
* text drawn rotated T degrees CCW in page space needs T clockwise to correct. Combined, the
|
||||
* expected correction is (T - R) mod 360.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
// textAngle, pageRotation, expectedCorrection
|
||||
"0, 0, 0",
|
||||
"0, 90, 270",
|
||||
"0, 180, 180",
|
||||
"0, 270, 90",
|
||||
"90, 0, 90",
|
||||
"180, 0, 180",
|
||||
"270, 0, 270",
|
||||
"90, 90, 0",
|
||||
"180, 90, 90",
|
||||
})
|
||||
void detectsCorrectionForRotatedTextAndPages(
|
||||
int textAngle, int pageRotation, int expectedCorrection) throws IOException {
|
||||
try (PDDocument document = docWithText(textAngle, pageRotation)) {
|
||||
TextDirection direction = AutoRotateDetection.detectTextDirections(document).get(0);
|
||||
|
||||
assertThat(direction.isConclusive())
|
||||
.as(
|
||||
"direction should be conclusive, glyphs=%d dominance=%s",
|
||||
direction.glyphCount(), direction.dominance())
|
||||
.isTrue();
|
||||
assertThat(
|
||||
AutoRotateDetection.correctionFromTextDirection(
|
||||
direction.dominantDirection(),
|
||||
Math.floorMod(pageRotation, 360)))
|
||||
.isEqualTo(expectedCorrection);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedDirectionsAreNotConclusive() throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
content.setTextMatrix(Matrix.getTranslateInstance(100, 400));
|
||||
content.showText(SAMPLE_TEXT);
|
||||
content.setTextMatrix(Matrix.getRotateInstance(Math.toRadians(90), 300, 200));
|
||||
content.showText(SAMPLE_TEXT);
|
||||
content.endText();
|
||||
}
|
||||
try (document) {
|
||||
TextDirection direction = AutoRotateDetection.detectTextDirections(document).get(0);
|
||||
assertThat(direction.isConclusive()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyPageIsNotConclusive() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new PDPage(PDRectangle.LETTER));
|
||||
TextDirection direction = AutoRotateDetection.detectTextDirections(document).get(0);
|
||||
assertThat(direction.glyphCount()).isZero();
|
||||
assertThat(direction.isConclusive()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortTextIsNotConclusive() throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
content.setTextMatrix(Matrix.getTranslateInstance(100, 400));
|
||||
content.showText("Short");
|
||||
content.endText();
|
||||
}
|
||||
try (document) {
|
||||
TextDirection direction = AutoRotateDetection.detectTextDirections(document).get(0);
|
||||
assertThat(direction.isConclusive()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unanimousShortTextIsConclusive() throws IOException {
|
||||
// Between MIN_GLYPHS_UNANIMOUS (8) and MIN_GLYPHS (30): trusted only because every
|
||||
// glyph agrees on direction, the sparse-page path (e.g. a lone header or URL line).
|
||||
PDDocument document = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
content.setTextMatrix(Matrix.getRotateInstance(Math.toRadians(90), 300, 200));
|
||||
content.showText("york.gov.uk/pay");
|
||||
content.endText();
|
||||
}
|
||||
try (document) {
|
||||
TextDirection direction = AutoRotateDetection.detectTextDirections(document).get(0);
|
||||
assertThat(direction.glyphCount())
|
||||
.isBetween(
|
||||
AutoRotateDetection.MIN_GLYPHS_UNANIMOUS,
|
||||
AutoRotateDetection.MIN_GLYPHS - 1);
|
||||
assertThat(direction.dominance()).isEqualTo(1.0);
|
||||
assertThat(direction.isConclusive()).isTrue();
|
||||
assertThat(direction.dominantDirection()).isEqualTo(90);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bucketsGlyphsPerPageInOneWalk() throws IOException {
|
||||
// Each page carries text at a different angle; the single-pass walk must attribute
|
||||
// glyphs to the right page rather than pooling them.
|
||||
int[] angles = {0, 90, 180, 270};
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
for (int angle : angles) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
content.setTextMatrix(
|
||||
Matrix.getRotateInstance(Math.toRadians(angle), 300, 400));
|
||||
content.showText(SAMPLE_TEXT);
|
||||
content.endText();
|
||||
}
|
||||
}
|
||||
|
||||
List<TextDirection> directions = AutoRotateDetection.detectTextDirections(document);
|
||||
|
||||
assertThat(directions).hasSize(angles.length);
|
||||
for (int i = 0; i < angles.length; i++) {
|
||||
assertThat(directions.get(i).isConclusive()).as("page %d", i + 1).isTrue();
|
||||
assertThat(directions.get(i).dominantDirection())
|
||||
.as("page %d direction", i + 1)
|
||||
.isEqualTo(angles[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsBlankAndInkedRenders() {
|
||||
BufferedImage blank = new BufferedImage(200, 200, BufferedImage.TYPE_BYTE_GRAY);
|
||||
Graphics2D g = blank.createGraphics();
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, 200, 200);
|
||||
g.dispose();
|
||||
assertThat(AutoRotateDetection.isBlankRender(blank)).isTrue();
|
||||
|
||||
BufferedImage speck = copyOf(blank);
|
||||
Graphics2D specked = speck.createGraphics();
|
||||
specked.setColor(Color.BLACK);
|
||||
specked.fillRect(0, 0, 2, 2); // a dust speck must not count as content
|
||||
specked.dispose();
|
||||
assertThat(AutoRotateDetection.isBlankRender(speck)).isTrue();
|
||||
|
||||
BufferedImage inked = copyOf(blank);
|
||||
Graphics2D inkedG = inked.createGraphics();
|
||||
inkedG.setColor(Color.BLACK);
|
||||
inkedG.fillRect(20, 20, 120, 60);
|
||||
inkedG.dispose();
|
||||
assertThat(AutoRotateDetection.isBlankRender(inked)).isFalse();
|
||||
}
|
||||
|
||||
private static BufferedImage copyOf(BufferedImage source) {
|
||||
BufferedImage copy =
|
||||
new BufferedImage(source.getWidth(), source.getHeight(), source.getType());
|
||||
Graphics2D g = copy.createGraphics();
|
||||
g.drawImage(source, 0, 0, null);
|
||||
g.dispose();
|
||||
return copy;
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesTypicalOsdOutput() {
|
||||
String output =
|
||||
"""
|
||||
Estimating resolution as 336
|
||||
Page number: 0
|
||||
Orientation in degrees: 180
|
||||
Rotate: 180
|
||||
Orientation confidence: 9.15
|
||||
Script: Latin
|
||||
Script confidence: 4.43
|
||||
""";
|
||||
Optional<OsdResult> result = AutoRotateDetection.parseOsd(output);
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().rotate()).isEqualTo(180);
|
||||
assertThat(result.get().confidence()).isEqualTo(9.15);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseOsdRejectsIncompleteOutput() {
|
||||
assertThat(AutoRotateDetection.parseOsd("Too few characters. Skipping this page"))
|
||||
.isEmpty();
|
||||
assertThat(AutoRotateDetection.parseOsd("Rotate: 90")).isEmpty();
|
||||
assertThat(AutoRotateDetection.parseOsd(null)).isEmpty();
|
||||
assertThat(AutoRotateDetection.parseOsd("")).isEmpty();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,33 +6,6 @@ repositories {
|
||||
bootRun {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/java/org/apache/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
// google-java-format 1.28.0 bundles Guava 32.x which crashes Spotless lint on JDK 24/25
|
||||
suppressLintsFor { setStep('google-java-format') }
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
yaml {
|
||||
target '**/*.yml', '**/*.yaml'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
format 'gradle', {
|
||||
target '**/gradle/*.gradle', '**/*.gradle'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
api "com.google.guava:guava:${guavaVersion}"
|
||||
@@ -94,6 +67,9 @@ dependencies {
|
||||
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
|
||||
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
|
||||
|
||||
// Streaming AEAD (AES-GCM-HKDF segments) for storage encryption at rest. Apache-2.0.
|
||||
implementation "com.google.crypto.tink:tink:${tinkVersion}"
|
||||
|
||||
// @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'
|
||||
@@ -103,6 +79,7 @@ dependencies {
|
||||
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
|
||||
testImplementation "org.testcontainers:minio:${testcontainersMinioVersion}"
|
||||
testImplementation "org.testcontainers:localstack:${testcontainersMinioVersion}"
|
||||
testImplementation "org.testcontainers:postgresql:${testcontainersMinioVersion}"
|
||||
testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}"
|
||||
}
|
||||
|
||||
|
||||
+52
-3
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -42,6 +43,52 @@ public class ResourceAccessService {
|
||||
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal access for a roster (admin, grant, or default policy). {@code activeTeamLeaderUserIds}
|
||||
* must hold ids of users who lead their own active team — the set the ADMINS_AND_TEAM_LEADS
|
||||
* default admits, matching {@link #canAccessPortal}.
|
||||
*/
|
||||
public Set<Long> usersWithPortalAccess(
|
||||
Collection<User> users, Set<Long> activeTeamLeaderUserIds) {
|
||||
Set<PrincipalRef> grantedPrincipals = new HashSet<>();
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) {
|
||||
if (permissionSatisfies(g.getPermission(), AccessPermission.USE)) {
|
||||
grantedPrincipals.add(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId()));
|
||||
}
|
||||
}
|
||||
Set<Long> leaderIds = activeTeamLeaderUserIds == null ? Set.of() : activeTeamLeaderUserIds;
|
||||
Set<Long> allowed = new HashSet<>();
|
||||
for (User user : users) {
|
||||
if (user != null
|
||||
&& user.getId() != null
|
||||
&& hasPortalAccess(user, grantedPrincipals, leaderIds)) {
|
||||
allowed.add(user.getId());
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
private boolean hasPortalAccess(
|
||||
User user, Set<PrincipalRef> grantedPrincipals, Set<Long> leaderIds) {
|
||||
if (isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
for (PrincipalRef principal : principalResolver.principalsOf(user)) {
|
||||
if (grantedPrincipals.contains(principal)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (portalDefaultPolicy == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (portalDefaultPolicy) {
|
||||
case ORG_ALL -> principalResolver.allowsDeploymentWideAccess();
|
||||
case ADMINS_AND_TEAM_LEADS -> leaderIds.contains(user.getId());
|
||||
case EXPLICIT_ONLY -> false;
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the user may use a resource, falling back to its default policy. */
|
||||
public boolean canUseResource(
|
||||
ResourceType type,
|
||||
@@ -172,11 +219,13 @@ public class ResourceAccessService {
|
||||
};
|
||||
}
|
||||
|
||||
// Portal (no owner) admits any team lead; a team-owned resource admits only that team's
|
||||
// leads; a user-owned resource admits no extra leads.
|
||||
// Portal (no owner) admits the leader of the user's active team; a team-owned resource
|
||||
// admits only that team's leads; a user-owned resource admits no extra leads.
|
||||
private boolean matchesTeamLeadDefault(PrincipalRef owner, User user) {
|
||||
if (owner == null) {
|
||||
return teamLeadLookup.isAnyTeamLeader(user);
|
||||
return user.getTeam() != null
|
||||
&& user.getTeam().getId() != null
|
||||
&& teamLeadLookup.isLeaderOfTeam(user, user.getTeam().getId());
|
||||
}
|
||||
return owner.type() == PrincipalType.TEAM
|
||||
&& owner.id() != null
|
||||
|
||||
+69
@@ -39,6 +39,11 @@ public class SecretMasker {
|
||||
"bearer",
|
||||
"signature");
|
||||
|
||||
// Keys whose nested map holds secrets under arbitrary, caller-named keys - a free-form HTTP
|
||||
// headers map is the case in point: the secret can sit under any header name (X-API-Key,
|
||||
// Ocp-Apim-Subscription-Key), so the name is no signal. Mask every value in these outright.
|
||||
private static final Set<String> SENSITIVE_VALUE_CONTAINERS = Set.of("headers");
|
||||
|
||||
/** Replace sensitive values with the mask (recursively) for safe display. */
|
||||
public Map<String, Object> mask(Map<String, Object> config) {
|
||||
return mask(config, 0);
|
||||
@@ -73,6 +78,12 @@ public class SecretMasker {
|
||||
if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) {
|
||||
continue;
|
||||
}
|
||||
if (isSensitiveContainer(e.getKey())
|
||||
&& e.getValue() instanceof Map<?, ?> m
|
||||
&& depth < MAX_DEPTH) {
|
||||
out.put(e.getKey(), sanitizeAllValues(castMap(m), depth + 1));
|
||||
continue;
|
||||
}
|
||||
out.put(
|
||||
e.getKey(),
|
||||
e.getValue() instanceof Map<?, ?> m && depth < MAX_DEPTH
|
||||
@@ -100,6 +111,14 @@ public class SecretMasker {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isSensitiveContainer(key)
|
||||
&& depth < MAX_DEPTH
|
||||
&& stored.get(key) instanceof Map<?, ?> s
|
||||
&& value instanceof Map<?, ?> i) {
|
||||
// Every value here is a secret, so restore a redacted one from stored per-entry.
|
||||
out.put(key, mergeAllValues(castMap(s), castMap(i), depth + 1));
|
||||
continue;
|
||||
}
|
||||
if (depth < MAX_DEPTH
|
||||
&& stored.get(key) instanceof Map<?, ?> s
|
||||
&& value instanceof Map<?, ?> i) {
|
||||
@@ -119,6 +138,9 @@ public class SecretMasker {
|
||||
}
|
||||
return MASK;
|
||||
}
|
||||
if (isSensitiveContainer(key) && value instanceof Map<?, ?> m && depth < MAX_DEPTH) {
|
||||
return maskAllValues(castMap(m), depth + 1);
|
||||
}
|
||||
if (depth >= MAX_DEPTH) {
|
||||
// Too deep to descend; mask containers rather than risk leaking an unmasked secret.
|
||||
return value instanceof Map<?, ?> || value instanceof List<?> ? MASK : value;
|
||||
@@ -141,6 +163,53 @@ public class SecretMasker {
|
||||
return SENSITIVE_HINTS.stream().anyMatch(lower::contains);
|
||||
}
|
||||
|
||||
private boolean isSensitiveContainer(String key) {
|
||||
return SENSITIVE_VALUE_CONTAINERS.contains(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/** Mask every value in a container map, whatever its keys are named. */
|
||||
private Map<String, Object> maskAllValues(Map<String, Object> map, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : map.entrySet()) {
|
||||
Object v = e.getValue();
|
||||
if (v == null || (v instanceof String s && s.isBlank())) {
|
||||
out.put(e.getKey(), v);
|
||||
} else if (v instanceof Map<?, ?> m && depth < MAX_DEPTH) {
|
||||
out.put(e.getKey(), maskAllValues(castMap(m), depth + 1));
|
||||
} else {
|
||||
out.put(e.getKey(), MASK);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge a container map treating every entry as a secret, restoring redacted from stored. */
|
||||
private Map<String, Object> mergeAllValues(
|
||||
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
if (isRedacted(e.getValue(), depth)) {
|
||||
if (stored.containsKey(e.getKey())) {
|
||||
out.put(e.getKey(), stored.get(e.getKey()));
|
||||
}
|
||||
} else {
|
||||
out.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop redacted entries from a container map on create, whatever their keys are named. */
|
||||
private Map<String, Object> sanitizeAllValues(Map<String, Object> map, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : map.entrySet()) {
|
||||
if (!isRedacted(e.getValue(), depth)) {
|
||||
out.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Blank, the mask placeholder, or any structure that still contains the mask. */
|
||||
private boolean isRedacted(Object value, int depth) {
|
||||
if (value == null) {
|
||||
|
||||
+8
-1
@@ -35,6 +35,7 @@ import stirling.software.proprietary.billing.ContentHasher;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
@@ -84,7 +85,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
instanceof ApiKeyAuthenticationToken;
|
||||
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
|
||||
request.setAttribute(ATTR_CATEGORY, category);
|
||||
decision = gate.evaluate(category != BillingCategory.BYPASSED);
|
||||
// A policy run kicks off billable automation, so block it up front when unentitled
|
||||
// rather than after its first tool. It carries no automation header itself (category
|
||||
// BYPASSED), so it's gated here but metered only via its dispatched sub-steps - keeping
|
||||
// the BYPASSED meter category avoids double-counting.
|
||||
boolean billable =
|
||||
category != BillingCategory.BYPASSED || PolicyRunRoutes.matches(request);
|
||||
decision = gate.evaluate(billable);
|
||||
} catch (RuntimeException e) {
|
||||
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
|
||||
// turn into a hard block on billable work.
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
/** Meters a client-side classification run; SaaS charges PAYG, other flavors have no bean. */
|
||||
public interface ClassificationRunBiller {
|
||||
|
||||
/** Charge one classification policy run covering {@code documentCount} documents. */
|
||||
void recordClassificationRun(int documentCount);
|
||||
}
|
||||
+13
-2
@@ -54,10 +54,21 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
return;
|
||||
}
|
||||
String rid = MDC.get("requestId");
|
||||
String apiKeyLabel =
|
||||
MDC.get(
|
||||
stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
|
||||
|
||||
if (rid != null) {
|
||||
if (rid != null || apiKeyLabel != null) {
|
||||
clean = new java.util.HashMap<>(clean);
|
||||
clean.put("requestId", rid);
|
||||
if (rid != null) {
|
||||
clean.put("requestId", rid);
|
||||
}
|
||||
// Named key that made the request; surfaces as the doc source in the processor
|
||||
// feed.
|
||||
if (apiKeyLabel != null) {
|
||||
clean.put("__apiKeyLabel", apiKeyLabel);
|
||||
}
|
||||
}
|
||||
|
||||
String source = MDC.get("auditSource");
|
||||
|
||||
+15
-6
@@ -7,7 +7,6 @@ import java.util.concurrent.Executor;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -28,6 +27,7 @@ import jakarta.validation.Valid;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
@@ -38,6 +38,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiEngineEndpointResolver;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.AiWorkflowService;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
@@ -60,15 +61,14 @@ public class AiEngineController {
|
||||
private final TaskManager taskManager;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
|
||||
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
|
||||
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
|
||||
* SSE emitter timeout (ms), long enough for multi-gigabyte PDF workflows without completing out
|
||||
* from under the executor. Derived from {@code aiEngine.streamTimeoutSeconds}.
|
||||
*/
|
||||
@Value("${stirling.ai.streamTimeoutMs:1800000}")
|
||||
private long streamTimeoutMs;
|
||||
private final long streamTimeoutMs;
|
||||
|
||||
public AiEngineController(
|
||||
AiEngineClient aiEngineClient,
|
||||
@@ -78,6 +78,8 @@ public class AiEngineController {
|
||||
TaskManager taskManager,
|
||||
JobOwnershipService jobOwnershipService,
|
||||
AiEngineEndpointResolver endpointResolver,
|
||||
AiFeatureGate aiFeatureGate,
|
||||
ApplicationProperties applicationProperties,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiWorkflowService = aiWorkflowService;
|
||||
@@ -86,7 +88,10 @@ public class AiEngineController {
|
||||
this.taskManager = taskManager;
|
||||
this.jobOwnershipService = jobOwnershipService;
|
||||
this.endpointResolver = endpointResolver;
|
||||
this.aiFeatureGate = aiFeatureGate;
|
||||
this.userService = userService;
|
||||
this.streamTimeoutMs =
|
||||
applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L;
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
@@ -111,6 +116,7 @@ public class AiEngineController {
|
||||
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
|
||||
public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
|
||||
throws IOException {
|
||||
aiFeatureGate.requireConversationalWorkflow();
|
||||
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
|
||||
registerFileResultAsJob(result);
|
||||
return result;
|
||||
@@ -123,6 +129,7 @@ public class AiEngineController {
|
||||
"Accepts a PDF upload and a user message, returns SSE events with progress"
|
||||
+ " updates followed by the final AI workflow result")
|
||||
public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
|
||||
aiFeatureGate.requireConversationalWorkflow();
|
||||
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
|
||||
|
||||
emitter.onTimeout(
|
||||
@@ -246,6 +253,8 @@ public class AiEngineController {
|
||||
"Sends a user message to the PDF edit agent which returns a structured plan"
|
||||
+ " of tool operations to perform")
|
||||
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
|
||||
// Same gate as /orchestrate: edit agent is a model call on the same conversational surface.
|
||||
aiFeatureGate.requireConversationalWorkflow();
|
||||
JsonNode parsed = parseJson(requestBody);
|
||||
if (!parsed.isObject()) {
|
||||
throw new ResponseStatusException(
|
||||
|
||||
+5
@@ -35,6 +35,7 @@ import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
@@ -67,6 +68,7 @@ public class ClassifyLabelController {
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final PdfMetadataService pdfMetadataService;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@@ -81,6 +83,7 @@ public class ClassifyLabelController {
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
AiFeatureGate aiFeatureGate,
|
||||
ObjectMapper objectMapper,
|
||||
ClassificationLabelProvider labelProvider,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
@@ -89,6 +92,7 @@ public class ClassifyLabelController {
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiFeatureGate = aiFeatureGate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.labelProvider = labelProvider;
|
||||
this.userService = userService;
|
||||
@@ -104,6 +108,7 @@ public class ClassifyLabelController {
|
||||
+ " intended for direct client use.")
|
||||
public ResponseEntity<Resource> classifyAndLabel(
|
||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||
aiFeatureGate.requireClassify();
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
|
||||
|
||||
+3
-1
@@ -34,6 +34,7 @@ import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
@@ -59,6 +60,7 @@ public class CreatePdfAgentController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiDocumentHtmlRenderer htmlRenderer;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
|
||||
/**
|
||||
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||
@@ -93,10 +95,10 @@ public class CreatePdfAgentController {
|
||||
public ResponseEntity<Resource> createPdf(
|
||||
@RequestParam("document") String document, @RequestParam("filename") String filename)
|
||||
throws Exception {
|
||||
|
||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
aiFeatureGate.requireCreatePdf();
|
||||
|
||||
AiDocument model;
|
||||
try {
|
||||
|
||||
+8
-2
@@ -2,6 +2,7 @@ package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -20,6 +21,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.Verdict;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.AiToolInputValidator;
|
||||
import stirling.software.proprietary.service.MathAuditorOrchestrator;
|
||||
|
||||
@@ -46,7 +48,9 @@ import stirling.software.proprietary.service.MathAuditorOrchestrator;
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class MathAuditorAgentController {
|
||||
|
||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
|
||||
private final MathAuditorOrchestrator orchestrator;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
|
||||
@PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
@@ -77,15 +81,17 @@ public class MathAuditorAgentController {
|
||||
+ " ignored (default: 0.01)")
|
||||
@RequestParam(value = "tolerance", defaultValue = "0.01")
|
||||
BigDecimal tolerance) {
|
||||
aiFeatureGate.requireMathAuditor();
|
||||
|
||||
AiToolInputValidator.validatePdfUpload(fileInput);
|
||||
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
String originalFilename = fileInput.getOriginalFilename();
|
||||
String safeName =
|
||||
fileInput.getOriginalFilename() != null
|
||||
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
|
||||
originalFilename != null
|
||||
? NEWLINE_PATTERN.matcher(originalFilename).replaceAll("_")
|
||||
: "<unnamed>";
|
||||
log.info("[math-auditor-agent] request file={} tolerance={}", safeName, tolerance);
|
||||
|
||||
|
||||
+8
-2
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -20,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||
@@ -45,8 +47,10 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class PdfCommentAgentController {
|
||||
|
||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
|
||||
private final PdfCommentAgentOrchestrator orchestrator;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
|
||||
@PostMapping(
|
||||
value = "/pdf-comment-agent",
|
||||
@@ -77,10 +81,12 @@ public class PdfCommentAgentController {
|
||||
@RequestParam("prompt")
|
||||
String prompt)
|
||||
throws IOException {
|
||||
aiFeatureGate.requirePdfComment();
|
||||
|
||||
String originalFilename = fileInput.getOriginalFilename();
|
||||
String safeName =
|
||||
fileInput.getOriginalFilename() != null
|
||||
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
|
||||
originalFilename != null
|
||||
? NEWLINE_PATTERN.matcher(originalFilename).replaceAll("_")
|
||||
: "<unnamed>";
|
||||
log.info(
|
||||
"[pdf-comment-agent] request file={} promptLen={}",
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
|
||||
import stirling.software.proprietary.security.service.ApiKeyManagementService;
|
||||
|
||||
/**
|
||||
* Real backing for the portal Infrastructure → API Keys tab: list/create/revoke named, personal API
|
||||
* keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API
|
||||
* keys are a core auth feature available on every self-hosted instance.
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
public class PortalApiKeysController {
|
||||
|
||||
private final ApiKeyManagementService apiKeyManagementService;
|
||||
|
||||
// tier accepted for endpoint symmetry with the other infra tabs; ignored here.
|
||||
@GetMapping("/infrastructure/api-keys")
|
||||
@Operation(summary = "List API keys", description = "The caller's personal API keys.")
|
||||
public ResponseEntity<PortalApiKeysResponse> list(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
return ResponseEntity.ok(apiKeyManagementService.listVisibleKeys());
|
||||
}
|
||||
|
||||
@PostMapping("/infrastructure/api-keys")
|
||||
@Operation(
|
||||
summary = "Create an API key",
|
||||
description = "Mints a personal key and returns its one-time secret.")
|
||||
public ResponseEntity<CreatedApiKeyDto> create(@RequestBody CreateApiKeyRequest request) {
|
||||
return ResponseEntity.ok(apiKeyManagementService.createKey(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/infrastructure/api-keys/{id}")
|
||||
@Operation(summary = "Revoke an API key", description = "Disables a key the caller owns.")
|
||||
public ResponseEntity<Void> revoke(@PathVariable("id") Long id) {
|
||||
apiKeyManagementService.revokeKey(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
+112
-103
@@ -3,7 +3,6 @@ package stirling.software.proprietary.controller.api;
|
||||
import static stirling.software.common.util.ProviderUtils.validateProvider;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -45,7 +44,6 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.security.database.repository.SessionRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.SessionEntity;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
@@ -169,16 +167,8 @@ public class ProprietaryUIDataController {
|
||||
boolean isFirstTimeSetup = false;
|
||||
boolean showDefaultCredentials = false;
|
||||
|
||||
List<User> allUsers = userRepository.findAll();
|
||||
List<User> realUsers =
|
||||
allUsers.stream()
|
||||
.filter(
|
||||
user ->
|
||||
!Role.INTERNAL_API_USER
|
||||
.getRoleId()
|
||||
.equals(user.getUsername()))
|
||||
.toList();
|
||||
long userCount = realUsers.size();
|
||||
// Count real users, excluding the internal API user.
|
||||
long userCount = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
|
||||
|
||||
if (userCount == 0) {
|
||||
isFirstTimeSetup = true;
|
||||
@@ -265,92 +255,67 @@ public class ProprietaryUIDataController {
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get admin settings data")
|
||||
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
|
||||
List<User> allUsers = userRepository.findAllWithTeam();
|
||||
Iterator<User> iterator = allUsers.iterator();
|
||||
List<User> allUsers = userRepository.findAllWithTeamAndAuthorities();
|
||||
Map<String, String> roleDetails = Role.getAllRoleDetails();
|
||||
|
||||
// Drop the internal API user and internal-team members; the roster never shows them.
|
||||
boolean hasInternalApiUser = false;
|
||||
List<User> visibleUsers = new ArrayList<>(allUsers.size());
|
||||
for (User user : allUsers) {
|
||||
if (user == null) {
|
||||
continue;
|
||||
}
|
||||
if (isInternalApiUser(user)) {
|
||||
hasInternalApiUser = true;
|
||||
continue;
|
||||
}
|
||||
if (user.getTeam() != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
continue;
|
||||
}
|
||||
visibleUsers.add(user);
|
||||
}
|
||||
if (hasInternalApiUser) {
|
||||
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
|
||||
}
|
||||
|
||||
// All users' settings in one query (mfaSecret masked).
|
||||
Map<Long, Map<String, String>> settingsByUserId =
|
||||
loadSettingsByUserId(visibleUsers.stream().map(User::getId).toList());
|
||||
|
||||
// Active = any non-expired session within the inactivity window; expiry is left to
|
||||
// SessionScheduled.
|
||||
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
|
||||
Instant activeCutoff = Instant.now().minusSeconds(maxInactiveInterval);
|
||||
Map<String, Instant> lastRequestByPrincipal = new HashMap<>();
|
||||
for (Object[] row : sessionRepository.findLatestRequestPerPrincipal()) {
|
||||
if (row[0] != null) {
|
||||
lastRequestByPrincipal.put((String) row[0], (Instant) row[1]);
|
||||
}
|
||||
}
|
||||
Set<String> activePrincipals =
|
||||
new HashSet<>(sessionRepository.findActivePrincipalsSince(activeCutoff));
|
||||
|
||||
Map<String, Boolean> userSessions = new HashMap<>();
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
Map<String, Map<String, String>> userSettings = new HashMap<>();
|
||||
int activeUsers = 0;
|
||||
int disabledUsers = 0;
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
User user = iterator.next();
|
||||
if (user != null) {
|
||||
String username = user.getUsername();
|
||||
boolean shouldRemove = false;
|
||||
|
||||
// Check if user is an INTERNAL_API_USER
|
||||
for (Authority authority : user.getAuthorities()) {
|
||||
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
|
||||
shouldRemove = true;
|
||||
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is part of the Internal team
|
||||
if (user.getTeam() != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
shouldRemove = true;
|
||||
}
|
||||
|
||||
if (shouldRemove) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Session status and last request time
|
||||
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
|
||||
boolean hasActiveSession = false;
|
||||
Date lastRequest = null;
|
||||
Optional<SessionEntity> latestSession =
|
||||
sessionPersistentRegistry.findLatestSession(username);
|
||||
|
||||
if (latestSession.isPresent()) {
|
||||
SessionEntity sessionEntity = latestSession.get();
|
||||
Instant lastAccessedTime =
|
||||
Optional.ofNullable(sessionEntity.getLastRequest())
|
||||
.orElse(Instant.EPOCH);
|
||||
Instant now = Instant.now();
|
||||
Instant expirationTime =
|
||||
lastAccessedTime.plus(maxInactiveInterval, ChronoUnit.SECONDS);
|
||||
|
||||
if (now.isAfter(expirationTime)) {
|
||||
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId());
|
||||
} else {
|
||||
hasActiveSession = !sessionEntity.isExpired();
|
||||
}
|
||||
lastRequest = Date.from(lastAccessedTime);
|
||||
} else {
|
||||
lastRequest = new Date(0);
|
||||
}
|
||||
|
||||
User userWithSettings =
|
||||
userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
|
||||
// Mask mfaSecret if present in settings
|
||||
Map<String, String> originalSettings = userWithSettings.getSettings();
|
||||
Map<String, String> settingsCopy =
|
||||
originalSettings != null
|
||||
? new HashMap<>(originalSettings)
|
||||
: new HashMap<>();
|
||||
if (settingsCopy.containsKey("mfaSecret")) {
|
||||
settingsCopy.put("mfaSecret", "********");
|
||||
}
|
||||
userSettings.put(username, settingsCopy);
|
||||
userSessions.put(username, hasActiveSession);
|
||||
userLastRequest.put(username, lastRequest);
|
||||
|
||||
if (hasActiveSession) activeUsers++;
|
||||
if (!user.isEnabled()) disabledUsers++;
|
||||
}
|
||||
for (User user : visibleUsers) {
|
||||
String username = user.getUsername();
|
||||
boolean hasActiveSession = activePrincipals.contains(username);
|
||||
Instant lastRequest = lastRequestByPrincipal.get(username);
|
||||
userSessions.put(username, hasActiveSession);
|
||||
userLastRequest.put(
|
||||
username, lastRequest != null ? Date.from(lastRequest) : new Date(0));
|
||||
userSettings.put(username, maskSecrets(settingsByUserId.get(user.getId())));
|
||||
if (hasActiveSession) activeUsers++;
|
||||
if (!user.isEnabled()) disabledUsers++;
|
||||
}
|
||||
|
||||
// Sort users by active status and last request date
|
||||
List<User> sortedUsers =
|
||||
allUsers.stream()
|
||||
visibleUsers.stream()
|
||||
.sorted(
|
||||
(u1, u2) -> {
|
||||
boolean u1Active = userSessions.get(u1.getUsername());
|
||||
@@ -380,11 +345,30 @@ public class ProprietaryUIDataController {
|
||||
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
|
||||
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
|
||||
|
||||
// Convert User entities to AdminUserSummary DTOs to exclude sensitive fields
|
||||
Set<Long> leaderUserIds = leaderUserIds();
|
||||
// Resolve portal access for the whole roster. The teamLead display flag counts a
|
||||
// LEADER membership on any team (mirrors /me), but the portal default policy only
|
||||
// admits leaders of their own active team, so the bulk check gets the narrower set.
|
||||
List<TeamMembership> leaderMemberships =
|
||||
teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER);
|
||||
Set<Long> leaderUserIds =
|
||||
leaderMemberships.stream()
|
||||
.map(row -> row.getUser().getId())
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> activeTeamLeaderUserIds =
|
||||
leaderMemberships.stream()
|
||||
.filter(
|
||||
row ->
|
||||
row.getUser().getTeam() != null
|
||||
&& row.getTeam()
|
||||
.getId()
|
||||
.equals(row.getUser().getTeam().getId()))
|
||||
.map(row -> row.getUser().getId())
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> portalAccessUserIds =
|
||||
resourceAccessService.usersWithPortalAccess(sortedUsers, activeTeamLeaderUserIds);
|
||||
List<AdminUserSummary> userSummaries =
|
||||
sortedUsers.stream()
|
||||
.map(user -> convertUserToSummary(user, leaderUserIds))
|
||||
.map(user -> convertUserToSummary(user, leaderUserIds, portalAccessUserIds))
|
||||
.toList();
|
||||
|
||||
AdminSettingsData data = new AdminSettingsData();
|
||||
@@ -393,7 +377,7 @@ public class ProprietaryUIDataController {
|
||||
data.setRoleDetails(roleDetails);
|
||||
data.setUserSessions(userSessions);
|
||||
data.setUserLastRequest(userLastRequest);
|
||||
data.setTotalUsers(allUsers.size());
|
||||
data.setTotalUsers(visibleUsers.size());
|
||||
data.setActiveUsers(activeUsers);
|
||||
data.setDisabledUsers(disabledUsers);
|
||||
data.setTeams(allTeams);
|
||||
@@ -516,7 +500,8 @@ public class ProprietaryUIDataController {
|
||||
}
|
||||
|
||||
List<User> teamUsers = userRepository.findAllByTeamId(id);
|
||||
List<User> allUsers = userRepository.findAllWithTeam();
|
||||
// Fetch authorities + team for the available-users list.
|
||||
List<User> allUsers = userRepository.findAllWithTeamAndAuthorities();
|
||||
List<User> availableUsers =
|
||||
allUsers.stream()
|
||||
.filter(
|
||||
@@ -568,24 +553,48 @@ public class ProprietaryUIDataController {
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
/** User ids holding a LEADER membership on any team. */
|
||||
private Set<Long> leaderUserIds() {
|
||||
return teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER).stream()
|
||||
.map(row -> row.getUser().getId())
|
||||
.collect(Collectors.toSet());
|
||||
/** Whether the user holds the internal-API authority (never shown in the roster). */
|
||||
private boolean isInternalApiUser(User user) {
|
||||
for (Authority authority : user.getAuthorities()) {
|
||||
if (Role.INTERNAL_API_USER.getRoleId().equals(authority.getAuthority())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Assemble per-user settings maps from the flat (id, key, value) rows of one bulk query. */
|
||||
private Map<Long, Map<String, String>> loadSettingsByUserId(List<Long> userIds) {
|
||||
Map<Long, Map<String, String>> byUser = new HashMap<>();
|
||||
if (userIds.isEmpty()) {
|
||||
return byUser;
|
||||
}
|
||||
for (Object[] row : userRepository.findSettingsByUserIds(userIds)) {
|
||||
byUser.computeIfAbsent((Long) row[0], id -> new HashMap<>())
|
||||
.put((String) row[1], (String) row[2]);
|
||||
}
|
||||
return byUser;
|
||||
}
|
||||
|
||||
/** Copy a settings map with mfaSecret masked; null-safe. */
|
||||
private Map<String, String> maskSecrets(Map<String, String> settings) {
|
||||
Map<String, String> copy = settings != null ? new HashMap<>(settings) : new HashMap<>();
|
||||
if (copy.containsKey("mfaSecret")) {
|
||||
copy.put("mfaSecret", "********");
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and
|
||||
* apiKey.
|
||||
* Convert a User to AdminUserSummary (excludes sensitive fields); portal access is passed in.
|
||||
*/
|
||||
private AdminUserSummary convertUserToSummary(User user, Set<Long> leaderUserIds) {
|
||||
private AdminUserSummary convertUserToSummary(
|
||||
User user, Set<Long> leaderUserIds, Set<Long> portalAccessUserIds) {
|
||||
AdminUserSummary summary = new AdminUserSummary();
|
||||
summary.setId(user.getId());
|
||||
summary.setTeamLead(leaderUserIds.contains(user.getId()));
|
||||
// Authoritative portal access, same call /me uses, so the roster honors the configured
|
||||
// policy instead of the frontend guessing from role/team-leadership.
|
||||
summary.setPortalAccess(resourceAccessService.canAccessPortal(user));
|
||||
// Portal access (same policy /me uses).
|
||||
summary.setPortalAccess(portalAccessUserIds.contains(user.getId()));
|
||||
summary.setUsername(user.getUsername());
|
||||
summary.setEmail(user.getUsername()); // Use username as email for consistency
|
||||
summary.setRoleName(user.getRoleName());
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
/**
|
||||
* How an {@link stirling.software.proprietary.integration.model.IntegrationType#API} connection
|
||||
* authenticates.
|
||||
*/
|
||||
public enum ApiAuthType {
|
||||
/** No credentials; the endpoint is open or authorises by network position. */
|
||||
NONE,
|
||||
/** {@code Authorization: Bearer <token>}. */
|
||||
BEARER,
|
||||
/** {@code Authorization: Basic base64(username:password)}. */
|
||||
BASIC,
|
||||
/** The token in a caller-named header, e.g. {@code X-API-Key: <token>}. */
|
||||
HEADER,
|
||||
/**
|
||||
* The connection logs in first and reuses the short-lived token it gets back.
|
||||
*
|
||||
* <p>For the large class of enterprise APIs - ConsignO Cloud, OAuth2 client-credentials, and
|
||||
* others - where credentials buy a token rather than authenticating a call directly. Without
|
||||
* this a step could not reach them at all: each call needs a token, and a stateless step has
|
||||
* nowhere to obtain or keep one. See {@link ApiTokenLogin}.
|
||||
*/
|
||||
TOKEN_LOGIN
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user