mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge remote-tracking branch 'origin/main' into fix/translation-dashes
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"${CLAUDE_PROJECT_DIR}/scripts/lint/comment-lint-hook.mjs"
|
||||
],
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,6 @@ updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directories:
|
||||
- "/" # Location of package manifests
|
||||
- "/app/common"
|
||||
- "/app/core"
|
||||
- "/app/proprietary"
|
||||
- "/app/saas"
|
||||
- "/buildSrc"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
|
||||
@@ -67,6 +67,7 @@ labels:
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
- '.taskfiles/frontend.yml'
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
|
||||
@@ -20,6 +20,7 @@ Closes #(issue_number)
|
||||
- [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable)
|
||||
- [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable)
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
|
||||
- [ ] My changes generate no new warnings
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
name: Auto SaaS Dev Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- saas-prod
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FRONTEND_PORT: "901"
|
||||
BACKEND_PORT: "902"
|
||||
DEPLOY_DIR: /stirling/SAAS-DEV
|
||||
|
||||
jobs:
|
||||
deploy-saas-dev:
|
||||
runs-on: ubuntu-latest
|
||||
environment: saas-dev
|
||||
concurrency:
|
||||
group: saas-dev-deploy
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check SaaS configuration
|
||||
id: config
|
||||
env:
|
||||
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
|
||||
run: |
|
||||
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
|
||||
echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get commit hash
|
||||
id: commit-hash
|
||||
run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-backend
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-backend
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
STIRLING_FLAVOR=saas
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-frontend
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-frontend
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
STIRLING_FLAVOR=saas
|
||||
VITE_BUILD_MODE=development
|
||||
VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push AI engine image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-engine
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-engine
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "$SSH_KEY" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }}
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ github.token }}
|
||||
VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }}
|
||||
SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }}
|
||||
SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }}
|
||||
SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
|
||||
SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }}
|
||||
PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }}
|
||||
STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }}
|
||||
KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }}
|
||||
KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }}
|
||||
KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}"
|
||||
|
||||
yaml() {
|
||||
printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')"
|
||||
}
|
||||
|
||||
ENGINE_SECRET="$(openssl rand -hex 32)"
|
||||
AI_BACKEND_VARS="
|
||||
SYSTEM_AIENGINE_ENABLED: \"true\"
|
||||
SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\"
|
||||
APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\"
|
||||
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")"
|
||||
AI_SERVICE="
|
||||
|
||||
saas-engine:
|
||||
container_name: stirling-saas-dev-engine
|
||||
image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG}
|
||||
environment:
|
||||
ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY")
|
||||
VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY")
|
||||
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")
|
||||
restart: on-failure:5"
|
||||
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
saas-backend:
|
||||
container_name: stirling-saas-dev-backend
|
||||
image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG}
|
||||
ports:
|
||||
- "${BACKEND_PORT}:8080"
|
||||
volumes:
|
||||
- ${DEPLOY_DIR}/config:/configs:rw
|
||||
- ${DEPLOY_DIR}/logs:/logs:rw
|
||||
- ${DEPLOY_DIR}/storage:/storage:rw
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: "saas"
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SAAS_DB_URL: $(yaml "$SAAS_DB_URL")
|
||||
SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME")
|
||||
SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD")
|
||||
SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF")
|
||||
SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET")
|
||||
PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT")
|
||||
STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED")
|
||||
KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID")
|
||||
KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN")
|
||||
KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID")
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
SWAGGER_SERVER_URL: "${BASE_URL}"
|
||||
baseUrl: "${BASE_URL}"${AI_BACKEND_VARS}
|
||||
restart: on-failure:5
|
||||
|
||||
saas-frontend:
|
||||
container_name: stirling-saas-dev-frontend
|
||||
image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG}
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://saas-backend:8080"
|
||||
depends_on:
|
||||
- saas-backend
|
||||
restart: on-failure:5${AI_SERVICE}
|
||||
EOF
|
||||
|
||||
SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
|
||||
scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml"
|
||||
|
||||
ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH
|
||||
set -e
|
||||
mkdir -p ${DEPLOY_DIR}/{config,logs,storage}
|
||||
mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml
|
||||
chmod 600 ${DEPLOY_DIR}/docker-compose.yml
|
||||
cd ${DEPLOY_DIR}
|
||||
printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin
|
||||
docker-compose down --remove-orphans 2>/dev/null || true
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
docker logout ghcr.io >/dev/null 2>&1 || true
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
ENDSSH
|
||||
|
||||
- name: Wait for the backend to answer
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
run: |
|
||||
URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status"
|
||||
for i in $(seq 1 60); do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true)
|
||||
if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::SaaS dev backend did not become healthy within 10 minutes"
|
||||
exit 1
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: rm -f ../private.key docker-compose.yml
|
||||
continue-on-error: true
|
||||
@@ -34,7 +34,6 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: ai-engine
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -42,7 +42,6 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: generated-models
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
|
||||
@@ -31,10 +31,14 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: pre-commit
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Run pre-commit checks
|
||||
run: task pre-commit
|
||||
|
||||
# The fixture corpus checks the comment rules themselves, so it runs here
|
||||
# rather than on every local commit.
|
||||
- name: Check the comment-lint fixture corpus
|
||||
run: task pre-commit:comment-lint:selftest
|
||||
|
||||
@@ -59,7 +59,6 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: sync-files
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
|
||||
+7
-2
@@ -298,8 +298,13 @@ docs/type3/signatures/
|
||||
|
||||
**/application-dev-local.properties
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
# Claude. Contents are ignored so personal config stays local, with the two
|
||||
# shared pieces re-included: settings.json (the comment-lint hook) and skills/.
|
||||
# The directory itself cannot be ignored or git will not look inside it.
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
!.claude/skills/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -23,7 +23,7 @@ tasks:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
status:
|
||||
- test -d node_modules
|
||||
- npm ls --depth=0
|
||||
env:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ vars:
|
||||
'.github/scripts/*.py'
|
||||
'app/core/src/main/resources/static/python/*.py'
|
||||
':(exclude)*split_photos.py'
|
||||
':(exclude)scripts/lint/fixtures/*'
|
||||
SPELL_FILES: >-
|
||||
'*.html'
|
||||
'*.css'
|
||||
@@ -59,6 +60,7 @@ tasks:
|
||||
- task: gitleaks
|
||||
- task: whitespace
|
||||
- task: toml-sort
|
||||
- task: comment-lint
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
|
||||
@@ -75,6 +77,7 @@ tasks:
|
||||
vars: { FIX: '1' }
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
- task: comment-lint
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools"
|
||||
@@ -130,6 +133,85 @@ tasks:
|
||||
cmds:
|
||||
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
|
||||
|
||||
comment-lint:
|
||||
desc: "Check comment quality on the lines this branch adds"
|
||||
summary: |
|
||||
Blocks a comment that restates the code below it, a section banner, or a
|
||||
block of commented-out code. Everything else it reports is advisory.
|
||||
|
||||
Scoped to added lines, so touching an old file never surfaces the standing
|
||||
backlog. The standard is devGuide/CODE_COMMENTS.md.
|
||||
|
||||
With no arguments it diffs the working tree against HEAD, which is what a
|
||||
pre-commit run wants: the lines you are about to commit. On a CI pull request
|
||||
it diffs against the target branch instead, via GITHUB_BASE_REF.
|
||||
|
||||
To ask what a whole branch adds instead, use the branch variant, which
|
||||
needs no argument passing:
|
||||
task comment-lint:branch
|
||||
|
||||
Full tree (report only): task pre-commit:comment-lint:all
|
||||
Fixture corpus: task pre-commit:comment-lint:selftest
|
||||
# Depends on the frontend install because the .ts/.tsx half of the rule set
|
||||
# runs as an oxlint plugin. Without it the TS engine warns and skips, which
|
||||
# would leave the frontend silently unchecked on CI.
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
|
||||
|
||||
comment-lint:branch:
|
||||
desc: "Check comment quality on everything this branch adds over its base"
|
||||
summary: |
|
||||
Like `task comment-lint`, but scoped to the whole branch rather than to
|
||||
uncommitted work, so it still reports after you commit.
|
||||
|
||||
Exists as its own task because passing `-- --since origin/main` through Task
|
||||
is not portable: with the npm build of Task the launcher is a PowerShell
|
||||
script, and PowerShell strips the `--` before Task sees it, leaving Task to
|
||||
print its own usage.
|
||||
|
||||
Override the base with BASE=<ref>.
|
||||
vars:
|
||||
BASE: '{{.BASE | default "origin/main"}}'
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --since {{.BASE}}
|
||||
|
||||
comment-lint:ci:
|
||||
desc: "Comment gate as CI runs it: fixture corpus, then the diff"
|
||||
summary: |
|
||||
The corpus checks the rules themselves rather than the code under review, so
|
||||
it belongs on CI and not on every local commit. Run this before changing a
|
||||
rule, and let CI run it on every pull request.
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --selftest
|
||||
- node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
|
||||
|
||||
comment-lint:hook:
|
||||
desc: "Comment gate for the editor hook: everything this turn changed"
|
||||
summary: |
|
||||
Same scope as `task comment-lint`, kept as its own name so the hook has a
|
||||
stable entry point and the taskfile shows every way the linter is invoked.
|
||||
|
||||
Not in the frontend-install dependency chain on purpose: this runs at the end
|
||||
of every turn, so it stays as short as it can be. If oxlint is missing the TS
|
||||
half warns and skips.
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs
|
||||
|
||||
comment-lint:all:
|
||||
desc: "Report every comment finding in the tree (never fails)"
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --all
|
||||
|
||||
comment-lint:selftest:
|
||||
desc: "Check both comment-lint engines against the fixture corpus"
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --selftest
|
||||
|
||||
gitleaks-bin:
|
||||
internal: true
|
||||
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
|
||||
|
||||
@@ -21,6 +21,43 @@ Task `desc:` fields should describe **what** the task does, not **how** it does
|
||||
- `task docker:build` — build standard Docker image
|
||||
- `task docker:up` — start Docker compose stack
|
||||
|
||||
## Comments
|
||||
|
||||
A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it.
|
||||
|
||||
Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue.
|
||||
|
||||
Write a comment when it does one of these four jobs:
|
||||
|
||||
- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring.
|
||||
- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason.
|
||||
- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z".
|
||||
- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not.
|
||||
|
||||
Never write:
|
||||
|
||||
- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise.
|
||||
- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`.
|
||||
- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine.
|
||||
- Commented-out code. Delete it.
|
||||
- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it.
|
||||
- Docs on self-explanatory members with no constraint to state.
|
||||
|
||||
Two tests before keeping a comment:
|
||||
|
||||
- **Delete it.** Is any information lost? If not, it stays deleted.
|
||||
- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change.
|
||||
|
||||
A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other.
|
||||
|
||||
A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies.
|
||||
|
||||
A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO.
|
||||
|
||||
A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo.
|
||||
|
||||
`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
@@ -70,7 +107,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
- Avoid nested functions and nested classes unless the language construct requires them.
|
||||
- Prefer composition to inheritance when combining concepts.
|
||||
- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle.
|
||||
- Add comments sparingly and only when they explain non-obvious intent.
|
||||
- Comments follow the repo-wide rules in the "Comments" section above.
|
||||
|
||||
#### Python Typing and Models
|
||||
- Deserialize into Pydantic models as early as possible.
|
||||
|
||||
@@ -42,6 +42,7 @@ Please make sure your Pull Request adheres to the following guidelines:
|
||||
- Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests.
|
||||
- Commits should be clear, concise, and easy to understand.
|
||||
- References to the Issue number in the Pull Request and/or Commit message.
|
||||
- Every comment in the diff should say something the code does not. See [Code comments](devGuide/CODE_COMMENTS.md); `task comment-lint` checks the mechanical part.
|
||||
|
||||
## Translations
|
||||
|
||||
|
||||
@@ -266,6 +266,20 @@ tasks:
|
||||
cmds:
|
||||
- task: frontend:lint
|
||||
- task: engine:lint
|
||||
- task: comment-lint
|
||||
|
||||
comment-lint:
|
||||
desc: "Check comment quality on the lines this branch adds"
|
||||
aliases: [comments]
|
||||
cmds:
|
||||
- task: pre-commit:comment-lint
|
||||
vars: { CLI_ARGS: '{{.CLI_ARGS}}' }
|
||||
|
||||
comment-lint:branch:
|
||||
desc: "Check comment quality on everything this branch adds over its base"
|
||||
cmds:
|
||||
- task: pre-commit:comment-lint:branch
|
||||
vars: { BASE: '{{.BASE}}' }
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix all components"
|
||||
|
||||
+32
-7
@@ -3,6 +3,10 @@ bootRun {
|
||||
enabled = false
|
||||
}
|
||||
dependencies {
|
||||
// Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection).
|
||||
// Declared as api here so core + proprietary (which depend on common) get it transitively,
|
||||
// keeping it off modules that don't need it (e.g. saas).
|
||||
api 'io.github.pixee:java-security-toolkit:1.2.3'
|
||||
api "com.google.guava:guava:${guavaVersion}"
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
@@ -22,7 +26,10 @@ dependencies {
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:9.3.2'
|
||||
api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support
|
||||
// MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't)
|
||||
api('org.simplejavamail:outlook-module:9.3.2') {
|
||||
exclude group: 'org.apache.commons', module: 'commons-math3'
|
||||
}
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
|
||||
@@ -36,12 +43,30 @@ dependencies {
|
||||
|
||||
api "com.stirling:jpdfium:${jpdfiumVersion}"
|
||||
|
||||
// -PjpdfiumPlatforms=all|none|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
// 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
// -PjpdfiumPlatforms=auto|all|none|<csv of linux-x64,linux-arm64,linux-musl-x64,linux-musl-arm64,darwin-x64,darwin-arm64,windows-x64> (windows-arm64 natives not published yet)
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim()
|
||||
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
def jpdfiumPlatforms
|
||||
if (jpdfiumPlatformsProp == 'all') {
|
||||
if (jpdfiumPlatformsProp == 'auto') {
|
||||
def osName = System.getProperty('os.name').toLowerCase()
|
||||
def osArch = System.getProperty('os.arch').toLowerCase()
|
||||
def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64')
|
||||
if (osName.contains('linux')) {
|
||||
jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64']
|
||||
} else if (osName.contains('mac')) {
|
||||
jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64']
|
||||
} else if (osName.contains('win')) {
|
||||
if (isArm64) {
|
||||
logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.")
|
||||
jpdfiumPlatforms = []
|
||||
} else {
|
||||
jpdfiumPlatforms = ['windows-x64']
|
||||
}
|
||||
} else {
|
||||
// Fallback: bundle all platforms when host can't be determined
|
||||
jpdfiumPlatforms = jpdfiumAllPlatforms
|
||||
}
|
||||
} else if (jpdfiumPlatformsProp == 'all') {
|
||||
jpdfiumPlatforms = jpdfiumAllPlatforms
|
||||
} else if (jpdfiumPlatformsProp == 'none') {
|
||||
jpdfiumPlatforms = []
|
||||
@@ -51,7 +76,7 @@ dependencies {
|
||||
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
|
||||
if (jpdfiumInvalid) {
|
||||
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.")
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
|
||||
jpdfiumPlatforms.each { platform ->
|
||||
|
||||
@@ -48,7 +48,7 @@ public class EndpointConfiguration {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
|
||||
private Set<String> disabledGroups = new HashSet<>();
|
||||
private Set<String> disabledGroups = ConcurrentHashMap.newKeySet();
|
||||
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser {
|
||||
score -= 0.3f;
|
||||
}
|
||||
|
||||
return Math.max(0f, Math.min(1f, score));
|
||||
return Math.clamp(score, 0f, 1f);
|
||||
}
|
||||
|
||||
private Bounds tableBounds(Table table) {
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport {
|
||||
@Override
|
||||
public void setAsText(String text) throws IllegalArgumentException {
|
||||
try {
|
||||
TypeReference<HashMap<String, String>> typeRef = new TypeReference<>() {};
|
||||
TypeReference<HashMap<String, String>> typeRef =
|
||||
new TypeReference<HashMap<String, String>>() {};
|
||||
Map<String, String> map = objectMapper.readValue(text, typeRef);
|
||||
setValue(map);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -113,6 +113,16 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute_editorRouteOwnedByFrontend() {
|
||||
// /editor (and its tool routes) is an SPA route: a direct-nav/refresh must
|
||||
// serve index.html, not the auth filter's 302-to-/login. Regression test for
|
||||
// the editor moving from / to /editor, whose refresh bounced processor users
|
||||
// to the processor because the redirect dropped the return path.
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/editor"));
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
|
||||
// /files and /files/<folder-uuid> are FileManagerView routes - they
|
||||
|
||||
@@ -106,6 +106,7 @@ SwaggerDoc.json
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
*.log.gz
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
+14
-2
@@ -62,8 +62,16 @@ dependencies {
|
||||
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5'
|
||||
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
// OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module.
|
||||
// Exclude commons-beanutils + commons-collections.
|
||||
implementation('com.opencsv:opencsv:5.12.0') {
|
||||
exclude group: 'commons-beanutils', module: 'commons-beanutils'
|
||||
exclude group: 'commons-collections', module: 'commons-collections'
|
||||
}
|
||||
// POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3.
|
||||
implementation('org.apache.poi:poi-ooxml:5.5.1') {
|
||||
exclude group: 'org.apache.commons', module: 'commons-math3'
|
||||
}
|
||||
|
||||
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
|
||||
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
|
||||
@@ -129,6 +137,10 @@ bootJar {
|
||||
exclude 'META-INF/*.RSA'
|
||||
exclude 'META-INF/*.EC'
|
||||
|
||||
// Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime
|
||||
exclude 'static/pdfjs-legacy/**/*.map'
|
||||
exclude 'static/**/*.map'
|
||||
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': 'Stirling-PDF',
|
||||
|
||||
+1
-1
@@ -237,7 +237,7 @@ public class EditTextController {
|
||||
|
||||
Matcher matcher = edit.pattern().matcher(joined);
|
||||
List<MatchSpan> spans = new ArrayList<>();
|
||||
StringBuffer interpolation = new StringBuffer();
|
||||
StringBuilder interpolation = new StringBuilder();
|
||||
int previousAppendPosition = 0;
|
||||
while (matcher.find()) {
|
||||
if (matcher.start() == matcher.end()) {
|
||||
|
||||
@@ -95,7 +95,8 @@ public class UIDataController {
|
||||
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
Map<String, List<Dependency>> licenseData =
|
||||
objectMapper.readValue(is, new TypeReference<>() {});
|
||||
objectMapper.readValue(
|
||||
is, new TypeReference<Map<String, List<Dependency>>>() {});
|
||||
data.setDependencies(licenseData.get("dependencies"));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to load licenses data", e);
|
||||
|
||||
+6
-3
@@ -25,12 +25,15 @@ final class FormPayloadParser {
|
||||
private static final String KEY_VALUE = "value";
|
||||
private static final String KEY_DEFAULT_VALUE = "defaultValue";
|
||||
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE =
|
||||
new TypeReference<Map<String, Object>>() {};
|
||||
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
|
||||
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
|
||||
MODIFY_FIELD_LIST_TYPE =
|
||||
new TypeReference<List<FormUtils.ModifyFormFieldDefinition>>() {};
|
||||
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
|
||||
new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE =
|
||||
new TypeReference<List<String>>() {};
|
||||
|
||||
private FormPayloadParser() {}
|
||||
|
||||
|
||||
+3
-1
@@ -96,7 +96,9 @@ public class AddCommentsController {
|
||||
|
||||
List<CommentSpecDto> dtos;
|
||||
try {
|
||||
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
|
||||
dtos =
|
||||
objectMapper.readValue(
|
||||
commentsJson, new TypeReference<List<CommentSpecDto>>() {});
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
|
||||
|
||||
@@ -114,6 +114,7 @@ public class OCRController {
|
||||
List<String> selectedLanguages = request.getLanguages();
|
||||
boolean sidecar = request.isSidecar();
|
||||
Boolean deskew = request.isDeskew();
|
||||
Boolean rotatePages = request.isRotatePages();
|
||||
Boolean clean = request.isClean();
|
||||
Boolean cleanFinal = request.isCleanFinal();
|
||||
String ocrType = request.getOcrType();
|
||||
@@ -154,6 +155,7 @@ public class OCRController {
|
||||
selectedLanguages,
|
||||
sidecar,
|
||||
deskew,
|
||||
rotatePages,
|
||||
clean,
|
||||
cleanFinal,
|
||||
ocrType,
|
||||
@@ -236,6 +238,7 @@ public class OCRController {
|
||||
List<String> selectedLanguages,
|
||||
Boolean sidecar,
|
||||
Boolean deskew,
|
||||
Boolean rotatePages,
|
||||
Boolean clean,
|
||||
Boolean cleanFinal,
|
||||
String ocrType,
|
||||
@@ -268,6 +271,10 @@ public class OCRController {
|
||||
if (deskew != null && deskew) {
|
||||
command.add("--deskew");
|
||||
}
|
||||
if (rotatePages != null && rotatePages) {
|
||||
// Tesseract OSD-based automatic page orientation correction (90/180/270)
|
||||
command.add("--rotate-pages");
|
||||
}
|
||||
if (clean != null && clean) {
|
||||
command.add("--clean");
|
||||
}
|
||||
|
||||
+4
@@ -221,6 +221,10 @@ public class RedactController {
|
||||
.normalizeFonts(false)
|
||||
.fixToUnicode(false)
|
||||
.glyphAware(true)
|
||||
.ligatureAware(true)
|
||||
.bidiAware(true)
|
||||
.graphemeSafe(true)
|
||||
.sanitizeStructure(false) // WIP/Experimental API
|
||||
.redactMetadata(true)
|
||||
.build();
|
||||
|
||||
|
||||
+4
@@ -110,6 +110,10 @@ class TextRedactionService {
|
||||
.fixToUnicode(false)
|
||||
.repairWidths(false)
|
||||
.glyphAware(true)
|
||||
.ligatureAware(true)
|
||||
.bidiAware(true)
|
||||
.graphemeSafe(true)
|
||||
.sanitizeStructure(false)
|
||||
.build();
|
||||
|
||||
try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) {
|
||||
|
||||
+5
@@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile {
|
||||
@Schema(description = "Deskew the input file if set to true")
|
||||
private boolean deskew;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true")
|
||||
private boolean rotatePages;
|
||||
|
||||
@Schema(description = "Clean the input file if set to true")
|
||||
private boolean clean;
|
||||
|
||||
|
||||
+11
-8
@@ -4,7 +4,9 @@ import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -21,7 +23,7 @@ public class WeeklyActiveUsersService {
|
||||
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
|
||||
|
||||
// Track total unique browsers seen (overall)
|
||||
private long totalUniqueBrowsers = 0;
|
||||
private final AtomicLong totalUniqueBrowsers = new AtomicLong(0);
|
||||
|
||||
// Application start time
|
||||
private final Instant startTime = Instant.now();
|
||||
@@ -36,12 +38,12 @@ public class WeeklyActiveUsersService {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
|
||||
activeBrowsers.put(browserId, Instant.now());
|
||||
Instant now = Instant.now();
|
||||
Instant previous = activeBrowsers.put(browserId, now);
|
||||
|
||||
if (isNewBrowser) {
|
||||
totalUniqueBrowsers++;
|
||||
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
|
||||
if (previous == null) {
|
||||
long total = totalUniqueBrowsers.incrementAndGet();
|
||||
log.debug("New browser recorded: {} (Total: {})", browserId, total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ public class WeeklyActiveUsersService {
|
||||
* @return Total unique browsers count
|
||||
*/
|
||||
public long getTotalUniqueBrowsers() {
|
||||
return totalUniqueBrowsers;
|
||||
return totalUniqueBrowsers.get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +90,8 @@ public class WeeklyActiveUsersService {
|
||||
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||
}
|
||||
|
||||
/** Manual cleanup trigger (can be called by scheduled task if needed) */
|
||||
/** Scheduled cleanup trigger running every hour */
|
||||
@Scheduled(fixedRate = 3600000)
|
||||
public void performCleanup() {
|
||||
int sizeBefore = activeBrowsers.size();
|
||||
cleanupOldEntries();
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>1</maxHistory>
|
||||
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
|
||||
<maxHistory>7</maxHistory>
|
||||
<totalSizeCap>64MB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
@@ -28,8 +29,9 @@
|
||||
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>1</maxHistory>
|
||||
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
|
||||
<maxHistory>7</maxHistory>
|
||||
<totalSizeCap>256MB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public enum AuditLevel {
|
||||
*/
|
||||
public static AuditLevel fromInt(int level) {
|
||||
// Ensure level is within valid bounds
|
||||
int boundedLevel = Math.min(Math.max(level, 0), 3);
|
||||
int boundedLevel = Math.clamp(level, 0, 3);
|
||||
|
||||
for (AuditLevel auditLevel : values()) {
|
||||
if (auditLevel.level == boundedLevel) {
|
||||
|
||||
+11
-9
@@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId.
|
||||
*
|
||||
@@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore {
|
||||
private static final String FILE_INDEX_PREFIX = "stirling:file2job:";
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final TypeReference<List<String>> LIST_STRING = new TypeReference<>() {};
|
||||
private static final TypeReference<Map<String, String>> MAP_STRING = new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> LIST_STRING =
|
||||
new TypeReference<List<String>>() {};
|
||||
private static final TypeReference<Map<String, String>> MAP_STRING =
|
||||
new TypeReference<Map<String, String>>() {};
|
||||
|
||||
private final StringRedisTemplate template;
|
||||
|
||||
@@ -265,7 +267,7 @@ public class ValkeyJobStore implements JobStore {
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(v.toString(), MAP_STRING);
|
||||
} catch (JsonProcessingException e) {
|
||||
} catch (JacksonException e) {
|
||||
log.warn(
|
||||
"JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty",
|
||||
key,
|
||||
@@ -277,7 +279,7 @@ public class ValkeyJobStore implements JobStore {
|
||||
private static String writeJson(Object value) {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
} catch (JacksonException e) {
|
||||
throw new IllegalStateException("Failed to JSON-serialize JobStore field", e);
|
||||
}
|
||||
}
|
||||
@@ -286,7 +288,7 @@ public class ValkeyJobStore implements JobStore {
|
||||
try {
|
||||
List<String> parsed = MAPPER.readValue(json, LIST_STRING);
|
||||
return parsed == null ? new ArrayList<>() : parsed;
|
||||
} catch (JsonProcessingException e) {
|
||||
} catch (JacksonException e) {
|
||||
log.warn(
|
||||
"JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty",
|
||||
key,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ public class AuditConfigurationProperties {
|
||||
|
||||
// Ensure level is within valid bounds (0-3)
|
||||
int configLevel = auditConfig.getLevel();
|
||||
this.level = Math.min(Math.max(configLevel, 0), 3);
|
||||
this.level = Math.clamp(configLevel, 0, 3);
|
||||
|
||||
// Retention days (0 means infinite)
|
||||
this.retentionDays = auditConfig.getRetentionDays();
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ public class UsageRestController {
|
||||
@RequestParam(value = "dataType", defaultValue = "all") String dataType,
|
||||
@RequestParam(value = "days", defaultValue = "30") Integer days) {
|
||||
|
||||
int lookbackDays = Math.max(1, Math.min(days, 365));
|
||||
int lookbackDays = Math.clamp(days, 1, 365);
|
||||
|
||||
// Get audit events filtered by type
|
||||
List<PersistentAuditEvent> events = getEventsByDataType(dataType, lookbackDays);
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
@@ -19,7 +20,7 @@ import lombok.*;
|
||||
@ToString
|
||||
public class UserLicenseSettings implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final Long SINGLETON_ID = 1L;
|
||||
|
||||
|
||||
+17
-15
@@ -70,21 +70,23 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
|
||||
if (!response.isCommitted()) {
|
||||
if (authentication != null) {
|
||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
// Handle SAML2 logout redirection
|
||||
getRedirect_saml2(request, response, samlAuthentication);
|
||||
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
|
||||
// Handle OAuth2 logout redirection
|
||||
getRedirect_oauth2(request, response, oAuthToken);
|
||||
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
|
||||
// Handle Username/Password logout
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
} else {
|
||||
// Handle unknown authentication types
|
||||
log.error(
|
||||
"Authentication class unknown: {}",
|
||||
authentication.getClass().getSimpleName());
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
switch (authentication) {
|
||||
case Saml2Authentication samlAuthentication ->
|
||||
// Handle SAML2 logout redirection
|
||||
getRedirect_saml2(request, response, samlAuthentication);
|
||||
case OAuth2AuthenticationToken oAuthToken ->
|
||||
// Handle OAuth2 logout redirection
|
||||
getRedirect_oauth2(request, response, oAuthToken);
|
||||
case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken ->
|
||||
// Handle Username/Password logout
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
default -> {
|
||||
// Handle unknown authentication types
|
||||
log.error(
|
||||
"Authentication class unknown: {}",
|
||||
authentication.getClass().getSimpleName());
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (jwtService != null) {
|
||||
|
||||
+37
-37
@@ -357,12 +357,12 @@ public class SecurityConfiguration {
|
||||
req -> {
|
||||
String uri = req.getRequestURI();
|
||||
String contextPath = req.getContextPath();
|
||||
// Check if it's a public auth endpoint or static
|
||||
// resource
|
||||
return RequestUriUtils.isStaticResource(
|
||||
contextPath, uri)
|
||||
|| RequestUriUtils.isPublicAuthEndpoint(
|
||||
uri, contextPath);
|
||||
uri, contextPath)
|
||||
|| RequestUriUtils.isFrontendRoute(
|
||||
contextPath, uri);
|
||||
})
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
@@ -392,40 +392,40 @@ public class SecurityConfiguration {
|
||||
// Handle OAUTH2 Logins
|
||||
if (securityProperties.isOauth2Active()) {
|
||||
http.oauth2Login(
|
||||
oauth2 -> {
|
||||
oauth2.loginPage("/login")
|
||||
.authorizationEndpoint(
|
||||
authorizationEndpoint -> {
|
||||
if (clientRegistrationRepository != null) {
|
||||
authorizationEndpoint
|
||||
.authorizationRequestResolver(
|
||||
new TauriAuthorizationRequestResolver(
|
||||
clientRegistrationRepository));
|
||||
}
|
||||
})
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService,
|
||||
applicationProperties))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
securityProperties
|
||||
.getOauth2(),
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll();
|
||||
});
|
||||
oauth2 ->
|
||||
oauth2.loginPage("/login")
|
||||
.authorizationEndpoint(
|
||||
authorizationEndpoint -> {
|
||||
if (clientRegistrationRepository != null) {
|
||||
authorizationEndpoint
|
||||
.authorizationRequestResolver(
|
||||
new TauriAuthorizationRequestResolver(
|
||||
clientRegistrationRepository));
|
||||
}
|
||||
})
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService,
|
||||
applicationProperties))
|
||||
.failureHandler(
|
||||
new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
securityProperties
|
||||
.getOauth2(),
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll());
|
||||
}
|
||||
// Handle SAML
|
||||
if (securityProperties.isSaml2Active() && runningProOrHigher) {
|
||||
|
||||
+12
-11
@@ -703,17 +703,18 @@ public class AuthController {
|
||||
}
|
||||
|
||||
private long extractEpochMillis(Object claimValue) {
|
||||
if (claimValue == null) {
|
||||
return -1L;
|
||||
}
|
||||
|
||||
if (claimValue instanceof java.util.Date date) {
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
if (claimValue instanceof Number number) {
|
||||
long epochSeconds = number.longValue();
|
||||
return epochSeconds * 1000L;
|
||||
switch (claimValue) {
|
||||
case null -> {
|
||||
return -1L;
|
||||
}
|
||||
case java.util.Date date -> {
|
||||
return date.getTime();
|
||||
}
|
||||
case Number number -> {
|
||||
long epochSeconds = number.longValue();
|
||||
return epochSeconds * 1000L;
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
|
||||
return -1L;
|
||||
|
||||
+8
-8
@@ -760,14 +760,14 @@ public class UserController {
|
||||
for (Object principal : principals) {
|
||||
List<SessionInformation> sessionsInformation =
|
||||
sessionRegistry.getAllSessions(principal, false);
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
userNameP = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
userNameP = oAuth2User.getName();
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
userNameP = saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
userNameP = stringUser;
|
||||
switch (principal) {
|
||||
case null -> {}
|
||||
case UserDetails detailsUser -> userNameP = detailsUser.getUsername();
|
||||
case OAuth2User oAuth2User -> userNameP = oAuth2User.getName();
|
||||
case CustomSaml2AuthenticatedPrincipal saml2User ->
|
||||
userNameP = saml2User.name();
|
||||
case String stringUser -> userNameP = stringUser;
|
||||
default -> {}
|
||||
}
|
||||
if (userNameP.equalsIgnoreCase(username)) {
|
||||
for (SessionInformation sessionInfo : sessionsInformation) {
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
@@ -28,7 +29,7 @@ import lombok.Setter;
|
||||
@Setter
|
||||
public class Authority implements GrantedAuthority, Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -18,7 +19,7 @@ import lombok.Setter;
|
||||
@Setter
|
||||
public class InviteToken implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
+52
-47
@@ -36,57 +36,62 @@ public class CustomOAuth2AuthenticationFailureHandler
|
||||
AuthenticationException exception)
|
||||
throws IOException, ServletException {
|
||||
|
||||
if (exception instanceof BadCredentialsException) {
|
||||
log.error("BadCredentialsException", exception);
|
||||
getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials");
|
||||
return;
|
||||
}
|
||||
if (exception instanceof DisabledException) {
|
||||
log.error("User is deactivated: ", exception);
|
||||
getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
return;
|
||||
}
|
||||
if (exception instanceof LockedException) {
|
||||
log.error("Account locked: ", exception);
|
||||
getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked");
|
||||
return;
|
||||
}
|
||||
if (exception instanceof OAuth2AuthenticationException oAuth2Exception) {
|
||||
OAuth2Error error = oAuth2Exception.getError();
|
||||
|
||||
String errorCode = error.getErrorCode();
|
||||
|
||||
if ("Password must not be null".equals(error.getErrorCode())) {
|
||||
errorCode = "userAlreadyExistsWeb";
|
||||
switch (exception) {
|
||||
case BadCredentialsException badCredentialsException -> {
|
||||
log.error("BadCredentialsException", exception);
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(request, response, "/login?error=badCredentials");
|
||||
return;
|
||||
}
|
||||
case DisabledException disabledException -> {
|
||||
log.error("User is deactivated: ", exception);
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
return;
|
||||
}
|
||||
case LockedException lockedException -> {
|
||||
log.error("Account locked: ", exception);
|
||||
getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked");
|
||||
return;
|
||||
}
|
||||
case OAuth2AuthenticationException oAuth2Exception -> {
|
||||
OAuth2Error error = oAuth2Exception.getError();
|
||||
|
||||
log.error(
|
||||
"OAuth2 Authentication error: {}",
|
||||
errorCode != null ? errorCode : exception.getMessage(),
|
||||
exception);
|
||||
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
|
||||
clearRedirectCookie(response);
|
||||
boolean tauriState = TauriOAuthUtils.isTauriState(request);
|
||||
String redirectUrl;
|
||||
if (tauriState) {
|
||||
String basePath =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
redirectUrl = basePath;
|
||||
String stateParam = request.getParameter("state");
|
||||
if (stateParam != null && !stateParam.isBlank()) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
|
||||
// Extract and pass nonce for CSRF validation
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
String errorCode = error.getErrorCode();
|
||||
|
||||
if ("Password must not be null".equals(error.getErrorCode())) {
|
||||
errorCode = "userAlreadyExistsWeb";
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
|
||||
} else {
|
||||
redirectUrl = buildFailureRedirectUrl(request, errorValue);
|
||||
|
||||
log.error(
|
||||
"OAuth2 Authentication error: {}",
|
||||
errorCode != null ? errorCode : exception.getMessage(),
|
||||
exception);
|
||||
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
|
||||
clearRedirectCookie(response);
|
||||
boolean tauriState = TauriOAuthUtils.isTauriState(request);
|
||||
String redirectUrl;
|
||||
if (tauriState) {
|
||||
String basePath =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
redirectUrl = basePath;
|
||||
String stateParam = request.getParameter("state");
|
||||
if (stateParam != null && !stateParam.isBlank()) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
|
||||
// Extract and pass nonce for CSRF validation
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
|
||||
} else {
|
||||
redirectUrl = buildFailureRedirectUrl(request, errorValue);
|
||||
}
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
}
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
default -> {}
|
||||
}
|
||||
log.error("Unhandled authentication exception", exception);
|
||||
super.onAuthenticationFailure(request, response, exception);
|
||||
|
||||
+6
-1
@@ -61,7 +61,12 @@ public class CustomSaml2ResponseAuthenticationConverter
|
||||
|
||||
@Override
|
||||
public Saml2Authentication convert(ResponseToken responseToken) {
|
||||
Assertion assertion = responseToken.getResponse().getAssertions().getFirst();
|
||||
List<Assertion> assertions = responseToken.getResponse().getAssertions();
|
||||
if (assertions == null || assertions.isEmpty()) {
|
||||
log.error("SAML response contains no assertions");
|
||||
return null;
|
||||
}
|
||||
Assertion assertion = assertions.getFirst();
|
||||
Map<String, List<Object>> attributes = extractAttributes(assertion);
|
||||
|
||||
// Debug log with actual values
|
||||
|
||||
+5
-2
@@ -213,8 +213,11 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
}
|
||||
|
||||
sb.append(
|
||||
"\nWARNING: this block contains PII. Set security.oauth2.debugLogging=false once"
|
||||
+ " troubleshooting is complete.\n");
|
||||
"""
|
||||
|
||||
WARNING: this block contains PII. Set security.oauth2.debugLogging=false once\
|
||||
troubleshooting is complete.
|
||||
""");
|
||||
sb.append("========== [/OAUTH2 DEBUG] ==========");
|
||||
|
||||
if (failure) {
|
||||
|
||||
+3
-1
@@ -132,7 +132,9 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
verifyingKeyCache.put(
|
||||
key.getKeyId(), new JwtVerificationKey(key.getKeyId(), key.getVerifyingKey()));
|
||||
}
|
||||
activeKey = new JwtVerificationKey(keys.get(0).getKeyId(), keys.get(0).getVerifyingKey());
|
||||
activeKey =
|
||||
new JwtVerificationKey(
|
||||
keys.getFirst().getKeyId(), keys.getFirst().getVerifyingKey());
|
||||
log.info("Loaded {} JWT key(s) from DB, active key: {}", keys.size(), activeKey.getKeyId());
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -640,14 +640,14 @@ public class UserService implements UserServiceInterface {
|
||||
for (Object principal : sessionRegistry.getAllPrincipals()) {
|
||||
for (SessionInformation sessionsInformation :
|
||||
sessionRegistry.getAllSessions(principal, false)) {
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
usernameP = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
usernameP = oAuth2User.getName();
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
usernameP = saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
usernameP = stringUser;
|
||||
switch (principal) {
|
||||
case null -> {}
|
||||
case UserDetails detailsUser -> usernameP = detailsUser.getUsername();
|
||||
case OAuth2User oAuth2User -> usernameP = oAuth2User.getName();
|
||||
case CustomSaml2AuthenticatedPrincipal saml2User ->
|
||||
usernameP = saml2User.name();
|
||||
case String stringUser -> usernameP = stringUser;
|
||||
default -> {}
|
||||
}
|
||||
if (usernameP.equalsIgnoreCase(username)) {
|
||||
sessionRegistry.expireSession(sessionsInformation.getSessionId());
|
||||
|
||||
+14
-16
@@ -47,14 +47,13 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
List<SessionInformation> sessionInformations = new ArrayList<>();
|
||||
String principalName = null;
|
||||
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
principalName = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
principalName = oAuth2User.getName();
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
principalName = saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
principalName = stringUser;
|
||||
switch (principal) {
|
||||
case null -> {}
|
||||
case UserDetails detailsUser -> principalName = detailsUser.getUsername();
|
||||
case OAuth2User oAuth2User -> principalName = oAuth2User.getName();
|
||||
case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name();
|
||||
case String stringUser -> principalName = stringUser;
|
||||
default -> {}
|
||||
}
|
||||
|
||||
if (principalName != null) {
|
||||
@@ -78,14 +77,13 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
public void registerNewSession(String sessionId, Object principal) {
|
||||
String principalName = null;
|
||||
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
principalName = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
principalName = oAuth2User.getName();
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
principalName = saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
principalName = stringUser;
|
||||
switch (principal) {
|
||||
case null -> {}
|
||||
case UserDetails detailsUser -> principalName = detailsUser.getUsername();
|
||||
case OAuth2User oAuth2User -> principalName = oAuth2User.getName();
|
||||
case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name();
|
||||
case String stringUser -> principalName = stringUser;
|
||||
default -> {}
|
||||
}
|
||||
|
||||
if (principalName != null) {
|
||||
|
||||
+8
-8
@@ -3,16 +3,16 @@ package stirling.software.proprietary.storage.converter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* JPA AttributeConverter for storing Map<String, Object> as JSON in database columns.
|
||||
*
|
||||
@@ -33,7 +33,7 @@ public class JsonMapConverter implements AttributeConverter<Map<String, Object>,
|
||||
|
||||
try {
|
||||
return objectMapper.writeValueAsString(attribute);
|
||||
} catch (JsonProcessingException e) {
|
||||
} catch (JacksonException e) {
|
||||
log.error("Failed to convert map to JSON", e);
|
||||
throw new RuntimeException("Failed to convert map to JSON", e);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public class JsonMapConverter implements AttributeConverter<Map<String, Object>,
|
||||
try {
|
||||
// Try normal parsing first
|
||||
return objectMapper.readValue(dbData, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (JsonProcessingException e) {
|
||||
} catch (JacksonException e) {
|
||||
// Fallback: try double-parsing for legacy double-encoded data
|
||||
// This handles data that was stored as JSON strings instead of JSON objects
|
||||
log.debug("Attempting double-decode fallback for legacy metadata format");
|
||||
@@ -69,7 +69,7 @@ public class JsonMapConverter implements AttributeConverter<Map<String, Object>,
|
||||
return objectMapper.readValue(
|
||||
node.asText(), new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
} catch (JsonProcessingException e2) {
|
||||
} catch (JacksonException e2) {
|
||||
log.error("Failed to parse metadata even with double-decode fallback", e2);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.storage.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -46,7 +47,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
@Setter
|
||||
public class FileShare implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.storage.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -39,7 +40,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
@Setter
|
||||
public class FileShareAccess implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.storage.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -24,7 +25,7 @@ import lombok.Setter;
|
||||
@Setter
|
||||
public class StorageCleanupEntry implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.storage.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
@@ -45,7 +46,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
@Setter
|
||||
public class StoredFile implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.storage.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
@@ -19,7 +20,7 @@ import lombok.Setter;
|
||||
@Setter
|
||||
public class StoredFileBlob implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "storage_key", nullable = false, length = 128)
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.slf4j.MDC;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
@@ -64,7 +65,7 @@ public class AuditWebFilter extends OncePerRequestFilter {
|
||||
if (auth != null && auth.getAuthorities() != null) {
|
||||
String roles =
|
||||
auth.getAuthorities().stream()
|
||||
.map(a -> a.getAuthority())
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.reduce((a, b) -> a + "," + b)
|
||||
.orElse("");
|
||||
MDC.put("userRoles", roles);
|
||||
|
||||
+6
-3
@@ -20,8 +20,6 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@@ -39,11 +37,14 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo;
|
||||
import stirling.software.proprietary.workflow.dto.CertificateValidationResponse;
|
||||
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
|
||||
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
|
||||
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
|
||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security")
|
||||
@@ -259,7 +260,9 @@ public class SigningSessionController {
|
||||
+ "database until manual cleanup.",
|
||||
sessionId,
|
||||
session.getParticipants() != null
|
||||
? session.getParticipants().stream().map(p -> p.getEmail()).toList()
|
||||
? session.getParticipants().stream()
|
||||
.map(WorkflowParticipant::getEmail)
|
||||
.toList()
|
||||
: "unknown",
|
||||
e);
|
||||
throw new ResponseStatusException(
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.ContentDisposition;
|
||||
@@ -429,7 +430,7 @@ public class WorkflowParticipantController {
|
||||
java.util.List<Map<String, Object>> wetSigs =
|
||||
objectMapper.readValue(
|
||||
request.getWetSignaturesData(),
|
||||
new TypeReference<java.util.List<Map<String, Object>>>() {});
|
||||
new TypeReference<List<Map<String, Object>>>() {});
|
||||
if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Too many wet signatures submitted");
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.workflow.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
@@ -51,7 +52,7 @@ import stirling.software.proprietary.storage.model.ShareAccessRole;
|
||||
@Setter
|
||||
public class WorkflowParticipant implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.workflow.model;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
@@ -53,7 +54,7 @@ import stirling.software.proprietary.storage.model.StoredFile;
|
||||
@Setter
|
||||
public class WorkflowSession implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
+4
-10
@@ -217,16 +217,13 @@ public class SigningFinalizationService {
|
||||
wetSignatures.size(),
|
||||
session.getSessionId());
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes));
|
||||
try {
|
||||
try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) {
|
||||
for (WetSignatureMetadata wetSig : wetSignatures) {
|
||||
applyWetSignatureToPage(document, wetSig);
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
} finally {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,11 +239,10 @@ public class SigningFinalizationService {
|
||||
}
|
||||
|
||||
PDPage page = document.getPage(pageIndex);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
|
||||
try {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
// Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix
|
||||
String base64Data = wetSig.extractBase64Data();
|
||||
if (base64Data == null || base64Data.isBlank()) {
|
||||
@@ -279,8 +275,6 @@ public class SigningFinalizationService {
|
||||
pdfY,
|
||||
width,
|
||||
height);
|
||||
} finally {
|
||||
contentStream.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-15
@@ -954,21 +954,22 @@ public class WorkflowSessionService {
|
||||
Object pemObject = pemParser.readObject();
|
||||
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
|
||||
PrivateKeyInfo keyInfo;
|
||||
if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) {
|
||||
InputDecryptorProvider decryptor =
|
||||
new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password);
|
||||
keyInfo = encrypted.decryptPrivateKeyInfo(decryptor);
|
||||
} else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) {
|
||||
PEMDecryptorProvider decryptor =
|
||||
new JcePEMDecryptorProviderBuilder().build(password);
|
||||
keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo();
|
||||
} else if (pemObject instanceof PEMKeyPair keyPair) {
|
||||
keyInfo = keyPair.getPrivateKeyInfo();
|
||||
} else if (pemObject instanceof PrivateKeyInfo info) {
|
||||
keyInfo = info;
|
||||
} else {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Unsupported PEM private key format");
|
||||
switch (pemObject) {
|
||||
case PKCS8EncryptedPrivateKeyInfo encrypted -> {
|
||||
InputDecryptorProvider decryptor =
|
||||
new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password);
|
||||
keyInfo = encrypted.decryptPrivateKeyInfo(decryptor);
|
||||
}
|
||||
case PEMEncryptedKeyPair encryptedKeyPair -> {
|
||||
PEMDecryptorProvider decryptor =
|
||||
new JcePEMDecryptorProviderBuilder().build(password);
|
||||
keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo();
|
||||
}
|
||||
case PEMKeyPair keyPair -> keyInfo = keyPair.getPrivateKeyInfo();
|
||||
case PrivateKeyInfo info -> keyInfo = info;
|
||||
case null, default ->
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Unsupported PEM private key format");
|
||||
}
|
||||
return converter.getPrivateKey(keyInfo);
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
|
||||
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
|
||||
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent
|
||||
* API responses.
|
||||
|
||||
@@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -167,7 +167,7 @@ public class AiCreateController {
|
||||
if (request.constraints() != null) {
|
||||
try {
|
||||
constraintsPayload = objectMapper.writeValueAsString(request.constraints());
|
||||
} catch (JsonProcessingException exc) {
|
||||
} catch (JacksonException exc) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc);
|
||||
}
|
||||
@@ -202,7 +202,7 @@ public class AiCreateController {
|
||||
String payload;
|
||||
try {
|
||||
payload = objectMapper.writeValueAsString(request.draftSections());
|
||||
} catch (JsonProcessingException exc) {
|
||||
} catch (JacksonException exc) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
|
||||
}
|
||||
@@ -392,7 +392,7 @@ public class AiCreateController {
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(List.class, DraftSection.class));
|
||||
} catch (JsonProcessingException exc) {
|
||||
} catch (JacksonException exc) {
|
||||
log.warn("Failed to parse draft sections payload", exc);
|
||||
return null;
|
||||
}
|
||||
@@ -408,7 +408,7 @@ public class AiCreateController {
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructMapType(Map.class, String.class, Object.class));
|
||||
} catch (JsonProcessingException exc) {
|
||||
} catch (JacksonException exc) {
|
||||
log.warn("Failed to parse outline constraints payload", exc);
|
||||
return null;
|
||||
}
|
||||
|
||||
+6
-6
@@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -61,7 +61,7 @@ public class AiCreateInternalController {
|
||||
try {
|
||||
outlineConstraintsPayload =
|
||||
objectMapper.writeValueAsString(request.outlineConstraints());
|
||||
} catch (JsonProcessingException exc) {
|
||||
} catch (JacksonException exc) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class AiCreateInternalController {
|
||||
if (request.draftSections() != null) {
|
||||
try {
|
||||
draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections());
|
||||
} catch (JsonProcessingException exc) {
|
||||
} catch (JacksonException exc) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public class AiCreateInternalController {
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(
|
||||
List.class, AiCreateController.DraftSection.class));
|
||||
} catch (JsonProcessingException exc) {
|
||||
} catch (JacksonException exc) {
|
||||
log.warn("Failed to parse draft sections payload", exc);
|
||||
return null;
|
||||
}
|
||||
@@ -152,7 +152,7 @@ public class AiCreateInternalController {
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructMapType(Map.class, String.class, Object.class));
|
||||
} catch (JsonProcessingException exc) {
|
||||
} catch (JacksonException exc) {
|
||||
log.warn("Failed to parse outline constraints payload", exc);
|
||||
return null;
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import java.util.regex.Pattern;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
@@ -56,28 +56,26 @@ public class LegalDocumentRegistry {
|
||||
subprocessorUrl = root.path("subprocessorUrl").asText("");
|
||||
eulaUrl = root.path("eulaUrl").asText("");
|
||||
JsonNode docs = root.path("documents");
|
||||
docs.fieldNames()
|
||||
.forEachRemaining(
|
||||
id -> {
|
||||
JsonNode d = docs.get(id);
|
||||
List<String> parts =
|
||||
objectMapper.convertValue(
|
||||
d.path("parts"),
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(
|
||||
List.class, String.class));
|
||||
documents.put(
|
||||
docs.forEachEntry(
|
||||
(id, d) -> {
|
||||
List<String> parts =
|
||||
objectMapper.convertValue(
|
||||
d.path("parts"),
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(
|
||||
List.class, String.class));
|
||||
documents.put(
|
||||
id,
|
||||
new LegalDocumentMeta(
|
||||
id,
|
||||
new LegalDocumentMeta(
|
||||
id,
|
||||
d.path("label").asText(id),
|
||||
d.path("displayName").asText(id),
|
||||
d.path("version").asText("0"),
|
||||
d.path("effectiveDate").asText(""),
|
||||
d.path("status").asText("draft"),
|
||||
parts == null ? List.of() : parts));
|
||||
});
|
||||
d.path("label").asText(id),
|
||||
d.path("displayName").asText(id),
|
||||
d.path("version").asText("0"),
|
||||
d.path("effectiveDate").asText(""),
|
||||
d.path("status").asText("draft"),
|
||||
parts == null ? List.of() : parts));
|
||||
});
|
||||
log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -707,7 +707,7 @@ public class ProcurementService {
|
||||
private String writeLineItems(QuoteBreakdown breakdown) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
|
||||
} catch (JsonProcessingException e) {
|
||||
} catch (JacksonException e) {
|
||||
log.warn("[procurement] failed to serialise line items", e);
|
||||
return "[]";
|
||||
}
|
||||
|
||||
@@ -113,19 +113,13 @@ public class RateLimitService {
|
||||
public void cleanupExpiredBuckets() {
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
int hourlyRemoved =
|
||||
(int)
|
||||
hourlyLimits.entrySet().stream()
|
||||
.filter(e -> e.getValue().getResetTime() < now)
|
||||
.peek(e -> hourlyLimits.remove(e.getKey()))
|
||||
.count();
|
||||
int hourlyBefore = hourlyLimits.size();
|
||||
hourlyLimits.entrySet().removeIf(e -> e.getValue().getResetTime() < now);
|
||||
int hourlyRemoved = hourlyBefore - hourlyLimits.size();
|
||||
|
||||
int dailyRemoved =
|
||||
(int)
|
||||
dailyLimits.entrySet().stream()
|
||||
.filter(e -> e.getValue().getResetTime() < now)
|
||||
.peek(e -> dailyLimits.remove(e.getKey()))
|
||||
.count();
|
||||
int dailyBefore = dailyLimits.size();
|
||||
dailyLimits.entrySet().removeIf(e -> e.getValue().getResetTime() < now);
|
||||
int dailyRemoved = dailyBefore - dailyLimits.size();
|
||||
|
||||
if (hourlyRemoved + dailyRemoved > 0) {
|
||||
log.debug(
|
||||
|
||||
+2
-2
@@ -28,8 +28,8 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
+9
-3
@@ -42,7 +42,7 @@ ext {
|
||||
bucket4jVersion = "8.19.0"
|
||||
archunitVersion = "1.4.2"
|
||||
batikVersion = "1.19"
|
||||
jpdfiumVersion = "1.0.4"
|
||||
jpdfiumVersion = "1.1.3"
|
||||
jwtVersion = "0.13.0"
|
||||
awsSdkVersion = "2.51.3"
|
||||
jschVersion = "2.28.6"
|
||||
@@ -265,7 +265,6 @@ subprojects {
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'io.github.pixee:java-security-toolkit:1.2.3'
|
||||
|
||||
//tmp for security bumps
|
||||
implementation "ch.qos.logback:logback-core:$logback"
|
||||
@@ -307,7 +306,7 @@ subprojects {
|
||||
systemProperty 'apple.awt.UIElement', 'true'
|
||||
|
||||
testLogging {
|
||||
events "started", "failed"
|
||||
events "skipped", "failed"
|
||||
showExceptions = true
|
||||
showCauses = true
|
||||
showStackTraces = true
|
||||
@@ -543,6 +542,13 @@ subprojects {
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy initialization defers bean creation until first use,
|
||||
// reducing dev-mode RSS significantly (heap drops ~40-60%).
|
||||
// Enable with: ./gradlew bootRun -PlazyInit=true
|
||||
if (rootProject.findProperty('lazyInit') == 'true') {
|
||||
runtimeArgs.add("-Dspring.main.lazy-initialization=true")
|
||||
logger.lifecycle("Lazy initialization enabled (-PlazyInit=true)")
|
||||
}
|
||||
jvmArgs = runtimeArgs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Code comments
|
||||
|
||||
A comment must carry information the code cannot. If a reader could derive it from
|
||||
the code in front of them, delete it: a redundant comment still has to be
|
||||
maintained, will eventually contradict the code, and dilutes the comments that
|
||||
matter.
|
||||
|
||||
The operative rules are in `AGENTS.md`, kept short so they stay in an agent's
|
||||
context. This document is the reasoning and the worked examples behind them, plus
|
||||
how to run the linter.
|
||||
|
||||
## Comment the current state
|
||||
|
||||
Describe the code as it is. Not what it used to be, not what changed, not why it
|
||||
changed. A comment that narrates history is stale the moment the next change
|
||||
lands, and git already holds that record.
|
||||
|
||||
When you know the history and it explains the shape of the code, the useful half is
|
||||
the reason, not the sequence. State the reason:
|
||||
|
||||
```java
|
||||
// Don't:
|
||||
// This used to reimplement the modal internals, which is how the procurement
|
||||
// dialogs drifted from the billing ones.
|
||||
|
||||
// Do:
|
||||
// Thin wrapper over the shared Modal: duplicating its portal and focus trap is
|
||||
// how dialogs drift apart.
|
||||
```
|
||||
|
||||
Future state is the exception, and it belongs in a TODO with an issue.
|
||||
|
||||
## The four jobs
|
||||
|
||||
**Contract.** What a caller must know that the signature cannot say:
|
||||
preconditions, invariants, units, ownership and lifetime, thread-safety, error
|
||||
semantics, side effects.
|
||||
|
||||
The bound is the surface, not the volume: document the contract of everything a
|
||||
caller outside the file can reach, and nothing else. Inside that surface say
|
||||
whatever a caller needs; outside it a comment earns its place on the same terms as
|
||||
any other.
|
||||
|
||||
```java
|
||||
/**
|
||||
* Authority on which filesystem locations a policy may read or write. Fail-closed
|
||||
* in order: denied entirely under the saas profile; Stirling's own config dir is
|
||||
* always rejected; the path must resolve within policies.allowedFolderRoots.
|
||||
*
|
||||
* <p>Compared after normalisation so {@code ..} cannot escape a root. Symlink
|
||||
* escape is not defended: an operator who roots an allowlist on a symlink to a
|
||||
* sensitive location is trusted.
|
||||
*/
|
||||
```
|
||||
|
||||
**Why.** The constraint the code satisfies, the bug it avoids, the alternative
|
||||
rejected and the reason.
|
||||
|
||||
```java
|
||||
// whenComplete runs on the worker thread after the run finishes, so the
|
||||
// terminal event never races the step events.
|
||||
handle.completion()
|
||||
```
|
||||
|
||||
A reference is supplementary, never load-bearing: the comment must survive
|
||||
deleting it. `// See #1234` is a dead end.
|
||||
|
||||
```java
|
||||
// flatten() reads the annotation list that save() clears, so saving first loses
|
||||
// every annotation (#6865).
|
||||
document.flatten(annotations);
|
||||
```
|
||||
|
||||
Prefer a spec (`RFC 3161`, `ISO 4217`) or a CVE where one applies. Both are
|
||||
immutable; a ticket can be closed, moved or made private.
|
||||
|
||||
**Hazard.** "Must stay in sync with X." "Order matters because Y." "Do not remove,
|
||||
it prevents Z."
|
||||
|
||||
**Map.** A short orientation at the head of a genuinely complex file: what it owns,
|
||||
and what it deliberately does not.
|
||||
|
||||
## The test that decides it
|
||||
|
||||
A comment earns its place when it sits at a different level of detail than the line
|
||||
below it: lower, stating a precise fact the code implies but does not say, or
|
||||
higher, giving intent a reader would otherwise assemble from ten lines.
|
||||
Same-altitude is the definition of redundant.
|
||||
|
||||
- **Delete it.** Is any information lost? If not, it stays deleted.
|
||||
- **Could a name carry it instead?** A better identifier, an extracted function or
|
||||
a named constant beats a comment. Prefer the code change.
|
||||
|
||||
## What not to write
|
||||
|
||||
| Don't | Instead |
|
||||
| --- | --- |
|
||||
| `// Handle drag start` above `handleDragStart` | Nothing. The name already says it. |
|
||||
| `// ─── Types ───`, `// Helpers`, `// ====` | If a file needs internal signposting, split the file. |
|
||||
| `// Step 1:` narrating a function body | Extract functions. If the steps need labels they need names. |
|
||||
| `// No longer needed`, `// Previously this used X` | State why the code is as it is now, or nothing. |
|
||||
| Commented-out code | Delete it. Git remembers. |
|
||||
| `@param blob - The blob to download` | Omit the tag rather than pad it. |
|
||||
| Docs on a self-explanatory member | Nothing, unless there is a real constraint to state. |
|
||||
|
||||
Step numbering is fine where it labels a genuinely numbered thing, such as a wizard
|
||||
step or a step in a written test procedure. It is narration when it numbers the
|
||||
lines of one function.
|
||||
|
||||
## Comments at the end of a line
|
||||
|
||||
A trailing comment usually does a different job from one above the code: it decodes
|
||||
the line it sits on. Those are worth keeping, and the linter leaves them alone.
|
||||
|
||||
```java
|
||||
byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF"
|
||||
long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB
|
||||
double buffer = 0.10; // 10% headroom
|
||||
default -> toBytes(value, 2); // MB
|
||||
```
|
||||
|
||||
Each overlaps in words with the code and each adds the interpretation the code
|
||||
leaves implicit, which is the lower-altitude case the test above asks for. So
|
||||
`CMT001` does not judge trailing comments; on this codebase it would have been
|
||||
wrong about roughly six in seven of them.
|
||||
|
||||
What still applies is anything that does not depend on the code below: a trailing
|
||||
`// TODO fix this` is as unowned as one on its own line, and a trailing
|
||||
`// this used to run before the flush` narrates history wherever it sits.
|
||||
|
||||
A comment block over about 12 lines, outside a file or type header, is usually a
|
||||
sign the code needs restructuring. If it is genuinely product documentation, it
|
||||
belongs in the docs repo.
|
||||
|
||||
## TODOs
|
||||
|
||||
A TODO needs an issue, because an issue is the only part that will close it:
|
||||
|
||||
```java
|
||||
// TODO(#1234): re-enable the checkout gate once account syncing lands
|
||||
```
|
||||
|
||||
An owner is not a substitute: a username goes stale when someone changes team and
|
||||
means nothing to an outside contributor. If the work is not worth an issue it is
|
||||
not worth a TODO, and the options are to do it now or leave the code alone. A
|
||||
question is not a TODO.
|
||||
|
||||
## Per language
|
||||
|
||||
**Java.** Google Java Style, which this repo already formats to. Its §7.3.1
|
||||
exception applies: omit Javadoc on a self-explanatory member where there is
|
||||
genuinely nothing to add, but do not cite it to skip something a reader needs.
|
||||
Summary fragments are noun or verb phrases, not sentences starting "This method
|
||||
returns".
|
||||
|
||||
**TypeScript.** JSDoc on the `@app/*` seams, exported hooks, and anything crossing
|
||||
a layer boundary. No `@param`/`@returns` that restates a typed signature. JSX
|
||||
comments follow the same rules as any other.
|
||||
|
||||
**Python.** Docstrings on modules, public functions and Pydantic models where the
|
||||
contract is not obvious from the type.
|
||||
|
||||
## The linter
|
||||
|
||||
```bash
|
||||
task comment-lint # what the working tree adds over HEAD
|
||||
task comment-lint:branch # what the branch adds over origin/main (BASE=<ref> to change)
|
||||
task pre-commit:comment-lint:ci # the fixture corpus, then the diff
|
||||
```
|
||||
|
||||
`comment-lint` is the pre-commit question, so it reports nothing once you have
|
||||
committed; on a CI pull request it compares against the target branch via
|
||||
`GITHUB_BASE_REF`. `comment-lint:branch` is the review question. The corpus checks
|
||||
the rules themselves rather than the code under review, so it runs on CI and before
|
||||
a rule change, not on every local commit.
|
||||
|
||||
`task comment-lint` also runs inside `task pre-commit`, and as a Claude Code `Stop`
|
||||
hook, so an agent is told before it finishes a turn and fixes the comment inside
|
||||
that turn. Stop rather than per file write: a run costs the same for one file as for
|
||||
twenty-five, and half of all writes in a turn go to a file already written in it.
|
||||
|
||||
Findings are scoped to comment text that is new, not to lines git calls new, so
|
||||
reindenting or moving code does not resurface comments you did not write.
|
||||
|
||||
The rules are the `RULES` object in
|
||||
[`scripts/lint/comment-rules.mjs`](../scripts/lint/comment-rules.mjs); the exact
|
||||
condition for each is the predicate of the same name in that file, with the
|
||||
readings it deliberately excludes beside it.
|
||||
|
||||
**Every rule blocks.** A rule that only warns is a rule nobody acts on. So a
|
||||
finding you believe is wrong is a bug in the rule, not something to live with:
|
||||
narrow the rule, or mark the line and say why.
|
||||
|
||||
Every comment form the repo writes is covered: `//` and `/* */`, Javadoc and JSDoc,
|
||||
JSX comments, `#`, and Python docstrings. `CMT007` reads all three parameter
|
||||
conventions in use here, Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google
|
||||
`name: description` under `Args:`.
|
||||
|
||||
Two engines, one rule set. `.ts`/`.tsx`/`.mjs` go to an oxlint JS plugin, so
|
||||
comments come from the parser: a `//` inside a string is not a comment, and JSX
|
||||
`{/* … */}` is. `.java`/`.py` go to a line scanner. Neither reads the other's
|
||||
files, so they cannot disagree about one file. `scripts/lint/fixtures/` is the
|
||||
corpus that keeps them meaning the same thing.
|
||||
|
||||
### When a finding is wrong
|
||||
|
||||
Name the rule on the line above:
|
||||
|
||||
```ts
|
||||
// comment-lint-allow: CMT002
|
||||
// ─── kept deliberately, because <reason> ───
|
||||
```
|
||||
|
||||
There is no form that disables every rule, and the directive has to earn its
|
||||
place. `CMT008` reports one that names something which is not a rule, and one that
|
||||
silences nothing, so a typo does not read as a suppression and a stale
|
||||
suppression does not sit there blinding the line. The whole comment must be the
|
||||
directive; prose that mentions the syntax is just prose.
|
||||
|
||||
If you reach for this more than occasionally the rule is wrong: fix it in
|
||||
`comment-rules.mjs` and update the fixture corpus in the same commit, so the diff
|
||||
shows what moved.
|
||||
|
||||
### The existing backlog
|
||||
|
||||
`task pre-commit:comment-lint:all` reports the whole tree and never fails. There is
|
||||
a standing backlog being cleared by directory; diff scoping is what keeps it off
|
||||
whoever touches a file first.
|
||||
|
||||
To turn the editor hook off, put `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in
|
||||
`.claude/settings.local.json`. The commit-time gate still applies, so you lose the
|
||||
early warning rather than the check.
|
||||
@@ -8,6 +8,7 @@ This directory contains all development-related documentation for Stirling PDF.
|
||||
- **[DeveloperGuide.md](../DeveloperGuide.md)** - Main developer setup and architecture guide (in repo root)
|
||||
- **[Taskfile.yml](../Taskfile.yml)** - Unified task runner for all build/dev/test/lint commands
|
||||
- **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices
|
||||
- **[CODE_COMMENTS.md](./CODE_COMMENTS.md)** - What a comment is for, what not to write, and the `task comment-lint` rules
|
||||
- **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide
|
||||
- **[STORAGE_ENCRYPTION_AT_REST.md](./STORAGE_ENCRYPTION_AT_REST.md)** - Encryption at rest for stored files: key setup, migration, revocation, rotation
|
||||
|
||||
|
||||
@@ -725,6 +725,9 @@ class OcrPdfParams(ApiModel):
|
||||
)
|
||||
ocr_type: OcrType = Field(..., description="Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'")
|
||||
remove_images_after: bool | None = Field(None, description="Remove images from the output PDF if set to true")
|
||||
rotate_pages: bool | None = Field(
|
||||
None, description="Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true"
|
||||
)
|
||||
sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true")
|
||||
|
||||
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "إحداثي Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "فشل قصّ PDF"
|
||||
invalidArea = "منطقة القص تتجاوز حدود PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "معاينة منطقة القص"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y mövqeyi"
|
||||
|
||||
[crop.error]
|
||||
failed = "PDF-i kəsmək alınmadı"
|
||||
invalidArea = "Kəsmə sahəsi PDF sərhədlərini aşır"
|
||||
|
||||
[crop.preview]
|
||||
title = "Kəsmə sahəsinin seçimi"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y позиция"
|
||||
|
||||
[crop.error]
|
||||
failed = "Неуспешно изрязване на PDF"
|
||||
invalidArea = "Областта за изрязване излиза извън границите на PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Избор на област за изрязване"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Yཡི་གནས་བབ།"
|
||||
|
||||
[crop.error]
|
||||
failed = "སོན་བཟང་མ་འདང་བ། PDF"
|
||||
invalidArea = "སོན་འདེབས་རྒྱ་ཁྱོན་དེ་PDFམཚམས་ཐིག་ལས་བརྒལ་ཡོད།"
|
||||
|
||||
[crop.preview]
|
||||
title = "སོན་བཟང་ཁུལ་འདེམས་པ།"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Posició Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "No s'ha pogut retallar el PDF"
|
||||
invalidArea = "L'àrea de retall s'estén més enllà dels límits del PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Selecció de l'àrea de retall"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Pozice Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "Oříznutí PDF se nezdařilo"
|
||||
invalidArea = "Oblast ořezu přesahuje hranice PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Výběr oblasti ořezu"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y-position"
|
||||
|
||||
[crop.error]
|
||||
failed = "Kunne ikke beskære PDF"
|
||||
invalidArea = "Beskæringsområdet strækker sig ud over PDF'ens grænser"
|
||||
|
||||
[crop.preview]
|
||||
title = "Valg af beskæringsområde"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y-Position"
|
||||
|
||||
[crop.error]
|
||||
failed = "PDF zuschneiden fehlgeschlagen"
|
||||
invalidArea = "Zuschneidebereich überschreitet die PDF-Grenzen"
|
||||
|
||||
[crop.preview]
|
||||
title = "Zuschneidebereich-Auswahl"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Θέση Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "Αποτυχία περικοπής του PDF"
|
||||
invalidArea = "Η περιοχή περικοπής εκτείνεται πέρα από τα όρια του PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Επιλογή περιοχής περικοπής"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y Position"
|
||||
|
||||
[crop.error]
|
||||
failed = "Failed to crop PDF"
|
||||
invalidArea = "Crop area extends beyond PDF boundaries"
|
||||
|
||||
[crop.preview]
|
||||
title = "Crop Area Selection"
|
||||
|
||||
@@ -3846,7 +3846,6 @@ label = "Y Position"
|
||||
|
||||
[crop.error]
|
||||
failed = "Failed to crop PDF"
|
||||
invalidArea = "Crop area extends beyond PDF boundaries"
|
||||
|
||||
[crop.preview]
|
||||
title = "Crop Area Selection"
|
||||
@@ -4141,7 +4140,6 @@ mobileShort = "Mobile"
|
||||
mobileUpload = "Mobile Upload"
|
||||
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||
moreOptions = "More options"
|
||||
myFiles = "My Files"
|
||||
nextFile = "Next file"
|
||||
noFiles = "No files available"
|
||||
noFilesFound = "No files found matching your search"
|
||||
@@ -4242,9 +4240,9 @@ duplicateFailed = "Could not duplicate file"
|
||||
expand = "Expand sidebar"
|
||||
googleDrive = "Google Drive"
|
||||
googleDriveDisabled = "Google Drive is not configured"
|
||||
leaveMyFiles = "Leave My Files"
|
||||
leaveMyFiles = "Leave File library"
|
||||
library = "PDF Library"
|
||||
myFiles = "My Files"
|
||||
myFiles = "File library"
|
||||
noFiles = "No files yet"
|
||||
openFileManager = "Browse all files & folders"
|
||||
openFromComputer = "Open from computer"
|
||||
@@ -4287,7 +4285,7 @@ addToWorkspaceCount = "Add {{count}} to workspace"
|
||||
allFiles = "All files"
|
||||
back = "Back"
|
||||
backToFolder = "Back to {{folder}}"
|
||||
backToMyFiles = "Back to My Files"
|
||||
backToMyFiles = "Back to File library"
|
||||
breadcrumbs = "Folder path"
|
||||
bulkActions = "Actions"
|
||||
cancel = "Cancel"
|
||||
@@ -4339,7 +4337,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to
|
||||
moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)."
|
||||
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
|
||||
moveTo = "Move to…"
|
||||
myFiles = "My Files"
|
||||
newFolder = "New folder"
|
||||
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
|
||||
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
|
||||
@@ -9173,7 +9170,6 @@ appEditor = "Editor"
|
||||
appProcessor = "Processor"
|
||||
linkAccount = "Link Stirling account"
|
||||
primaryNav = "Primary navigation"
|
||||
switchApp = "Switch app"
|
||||
|
||||
[portal.shell.topbar]
|
||||
closeNav = "Close navigation"
|
||||
@@ -9652,6 +9648,16 @@ automate = "Automate"
|
||||
config = "Config"
|
||||
files = "Files"
|
||||
|
||||
[quickNav]
|
||||
editor = "Editor"
|
||||
home = "Stirling"
|
||||
invite = "Invite"
|
||||
landmark = "Quick navigation"
|
||||
noProcessorAccess = "Ask an admin for processor access"
|
||||
notifications = "Notifications"
|
||||
processor = "Processor"
|
||||
reader = "Reader"
|
||||
|
||||
[read]
|
||||
tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse"
|
||||
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Posición Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "Error al recortar PDF"
|
||||
invalidArea = "El área de recorte se extiende más allá de los límites del PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Selección de Área de Recorte"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y posizioa"
|
||||
|
||||
[crop.error]
|
||||
failed = "Huts egin du PDFa mozteak"
|
||||
invalidArea = "Mozketa-area PDFaren mugak baino harago doa"
|
||||
|
||||
[crop.preview]
|
||||
title = "Mozketa-arearen hautapena"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "موقعیت Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "برش PDF ناموفق بود"
|
||||
invalidArea = "ناحیه برش از مرزهای PDF فراتر رفته است"
|
||||
|
||||
[crop.preview]
|
||||
title = "انتخاب ناحیه برش"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Position Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "Échec du recadrage du PDF"
|
||||
invalidArea = "La zone de recadrage dépasse les limites du PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Sélection de la zone de recadrage"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Suíomh Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "Theip ar an PDF a bhearradh"
|
||||
invalidArea = "Téann an limistéar bearrtha thar theorainneacha an PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Roghnú Limistéir Bhearrtha"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y स्थान"
|
||||
|
||||
[crop.error]
|
||||
failed = "PDF क्रॉप करने में विफल"
|
||||
invalidArea = "क्रॉप क्षेत्र PDF सीमाओं से बाहर जा रहा है"
|
||||
|
||||
[crop.preview]
|
||||
title = "क्रॉप क्षेत्र चयन"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y položaj"
|
||||
|
||||
[crop.error]
|
||||
failed = "Izrezivanje PDF-a nije uspjelo"
|
||||
invalidArea = "Područje izrezivanja prelazi granice PDF-a"
|
||||
|
||||
[crop.preview]
|
||||
title = "Odabir područja izrezivanja"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y pozíció"
|
||||
|
||||
[crop.error]
|
||||
failed = "A PDF vágása sikertelen"
|
||||
invalidArea = "A vágási terület túlnyúlik a PDF határain"
|
||||
|
||||
[crop.preview]
|
||||
title = "Vágási terület kiválasztása"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Posisi Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "Gagal memangkas PDF"
|
||||
invalidArea = "Area pangkas melampaui batas PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Pilihan Area Pangkas"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Posizione Y"
|
||||
|
||||
[crop.error]
|
||||
failed = "Impossibile ritagliare il PDF"
|
||||
invalidArea = "L’area di ritaglio supera i limiti del PDF"
|
||||
|
||||
[crop.preview]
|
||||
title = "Selezione area di ritaglio"
|
||||
|
||||
@@ -3526,7 +3526,6 @@ label = "Y 位置"
|
||||
|
||||
[crop.error]
|
||||
failed = "PDF の切り抜きに失敗しました"
|
||||
invalidArea = "切り抜き範囲が PDF の境界を超えています"
|
||||
|
||||
[crop.preview]
|
||||
title = "切り抜き範囲の選択"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user