mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e556eba832 | ||
|
|
4ea39559c0 | ||
|
|
00d6a6e071 | ||
|
|
cc22deda3f | ||
|
|
1ed7338bb9 | ||
|
|
5dc4ba0204 | ||
|
|
ad95a046ed | ||
|
|
2d0d4443f8 | ||
|
|
656a0ae268 | ||
|
|
f1ee0bdbab | ||
|
|
abc9a08566 | ||
|
|
ab51bcf0f0 | ||
|
|
0ba8b9fcc1 | ||
|
|
e43af565ab | ||
|
|
58326adee4 | ||
|
|
fd9f52d756 | ||
|
|
91f4376371 | ||
|
|
29df488b4e | ||
|
|
e3ff34efd1 | ||
|
|
38ccea074c | ||
|
|
a7307ff393 | ||
|
|
72729e99c1 | ||
|
|
5fba2720f0 | ||
|
|
01a1ef8c44 | ||
|
|
8535c7e9ac | ||
|
|
cca3f42623 | ||
|
|
1b7ffcdbac | ||
|
|
67a0ca6110 | ||
|
|
8e4b2e2fc6 | ||
|
|
3c93457021 |
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.0
|
||||
pkgver=2.14.3
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.0
|
||||
pkgver=2.14.3
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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,10 +108,10 @@ 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
|
||||
@@ -187,12 +183,7 @@ 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
|
||||
|
||||
- name: Get version number
|
||||
@@ -237,21 +228,9 @@ 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
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push V2 image
|
||||
if: steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -259,7 +238,9 @@ jobs:
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -290,6 +271,7 @@ jobs:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
@@ -333,9 +315,70 @@ jobs:
|
||||
# Set port for output
|
||||
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
|
||||
|
||||
# ---- Storybook preview (only when this PR touches stories/.storybook) ----
|
||||
# Runs inside the same approved-contributor-gated deploy job, so it deploys
|
||||
# under the exact same access rules as the app preview.
|
||||
- name: Detect Storybook changes
|
||||
id: sb-changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
with:
|
||||
list-files: json
|
||||
filters: |
|
||||
storybook:
|
||||
- 'frontend/**/*.stories.@(ts|tsx|mdx)'
|
||||
- 'frontend/**/*.mdx'
|
||||
- 'frontend/.storybook/**'
|
||||
|
||||
- name: Set up Node.js for Storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task for Storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build and deploy Storybook
|
||||
id: storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `prepare` generates the icon set stories import (not committed).
|
||||
task frontend:prepare
|
||||
task frontend:storybook:build
|
||||
PR=${{ needs.check-pr.outputs.pr_number }}
|
||||
# Served at the ROOT of its own port so Storybook's global MSW worker
|
||||
# (/mockServiceWorker.js) resolves. Port = PR + 20000 (bijective, offset
|
||||
# from the app preview's bare-PR-number port).
|
||||
SB_PORT=$((PR + 20000))
|
||||
DIR=/stirling/SB-PR-$PR
|
||||
tar czf storybook.tgz -C frontend/storybook-static .
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
storybook.tgz "$VPS_USER@$VPS_HOST:/tmp/storybook-$PR.tgz"
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T \
|
||||
"$VPS_USER@$VPS_HOST" << ENDSSH
|
||||
set -e
|
||||
rm -rf "$DIR" && mkdir -p "$DIR"
|
||||
tar xzf /tmp/storybook-$PR.tgz -C "$DIR"
|
||||
rm -f /tmp/storybook-$PR.tgz
|
||||
docker rm -f storybook-pr-$PR 2>/dev/null || true
|
||||
docker run -d --name storybook-pr-$PR --restart unless-stopped \
|
||||
-p $SB_PORT:80 -v "$DIR":/usr/share/nginx/html:ro nginx:alpine
|
||||
ENDSSH
|
||||
echo "url=http://$VPS_HOST:$SB_PORT/" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
SB_URL: ${{ steps.storybook.outputs.url }}
|
||||
SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }}
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -359,12 +402,40 @@ jobs:
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
// Only mention the portal when this image actually embeds it.
|
||||
// Use the direct IP URL - the SSL hostname isn't supported yet.
|
||||
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
|
||||
const portalNote = withPortal
|
||||
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
|
||||
: ``;
|
||||
|
||||
// Storybook preview: only present when this PR changed stories/config.
|
||||
const sbUrl = process.env.SB_URL;
|
||||
let storybookNote = "";
|
||||
if (sbUrl) {
|
||||
const files = JSON.parse(process.env.SB_FILES || "[]");
|
||||
const stories = files.filter((f) => /\.stories\.(ts|tsx|mdx)$/.test(f));
|
||||
const config = files.filter((f) => f.startsWith("frontend/.storybook/"));
|
||||
const shorten = (f) =>
|
||||
f.replace(/^frontend\/editor\/src\//, "").replace(/^frontend\//, "");
|
||||
const storyList = stories.map((f) => `- \`${shorten(f)}\``).join("\n");
|
||||
const configList = config.map((f) => `- \`${shorten(f)}\``).join("\n");
|
||||
const summary =
|
||||
`${stories.length} stor${stories.length === 1 ? "y" : "ies"} changed` +
|
||||
(config.length ? ` (+${config.length} config file${config.length === 1 ? "" : "s"})` : "");
|
||||
storybookNote =
|
||||
`📚 **Storybook:** [${sbUrl}](${sbUrl})\n\n` +
|
||||
`<details>\n<summary>${summary}</summary>\n\n` +
|
||||
(storyList ? `**Stories**\n${storyList}\n\n` : "") +
|
||||
(configList ? `**Config**\n${configList}\n` : "") +
|
||||
`</details>\n\n`;
|
||||
}
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
`Your V2 PR with embedded architecture has been deployed!\n\n` +
|
||||
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
portalNote +
|
||||
storybookNote +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
@@ -377,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
|
||||
@@ -460,7 +530,11 @@ jobs:
|
||||
else
|
||||
echo "V2 PR directory not found, nothing to clean up"
|
||||
fi
|
||||
|
||||
|
||||
# Remove this PR's Storybook preview (container + files), if any.
|
||||
docker rm -f storybook-pr-${{ github.event.pull_request.number }} 2>/dev/null || true
|
||||
rm -rf /stirling/SB-PR-${{ github.event.pull_request.number }}
|
||||
|
||||
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
|
||||
|
||||
@@ -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: |
|
||||
@@ -179,15 +175,11 @@ 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
|
||||
@@ -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,12 +232,7 @@ 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
|
||||
|
||||
- name: Login to Docker Hub
|
||||
@@ -254,23 +241,8 @@ jobs:
|
||||
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'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push PR-specific image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -283,20 +255,9 @@ 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'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- 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
|
||||
file: ./engine/Dockerfile
|
||||
@@ -510,8 +471,7 @@ 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
|
||||
|
||||
@@ -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:
|
||||
@@ -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,8 +18,6 @@ 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
|
||||
|
||||
@@ -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:
|
||||
@@ -47,7 +41,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
# Enterprise Playwright suite — exercises premium-key gated features (audit,
|
||||
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in three
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in four
|
||||
# situations:
|
||||
#
|
||||
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
|
||||
@@ -12,28 +12,14 @@ name: Enterprise E2E (Playwright)
|
||||
# - on a nightly cron schedule (catches Keycloak image drift, license
|
||||
# expiry, upstream proprietary changes),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
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
|
||||
@@ -52,13 +38,16 @@ jobs:
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
# 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: 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
|
||||
@@ -165,6 +154,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise OAuth Playwright tests
|
||||
id: oauth-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
|
||||
run: task e2e:enterprise -- --grep "OAuth"
|
||||
- name: Stop backend + tear down OAuth Keycloak
|
||||
if: always()
|
||||
@@ -238,6 +229,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise SAML Playwright tests
|
||||
id: saml-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
|
||||
run: task e2e:enterprise -- --grep "SAML"
|
||||
- name: Stop backend + tear down SAML Keycloak
|
||||
if: always()
|
||||
@@ -268,6 +261,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise feature Playwright tests
|
||||
id: feature-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
|
||||
run: task e2e:enterprise -- --grep "Enterprise license"
|
||||
- name: Print backend log on failure
|
||||
if: failure()
|
||||
@@ -280,10 +275,23 @@ jobs:
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
stop_backend
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcomes: a flaky test (passed on retry)
|
||||
# leaves its step green, so this is the only place it surfaces. Merges
|
||||
# all three phase reports (some may be absent if an earlier phase hard-
|
||||
# failed and skipped the rest). Emits ::warning:: annotations + a job
|
||||
# summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: >
|
||||
npx tsx editor/scripts/report-flaky-tests.mts
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -147,7 +147,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/test-build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
@@ -11,8 +11,6 @@ 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
|
||||
|
||||
@@ -10,14 +10,8 @@ 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
|
||||
@@ -34,7 +28,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -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
|
||||
|
||||
@@ -13,17 +13,24 @@ name: Aggregate backend coverage
|
||||
# producers themselves
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
frontend-validation-result:
|
||||
description: Result of the frontend-validation producer job
|
||||
required: false
|
||||
type: string
|
||||
default: skipped
|
||||
playwright-e2e-live-result:
|
||||
description: Result of the playwright-e2e-live producer job
|
||||
required: false
|
||||
type: string
|
||||
default: skipped
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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
|
||||
@@ -40,7 +47,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -49,9 +56,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: Set up Python
|
||||
@@ -196,9 +203,9 @@ jobs:
|
||||
# --------------------------------------------------------------
|
||||
- name: Download vitest coverage artifact
|
||||
# frontend-validation uploads as `frontend-coverage`. Tolerate
|
||||
# absence so a backend-only PR still produces the matrix with
|
||||
# just backend rows populated.
|
||||
if: always()
|
||||
# absence on backend-only runs by skipping the download entirely
|
||||
# when the producer job was not part of this workflow run.
|
||||
if: inputs.frontend-validation-result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: frontend-coverage
|
||||
@@ -206,12 +213,12 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Download Playwright frontend coverage artifact
|
||||
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
|
||||
# Same tolerance as vitest - matrix script handles missing inputs.
|
||||
if: always()
|
||||
# e2e-live uploads the artifact with a stable name. Skip the
|
||||
# download entirely when the producer job did not run.
|
||||
if: inputs.playwright-e2e-live-result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
name: playwright-frontend-coverage
|
||||
path: matrix-inputs/playwright/
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -12,15 +12,9 @@ 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
|
||||
@@ -37,7 +31,7 @@ jobs:
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -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
|
||||
|
||||
@@ -10,21 +10,11 @@ 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
|
||||
@@ -35,12 +25,7 @@ jobs:
|
||||
- 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
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
@@ -105,23 +90,9 @@ jobs:
|
||||
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'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
@@ -134,23 +105,9 @@ 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'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
|
||||
@@ -11,28 +11,17 @@ 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
|
||||
@@ -50,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -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
|
||||
|
||||
@@ -5,23 +5,13 @@ 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
|
||||
@@ -62,7 +52,17 @@ jobs:
|
||||
# .test-state/playwright/coverage-pw/ for the post-process step
|
||||
# to aggregate. Chromium-only - other engines silently skip.
|
||||
PW_COVERAGE: "1"
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:live
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Generate JaCoCo report from e2e:live .exec
|
||||
if: always()
|
||||
id: live-coverage
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
name: playwright-frontend-coverage
|
||||
path: |
|
||||
.test-state/playwright/coverage-pw-summary/
|
||||
.test-state/playwright/coverage-pw/
|
||||
|
||||
@@ -5,23 +5,13 @@ 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
|
||||
@@ -44,11 +34,22 @@ jobs:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -19,13 +19,9 @@ 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 }}
|
||||
@@ -48,8 +44,8 @@ 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
|
||||
@@ -98,6 +94,13 @@ jobs:
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Generate frontend license report (Push only)
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: task frontend:licenses:generate
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
env:
|
||||
@@ -292,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
|
||||
@@ -311,15 +317,13 @@ 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
|
||||
@@ -347,12 +351,13 @@ 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
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
|
||||
@@ -512,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,12 +11,8 @@ 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
|
||||
|
||||
@@ -12,13 +12,14 @@ on:
|
||||
- "true"
|
||||
- "false"
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
description: "Platform to build (windows, windows-arm64, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- windows-arm64
|
||||
- macos
|
||||
- linux
|
||||
sign:
|
||||
@@ -36,13 +37,9 @@ 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 }}
|
||||
@@ -61,7 +58,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -71,9 +68,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
|
||||
@@ -91,31 +88,40 @@ jobs:
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
# windows-arm64: NSIS only (WiX MSI has no arm64 support in Tauri) and no
|
||||
# JPDFium natives yet - flip to windows-arm64 once JPDFium ships them.
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
|
||||
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
|
||||
ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"windows-arm64")
|
||||
echo "matrix={\"include\":[$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$MACOS]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$LINUX]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push/release events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
|
||||
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:
|
||||
@@ -146,9 +152,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,7 +201,6 @@ 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
|
||||
@@ -243,16 +248,17 @@ jobs:
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || '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
|
||||
@@ -278,7 +284,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -288,7 +294,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -323,7 +329,7 @@ jobs:
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -391,7 +397,7 @@ jobs:
|
||||
# Without this, signCommand failures are opaque (Tauri captures but drops
|
||||
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
|
||||
- name: Preflight smctl
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -422,7 +428,7 @@ jobs:
|
||||
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
|
||||
# from env (set by prior DigiCert setup step). No --config-file needed.
|
||||
- name: Configure Windows code signing
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -493,6 +499,54 @@ jobs:
|
||||
args: ${{ matrix.args }}
|
||||
updaterJsonKeepUniversal: true
|
||||
|
||||
# Bundled libwayland conflicts with the host's on some distros (Fedora
|
||||
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
|
||||
# then regenerate the updater .sig (repack invalidates the original) and
|
||||
# GPG-sign again when release signing is on.
|
||||
- name: Strip bundled Wayland libs from AppImage
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
|
||||
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
|
||||
chmod +x "$AI"
|
||||
WORK=$(mktemp -d)
|
||||
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
|
||||
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
|
||||
echo "No bundled libwayland - nothing to strip"
|
||||
rm -rf "$WORK"
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
|
||||
curl -fsSL -o "$WORK/appimagetool" \
|
||||
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
# Pinned checksum: never execute an unverified downloaded binary. On
|
||||
# mismatch (upstream rebuilt continuous) the step aborts and the
|
||||
# original AppImage ships unchanged - update the pin deliberately.
|
||||
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
|
||||
chmod +x "$WORK/appimagetool"
|
||||
SIGN_ARGS=()
|
||||
if [ "$GPG_SIGN" = "1" ] && [ -n "${SIGN_KEY:-}" ]; then
|
||||
SIGN_ARGS=(--sign --sign-key "$SIGN_KEY")
|
||||
fi
|
||||
"$WORK/appimagetool" --appimage-extract-and-run "${SIGN_ARGS[@]}" "$WORK/squashfs-root" "$AI.new"
|
||||
# Updater payload signature must match the repacked bytes. The CLI
|
||||
# reads the key/password from env - never pass secrets as argv.
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
(cd frontend && npx tauri signer sign "$AI.new")
|
||||
mv "$AI.new.sig" "$AI.sig"
|
||||
fi
|
||||
mv "$AI.new" "$AI"
|
||||
rm -rf "$WORK"
|
||||
echo "Stripped bundled libwayland from $(basename "$AI")"
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
env:
|
||||
@@ -509,9 +563,31 @@ jobs:
|
||||
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
|
||||
# inner exe before packing and the setup exe after, so verifying the setup
|
||||
# exe is the arm64 equivalent of the MSI + inner-exe check below.
|
||||
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
|
||||
$setupExes = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
|
||||
if ($setupExes.Count -eq 0) {
|
||||
Write-Host "[ERROR] No NSIS installer found under target/"
|
||||
exit 1
|
||||
}
|
||||
foreach ($exe in $setupExes) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exe.FullName
|
||||
Write-Host "NSIS installer: $($exe.Name) Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
|
||||
if ($sig.Status -ne "Valid") {
|
||||
Write-Host "[ERROR] NSIS installer is not signed"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
Write-Host "[SUCCESS] NSIS installer is properly signed"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$allSigned = $true
|
||||
|
||||
# Check MSI installer (outer wrapper - what users download)
|
||||
@@ -531,11 +607,26 @@ jobs:
|
||||
|
||||
# Extract MSI and verify the inner exe (the file that actually gets installed).
|
||||
# This is the critical check - AV flags the installed exe at runtime.
|
||||
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
|
||||
# _MSIExecute mutex and hangs forever on hosted runners when another
|
||||
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
|
||||
$msi = $msiFiles[0].FullName
|
||||
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
|
||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
choco install lessmsi -y --no-progress --limit-output | Out-Null
|
||||
|
||||
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
|
||||
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
|
||||
if (-not $proc.WaitForExit(120000)) {
|
||||
try { $proc.Kill() } catch {}
|
||||
Write-Host "[ERROR] MSI extraction timed out after 120s"
|
||||
$allSigned = $false
|
||||
} elseif ($proc.ExitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
|
||||
if ($innerExe) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
|
||||
@@ -548,9 +639,6 @@ jobs:
|
||||
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
|
||||
$allSigned = $false
|
||||
}
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
}
|
||||
|
||||
if (-not $allSigned) {
|
||||
@@ -563,7 +651,7 @@ jobs:
|
||||
# but drops stderr when the command exits non-zero, making failures opaque.
|
||||
# The real errors live in smctl's log files - surface them here for debugging.
|
||||
- name: Dump smctl logs on failure
|
||||
if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$logDir = "$env:USERPROFILE\.signingmanager\logs"
|
||||
@@ -599,6 +687,11 @@ jobs:
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \;
|
||||
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
|
||||
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
|
||||
# The setup exe is also its own updater payload (-> sibling .sig).
|
||||
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
|
||||
find . -name "*-setup.exe.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe.sig" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
# DMG = manual install; .app.tar.gz (+ .sig) = updater payload.
|
||||
# Raw .app is intentionally not shipped (hundreds of MB of uncompressed input).
|
||||
@@ -625,8 +718,8 @@ 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:
|
||||
@@ -713,6 +806,10 @@ jobs:
|
||||
'bundles': ['Stirling-PDF-windows-x86_64.msi'],
|
||||
'targets': ['windows-x86_64-msi', 'windows-x86_64'],
|
||||
},
|
||||
{
|
||||
'bundles': ['Stirling-PDF-windows-arm64-setup.exe'],
|
||||
'targets': ['windows-aarch64-nsis', 'windows-aarch64'],
|
||||
},
|
||||
{
|
||||
'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'],
|
||||
'targets': ['darwin-x86_64', 'darwin-aarch64'],
|
||||
@@ -800,13 +897,18 @@ jobs:
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
# Don't regenerate/append notes on re-runs, and don't force this into the
|
||||
# "Latest" slot - leave the release body and latest marker as they are.
|
||||
generate_release_notes: false
|
||||
append_body: false
|
||||
make_latest: false
|
||||
fail_on_unmatched_files: true
|
||||
# Installers + updater payloads + manifest. .sig contents are embedded
|
||||
# in latest.json so the .sig files themselves are not uploaded.
|
||||
files: |
|
||||
./artifacts/**/*.jar
|
||||
./artifacts/**/*.msi
|
||||
./artifacts/**/*-setup.exe
|
||||
./artifacts/**/*.dmg
|
||||
./artifacts/**/*.app.tar.gz
|
||||
./artifacts/**/*.deb
|
||||
|
||||
@@ -13,13 +13,9 @@ 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
|
||||
@@ -53,8 +49,8 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
name: playwright-report-nightly-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
|
||||
@@ -22,15 +22,9 @@ 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
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)."
|
||||
description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)."
|
||||
required: false
|
||||
type: string
|
||||
default: "all"
|
||||
@@ -21,23 +21,35 @@ 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, windows-arm64, macos, linux, windows-macos, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- windows-arm64
|
||||
- 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 +63,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
|
||||
|
||||
@@ -62,14 +74,19 @@ jobs:
|
||||
PLATFORM: ${{ inputs.platform }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
|
||||
# ARM64: NSIS only (WiX MSI has no arm64 support in Tauri) and no JPDFium
|
||||
# natives yet - flip jpdfium_platforms to windows-arm64 once JPDFium ships it.
|
||||
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
|
||||
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" "$WINDOWS_ARM64") ;;
|
||||
windows-arm64) ENTRIES=("$WINDOWS_ARM64") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64" "$MACOS" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
@@ -96,10 +113,14 @@ 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 }}
|
||||
# Per-platform sign gate. macOS signs on any run with the cert available,
|
||||
# PRs included: Gatekeeper blocks an unsigned .dmg, so an unsigned macOS
|
||||
# PR build is not testable. Windows and Linux stay main-only, matching the
|
||||
# gates on their own signing steps below.
|
||||
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
|
||||
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
|
||||
|
||||
@@ -113,7 +134,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -151,16 +172,17 @@ jobs:
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || '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
|
||||
@@ -187,7 +209,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -197,7 +219,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -232,7 +254,7 @@ jobs:
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -263,7 +285,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -284,7 +306,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -307,7 +329,7 @@ jobs:
|
||||
ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
|
||||
- name: Preflight smctl
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -320,7 +342,7 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
|
||||
|
||||
- name: Configure Windows code signing
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -357,7 +379,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build Tauri app (signed)
|
||||
if: inputs.sign
|
||||
if: env.SIGN_BUNDLE == 'true'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -386,13 +408,13 @@ 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 }}
|
||||
if: env.SIGN_BUNDLE != 'true'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -406,15 +428,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:
|
||||
@@ -433,6 +458,37 @@ jobs:
|
||||
tauriScript: npx tauri
|
||||
args: --bundles appimage
|
||||
|
||||
# Bundled libwayland conflicts with the host's on some distros (Fedora
|
||||
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). The AppImage
|
||||
# ecosystem excludelist agrees these libs must come from the system.
|
||||
- name: Strip bundled Wayland libs from AppImage
|
||||
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
|
||||
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
|
||||
chmod +x "$AI"
|
||||
WORK=$(mktemp -d)
|
||||
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
|
||||
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
|
||||
echo "No bundled libwayland - nothing to strip"
|
||||
rm -rf "$WORK"
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
|
||||
curl -fsSL -o "$WORK/appimagetool" \
|
||||
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
# Pinned checksum: never execute an unverified downloaded binary. On
|
||||
# mismatch (upstream rebuilt continuous) the step aborts and the
|
||||
# original AppImage ships unchanged - update the pin deliberately.
|
||||
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
|
||||
chmod +x "$WORK/appimagetool"
|
||||
"$WORK/appimagetool" --appimage-extract-and-run "$WORK/squashfs-root" "$AI.new"
|
||||
mv "$AI.new" "$AI"
|
||||
rm -rf "$WORK"
|
||||
echo "Stripped bundled libwayland from $(basename "$AI")"
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
@@ -444,7 +500,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: inputs.sign && matrix.platform == 'macos-15'
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
@@ -471,6 +527,9 @@ jobs:
|
||||
# Only ship the MSI installer. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
|
||||
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
|
||||
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
else
|
||||
@@ -482,9 +541,28 @@ jobs:
|
||||
# Verify the MSI AND the inner exe extracted from it are signed.
|
||||
# The inner exe is what gets installed on users' machines and what AV scans.
|
||||
- name: Verify Windows Code Signature
|
||||
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
|
||||
# inner exe before packing and the setup exe after, so verifying the setup
|
||||
# exe is the arm64 equivalent of the MSI + inner-exe check below.
|
||||
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
|
||||
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}-setup.exe"
|
||||
if (-not (Test-Path $exePath)) {
|
||||
Write-Host "[ERROR] NSIS installer not found at $exePath"
|
||||
exit 1
|
||||
}
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exePath
|
||||
Write-Host "NSIS installer: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
|
||||
if ($sig.Status -ne "Valid") {
|
||||
Write-Host "[ERROR] NSIS installer is not signed"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[SUCCESS] NSIS installer is properly signed"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$allSigned = $true
|
||||
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
|
||||
|
||||
@@ -530,7 +608,7 @@ jobs:
|
||||
Write-Host "[SUCCESS] MSI and inner exe are properly signed"
|
||||
|
||||
- name: Dump smctl logs on failure
|
||||
if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$logDir = "$env:USERPROFILE\.signingmanager\logs"
|
||||
@@ -557,7 +635,7 @@ jobs:
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
|
||||
# Check for expected artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ] || [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
|
||||
echo "Checking for Windows artifacts..."
|
||||
find . -name "*.exe" -o -name "*.msi" | head -5
|
||||
if [ $(find . -name "*.exe" | wc -l) -eq 0 ]; then
|
||||
@@ -601,12 +679,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
|
||||
|
||||
@@ -629,6 +714,7 @@ jobs:
|
||||
// Map of expected artifact names to display info
|
||||
const artifactMap = {
|
||||
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
|
||||
'Stirling-PDF-windows-arm64': { icon: '🪟', platform: 'Windows ARM64', files: '-setup.exe (NSIS)' },
|
||||
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
|
||||
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
|
||||
};
|
||||
@@ -697,7 +783,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:
|
||||
@@ -95,7 +85,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -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,16 +110,10 @@ 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
|
||||
|
||||
@@ -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,25 +161,11 @@ 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'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- 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 }}
|
||||
context: .
|
||||
@@ -213,14 +192,7 @@ 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
|
||||
@@ -230,36 +202,15 @@ jobs:
|
||||
- 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
|
||||
|
||||
- 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'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
@@ -20,19 +20,9 @@ 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
|
||||
@@ -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,12 +51,7 @@ 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
|
||||
|
||||
- name: Get version number
|
||||
@@ -81,21 +66,8 @@ jobs:
|
||||
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'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build and push test image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -153,8 +125,7 @@ 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 }}
|
||||
@@ -174,8 +145,8 @@ jobs:
|
||||
|
||||
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
|
||||
@@ -208,8 +179,8 @@ 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:
|
||||
|
||||
@@ -22,6 +22,7 @@ vars:
|
||||
linux-amd64) echo "linux-x64";;
|
||||
linux-arm64) echo "linux-arm64";;
|
||||
windows-amd64) echo "windows-x64";;
|
||||
windows-arm64) echo "none";; # no JPDFium windows-arm64 natives published yet
|
||||
*) echo "all";;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -55,15 +55,6 @@ tasks:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
|
||||
prepare:classifier-categories:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate the engine classifier categories JSON from the TS source of truth"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts
|
||||
sources:
|
||||
- editor/src/proprietary/data/classificationTaxonomy.ts
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: when_changed
|
||||
@@ -74,7 +65,6 @@ tasks:
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
- prepare:og
|
||||
- prepare:classifier-categories
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
@@ -411,23 +401,12 @@ tasks:
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
|
||||
classifier-categories:
|
||||
desc: "Regenerate the engine classifier categories JSON from the TS source"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts
|
||||
|
||||
classifier-categories:check:
|
||||
desc: "Fail if the committed classifier categories JSON is out of date"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts --check
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
cmds:
|
||||
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
|
||||
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
|
||||
- task: og:check
|
||||
- task: classifier-categories:check
|
||||
- task: typecheck:all
|
||||
- task: lint
|
||||
- task: format:check
|
||||
|
||||
@@ -95,7 +95,6 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
@@ -118,32 +117,6 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:all:saas:
|
||||
desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
|
||||
+12
-6
@@ -62,18 +62,24 @@ dependencies {
|
||||
|
||||
api "com.stirling:jpdfium:${jpdfiumVersion}"
|
||||
|
||||
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
// -PjpdfiumPlatforms=all|none|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
// 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all'
|
||||
? jpdfiumAllPlatforms
|
||||
: jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
|
||||
def jpdfiumPlatforms
|
||||
if (jpdfiumPlatformsProp == 'all') {
|
||||
jpdfiumPlatforms = jpdfiumAllPlatforms
|
||||
} else if (jpdfiumPlatformsProp == 'none') {
|
||||
jpdfiumPlatforms = []
|
||||
} else {
|
||||
jpdfiumPlatforms = jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
|
||||
}
|
||||
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
|
||||
if (jpdfiumInvalid) {
|
||||
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')} or 'all'.")
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
|
||||
jpdfiumPlatforms.each { platform ->
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Calendar;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -18,11 +17,6 @@ import stirling.software.common.model.PdfMetadata;
|
||||
@Service
|
||||
public class PdfMetadataService {
|
||||
|
||||
/**
|
||||
* ({@code {category, docType, typeConfidence, tags}}). Written by the classify-and-tag tool.
|
||||
*/
|
||||
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final String stirlingPDFLabel;
|
||||
private final UserServiceInterface userService;
|
||||
@@ -183,14 +177,4 @@ public class PdfMetadataService {
|
||||
}
|
||||
pdf.getDocumentInformation().setAuthor(author);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
|
||||
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
|
||||
*/
|
||||
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
|
||||
PDDocumentInformation info = pdf.getDocumentInformation();
|
||||
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
|
||||
pdf.setDocumentInformation(info);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -144,8 +144,10 @@ public class TempFileCleanupService {
|
||||
int directoriesDeletedCount = 0;
|
||||
for (Path directory : registry.getTempDirectories()) {
|
||||
try {
|
||||
if (Files.exists(directory)) {
|
||||
if (Files.exists(directory)
|
||||
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
directoriesDeletedCount++;
|
||||
log.debug("Cleaned up temporary directory: {}", directory);
|
||||
}
|
||||
@@ -275,6 +277,21 @@ public class TempFileCleanupService {
|
||||
return totalDeletedCount.get();
|
||||
}
|
||||
|
||||
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
|
||||
if (maxAgeMillis <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long lastModified = Files.getLastModifiedTime(directory).toMillis();
|
||||
return (currentTime - lastModified) > maxAgeMillis;
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the system temp directory path based on configuration or system property. */
|
||||
private Path getSystemTempPath() {
|
||||
String systemTempDir =
|
||||
|
||||
@@ -155,6 +155,7 @@ public class TempFileManager {
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
try {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
log.debug("Deleted temp directory: {}", directory.toString());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
|
||||
|
||||
@@ -85,6 +85,18 @@ public class TempFileRegistry {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a temporary directory from the registry.
|
||||
*
|
||||
* @param directory The directory to unregister
|
||||
*/
|
||||
public void unregisterDirectory(Path directory) {
|
||||
if (directory != null) {
|
||||
tempDirectories.remove(directory);
|
||||
log.debug("Unregistered temp directory: {}", directory.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a third-party temporary file that requires special handling.
|
||||
*
|
||||
|
||||
+19
-1
@@ -176,11 +176,13 @@ class TempFileCleanupServiceMoreTest {
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
@DisplayName("deletes stale registered temp directories and reports counts")
|
||||
void deletesRegisteredDirectories() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Files.setLastModifiedTime(
|
||||
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
@@ -193,6 +195,22 @@ class TempFileCleanupServiceMoreTest {
|
||||
verify(tempFileManager).cleanupOldTempFiles(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps a fresh registered temp directory")
|
||||
void keepsFreshRegisteredDirectory() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
|
||||
assertThat(Files.exists(regDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips a registered directory that no longer exists")
|
||||
void skipsMissingRegisteredDirectory() {
|
||||
|
||||
+20
@@ -57,6 +57,7 @@ public class PdfOverlayController {
|
||||
int overlayPos = request.getOverlayPosition();
|
||||
|
||||
MultipartFile[] overlayFiles = request.getOverlayFiles();
|
||||
validateOverlayFiles(overlayFiles);
|
||||
File[] overlayPdfFiles = new File[overlayFiles.length];
|
||||
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
|
||||
|
||||
@@ -116,10 +117,29 @@ public class PdfOverlayController {
|
||||
}
|
||||
}
|
||||
|
||||
// Both fields are declared required, but @ModelAttribute binding leaves them null when the
|
||||
// caller omits them, which would otherwise surface as a 500 instead of a 400.
|
||||
private void validateOverlayFiles(MultipartFile[] overlayFiles) {
|
||||
if (overlayFiles == null || overlayFiles.length == 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.overlayFilesRequired", "At least one overlay file is required");
|
||||
}
|
||||
for (MultipartFile overlayFile : overlayFiles) {
|
||||
if (overlayFile == null || overlayFile.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.overlayFileEmpty", "Overlay files must not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Integer, String> prepareOverlayGuide(
|
||||
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
|
||||
throws IOException {
|
||||
Map<Integer, String> overlayGuide = new HashMap<>();
|
||||
if (mode == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null");
|
||||
}
|
||||
switch (mode) {
|
||||
case "SequentialOverlay":
|
||||
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
|
||||
|
||||
-29
@@ -305,23 +305,6 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info-dictionary keys exposed above via typed getters; any other key in the dictionary is
|
||||
* surfaced as custom metadata (e.g. the classification policy's StirlingPDFClassification
|
||||
* entry).
|
||||
*/
|
||||
private static final java.util.Set<String> STANDARD_INFO_KEYS =
|
||||
java.util.Set.of(
|
||||
"Title",
|
||||
"Author",
|
||||
"Subject",
|
||||
"Keywords",
|
||||
"Producer",
|
||||
"Creator",
|
||||
"CreationDate",
|
||||
"ModDate",
|
||||
"Trapped");
|
||||
|
||||
private static ObjectNode extractMetadata(PDDocument document) {
|
||||
ObjectNode metadata = objectMapper.createObjectNode();
|
||||
|
||||
@@ -352,18 +335,6 @@ public class GetInfoOnPDF {
|
||||
if (modificationDate != null) {
|
||||
metadata.put("ModificationDate", modificationDate);
|
||||
}
|
||||
|
||||
// Surface custom Info-dictionary entries (anything beyond the
|
||||
// standard fields above) — e.g. StirlingPDFClassification
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
if (STANDARD_INFO_KEYS.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
String value = info.getCustomMetadataValue(key);
|
||||
if (value != null && !value.isBlank()) {
|
||||
metadata.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error extracting metadata: {}", e.getMessage());
|
||||
|
||||
+8
-2
@@ -136,11 +136,17 @@ public class RedactController {
|
||||
+ "Users can provide text patterns to redact, with options for regex and whole word matching. "
|
||||
+ "Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
|
||||
String[] listOfText = request.getListOfText().split("\n");
|
||||
String rawListOfText = request.getListOfText();
|
||||
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
|
||||
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
|
||||
if (rawListOfText == null || rawListOfText.trim().isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
String[] listOfText = rawListOfText.split("\n");
|
||||
if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
+12
@@ -299,6 +299,18 @@ class RedactControllerMoreTest {
|
||||
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null listOfText throws an illegal-argument error before any load")
|
||||
void nullPatternsThrows() throws Exception {
|
||||
RedactPdfRequest request = new RedactPdfRequest();
|
||||
request.setFileInput(pdfFile(new byte[] {1, 2, 3}));
|
||||
request.setListOfText(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.redactPdf(request))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null file input is reported as a failure")
|
||||
void nullFileThrows() {
|
||||
|
||||
@@ -80,10 +80,6 @@ dependencies {
|
||||
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
|
||||
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
|
||||
|
||||
// @DataJpaTest slice (Boot 4 ships test slices as separate starters, like webmvc-test at the
|
||||
// root) so policy.source repositories can be exercised against embedded H2.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
|
||||
|
||||
// Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without
|
||||
// manually-started instances. Tests skip cleanly when Docker is unavailable.
|
||||
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package stirling.software.proprietary.access.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import stirling.software.proprietary.access.service.DefaultTeamLeadLookup;
|
||||
import stirling.software.proprietary.access.service.TeamLeadLookup;
|
||||
|
||||
/** Access-layer bean wiring. */
|
||||
@Configuration
|
||||
public class AccessConfig {
|
||||
|
||||
/** No-op {@link TeamLeadLookup} unless another bean is defined. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(TeamLeadLookup.class)
|
||||
TeamLeadLookup defaultTeamLeadLookup() {
|
||||
return new DefaultTeamLeadLookup();
|
||||
}
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
package stirling.software.proprietary.access.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.access.model.AccessPermission;
|
||||
import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.service.ResourceAccessService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/access")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)")
|
||||
public class ResourceGrantController {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
|
||||
@GetMapping("/grants")
|
||||
public ResponseEntity<?> list(
|
||||
@RequestParam ResourceType resourceType,
|
||||
@RequestParam(required = false, defaultValue = "") String resourceId) {
|
||||
List<ResourceGrant> grants = accessService.listGrants(resourceType, resourceId);
|
||||
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/grants")
|
||||
public ResponseEntity<?> create(
|
||||
@RequestBody GrantRequest request, @AuthenticationPrincipal User admin) {
|
||||
if (request.resourceType() == null
|
||||
|| request.principalType() == null
|
||||
|| request.principalId() == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"resourceType, principalType and principalId are required"));
|
||||
}
|
||||
AccessPermission permission =
|
||||
request.permission() == null ? AccessPermission.USE : request.permission();
|
||||
// PORTAL is a singleton resource; its grants always target the whole type.
|
||||
String resourceId =
|
||||
request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId();
|
||||
ResourceGrant grant =
|
||||
accessService.grant(
|
||||
request.resourceType(),
|
||||
resourceId,
|
||||
request.principalType(),
|
||||
request.principalId(),
|
||||
permission,
|
||||
admin);
|
||||
return ResponseEntity.ok(toDto(grant));
|
||||
}
|
||||
|
||||
@DeleteMapping("/grants/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
accessService.revoke(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Grant revoked"));
|
||||
}
|
||||
|
||||
private Map<String, Object> toDto(ResourceGrant g) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", g.getId());
|
||||
m.put("resourceType", g.getResourceType());
|
||||
m.put("resourceId", g.getResourceId());
|
||||
m.put("principalType", g.getPrincipalType());
|
||||
m.put("principalId", g.getPrincipalId());
|
||||
m.put("permission", g.getPermission());
|
||||
m.put("createdAt", g.getCreatedAt());
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Request body for creating a grant. */
|
||||
public record GrantRequest(
|
||||
ResourceType resourceType,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId,
|
||||
AccessPermission permission) {}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Permission level a grant confers. MANAGE implies USE. */
|
||||
public enum AccessPermission {
|
||||
USE,
|
||||
MANAGE
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/**
|
||||
* Fallback policy applied when no explicit {@link ResourceGrant} matches. Admins (org owners)
|
||||
* always pass regardless of this policy.
|
||||
*/
|
||||
public enum DefaultAccessPolicy {
|
||||
// Every authenticated user in the deployment (org) may use the resource.
|
||||
ORG_ALL,
|
||||
// Only org admins and team leaders. This is the default for the portal.
|
||||
ADMINS_AND_TEAM_LEADS,
|
||||
// Nobody but the owner, admins, and explicit grantees.
|
||||
EXPLICIT_ONLY
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Base for a resource owned by a user, a team, or the server, with grant-based access. */
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
public abstract class OwnedResource {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "scope", nullable = false, length = 32)
|
||||
private OwnerScope scope;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_user_id")
|
||||
private User ownerUser;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_team_id")
|
||||
private Team ownerTeam;
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private boolean enabled = true;
|
||||
|
||||
// Server resource that users cannot override with their own of the same kind.
|
||||
@Column(name = "locked", nullable = false)
|
||||
private boolean locked = false;
|
||||
|
||||
// Who, besides owner/admin/grantees, may use this resource.
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "default_access", nullable = false, length = 32)
|
||||
private DefaultAccessPolicy defaultAccess = DefaultAccessPolicy.EXPLICIT_ONLY;
|
||||
|
||||
/** Subclass primary key. */
|
||||
public abstract Long getId();
|
||||
|
||||
public Long getOwnerUserId() {
|
||||
return ownerUser != null ? ownerUser.getId() : null;
|
||||
}
|
||||
|
||||
public Long getOwnerTeamId() {
|
||||
return ownerTeam != null ? ownerTeam.getId() : null;
|
||||
}
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Ownership scope of an {@link OwnedResource}: a single user, a team, or the whole server. */
|
||||
public enum OwnerScope {
|
||||
USER,
|
||||
TEAM,
|
||||
SERVER
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */
|
||||
public enum PrincipalType {
|
||||
USER,
|
||||
TEAM
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Grants a user or team access to a resource. Owner and admin access are implicit. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "resource_grants",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_resource_grant",
|
||||
columnNames = {
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission"
|
||||
}),
|
||||
indexes = {
|
||||
@Index(name = "idx_resource_grants_lookup", columnList = "resource_type,resource_id"),
|
||||
@Index(
|
||||
name = "idx_resource_grants_principal",
|
||||
columnList = "principal_type,principal_id")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ResourceGrant implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "resource_grant_id")
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "resource_type", nullable = false, length = 64)
|
||||
private ResourceType resourceType;
|
||||
|
||||
// Empty string (never null) for a whole-type grant such as the portal.
|
||||
@Column(name = "resource_id", nullable = false, length = 255)
|
||||
private String resourceId = "";
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "principal_type", nullable = false, length = 32)
|
||||
private PrincipalType principalType;
|
||||
|
||||
@Column(name = "principal_id", nullable = false)
|
||||
private Long principalId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "permission", nullable = false, length = 32)
|
||||
private AccessPermission permission;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "granted_by_user_id")
|
||||
private User grantedBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
|
||||
public enum ResourceType {
|
||||
// The admin portal / processor (frontend/portal). Singleton resource (empty resourceId).
|
||||
PORTAL,
|
||||
// A stored S3/MCP/API integration configuration.
|
||||
INTEGRATION_CONFIG
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package stirling.software.proprietary.access.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
|
||||
@Repository
|
||||
public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Long> {
|
||||
|
||||
List<ResourceGrant> findByResourceTypeAndResourceId(
|
||||
ResourceType resourceType, String resourceId);
|
||||
|
||||
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType, PrincipalType principalType, Long principalId);
|
||||
|
||||
void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId);
|
||||
|
||||
boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId);
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
package stirling.software.proprietary.access.security;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.access.service.ResourceAccessService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */
|
||||
@Component("resourceAccess")
|
||||
@RequiredArgsConstructor
|
||||
public class ResourceAccessSecurity {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
private final UserService userService;
|
||||
|
||||
public boolean canUsePortal() {
|
||||
User user = currentUser();
|
||||
return user != null && accessService.canAccessPortal(user);
|
||||
}
|
||||
|
||||
private User currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
if (principal instanceof UserDetails userDetails) {
|
||||
return userService.findByUsername(userDetails.getUsername()).orElse(null);
|
||||
}
|
||||
if (principal instanceof String username && !"anonymousUser".equals(username)) {
|
||||
return userService.findByUsername(username).orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** No-op {@link TeamLeadLookup}: always false. */
|
||||
public class DefaultTeamLeadLookup implements TeamLeadLookup {
|
||||
|
||||
@Override
|
||||
public boolean isAnyTeamLeader(User user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLeaderOfTeam(User user, Long teamId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.access.model.OwnedResource;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
/** Ownership and access checks for {@link OwnedResource}, backed by the resource-grant ACL. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class OwnershipService {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
private final TeamLeadLookup teamLeadLookup;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
/** Whether the user may use the resource. */
|
||||
public boolean canUse(ResourceType type, OwnedResource resource, User user) {
|
||||
if (!resource.isEnabled()) {
|
||||
return isAdmin(user) || isOwner(resource, user);
|
||||
}
|
||||
return accessService.canUseResource(
|
||||
type,
|
||||
String.valueOf(resource.getId()),
|
||||
resource.getOwnerUserId(),
|
||||
resource.getDefaultAccess(),
|
||||
user);
|
||||
}
|
||||
|
||||
/** Whether the user may manage the resource. */
|
||||
public boolean canManage(ResourceType type, OwnedResource resource, User user) {
|
||||
return accessService.canManageResource(
|
||||
type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizes the scope and assigns ownership; {@code lockedOverrideBlocks} guards USER scope.
|
||||
*/
|
||||
public void assignOwnership(
|
||||
OwnedResource resource,
|
||||
OwnerScope scope,
|
||||
Long teamId,
|
||||
User user,
|
||||
BooleanSupplier lockedOverrideBlocks) {
|
||||
resource.setScope(scope);
|
||||
switch (scope) {
|
||||
case USER -> {
|
||||
if (lockedOverrideBlocks.getAsBoolean() && !isAdmin(user)) {
|
||||
throw forbidden(
|
||||
"This is locked to the server configuration by an administrator");
|
||||
}
|
||||
resource.setOwnerUser(user);
|
||||
}
|
||||
case SERVER -> {
|
||||
if (!isAdmin(user)) {
|
||||
throw forbidden("Only administrators can create server-owned resources");
|
||||
}
|
||||
}
|
||||
case TEAM -> {
|
||||
if (teamId == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "ownerTeamId is required");
|
||||
}
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> notFound("Team not found"));
|
||||
if (!isAdmin(user) && !teamLeadLookup.isLeaderOfTeam(user, team.getId())) {
|
||||
throw forbidden("Only admins or team leaders can create team-owned resources");
|
||||
}
|
||||
resource.setOwnerTeam(team);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resource ids of the given type the user or their team holds a grant on. */
|
||||
public Set<String> grantedResourceIds(ResourceType type, User user) {
|
||||
return accessService.grantedResourceIds(type, user);
|
||||
}
|
||||
|
||||
public boolean isAdmin(User user) {
|
||||
return user.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
public boolean isOwner(OwnedResource resource, User user) {
|
||||
if (resource.getOwnerUserId() != null && resource.getOwnerUserId().equals(user.getId())) {
|
||||
return true;
|
||||
}
|
||||
// Team-owned: the lead of the owning team owns it.
|
||||
return resource.getOwnerTeamId() != null
|
||||
&& teamLeadLookup.isLeaderOfTeam(user, resource.getOwnerTeamId());
|
||||
}
|
||||
|
||||
private ResponseStatusException forbidden(String message) {
|
||||
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
|
||||
}
|
||||
|
||||
private ResponseStatusException notFound(String message) {
|
||||
return new ResponseStatusException(HttpStatus.NOT_FOUND, message);
|
||||
}
|
||||
}
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.access.model.AccessPermission;
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Resolves access to gated resources: owner, then admin, then grant, then default policy. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional(readOnly = true)
|
||||
public class ResourceAccessService {
|
||||
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
private final TeamLeadLookup teamLeadLookup;
|
||||
|
||||
@Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}")
|
||||
private DefaultAccessPolicy portalDefaultPolicy;
|
||||
|
||||
// ---- public checks ----
|
||||
|
||||
/** Whether the user may use the portal / processor. */
|
||||
public boolean canAccessPortal(User user) {
|
||||
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
|
||||
}
|
||||
|
||||
/** Whether the user may use a resource, falling back to its default policy. */
|
||||
public boolean canUseResource(
|
||||
ResourceType type,
|
||||
String resourceId,
|
||||
Long ownerUserId,
|
||||
DefaultAccessPolicy defaultPolicy,
|
||||
User user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner(ownerUserId, user) || isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) {
|
||||
return true;
|
||||
}
|
||||
return matchesDefault(defaultPolicy, user);
|
||||
}
|
||||
|
||||
/** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */
|
||||
public boolean canManageResource(
|
||||
ResourceType type, String resourceId, Long ownerUserId, User user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner(ownerUserId, user) || isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE);
|
||||
}
|
||||
|
||||
// ---- grant management ----
|
||||
|
||||
@Transactional
|
||||
public ResourceGrant grant(
|
||||
ResourceType type,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId,
|
||||
AccessPermission permission,
|
||||
User grantedBy) {
|
||||
String rid = normalize(resourceId);
|
||||
ResourceGrant grant =
|
||||
grantRepository.findByResourceTypeAndResourceId(type, rid).stream()
|
||||
.filter(
|
||||
g ->
|
||||
g.getPrincipalType() == principalType
|
||||
&& g.getPrincipalId().equals(principalId))
|
||||
.findFirst()
|
||||
.orElseGet(ResourceGrant::new);
|
||||
grant.setResourceType(type);
|
||||
grant.setResourceId(rid);
|
||||
grant.setPrincipalType(principalType);
|
||||
grant.setPrincipalId(principalId);
|
||||
grant.setPermission(permission);
|
||||
if (grantedBy != null) {
|
||||
grant.setGrantedBy(grantedBy);
|
||||
}
|
||||
return grantRepository.save(grant);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void revoke(Long grantId) {
|
||||
grantRepository.deleteById(grantId);
|
||||
}
|
||||
|
||||
public List<ResourceGrant> listGrants(ResourceType type, String resourceId) {
|
||||
return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId));
|
||||
}
|
||||
|
||||
/** Resource ids of the given type that this user (or their team) holds any grant on. */
|
||||
public Set<String> grantedResourceIds(ResourceType type, User user) {
|
||||
if (user == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
type, PrincipalType.USER, user.getId())) {
|
||||
ids.add(g.getResourceId());
|
||||
}
|
||||
if (user.getTeam() != null) {
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
type, PrincipalType.TEAM, user.getTeam().getId())) {
|
||||
ids.add(g.getResourceId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
private boolean hasGrant(
|
||||
ResourceType type, String resourceId, User user, AccessPermission required) {
|
||||
Long teamId = user.getTeam() != null ? user.getTeam().getId() : null;
|
||||
for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) {
|
||||
if (!permissionSatisfies(g.getPermission(), required)) {
|
||||
continue;
|
||||
}
|
||||
if (g.getPrincipalType() == PrincipalType.USER
|
||||
&& g.getPrincipalId().equals(user.getId())) {
|
||||
return true;
|
||||
}
|
||||
if (g.getPrincipalType() == PrincipalType.TEAM
|
||||
&& teamId != null
|
||||
&& g.getPrincipalId().equals(teamId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// MANAGE implies USE.
|
||||
private boolean permissionSatisfies(AccessPermission held, AccessPermission required) {
|
||||
if (required == AccessPermission.USE) {
|
||||
return held == AccessPermission.USE || held == AccessPermission.MANAGE;
|
||||
}
|
||||
return held == AccessPermission.MANAGE;
|
||||
}
|
||||
|
||||
private boolean matchesDefault(DefaultAccessPolicy policy, User user) {
|
||||
if (policy == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (policy) {
|
||||
case ORG_ALL -> true;
|
||||
// Admins already pass above; only team leads here.
|
||||
case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user);
|
||||
case EXPLICIT_ONLY -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isOwner(Long ownerUserId, User user) {
|
||||
return ownerUserId != null && ownerUserId.equals(user.getId());
|
||||
}
|
||||
|
||||
private boolean isAdmin(User user) {
|
||||
return user.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
private String normalize(String resourceId) {
|
||||
return resourceId == null ? "" : resourceId;
|
||||
}
|
||||
}
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Masks, merges and sanitizes secret values in a config map, recursing into nested maps/lists. */
|
||||
@Component
|
||||
public class SecretMasker {
|
||||
|
||||
public static final String MASK = "********";
|
||||
|
||||
// Cap recursion so a pathologically nested payload cannot overflow the stack.
|
||||
private static final int MAX_DEPTH = 32;
|
||||
|
||||
private static final Set<String> SENSITIVE_HINTS =
|
||||
Set.of(
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"credential",
|
||||
"privatekey");
|
||||
|
||||
/** Replace sensitive values with the mask (recursively) for safe display. */
|
||||
public Map<String, Object> mask(Map<String, Object> config) {
|
||||
return mask(config, 0);
|
||||
}
|
||||
|
||||
/** Drop sensitive blank/masked values from an incoming create payload. */
|
||||
public Map<String, Object> sanitize(Map<String, Object> config) {
|
||||
return sanitize(config, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an update over the stored map, keeping stored secrets where the incoming is redacted.
|
||||
*/
|
||||
public Map<String, Object> merge(Map<String, Object> stored, Map<String, Object> incoming) {
|
||||
return merge(stored, incoming, 0);
|
||||
}
|
||||
|
||||
private Map<String, Object> mask(Map<String, Object> config, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : config.entrySet()) {
|
||||
out.put(e.getKey(), maskValue(e.getKey(), e.getValue(), depth));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> sanitize(Map<String, Object> config, int depth) {
|
||||
if (config == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : config.entrySet()) {
|
||||
if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) {
|
||||
continue;
|
||||
}
|
||||
out.put(
|
||||
e.getKey(),
|
||||
e.getValue() instanceof Map<?, ?> m && depth < MAX_DEPTH
|
||||
? sanitize(castMap(m), depth + 1)
|
||||
: e.getValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> merge(
|
||||
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>(stored);
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
String key = e.getKey();
|
||||
Object value = e.getValue();
|
||||
if (isSensitive(key)) {
|
||||
if (!isRedacted(value, depth)) {
|
||||
out.put(key, value); // a real new secret replaces the stored one
|
||||
}
|
||||
continue; // redacted (blank / mask) -> keep stored
|
||||
}
|
||||
if (depth < MAX_DEPTH
|
||||
&& out.get(key) instanceof Map<?, ?> s
|
||||
&& value instanceof Map<?, ?> i) {
|
||||
out.put(key, merge(castMap(s), castMap(i), depth + 1));
|
||||
} else {
|
||||
out.put(key, value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A sensitive key masks its whole value; recurse into non-sensitive containers.
|
||||
private Object maskValue(String key, Object value, int depth) {
|
||||
if (isSensitive(key)) {
|
||||
if (value == null || (value instanceof String s && s.isBlank())) {
|
||||
return value;
|
||||
}
|
||||
return MASK;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (value instanceof Map<?, ?> m) {
|
||||
return mask(castMap(m), depth + 1);
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> out = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
out.add(item instanceof Map<?, ?> m ? mask(castMap(m), depth + 1) : item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private boolean isSensitive(String key) {
|
||||
String lower = key.toLowerCase(Locale.ROOT);
|
||||
return SENSITIVE_HINTS.stream().anyMatch(lower::contains);
|
||||
}
|
||||
|
||||
/** Blank, the mask placeholder, or any structure that still contains the mask. */
|
||||
private boolean isRedacted(Object value, int depth) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
if (value instanceof String s) {
|
||||
return s.isBlank() || MASK.equals(s);
|
||||
}
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
if (value instanceof Map<?, ?> m) {
|
||||
return m.values().stream().anyMatch(v -> isRedacted(v, depth + 1));
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
return list.stream().anyMatch(v -> isRedacted(v, depth + 1));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> castMap(Map<?, ?> map) {
|
||||
return (Map<String, Object>) map;
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Resolves whether a user leads a team. */
|
||||
public interface TeamLeadLookup {
|
||||
|
||||
/** Whether the user leads at least one team. */
|
||||
boolean isAnyTeamLeader(User user);
|
||||
|
||||
/** Whether the user leads the given team. */
|
||||
boolean isLeaderOfTeam(User user, Long teamId);
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
import stirling.software.proprietary.classification.model.TaxonomyValidator;
|
||||
import stirling.software.proprietary.classification.store.TaxonomyStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read/write the caller's team classification taxonomy — the vocabulary the document classifier
|
||||
* runs against. Team-scoped exactly like policies: every user reads their own team's taxonomy, and
|
||||
* only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
|
||||
* {@link PolicyManagementAuthority}) may change it. Editing is gated only when login is enabled;
|
||||
* single-user deployments trust the local operator. A team with no stored taxonomy reads as {@code
|
||||
* 204} and the classifier falls back to the engine's built-in default.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/classification/taxonomy")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Classification", description = "Team-scoped document-classification taxonomy")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class TaxonomyController {
|
||||
|
||||
private final TaxonomyStore taxonomyStore;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the team's classification taxonomy",
|
||||
description =
|
||||
"Returns the caller's team taxonomy, or 204 when the team has none (the"
|
||||
+ " classifier then uses the built-in default).")
|
||||
public ResponseEntity<ClassificationTaxonomy> getTaxonomy() {
|
||||
return taxonomyStore
|
||||
.findByTeam(currentTeamId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Save the team's classification taxonomy",
|
||||
description =
|
||||
"Validates and stores the taxonomy for the caller's team, shared by everyone on"
|
||||
+ " the team. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<ClassificationTaxonomy> saveTaxonomy(
|
||||
@RequestBody ClassificationTaxonomy taxonomy) {
|
||||
requireEditingAllowed();
|
||||
try {
|
||||
TaxonomyValidator.validate(taxonomy);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
ClassificationTaxonomy saved =
|
||||
taxonomyStore.save(currentTeamId(), taxonomy, currentUsername());
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Reset the team's classification taxonomy",
|
||||
description =
|
||||
"Removes the team's stored taxonomy so the classifier falls back to the built-in"
|
||||
+ " default. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<Void> resetTaxonomy() {
|
||||
requireEditingAllowed();
|
||||
taxonomyStore.deleteByTeam(currentTeamId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the taxonomy requires the editor role for the caller's team — the same gate policies
|
||||
* use (team leader on SaaS, global admin self-hosted). Single-user deployments (login disabled)
|
||||
* have no such role, so they trust the local operator.
|
||||
*/
|
||||
private void requireEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"The classification taxonomy may only be changed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
private Long currentTeamId() {
|
||||
return policyManagementAuthority.currentUserTeamId();
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
return userService == null ? null : userService.getCurrentUsername();
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The vocabulary a document is classified against — team-scoped and admin-editable. Its shape
|
||||
* mirrors the engine's {@code ClassificationTaxonomy} contract (categories owning doc_types, plus
|
||||
* free-standing cross-cutting tags), so a stored taxonomy is passed to the engine verbatim as the
|
||||
* per-request override. When a team has no stored taxonomy the engine falls back to its built-in
|
||||
* default.
|
||||
*/
|
||||
public record ClassificationTaxonomy(List<TaxonomyCategory> categories, List<String> tags) {
|
||||
|
||||
public ClassificationTaxonomy {
|
||||
categories = categories == null ? List.of() : List.copyOf(categories);
|
||||
tags = tags == null ? List.of() : List.copyOf(tags);
|
||||
}
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A structural family of documents, owning the doc_types shaped like it. {@code docTypes} is
|
||||
* serialized in the engine's camelCase shape (the engine's {@code ClassificationTaxonomy} model
|
||||
* aliases {@code doc_types} onto it), so a stored taxonomy passes straight through to the engine.
|
||||
*/
|
||||
public record TaxonomyCategory(String id, String label, List<TaxonomyDocumentType> docTypes) {
|
||||
|
||||
public TaxonomyCategory {
|
||||
docTypes = docTypes == null ? List.of() : List.copyOf(docTypes);
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
/**
|
||||
* A specific instrument within a category (e.g. {@code nda} under {@code contract}).
|
||||
* Category-scoped: the engine enforces that a doc_type can only apply to its owning category.
|
||||
*/
|
||||
public record TaxonomyDocumentType(String id, String label) {}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Structural validation for an admin-supplied (or imported) taxonomy, run before it is stored so a
|
||||
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
|
||||
* at least one category, non-blank ids/labels everywhere, ids unique among categories and among the
|
||||
* doc_types within a category, and non-blank unique tags.
|
||||
*/
|
||||
public final class TaxonomyValidator {
|
||||
|
||||
private TaxonomyValidator() {}
|
||||
|
||||
// Generous upper bounds so a legitimate taxonomy is never blocked, but a single team can't
|
||||
// store an unbounded blob that would bloat the row, balloon the classifier prompt, or exhaust
|
||||
// memory on deserialize.
|
||||
static final int MAX_CATEGORIES = 200;
|
||||
static final int MAX_DOC_TYPES_PER_CATEGORY = 200;
|
||||
static final int MAX_TAGS = 500;
|
||||
static final int MAX_TEXT_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a human-readable message when the taxonomy is invalid.
|
||||
*/
|
||||
public static void validate(ClassificationTaxonomy taxonomy) {
|
||||
if (taxonomy == null) {
|
||||
throw new IllegalArgumentException("Taxonomy is required");
|
||||
}
|
||||
if (taxonomy.categories().isEmpty()) {
|
||||
throw new IllegalArgumentException("Taxonomy must have at least one category");
|
||||
}
|
||||
if (taxonomy.categories().size() > MAX_CATEGORIES) {
|
||||
throw new IllegalArgumentException("Too many categories (max " + MAX_CATEGORIES + ")");
|
||||
}
|
||||
if (taxonomy.tags().size() > MAX_TAGS) {
|
||||
throw new IllegalArgumentException("Too many tags (max " + MAX_TAGS + ")");
|
||||
}
|
||||
Set<String> categoryIds = new HashSet<>();
|
||||
for (TaxonomyCategory category : taxonomy.categories()) {
|
||||
requireText(category.id(), "Category id");
|
||||
requireText(category.label(), "Category label");
|
||||
if (category.docTypes().size() > MAX_DOC_TYPES_PER_CATEGORY) {
|
||||
throw new IllegalArgumentException(
|
||||
"Too many sub-categories in '"
|
||||
+ category.id()
|
||||
+ "' (max "
|
||||
+ MAX_DOC_TYPES_PER_CATEGORY
|
||||
+ ")");
|
||||
}
|
||||
if (!categoryIds.add(category.id())) {
|
||||
throw new IllegalArgumentException("Duplicate category id: " + category.id());
|
||||
}
|
||||
Set<String> docTypeIds = new HashSet<>();
|
||||
for (TaxonomyDocumentType docType : category.docTypes()) {
|
||||
requireText(docType.id(), "Doc type id");
|
||||
requireText(docType.label(), "Doc type label");
|
||||
if (!docTypeIds.add(docType.id())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Duplicate doc type id '"
|
||||
+ docType.id()
|
||||
+ "' in category '"
|
||||
+ category.id()
|
||||
+ "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<String> tags = new HashSet<>();
|
||||
for (String tag : taxonomy.tags()) {
|
||||
requireText(tag, "Tag");
|
||||
if (!tags.add(tag)) {
|
||||
throw new IllegalArgumentException("Duplicate tag: " + tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
if (value.length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
/**
|
||||
* In-memory {@link TaxonomyStore} for tests and any future no-database mode. {@link
|
||||
* JpaTaxonomyStore} is the runtime bean.
|
||||
*/
|
||||
public class InProcessTaxonomyStore implements TaxonomyStore {
|
||||
|
||||
private final Map<Long, ClassificationTaxonomy> byTeam = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
|
||||
return Optional.ofNullable(byTeam.get(key(teamId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationTaxonomy save(
|
||||
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
|
||||
byTeam.put(key(teamId), taxonomy);
|
||||
return taxonomy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
return byTeam.remove(key(teamId)) != null;
|
||||
}
|
||||
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link TaxonomyStore} backed by JPA; the runtime store. Gated on {@code policies.enabled}
|
||||
* — a team taxonomy only matters when the Classification policy can run — so it shares the policy
|
||||
* subsystem's on/off switch. The taxonomy is persisted as JSON via {@link TaxonomyEntity}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaTaxonomyStore implements TaxonomyStore {
|
||||
|
||||
private final TaxonomyRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
|
||||
Optional<TaxonomyEntity> entity = repository.findById(key(teamId));
|
||||
if (entity.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(
|
||||
objectMapper.readValue(
|
||||
entity.get().getTaxonomyJson(), ClassificationTaxonomy.class));
|
||||
} catch (JacksonException e) {
|
||||
// A stored taxonomy that no longer parses (corruption / manual DB edit) must not break
|
||||
// classification: drop it so the caller falls back to the built-in default rather than
|
||||
// surfacing a 500 on every upload for the team.
|
||||
log.warn(
|
||||
"Discarding unparseable stored taxonomy for team {}: {}",
|
||||
teamId,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationTaxonomy save(
|
||||
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
|
||||
TaxonomyEntity entity = new TaxonomyEntity();
|
||||
entity.setTeamId(key(teamId));
|
||||
entity.setTaxonomyJson(objectMapper.writeValueAsString(taxonomy));
|
||||
entity.setUpdatedAt(Instant.now());
|
||||
entity.setUpdatedBy(updatedBy);
|
||||
repository.save(entity);
|
||||
return taxonomy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
long id = key(teamId);
|
||||
if (!repository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
repository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a team's classification taxonomy — one row per team. The taxonomy lives as JSON in
|
||||
* {@code taxonomyJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
|
||||
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
|
||||
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
|
||||
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
|
||||
* not a foreign key — so classification can be enabled or disabled without touching them.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "classification_taxonomies")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class TaxonomyEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Sentinel key for the unteamed taxonomy (login disabled / no resolvable team). */
|
||||
public static final long NO_TEAM = 0L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private long teamId;
|
||||
|
||||
@Column(name = "taxonomy_json", columnDefinition = "text")
|
||||
private String taxonomyJson;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "updated_by")
|
||||
private String updatedBy;
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TaxonomyRepository extends JpaRepository<TaxonomyEntity, Long> {}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
/**
|
||||
* Stores one {@link ClassificationTaxonomy} per team. A {@code null} teamId addresses the unteamed
|
||||
* taxonomy (login disabled / no resolvable team), mirroring how the policy store treats a null
|
||||
* team.
|
||||
*/
|
||||
public interface TaxonomyStore {
|
||||
|
||||
/** The team's stored taxonomy, or empty when it has none (callers fall back to the default). */
|
||||
Optional<ClassificationTaxonomy> findByTeam(Long teamId);
|
||||
|
||||
/** Create or replace the team's taxonomy. Returns the stored value. */
|
||||
ClassificationTaxonomy save(Long teamId, ClassificationTaxonomy taxonomy, String updatedBy);
|
||||
|
||||
/** Remove the team's taxonomy (reset to default). Returns whether one existed. */
|
||||
boolean deleteByTeam(Long teamId);
|
||||
}
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.classification.store.TaxonomyStore;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
|
||||
*
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document, and stores the engine's JSON answer — minus the transport-only
|
||||
* {@code outcome} field — in the custom Info-dictionary key {@link
|
||||
* PdfMetadataService#CLASSIFICATION_KEY}. Returns the tagged PDF. Not intended for direct client
|
||||
* use.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class ClassifyTagController {
|
||||
|
||||
/** Pages read from each end of the document — mirrors the engine's window. */
|
||||
private static final int WINDOW_PAGES = 2;
|
||||
|
||||
private static final String CLASSIFY_ENDPOINT = "/api/v1/documents/classify";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final PdfMetadataService pdfMetadataService;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
|
||||
* team authority are gated on it. Null otherwise, in which case classification falls back to
|
||||
* the engine's built-in default taxonomy.
|
||||
*/
|
||||
private final TaxonomyStore taxonomyStore;
|
||||
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
public ClassifyTagController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager,
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
ObjectMapper objectMapper,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) TaxonomyStore taxonomyStore,
|
||||
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.userService = userService;
|
||||
this.taxonomyStore = taxonomyStore;
|
||||
this.policyManagementAuthority = policyManagementAuthority;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-tag", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Classify a PDF and tag its metadata",
|
||||
description =
|
||||
"Reads the first two and last two pages, classifies the document via the AI"
|
||||
+ " engine, and stores the result in the StirlingPDFClassification"
|
||||
+ " metadata field. Dispatched by the Classification policy; not"
|
||||
+ " intended for direct client use.")
|
||||
public ResponseEntity<Resource> classifyAndTag(
|
||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
|
||||
List<AiPageText> pages = extractWindow(document);
|
||||
String requestBody =
|
||||
objectMapper.writeValueAsString(
|
||||
new ClassifyEngineRequest(fileName, pages, resolveTaxonomyOverride()));
|
||||
|
||||
String userId = userService != null ? userService.getCurrentUsername() : null;
|
||||
String responseJson = aiEngineClient.post(CLASSIFY_ENDPOINT, requestBody, userId);
|
||||
|
||||
pdfMetadataService.setClassificationMetadata(document, toMetadataValue(responseJson));
|
||||
log.debug("[classify-and-tag] tagged {} ({} window pages)", fileName, pages.size());
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
}
|
||||
|
||||
private List<AiPageText> extractWindow(PDDocument document) throws IOException {
|
||||
List<AiPageText> pages = new ArrayList<>();
|
||||
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
|
||||
String text = pdfContentExtractor.extractPageTextRaw(document, pageNumber);
|
||||
if (text != null && !text.isBlank()) {
|
||||
pages.add(new AiPageText(pageNumber, text));
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** First and last {@code window} page numbers (1-based), de-duplicated and in order. */
|
||||
static List<Integer> windowPageNumbers(int pageCount, int window) {
|
||||
Set<Integer> numbers = new LinkedHashSet<>();
|
||||
for (int page = 1; page <= Math.min(window, pageCount); page++) {
|
||||
numbers.add(page);
|
||||
}
|
||||
for (int page = Math.max(1, pageCount - window + 1); page <= pageCount; page++) {
|
||||
numbers.add(page);
|
||||
}
|
||||
return new ArrayList<>(numbers);
|
||||
}
|
||||
|
||||
/** Drop the transport-only {@code outcome} discriminator; keep the rest verbatim. */
|
||||
private String toMetadataValue(String engineResponseJson) {
|
||||
JsonNode node = objectMapper.readTree(engineResponseJson);
|
||||
if (node instanceof ObjectNode object) {
|
||||
object.remove("outcome");
|
||||
}
|
||||
return objectMapper.writeValueAsString(node);
|
||||
}
|
||||
|
||||
private static String safeFileName(String originalFilename) {
|
||||
String name = Filenames.toSimpleFileName(originalFilename);
|
||||
return (name == null || name.isBlank()) ? "classified.pdf" : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's team taxonomy and return it in the engine's shape to classify against;
|
||||
* {@code null} falls back to the engine's generated default. The stored taxonomy is already in
|
||||
* the engine's camelCase shape ({@code categories}/{@code docTypes}/{@code tags}), so it is
|
||||
* passed through verbatim. Returns null when the policy subsystem is disabled (no store), when
|
||||
* the team has no stored taxonomy, or when the team can't be resolved.
|
||||
*/
|
||||
private JsonNode resolveTaxonomyOverride() {
|
||||
if (taxonomyStore == null) {
|
||||
return null;
|
||||
}
|
||||
Long teamId =
|
||||
policyManagementAuthority == null
|
||||
? null
|
||||
: policyManagementAuthority.currentUserTeamId();
|
||||
return taxonomyStore
|
||||
.findByTeam(teamId)
|
||||
.map(taxonomy -> (JsonNode) objectMapper.valueToTree(taxonomy))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** Request body for the engine's {@code /api/v1/documents/classify} endpoint. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record ClassifyEngineRequest(
|
||||
String fileName, List<AiPageText> pages, JsonNode taxonomy) {}
|
||||
}
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
package stirling.software.proprietary.integration.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigRequest;
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigResponse;
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** CRUD for S3/MCP/API integration configs. Secrets are never returned. */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/integrations")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Tag(name = "Integrations", description = "Manage S3/MCP/API integration configurations")
|
||||
public class IntegrationConfigController {
|
||||
|
||||
private final IntegrationConfigService service;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<IntegrationConfigResponse>> list(
|
||||
@AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(
|
||||
service.listVisible(user).stream().map(c -> service.toResponse(c, user)).toList());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<IntegrationConfigResponse> create(
|
||||
@RequestBody IntegrationConfigRequest request, @AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(service.toResponse(service.create(request, user), user));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<IntegrationConfigResponse> get(
|
||||
@PathVariable Long id, @AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(service.toResponse(service.getForUse(id, user), user));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<IntegrationConfigResponse> update(
|
||||
@PathVariable Long id,
|
||||
@RequestBody IntegrationConfigRequest request,
|
||||
@AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(service.toResponse(service.update(id, request, user), user));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id, @AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
service.delete(id, user);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private void requireUser(User user) {
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
}
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
/**
|
||||
* AES-256-GCM for stored credentials. Key from property, env var, or an auto-generated key file.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class CredentialEncryption {
|
||||
|
||||
private static final String ALGORITHM = "AES";
|
||||
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
||||
private static final int GCM_TAG_BITS = 128;
|
||||
private static final int IV_BYTES = 12;
|
||||
private static final String KEY_FILE = "credential-encryption.key";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private static volatile SecretKey key;
|
||||
|
||||
private final String configuredKey;
|
||||
|
||||
public CredentialEncryption(
|
||||
@Value("${stirling.security.credentialEncryptionKey:}") String configuredKey) {
|
||||
this.configuredKey = configuredKey;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
key = resolveKey();
|
||||
log.info("Credential encryption initialised (AES-256-GCM)");
|
||||
}
|
||||
|
||||
private SecretKey resolveKey() {
|
||||
String configured = configuredKey;
|
||||
if (configured == null || configured.isBlank()) {
|
||||
configured = System.getenv("STIRLING_CREDENTIAL_ENCRYPTION_KEY");
|
||||
}
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
return new SecretKeySpec(Base64.getDecoder().decode(configured.trim()), ALGORITHM);
|
||||
}
|
||||
return loadOrCreateKeyFile();
|
||||
}
|
||||
|
||||
private SecretKey loadOrCreateKeyFile() {
|
||||
Path path = Path.of(InstallationPathConfig.getConfigPath(), KEY_FILE);
|
||||
try {
|
||||
if (Files.exists(path)) {
|
||||
String encoded = Files.readString(path).trim();
|
||||
return new SecretKeySpec(Base64.getDecoder().decode(encoded), ALGORITHM);
|
||||
}
|
||||
KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
|
||||
generator.init(256);
|
||||
SecretKey generated = generator.generateKey();
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, Base64.getEncoder().encodeToString(generated.getEncoded()));
|
||||
log.warn(
|
||||
"Generated a new credential encryption key at {}. Back this file up: losing it"
|
||||
+ " makes stored integration secrets unrecoverable.",
|
||||
path);
|
||||
return generated;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Unable to initialise credential encryption key", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String encrypt(String plaintext) {
|
||||
if (plaintext == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] iv = new byte[IV_BYTES];
|
||||
RANDOM.nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] combined = new byte[iv.length + ciphertext.length];
|
||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("Failed to encrypt credential", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String decrypt(String stored) {
|
||||
if (stored == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] combined = Base64.getDecoder().decode(stored);
|
||||
byte[] iv = Arrays.copyOfRange(combined, 0, IV_BYTES);
|
||||
byte[] ciphertext = Arrays.copyOfRange(combined, IV_BYTES, combined.length);
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("Failed to decrypt credential", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey requireKey() {
|
||||
SecretKey current = key;
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("Credential encryption not initialised");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
static void initialiseForTesting(SecretKey testKey) {
|
||||
key = testKey;
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
/** Transparently encrypts/decrypts a string column at rest via {@link CredentialEncryption}. */
|
||||
@Converter
|
||||
public class EncryptedStringConverter implements AttributeConverter<String, String> {
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(String attribute) {
|
||||
return CredentialEncryption.encrypt(attribute);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToEntityAttribute(String dbData) {
|
||||
return CredentialEncryption.decrypt(dbData);
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package stirling.software.proprietary.integration.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/** Create/update payload for an integration config. Sensitive config values left blank are kept. */
|
||||
public record IntegrationConfigRequest(
|
||||
IntegrationType integrationType,
|
||||
String name,
|
||||
OwnerScope scope,
|
||||
Long ownerTeamId,
|
||||
Boolean enabled,
|
||||
Boolean locked,
|
||||
DefaultAccessPolicy defaultAccess,
|
||||
Map<String, Object> config) {}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package stirling.software.proprietary.integration.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/** Integration config view. Sensitive config values are masked. */
|
||||
public record IntegrationConfigResponse(
|
||||
Long id,
|
||||
IntegrationType integrationType,
|
||||
String name,
|
||||
OwnerScope scope,
|
||||
Long ownerUserId,
|
||||
Long ownerTeamId,
|
||||
boolean enabled,
|
||||
boolean locked,
|
||||
DefaultAccessPolicy defaultAccess,
|
||||
Map<String, Object> config,
|
||||
boolean canManage,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt) {}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
package stirling.software.proprietary.integration.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.access.model.OwnedResource;
|
||||
import stirling.software.proprietary.integration.crypto.EncryptedStringConverter;
|
||||
|
||||
/** A named S3/MCP/API integration config; scope and ownership live on {@link OwnedResource}. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "integration_configs",
|
||||
indexes = {
|
||||
@Index(name = "idx_integration_configs_owner", columnList = "owner_user_id"),
|
||||
@Index(name = "idx_integration_configs_type", columnList = "integration_type"),
|
||||
@Index(name = "idx_integration_configs_scope", columnList = "scope")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class IntegrationConfig extends OwnedResource implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "integration_config_id")
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "integration_type", nullable = false, length = 32)
|
||||
private IntegrationType integrationType;
|
||||
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
// Type-specific fields as an AES-GCM encrypted JSON blob.
|
||||
@Convert(converter = EncryptedStringConverter.class)
|
||||
@Column(name = "config_encrypted", columnDefinition = "text")
|
||||
private String config;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package stirling.software.proprietary.integration.model;
|
||||
|
||||
/** Kind of external integration a stored config describes. */
|
||||
public enum IntegrationType {
|
||||
S3,
|
||||
MCP,
|
||||
API
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package stirling.software.proprietary.integration.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Repository
|
||||
public interface IntegrationConfigRepository extends JpaRepository<IntegrationConfig, Long> {
|
||||
|
||||
List<IntegrationConfig> findByOwnerUser(User ownerUser);
|
||||
|
||||
List<IntegrationConfig> findByOwnerTeam(Team ownerTeam);
|
||||
|
||||
List<IntegrationConfig> findByScope(OwnerScope scope);
|
||||
}
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.access.service.SecretMasker;
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigRequest;
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigResponse;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** CRUD for {@link IntegrationConfig}; delegates ownership and masking to shared services. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional(readOnly = true)
|
||||
public class IntegrationConfigService {
|
||||
|
||||
private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG;
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final IntegrationConfigRepository repository;
|
||||
private final OwnershipService ownership;
|
||||
private final SecretMasker secretMasker;
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
@Transactional
|
||||
public IntegrationConfig create(IntegrationConfigRequest request, User currentUser) {
|
||||
OwnerScope scope = request.scope() == null ? OwnerScope.USER : request.scope();
|
||||
IntegrationConfig cfg = new IntegrationConfig();
|
||||
cfg.setIntegrationType(require(request.integrationType(), "integrationType"));
|
||||
cfg.setName(require(request.name(), "name"));
|
||||
cfg.setEnabled(request.enabled() == null || request.enabled());
|
||||
cfg.setLocked(request.locked() != null && request.locked());
|
||||
cfg.setDefaultAccess(
|
||||
request.defaultAccess() == null
|
||||
? DefaultAccessPolicy.EXPLICIT_ONLY
|
||||
: request.defaultAccess());
|
||||
|
||||
ownership.assignOwnership(
|
||||
cfg,
|
||||
scope,
|
||||
request.ownerTeamId(),
|
||||
currentUser,
|
||||
() -> lockedServerExists(cfg.getIntegrationType()));
|
||||
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IntegrationConfig update(Long id, IntegrationConfigRequest request, User currentUser) {
|
||||
IntegrationConfig cfg = load(id);
|
||||
if (!ownership.canManage(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot manage this integration");
|
||||
}
|
||||
if (cfg.isLocked() && !ownership.isAdmin(currentUser)) {
|
||||
throw forbidden("This integration is locked by an administrator");
|
||||
}
|
||||
if (request.name() != null) {
|
||||
cfg.setName(request.name());
|
||||
}
|
||||
if (request.enabled() != null) {
|
||||
cfg.setEnabled(request.enabled());
|
||||
}
|
||||
if (request.locked() != null && request.locked() != cfg.isLocked()) {
|
||||
if (!ownership.isAdmin(currentUser)) {
|
||||
throw forbidden("Only administrators can change the locked flag");
|
||||
}
|
||||
cfg.setLocked(request.locked());
|
||||
}
|
||||
if (request.defaultAccess() != null) {
|
||||
cfg.setDefaultAccess(request.defaultAccess());
|
||||
}
|
||||
if (request.config() != null) {
|
||||
cfg.setConfig(
|
||||
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
|
||||
}
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, User currentUser) {
|
||||
IntegrationConfig cfg = load(id);
|
||||
if (!ownership.canManage(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot manage this integration");
|
||||
}
|
||||
repository.delete(cfg);
|
||||
}
|
||||
|
||||
// ---- queries ----
|
||||
|
||||
public IntegrationConfig getForUse(Long id, User currentUser) {
|
||||
IntegrationConfig cfg = load(id);
|
||||
if (!ownership.canUse(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot access this integration");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* All configs the user owns, plus server/team configs and grant-shared configs they may use.
|
||||
*/
|
||||
public List<IntegrationConfig> listVisible(User currentUser) {
|
||||
Map<Long, IntegrationConfig> byId = new LinkedHashMap<>();
|
||||
for (IntegrationConfig c : repository.findByOwnerUser(currentUser)) {
|
||||
byId.put(c.getId(), c);
|
||||
}
|
||||
for (IntegrationConfig c : repository.findByScope(OwnerScope.SERVER)) {
|
||||
if (ownership.canUse(TYPE, c, currentUser)) {
|
||||
byId.put(c.getId(), c);
|
||||
}
|
||||
}
|
||||
if (currentUser.getTeam() != null) {
|
||||
for (IntegrationConfig c : repository.findByOwnerTeam(currentUser.getTeam())) {
|
||||
if (ownership.canUse(TYPE, c, currentUser)) {
|
||||
byId.put(c.getId(), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String rid : ownership.grantedResourceIds(TYPE, currentUser)) {
|
||||
if (rid == null || rid.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
Long cid;
|
||||
try {
|
||||
cid = Long.valueOf(rid);
|
||||
} catch (NumberFormatException e) {
|
||||
continue;
|
||||
}
|
||||
if (byId.containsKey(cid)) {
|
||||
continue;
|
||||
}
|
||||
repository
|
||||
.findById(cid)
|
||||
.filter(c -> ownership.canUse(TYPE, c, currentUser))
|
||||
.ifPresent(c -> byId.put(c.getId(), c));
|
||||
}
|
||||
return new ArrayList<>(byId.values());
|
||||
}
|
||||
|
||||
public IntegrationConfigResponse toResponse(IntegrationConfig cfg, User user) {
|
||||
return new IntegrationConfigResponse(
|
||||
cfg.getId(),
|
||||
cfg.getIntegrationType(),
|
||||
cfg.getName(),
|
||||
cfg.getScope(),
|
||||
cfg.getOwnerUserId(),
|
||||
cfg.getOwnerTeamId(),
|
||||
cfg.isEnabled(),
|
||||
cfg.isLocked(),
|
||||
cfg.getDefaultAccess(),
|
||||
secretMasker.mask(readJson(cfg.getConfig())),
|
||||
ownership.canManage(TYPE, cfg, user),
|
||||
cfg.getCreatedAt(),
|
||||
cfg.getUpdatedAt());
|
||||
}
|
||||
|
||||
// ---- integration-specific glue ----
|
||||
|
||||
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
|
||||
private boolean lockedServerExists(IntegrationType type) {
|
||||
return repository.findByScope(OwnerScope.SERVER).stream()
|
||||
.anyMatch(c -> c.getIntegrationType() == type && c.isLocked());
|
||||
}
|
||||
|
||||
private IntegrationConfig load(Long id) {
|
||||
return repository
|
||||
.findById(id)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "Integration not found"));
|
||||
}
|
||||
|
||||
private String writeJson(Map<String, Object> config) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(config == null ? Map.of() : config);
|
||||
} catch (Exception e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid config payload");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> readJson(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json, new TypeReference<LinkedHashMap<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse integration config JSON", e);
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T require(T value, String field) {
|
||||
if (value == null || (value instanceof String s && s.isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private ResponseStatusException forbidden(String message) {
|
||||
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
|
||||
}
|
||||
}
|
||||
-7
@@ -21,11 +21,4 @@ public class AiWorkflowResultFile {
|
||||
|
||||
@Schema(description = "MIME type of the file", example = "application/pdf")
|
||||
private String contentType;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Index into the request's fileInputs that this output was derived from, or null"
|
||||
+ " when it has no single source (e.g. a merge, or a generated file)."
|
||||
+ " Lets the client replace that input in place as a new version.")
|
||||
private Integer sourceIndex;
|
||||
}
|
||||
|
||||
+3
-7
@@ -8,11 +8,7 @@ import tools.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
|
||||
* {@code origins} is parallel to {@code files}: each entry is the index into the original pipeline
|
||||
* inputs that the output traces back to, or {@code null} when it has no single source (e.g. a merge
|
||||
* combining several inputs, or a generated file). Callers use it to map an output back onto the
|
||||
* file it came from. {@code report}/{@code reportTool} carry the last step's structured report and
|
||||
* its operation, or null if no step produced one.
|
||||
* {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
|
||||
* null if no step produced one.
|
||||
*/
|
||||
public record PolicyExecutionResult(
|
||||
List<Resource> files, List<Integer> origins, JsonNode report, String reportTool) {}
|
||||
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
|
||||
|
||||
+9
-40
@@ -58,11 +58,6 @@ public class PolicyExecutor {
|
||||
// payload the tool surfaced alongside or instead of a file.
|
||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||
|
||||
// A step's output files paired with each file's origin (the index into the original pipeline
|
||||
// inputs it traces back to, or null when it has no single source). Origins compose across steps
|
||||
// so the final result can be mapped back onto the files that entered the pipeline.
|
||||
private record StepOutput(List<Resource> files, List<Integer> origins, JsonNode report) {}
|
||||
|
||||
/**
|
||||
* Run every step in order, feeding each step's output into the next. Supporting files in {@code
|
||||
* inputs} bind to named file fields and never enter the document stream.
|
||||
@@ -80,12 +75,6 @@ public class PolicyExecutor {
|
||||
|
||||
List<Resource> currentFiles = inputs.primary();
|
||||
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
|
||||
// Seed each input with its own index as origin; steps carry these through so the final
|
||||
// outputs can be traced back to the files that entered the pipeline.
|
||||
List<Integer> currentOrigins = new ArrayList<>();
|
||||
for (int k = 0; k < currentFiles.size(); k++) {
|
||||
currentOrigins.add(k);
|
||||
}
|
||||
// Last non-null report wins: the terminal step defines the output.
|
||||
JsonNode lastReport = null;
|
||||
String lastReportTool = null;
|
||||
@@ -98,10 +87,8 @@ public class PolicyExecutor {
|
||||
"Pipeline step " + (i + 1) + " has no operation");
|
||||
}
|
||||
listener.onStepStart(i + 1, steps.size(), operation);
|
||||
StepOutput stepResult =
|
||||
executeStep(step, currentFiles, currentOrigins, supportingFiles);
|
||||
ToolResult stepResult = executeStep(step, currentFiles, supportingFiles);
|
||||
currentFiles = stepResult.files();
|
||||
currentOrigins = stepResult.origins();
|
||||
if (stepResult.report() != null) {
|
||||
lastReport = stepResult.report();
|
||||
lastReportTool = operation;
|
||||
@@ -109,7 +96,7 @@ public class PolicyExecutor {
|
||||
listener.onStepComplete(i + 1, steps.size(), operation);
|
||||
}
|
||||
|
||||
return new PolicyExecutionResult(currentFiles, currentOrigins, lastReport, lastReportTool);
|
||||
return new PolicyExecutionResult(currentFiles, lastReport, lastReportTool);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,50 +104,32 @@ public class PolicyExecutor {
|
||||
* responses are unpacked so each inner file is its own result (e.g. split). For per-file
|
||||
* dispatch the first non-null report wins.
|
||||
*/
|
||||
private StepOutput executeStep(
|
||||
private ToolResult executeStep(
|
||||
PipelineStep step,
|
||||
List<Resource> inputFiles,
|
||||
List<Integer> inputOrigins,
|
||||
Map<String, List<Resource>> supportingFiles)
|
||||
throws IOException {
|
||||
requireAcceptedTypes(step.operation(), inputFiles);
|
||||
List<Resource> files = new ArrayList<>();
|
||||
List<Integer> origins = new ArrayList<>();
|
||||
JsonNode report = null;
|
||||
if (toolMetadataService.isMultiInput(step.operation())) {
|
||||
// One call over all inputs. The outputs derive from a single input only when exactly
|
||||
// one entered; otherwise (a genuine merge) there is no single source.
|
||||
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
|
||||
Integer origin = inputOrigins.size() == 1 ? inputOrigins.get(0) : null;
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(origin);
|
||||
}
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else if (inputFiles.isEmpty()) {
|
||||
ToolResult r = callEndpoint(step, List.of(), supportingFiles);
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(null);
|
||||
}
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else {
|
||||
// One call per file: every output of this call inherits that input's origin, so a 1:1
|
||||
// op keeps its chain and a split (one input, many outputs) tags each output with the
|
||||
// same source.
|
||||
for (int k = 0; k < inputFiles.size(); k++) {
|
||||
Integer origin = inputOrigins.get(k);
|
||||
ToolResult r = callEndpoint(step, List.of(inputFiles.get(k)), supportingFiles);
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(origin);
|
||||
}
|
||||
for (Resource file : inputFiles) {
|
||||
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
|
||||
files.addAll(r.files());
|
||||
if (report == null) {
|
||||
report = r.report();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new StepOutput(files, origins, report);
|
||||
return new ToolResult(files, report);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-11
@@ -21,7 +21,6 @@ import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
|
||||
/**
|
||||
@@ -38,7 +37,6 @@ public class PolicyRunner {
|
||||
private final PolicyEngine policyEngine;
|
||||
private final List<InputSource> inputSources;
|
||||
private final SourceStore sourceStore;
|
||||
private final SourceDocCounter docCounter;
|
||||
|
||||
/**
|
||||
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
|
||||
@@ -67,7 +65,7 @@ public class PolicyRunner {
|
||||
policy.id());
|
||||
continue;
|
||||
}
|
||||
runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec()));
|
||||
runIds.addAll(pullAndRun(policy, source.toInputSpec()));
|
||||
}
|
||||
return runIds;
|
||||
}
|
||||
@@ -84,11 +82,7 @@ public class PolicyRunner {
|
||||
return policyEngine.submit(definition, inputs, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the source and starts a run per unit; records how many documents the source fed and
|
||||
* returns the ids of the runs started.
|
||||
*/
|
||||
private List<String> pullAndRun(Policy policy, String sourceId, InputSpec spec) {
|
||||
private List<String> pullAndRun(Policy policy, InputSpec spec) {
|
||||
InputSource source = sourceFor(spec);
|
||||
if (source == null) {
|
||||
log.warn(
|
||||
@@ -109,12 +103,9 @@ public class PolicyRunner {
|
||||
return List.of();
|
||||
}
|
||||
List<String> runIds = new ArrayList<>();
|
||||
long docsFed = 0;
|
||||
for (ResolvedInput unit : work) {
|
||||
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
|
||||
docsFed += unit.inputs().primary().size();
|
||||
}
|
||||
docCounter.record(sourceId, docsFed);
|
||||
return runIds;
|
||||
}
|
||||
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
/**
|
||||
* Per-source document throughput for the overview row: how many documents a source has fed into
|
||||
* runs in total and over the trailing 24-hour and 30-day windows. Counts documents <em>fed</em>
|
||||
* (picked up by a run), so a snapshot-mode source that re-reads the same files each run counts them
|
||||
* per run.
|
||||
*/
|
||||
public record DocStats(long total, long last24h, long last30d) {
|
||||
|
||||
/** Number of trailing daily buckets in a source's daily series. */
|
||||
public static final int DAYS = 30;
|
||||
|
||||
public static final DocStats ZERO = new DocStats(0, 0, 0);
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* In-memory {@link SourceDocCounter} for tests and any future no-database mode. Holds hourly
|
||||
* buckets per source; the clock is injectable so window boundaries can be exercised
|
||||
* deterministically. {@link JpaSourceDocCounter} is the runtime bean.
|
||||
*/
|
||||
public class InProcessSourceDocCounter implements SourceDocCounter {
|
||||
|
||||
private final Supplier<Instant> clock;
|
||||
private final Map<String, Map<Long, Long>> bucketsBySource = new ConcurrentHashMap<>();
|
||||
|
||||
public InProcessSourceDocCounter() {
|
||||
this(Instant::now);
|
||||
}
|
||||
|
||||
public InProcessSourceDocCounter(Supplier<Instant> clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(String sourceId, long docs) {
|
||||
if (docs <= 0) {
|
||||
return;
|
||||
}
|
||||
bucketsBySource
|
||||
.computeIfAbsent(sourceId, key -> new ConcurrentHashMap<>())
|
||||
.merge(currentHour(), docs, Long::sum);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, DocStats> statsFor(Collection<String> sourceIds) {
|
||||
long now = currentHour();
|
||||
Map<String, DocStats> stats = new HashMap<>();
|
||||
for (String id : sourceIds) {
|
||||
Map<Long, Long> buckets = bucketsBySource.getOrDefault(id, Map.of());
|
||||
long total = buckets.values().stream().mapToLong(Long::longValue).sum();
|
||||
long last24h =
|
||||
SourceDocWindows.sumSince(buckets, now - (SourceDocWindows.HOURS_IN_24H - 1));
|
||||
long last30d = SourceDocWindows.sumSince(buckets, SourceDocWindows.firstDayHour(now));
|
||||
stats.put(id, new DocStats(total, last24h, last30d));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> dailySeriesFor(String sourceId) {
|
||||
long now = currentHour();
|
||||
Map<Long, Long> buckets = bucketsBySource.getOrDefault(sourceId, Map.of());
|
||||
return SourceDocWindows.series(SourceDocWindows.byDay(buckets), now / 24);
|
||||
}
|
||||
|
||||
private long currentHour() {
|
||||
return clock.get().getEpochSecond() / 3600;
|
||||
}
|
||||
}
|
||||
-154
@@ -1,154 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Durable {@link SourceDocCounter}; the runtime bean. {@code record} keeps two things in step: an
|
||||
* hourly bucket ({@link SourceDocCountEntity}) that feeds the rolling 24h / 30d / daily-series
|
||||
* windows, and a denormalized lifetime total ({@link SourceDocTotalEntity}) read directly for the
|
||||
* all-time figure. The lifetime counter means the overview never scans a source's whole bucket
|
||||
* history, and lets {@link #pruneOldBuckets()} retire buckets past the 30-day window so the hourly
|
||||
* table stays bounded (~one row per source per active hour, for at most 30 days).
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaSourceDocCounter implements SourceDocCounter {
|
||||
|
||||
private final SourceDocCountRepository countRepository;
|
||||
private final SourceDocTotalRepository totalRepository;
|
||||
private final Supplier<Instant> clock;
|
||||
|
||||
@Autowired
|
||||
public JpaSourceDocCounter(
|
||||
SourceDocCountRepository countRepository, SourceDocTotalRepository totalRepository) {
|
||||
this(countRepository, totalRepository, Instant::now);
|
||||
}
|
||||
|
||||
// Clock seam so tests can pin "now"; the runtime bean uses the wall clock above.
|
||||
JpaSourceDocCounter(
|
||||
SourceDocCountRepository countRepository,
|
||||
SourceDocTotalRepository totalRepository,
|
||||
Supplier<Instant> clock) {
|
||||
this.countRepository = countRepository;
|
||||
this.totalRepository = totalRepository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(String sourceId, long docs) {
|
||||
if (docs <= 0) {
|
||||
return;
|
||||
}
|
||||
long bucketHour = currentHour();
|
||||
upsert(
|
||||
() -> totalRepository.increment(sourceId, docs),
|
||||
() -> totalRepository.saveAndFlush(new SourceDocTotalEntity(sourceId, docs)));
|
||||
upsert(
|
||||
() -> countRepository.increment(sourceId, bucketHour, docs),
|
||||
() ->
|
||||
countRepository.saveAndFlush(
|
||||
new SourceDocCountEntity(sourceId, bucketHour, docs)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add {@code docs} to a per-source running total: increment the existing row, else insert a new
|
||||
* one. The insert is flushed now (in its own transaction, since {@code record} is not
|
||||
* {@code @Transactional}) so a concurrent run's winning insert surfaces as a constraint
|
||||
* violation we retry as an increment, rather than as a silent {@code merge} overwrite or a
|
||||
* later doomed commit.
|
||||
*/
|
||||
private static void upsert(IntSupplier increment, Runnable insert) {
|
||||
if (increment.getAsInt() > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
insert.run();
|
||||
} catch (DataIntegrityViolationException concurrentInsert) {
|
||||
increment.getAsInt();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, DocStats> statsFor(Collection<String> sourceIds) {
|
||||
if (sourceIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
long now = currentHour();
|
||||
Map<String, Long> totals = sums(totalRepository.totalsFor(sourceIds));
|
||||
Map<String, Long> last24h =
|
||||
sums(
|
||||
countRepository.sumBySourceSince(
|
||||
sourceIds, now - (SourceDocWindows.HOURS_IN_24H - 1)));
|
||||
Map<String, Long> last30d =
|
||||
sums(
|
||||
countRepository.sumBySourceSince(
|
||||
sourceIds, SourceDocWindows.firstDayHour(now)));
|
||||
|
||||
Map<String, DocStats> stats = new HashMap<>();
|
||||
for (String id : sourceIds) {
|
||||
stats.put(
|
||||
id,
|
||||
new DocStats(
|
||||
totals.getOrDefault(id, 0L),
|
||||
last24h.getOrDefault(id, 0L),
|
||||
last30d.getOrDefault(id, 0L)));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> dailySeriesFor(String sourceId) {
|
||||
long now = currentHour();
|
||||
Collection<String> ids = List.of(sourceId);
|
||||
Map<Long, Long> dailyCounts =
|
||||
dailyBySource(
|
||||
countRepository.dailyCountsSince(
|
||||
ids, SourceDocWindows.firstDayHour(now)))
|
||||
.getOrDefault(sourceId, Map.of());
|
||||
return SourceDocWindows.series(dailyCounts, now / 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire hourly buckets older than the 30-day window; the lifetime total is held separately, so
|
||||
* nothing reported is lost. Daily is ample - the read queries already ignore older buckets, so
|
||||
* this is purely storage hygiene.
|
||||
*/
|
||||
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
|
||||
public void pruneOldBuckets() {
|
||||
countRepository.deleteOlderThan(SourceDocWindows.firstDayHour(currentHour()));
|
||||
}
|
||||
|
||||
private long currentHour() {
|
||||
return clock.get().getEpochSecond() / 3600;
|
||||
}
|
||||
|
||||
private static Map<String, Long> sums(List<SourceDocSum> rows) {
|
||||
Map<String, Long> map = new HashMap<>();
|
||||
for (SourceDocSum row : rows) {
|
||||
map.put(row.sourceId(), row.count() == null ? 0L : row.count());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<String, Map<Long, Long>> dailyBySource(List<SourceDayDocSum> rows) {
|
||||
Map<String, Map<Long, Long>> bySource = new HashMap<>();
|
||||
for (SourceDayDocSum row : rows) {
|
||||
bySource.computeIfAbsent(row.sourceId(), key -> new HashMap<>())
|
||||
.merge(row.day(), row.docs() == null ? 0L : row.docs(), Long::sum);
|
||||
}
|
||||
return bySource;
|
||||
}
|
||||
}
|
||||
-14
@@ -74,20 +74,6 @@ public class SourceController {
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/{sourceId}/document-counts")
|
||||
@Operation(
|
||||
summary = "Daily document counts for a source",
|
||||
description =
|
||||
"The trailing 30-day per-day document series (oldest first) for the source's"
|
||||
+ " sparkline.")
|
||||
public ResponseEntity<List<Long>> documentCounts(@PathVariable String sourceId) {
|
||||
return sourceStore
|
||||
.get(sourceId)
|
||||
.filter(sourceAccessGuard::canAccess)
|
||||
.map(source -> ResponseEntity.ok(overviewService.dailySeries(source.id())))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Create or update a source",
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
/**
|
||||
* A {@code (sourceId, epoch-day, summed document count)} row from the daily-aggregate query. The
|
||||
* day is {@code floor(bucketHour / 24)}, i.e. hours-since-epoch collapsed to days-since-epoch.
|
||||
*/
|
||||
public record SourceDayDocSum(String sourceId, Long day, Long docs) {}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import org.springframework.data.domain.Persistable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* One hour's document tally for a source: {@code bucketHour} is the hours-since-epoch the documents
|
||||
* were fed, {@code docCount} the running total for that hour. Rolling-window totals are summed from
|
||||
* these buckets. {@code sourceId} is a plain value, not a foreign key, matching the rest of the
|
||||
* subsystem so it stays decoupled from the security entities.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "policy_source_doc_counts")
|
||||
@IdClass(SourceDocCountId.class)
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class SourceDocCountEntity implements Serializable, Persistable<SourceDocCountId> {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "source_id")
|
||||
private String sourceId;
|
||||
|
||||
@Id
|
||||
@Column(name = "bucket_hour")
|
||||
private long bucketHour;
|
||||
|
||||
@Column(name = "doc_count")
|
||||
private long docCount;
|
||||
|
||||
public SourceDocCountEntity(String sourceId, long bucketHour, long docCount) {
|
||||
this.sourceId = sourceId;
|
||||
this.bucketHour = bucketHour;
|
||||
this.docCount = docCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public SourceDocCountId getId() {
|
||||
return new SourceDocCountId(sourceId, bucketHour);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public boolean isNew() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Composite key for {@link SourceDocCountEntity}: one row per source per hour bucket. */
|
||||
public class SourceDocCountId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String sourceId;
|
||||
private long bucketHour;
|
||||
|
||||
public SourceDocCountId() {}
|
||||
|
||||
public SourceDocCountId(String sourceId, long bucketHour) {
|
||||
this.sourceId = sourceId;
|
||||
this.bucketHour = bucketHour;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof SourceDocCountId other)) {
|
||||
return false;
|
||||
}
|
||||
return bucketHour == other.bucketHour && Objects.equals(sourceId, other.sourceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(sourceId, bucketHour);
|
||||
}
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Repository
|
||||
public interface SourceDocCountRepository
|
||||
extends JpaRepository<SourceDocCountEntity, SourceDocCountId> {
|
||||
|
||||
/**
|
||||
* Add to an existing bucket; returns the number of rows updated (0 when the bucket is new).
|
||||
* Transactional per call so {@code JpaSourceDocCounter.record} can run it (and the retry after
|
||||
* a concurrent insert) without an enclosing transaction.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"update SourceDocCountEntity e set e.docCount = e.docCount + :docs"
|
||||
+ " where e.sourceId = :sourceId and e.bucketHour = :bucketHour")
|
||||
int increment(
|
||||
@Param("sourceId") String sourceId,
|
||||
@Param("bucketHour") long bucketHour,
|
||||
@Param("docs") long docs);
|
||||
|
||||
/**
|
||||
* Delete hourly buckets older than {@code floor} (hours-since-epoch). Nothing reads buckets
|
||||
* before the 30-day window ({@code SourceDocWindows.firstDayHour}); the lifetime total lives in
|
||||
* {@code policy_source_doc_totals}, so retiring old buckets keeps the table bounded without
|
||||
* losing any reported figure.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("delete from SourceDocCountEntity e where e.bucketHour < :floor")
|
||||
int deleteOlderThan(@Param("floor") long floor);
|
||||
|
||||
/**
|
||||
* Document total per source restricted to buckets at or after {@code since} (the 24h window).
|
||||
*/
|
||||
@Query(
|
||||
"select new stirling.software.proprietary.policy.source.SourceDocSum("
|
||||
+ "e.sourceId, sum(e.docCount))"
|
||||
+ " from SourceDocCountEntity e"
|
||||
+ " where e.sourceId in :ids and e.bucketHour >= :since"
|
||||
+ " group by e.sourceId")
|
||||
List<SourceDocSum> sumBySourceSince(
|
||||
@Param("ids") Collection<String> ids, @Param("since") long since);
|
||||
|
||||
/**
|
||||
* Per-source, per-day document totals for buckets at or after {@code since}, summed in the
|
||||
* database so the overview reads ~one row per source per active day instead of per active hour.
|
||||
* The day is {@code cast(floor(bucketHour / 24.0) as long)}: {@code 24.0} forces decimal
|
||||
* division and the cast pins the result to a whole day on every dialect.
|
||||
*/
|
||||
@Query(
|
||||
"select new stirling.software.proprietary.policy.source.SourceDayDocSum("
|
||||
+ "e.sourceId, cast(floor(e.bucketHour / 24.0) as long), sum(e.docCount))"
|
||||
+ " from SourceDocCountEntity e"
|
||||
+ " where e.sourceId in :ids and e.bucketHour >= :since"
|
||||
+ " group by cast(floor(e.bucketHour / 24.0) as long), e.sourceId")
|
||||
List<SourceDayDocSum> dailyCountsSince(
|
||||
@Param("ids") Collection<String> ids, @Param("since") long since);
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Records and reports how many documents each source feeds into runs. Counting is bucketed by hour
|
||||
* so the overview can report rolling totals ({@link DocStats}) cheaply; {@link JpaSourceDocCounter}
|
||||
* is the runtime bean and {@link InProcessSourceDocCounter} backs tests.
|
||||
*/
|
||||
public interface SourceDocCounter {
|
||||
|
||||
/** Record that {@code docs} documents were fed from {@code sourceId} at the current time. */
|
||||
void record(String sourceId, long docs);
|
||||
|
||||
/**
|
||||
* Document totals for each given source; a source with no recorded docs maps to {@link
|
||||
* DocStats#ZERO}.
|
||||
*/
|
||||
Map<String, DocStats> statsFor(Collection<String> sourceIds);
|
||||
|
||||
/**
|
||||
* The trailing {@link DocStats#DAYS}-day daily document series for one source, oldest first,
|
||||
* for the detail-panel sparkline.
|
||||
*/
|
||||
List<Long> dailySeriesFor(String sourceId);
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
/**
|
||||
* A {@code (sourceId, summed document count)} row, populated by the doc-count aggregate queries.
|
||||
*/
|
||||
public record SourceDocSum(String sourceId, Long count) {}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import org.springframework.data.domain.Persistable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A source's lifetime document total, denormalized so the overview reads the all-time count in one
|
||||
* row instead of scanning the source's whole hourly-bucket history, and so {@link
|
||||
* SourceDocCountEntity} buckets can be pruned to the rolling 30-day window without losing it.
|
||||
*
|
||||
* <p>Like {@link SourceDocCountEntity}, implements {@link Persistable} reporting {@code isNew() ==
|
||||
* true} so a new source's first {@code save} {@code persist}s (a raw INSERT) and a concurrent
|
||||
* insert surfaces as a constraint violation the counter retries as an increment, rather than {@code
|
||||
* merge} silently overwriting it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "policy_source_doc_totals")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class SourceDocTotalEntity implements Serializable, Persistable<String> {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "source_id")
|
||||
private String sourceId;
|
||||
|
||||
@Column(name = "doc_total")
|
||||
private long docTotal;
|
||||
|
||||
public SourceDocTotalEntity(String sourceId, long docTotal) {
|
||||
this.sourceId = sourceId;
|
||||
this.docTotal = docTotal;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public String getId() {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public boolean isNew() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Repository
|
||||
public interface SourceDocTotalRepository extends JpaRepository<SourceDocTotalEntity, String> {
|
||||
|
||||
/**
|
||||
* Add to a source's lifetime total; returns the number of rows updated (0 when the source has
|
||||
* no total row yet). Transactional per call so {@code JpaSourceDocCounter.record} can run it
|
||||
* (and the retry after a concurrent insert) without an enclosing transaction.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"update SourceDocTotalEntity e set e.docTotal = e.docTotal + :docs"
|
||||
+ " where e.sourceId = :sourceId")
|
||||
int increment(@Param("sourceId") String sourceId, @Param("docs") long docs);
|
||||
|
||||
/** Lifetime totals for the given sources, as {@code (sourceId, total)} rows. */
|
||||
@Query(
|
||||
"select new stirling.software.proprietary.policy.source.SourceDocSum("
|
||||
+ "e.sourceId, e.docTotal)"
|
||||
+ " from SourceDocTotalEntity e where e.sourceId in :ids")
|
||||
List<SourceDocSum> totalsFor(@Param("ids") Collection<String> ids);
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shared rolling-window math over a source's document buckets, so the JPA and in-memory counters
|
||||
* agree on the window boundaries and the daily series. Both define "last 30 days" as the buckets at
|
||||
* or after {@link #firstDayHour}, and build the series from per-day counts with {@link #series}
|
||||
* (the JPA counter aggregates by day in SQL; the in-memory counter groups its hourly buckets with
|
||||
* {@link #byDay}).
|
||||
*/
|
||||
final class SourceDocWindows {
|
||||
|
||||
static final long HOURS_IN_24H = 24;
|
||||
|
||||
private SourceDocWindows() {}
|
||||
|
||||
/**
|
||||
* The hours-since-epoch at the start of the oldest day in the 30-day window, given the current
|
||||
* hour bucket. Both the "last 30 days" total and the daily series are measured from here, so
|
||||
* the KPI and the sparkline always cover the same buckets.
|
||||
*/
|
||||
static long firstDayHour(long nowHour) {
|
||||
return ((nowHour / 24) - (DocStats.DAYS - 1)) * 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the {@link DocStats#DAYS}-day daily series (oldest first) from per-day document counts
|
||||
* (keyed by epoch-day, i.e. hours-since-epoch / 24). {@code currentDay} is today's epoch-day;
|
||||
* the series runs back {@code DAYS} days from it.
|
||||
*/
|
||||
static List<Long> series(Map<Long, Long> dailyCounts, long currentDay) {
|
||||
long firstDay = currentDay - (DocStats.DAYS - 1);
|
||||
long[] daily = new long[DocStats.DAYS];
|
||||
for (Map.Entry<Long, Long> day : dailyCounts.entrySet()) {
|
||||
int dayIndex = (int) (day.getKey() - firstDay);
|
||||
if (dayIndex >= 0 && dayIndex < DocStats.DAYS) {
|
||||
daily[dayIndex] += day.getValue();
|
||||
}
|
||||
}
|
||||
return Arrays.stream(daily).boxed().toList();
|
||||
}
|
||||
|
||||
/** Collapse hourly buckets (keyed by hours-since-epoch) into per-day counts (keyed by day). */
|
||||
static Map<Long, Long> byDay(Map<Long, Long> hourlyCounts) {
|
||||
Map<Long, Long> dailyCounts = new HashMap<>();
|
||||
hourlyCounts.forEach((hour, count) -> dailyCounts.merge(hour / 24, count, Long::sum));
|
||||
return dailyCounts;
|
||||
}
|
||||
|
||||
/** Sum hourly buckets at or after {@code since} (hours-since-epoch). */
|
||||
static long sumSince(Map<Long, Long> hourlyCounts, long since) {
|
||||
return hourlyCounts.entrySet().stream()
|
||||
.filter(entry -> entry.getKey() >= since)
|
||||
.mapToLong(Map.Entry::getValue)
|
||||
.sum();
|
||||
}
|
||||
}
|
||||
+3
-18
@@ -30,15 +30,12 @@ public class SourceOverviewService {
|
||||
private final PolicyStore policyStore;
|
||||
private final SourceAccessGuard sourceAccessGuard;
|
||||
private final PolicyAccessGuard policyAccessGuard;
|
||||
private final SourceDocCounter docCounter;
|
||||
|
||||
public SourcesResponse overview() {
|
||||
List<Source> sources = sourceAccessGuard.visibleFrom(sourceStore);
|
||||
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
|
||||
|
||||
Map<String, List<Policy>> referencesBySource = referencesBySource(policies);
|
||||
Map<String, DocStats> docStats =
|
||||
docCounter.statsFor(sources.stream().map(Source::id).toList());
|
||||
|
||||
List<SourceView> views =
|
||||
sources.stream()
|
||||
@@ -47,8 +44,7 @@ public class SourceOverviewService {
|
||||
toView(
|
||||
source,
|
||||
referencesBySource.getOrDefault(
|
||||
source.id(), List.of()),
|
||||
docStats.getOrDefault(source.id(), DocStats.ZERO)))
|
||||
source.id(), List.of())))
|
||||
.sorted(
|
||||
Comparator.comparingInt(SourceView::referenceCount)
|
||||
.reversed()
|
||||
@@ -58,14 +54,6 @@ public class SourceOverviewService {
|
||||
return new SourcesResponse(buildKpis(views), views);
|
||||
}
|
||||
|
||||
/**
|
||||
* The 30-day daily document series for one source (oldest first), for the expanded row's
|
||||
* sparkline.
|
||||
*/
|
||||
public List<Long> dailySeries(String sourceId) {
|
||||
return docCounter.dailySeriesFor(sourceId);
|
||||
}
|
||||
|
||||
/** Policies referencing each source id, across the caller's visible policies. */
|
||||
private static Map<String, List<Policy>> referencesBySource(List<Policy> policies) {
|
||||
Map<String, List<Policy>> bySource = new HashMap<>();
|
||||
@@ -77,8 +65,7 @@ public class SourceOverviewService {
|
||||
return bySource;
|
||||
}
|
||||
|
||||
private static SourceView toView(
|
||||
Source source, List<Policy> referencingPolicies, DocStats docs) {
|
||||
private static SourceView toView(Source source, List<Policy> referencingPolicies) {
|
||||
List<SourceView.PolicyRef> refs =
|
||||
referencingPolicies.stream()
|
||||
.map(policy -> new SourceView.PolicyRef(policy.id(), policy.name()))
|
||||
@@ -91,9 +78,7 @@ public class SourceOverviewService {
|
||||
refs.size(),
|
||||
refs,
|
||||
configRows(source),
|
||||
docs.total(),
|
||||
docs.last24h(),
|
||||
docs.last30d());
|
||||
null);
|
||||
}
|
||||
|
||||
/** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */
|
||||
|
||||
+3
-5
@@ -4,8 +4,8 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
|
||||
* policies reference it (and which) and how many documents it has fed into runs ({@code docsTotal}
|
||||
* lifetime plus the trailing 24-hour and 30-day windows).
|
||||
* policies reference it (and which). {@code docsTotal} is {@code null} - per-source document volume
|
||||
* is not tracked yet; the field is reserved so a later doc-accounting pass is additive.
|
||||
*/
|
||||
public record SourceView(
|
||||
String id,
|
||||
@@ -15,9 +15,7 @@ public record SourceView(
|
||||
int referenceCount,
|
||||
List<PolicyRef> referencingPolicies,
|
||||
List<DetailRow> config,
|
||||
long docsTotal,
|
||||
long docs24h,
|
||||
long docs30d) {
|
||||
Long docsTotal) {
|
||||
|
||||
/** A policy that references this source. */
|
||||
public record PolicyRef(String id, String name) {}
|
||||
|
||||
+2
-8
@@ -33,11 +33,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.storage.repository",
|
||||
"stirling.software.proprietary.workflow.repository",
|
||||
"stirling.software.proprietary.policy.store",
|
||||
"stirling.software.proprietary.policy.source",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.repository",
|
||||
"stirling.software.proprietary.integration.repository",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
"stirling.software.proprietary.policy.source"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
@@ -45,11 +42,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.storage.model",
|
||||
"stirling.software.proprietary.workflow.model",
|
||||
"stirling.software.proprietary.policy.store",
|
||||
"stirling.software.proprietary.policy.source",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.model",
|
||||
"stirling.software.proprietary.integration.model",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
"stirling.software.proprietary.policy.source"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
+4
-2
@@ -843,8 +843,10 @@ public class AdminSettingsController {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for fields containing 'password' or 'secret'
|
||||
return lowerField.contains("password") || lowerField.contains("secret");
|
||||
// Check for fields containing 'password' or 'secret' or 'bottoken'
|
||||
return lowerField.contains("password")
|
||||
|| lowerField.contains("secret")
|
||||
|| lowerField.contains("bottoken");
|
||||
}
|
||||
|
||||
/** Create a masked representation for sensitive fields */
|
||||
|
||||
-3
@@ -28,7 +28,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.access.service.ResourceAccessService;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
@@ -65,7 +64,6 @@ public class AuthController {
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final AiUserDataService aiUserDataService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
@@ -630,7 +628,6 @@ public class AuthController {
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
userMap.put("portalAccess", resourceAccessService.canAccessPortal(user));
|
||||
userMap.put(
|
||||
"authenticationType",
|
||||
user.getAuthenticationType()); // Expose authentication type for SSO detection
|
||||
|
||||
@@ -103,7 +103,6 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
|
||||
+9
-40
@@ -352,7 +352,6 @@ public class AiWorkflowService {
|
||||
|
||||
try {
|
||||
List<Resource> resultFiles = new ArrayList<>();
|
||||
List<Integer> origins = new ArrayList<>();
|
||||
List<String> inputNames = new ArrayList<>();
|
||||
for (int i = 0; i < filesToConvert.size(); i++) {
|
||||
AiFile file = filesToConvert.get(i);
|
||||
@@ -377,15 +376,11 @@ public class AiWorkflowService {
|
||||
definition,
|
||||
PolicyInputs.of(List.of(input)),
|
||||
PolicyProgressListener.NOOP);
|
||||
// Each conversion runs on one input, so every output traces back to file i.
|
||||
for (Resource output : result.files()) {
|
||||
resultFiles.add(output);
|
||||
origins.add(i);
|
||||
}
|
||||
resultFiles.addAll(result.files());
|
||||
inputNames.add(multipartFile.getOriginalFilename());
|
||||
}
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(null, resultFiles, origins, inputNames, null));
|
||||
buildCompletedResponse(null, resultFiles, inputNames, null));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -477,7 +472,6 @@ public class AiWorkflowService {
|
||||
buildCompletedResponse(
|
||||
response.getRationale(),
|
||||
result.files(),
|
||||
result.origins(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
@@ -539,8 +533,7 @@ public class AiWorkflowService {
|
||||
}
|
||||
};
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
response.getSummary(), List.of(resource), null, List.of(), null));
|
||||
buildCompletedResponse(response.getSummary(), List.of(resource), List.of(), null));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -598,11 +591,7 @@ public class AiWorkflowService {
|
||||
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
summary,
|
||||
result.files(),
|
||||
result.origins(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
summary, result.files(), inputFileNames(filesById), result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -691,35 +680,19 @@ public class AiWorkflowService {
|
||||
private AiWorkflowResponse buildCompletedResponse(
|
||||
String summary,
|
||||
List<Resource> resultFiles,
|
||||
List<Integer> origins,
|
||||
List<String> inputFileNames,
|
||||
JsonNode report)
|
||||
throws IOException {
|
||||
// Store every output file individually so each gets its own Stirling file ID and the
|
||||
// frontend can add them as independent variants without going through a zip.
|
||||
// Count outputs per source so only a clean 1:1 transform (one output for a source) reuses
|
||||
// the input's name; a split (one input → many outputs) keeps each entry's own name.
|
||||
Map<Integer, Long> outputsPerOrigin =
|
||||
origins == null
|
||||
? Map.of()
|
||||
: origins.stream()
|
||||
.filter(o -> o != null)
|
||||
.collect(Collectors.groupingBy(o -> o, Collectors.counting()));
|
||||
boolean preserveInputNames = inputFileNames.size() == resultFiles.size();
|
||||
List<AiWorkflowResultFile> descriptors = new ArrayList<>();
|
||||
for (int i = 0; i < resultFiles.size(); i++) {
|
||||
Resource resource = resultFiles.get(i);
|
||||
String responseName = resource.getFilename();
|
||||
// The output's source input (from the executor), used both to name it and to tell the
|
||||
// client which file to version in place.
|
||||
Integer origin = origins != null && i < origins.size() ? origins.get(i) : null;
|
||||
boolean uniqueOrigin =
|
||||
origin != null && outputsPerOrigin.getOrDefault(origin, 0L) == 1L;
|
||||
String inputName =
|
||||
uniqueOrigin && origin >= 0 && origin < inputFileNames.size()
|
||||
? inputFileNames.get(origin)
|
||||
: null;
|
||||
// Prefer the source input's name only for 1:1 operations where the output keeps the
|
||||
// same extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
String inputName = preserveInputNames ? inputFileNames.get(i) : null;
|
||||
// Prefer the input name only for 1:1 operations where the output keeps the same
|
||||
// extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
// tools, the response filename from Content-Disposition is authoritative.
|
||||
String name;
|
||||
if (inputName != null
|
||||
@@ -739,11 +712,7 @@ public class AiWorkflowService {
|
||||
try (java.io.InputStream is = resource.getInputStream()) {
|
||||
fileId = fileStorage.storeInputStream(is, name).fileId();
|
||||
}
|
||||
// Only expose the source when this is a clean 1:1 transform, so the client can treat a
|
||||
// present sourceIndex as "replace that input in place" without further disambiguation.
|
||||
descriptors.add(
|
||||
new AiWorkflowResultFile(
|
||||
fileId, name, contentType, uniqueOrigin ? origin : null));
|
||||
descriptors.add(new AiWorkflowResultFile(fileId, name, contentType));
|
||||
}
|
||||
|
||||
AiWorkflowResponse completed = new AiWorkflowResponse();
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.proprietary.access.model.OwnedResource;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OwnershipServiceTest {
|
||||
|
||||
private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG;
|
||||
|
||||
@Mock private ResourceAccessService accessService;
|
||||
@Mock private TeamLeadLookup teamLeadLookup;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
|
||||
@InjectMocks private OwnershipService ownership;
|
||||
|
||||
/** Minimal concrete OwnedResource for exercising the base behaviour. */
|
||||
static class TestResource extends OwnedResource {
|
||||
private final Long id;
|
||||
|
||||
TestResource(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- scope-based create authorization ----
|
||||
|
||||
@Test
|
||||
void userScopeSetsOwner() {
|
||||
User user = user(7);
|
||||
TestResource r = new TestResource(1L);
|
||||
|
||||
ownership.assignOwnership(r, OwnerScope.USER, null, user, () -> false);
|
||||
|
||||
assertThat(r.getScope()).isEqualTo(OwnerScope.USER);
|
||||
assertThat(r.getOwnerUser()).isSameAs(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userScopeBlockedByLockedServerOverride() {
|
||||
assertForbidden(
|
||||
() ->
|
||||
ownership.assignOwnership(
|
||||
new TestResource(1L), OwnerScope.USER, null, user(7), () -> true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminMayCreateServerScope() {
|
||||
TestResource r = new TestResource(1L);
|
||||
ownership.assignOwnership(r, OwnerScope.SERVER, null, admin(1), () -> false);
|
||||
assertThat(r.getOwnerUser()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminCannotCreateServerScope() {
|
||||
assertForbidden(
|
||||
() ->
|
||||
ownership.assignOwnership(
|
||||
new TestResource(1L),
|
||||
OwnerScope.SERVER,
|
||||
null,
|
||||
user(7),
|
||||
() -> false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamLeaderMayCreateTeamScope() {
|
||||
Team team = new Team();
|
||||
team.setId(5L);
|
||||
User user = user(7);
|
||||
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
|
||||
when(teamLeadLookup.isLeaderOfTeam(user, 5L)).thenReturn(true);
|
||||
|
||||
TestResource r = new TestResource(1L);
|
||||
ownership.assignOwnership(r, OwnerScope.TEAM, 5L, user, () -> false);
|
||||
|
||||
assertThat(r.getOwnerTeam()).isSameAs(team);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonLeaderCannotCreateTeamScope() {
|
||||
Team team = new Team();
|
||||
team.setId(5L);
|
||||
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
|
||||
|
||||
assertForbidden(
|
||||
() ->
|
||||
ownership.assignOwnership(
|
||||
new TestResource(1L), OwnerScope.TEAM, 5L, user(7), () -> false));
|
||||
}
|
||||
|
||||
// ---- use / manage ----
|
||||
|
||||
@Test
|
||||
void enabledResourceUseDelegatesToTheAcl() {
|
||||
TestResource r = new TestResource(1L);
|
||||
User user = user(7);
|
||||
when(accessService.canUseResource(any(), any(), any(), any(), any())).thenReturn(true);
|
||||
|
||||
assertThat(ownership.canUse(TYPE, r, user)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledResourceUsableOnlyByOwnerOrAdmin() {
|
||||
TestResource r = new TestResource(1L);
|
||||
r.setEnabled(false);
|
||||
r.setOwnerUser(user(7));
|
||||
|
||||
assertThat(ownership.canUse(TYPE, r, user(7))).isTrue(); // owner
|
||||
assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // someone else
|
||||
assertThat(ownership.canUse(TYPE, r, admin(2))).isTrue(); // admin
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledTeamResourceUsableByLeaderOfOwningTeam() {
|
||||
Team team = new Team();
|
||||
team.setId(5L);
|
||||
TestResource r = new TestResource(1L);
|
||||
r.setEnabled(false);
|
||||
r.setOwnerTeam(team);
|
||||
User leader = user(7);
|
||||
when(teamLeadLookup.isLeaderOfTeam(leader, 5L)).thenReturn(true);
|
||||
|
||||
assertThat(ownership.canUse(TYPE, r, leader)).isTrue(); // lead of the owning team
|
||||
assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // not a lead
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) {
|
||||
assertThatThrownBy(call)
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private User user(long id) {
|
||||
User u = new User();
|
||||
u.setId(id);
|
||||
u.setUsername("user" + id);
|
||||
return u;
|
||||
}
|
||||
|
||||
private User admin(long id) {
|
||||
User u = user(id);
|
||||
new Authority("ROLE_ADMIN", u);
|
||||
return u;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user