Compare commits

...
9 Commits
Author SHA1 Message Date
Anthony Stirling 4a09f30722 Delete .github/workflows directory 2025-09-22 14:00:15 +01:00
Anthony Stirling 2df943e110 init 2025-05-26 00:03:19 +01:00
Ludy f2f11496a2 Fix Chinese localization split page numbering (#3574)
# Description of Changes

Please provide a summary of the changes, including:

- **What was changed**  
Updated the values of `split.desc.6`, `split.desc.7`, and `split.desc.8`
in `src/main/resources/messages_zh_CN.properties` to correct the page
numbers:

- **Why the change was made**  
The previous numbering was inconsistent and would have led to incorrect
split outputs in the Chinese UI. This ensures that users splitting a
document see the correct page ranges.

- **Translation Method**  
The correction of these translation strings was generated and verified
using AI assistance.

Closes #3529

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
2025-05-23 22:22:05 +01:00
LudyandCopilot 75c325d15a Update messages_de_DE.properties (#3575)
# Description of Changes

Please provide a summary of the changes, including:

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-23 10:50:54 +01:00
daenur adcfe629f2 Russian translation (#3572)
Update messages_ru_RU.properties

# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
2025-05-22 10:44:14 +01:00
Ludy 35304a1491 Enhance email error handling and expand test coverage (#3561)
# Description of Changes

Please provide a summary of the changes, including:

- **What was changed**  
- **EmailController**: Added a `catch (MailSendException)` block to
handle invalid-address errors, log the exception, and return a 500
response with the raw error message.
- **EmailServiceTest**: Added unit tests for attachment-related error
cases (missing filename, null filename, missing file, null file) and
invalid “to” address (null or empty), expecting `MessagingException` or
`MailSendException`.
- **MailConfigTest**: New test class verifying `MailConfig.java`
correctly initializes `JavaMailSenderImpl` with host, port, username,
password, default encoding, and SMTP properties.
- **EmailControllerTest**: Refactored into a parameterized test
(`shouldHandleEmailRequests`) covering four scenarios: success, generic
messaging error, missing `to` parameter, and invalid address formatting.

- **Why the change was made**  
- To ensure invalid email addresses and missing attachments are handled
gracefully at the controller layer, providing clearer feedback to API
clients.
- To improve overall test coverage and guard against regressions in
email functionality.
  - To enforce correct mail configuration via automated tests.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
2025-05-21 15:42:08 +01:00
daenur cc938e1751 Ukrainian translation (#3567)
Update messages_uk_UA.properties

# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
2025-05-21 15:41:51 +01:00
Ludy b65624cf57 Enforce Locale.US for Consistent Decimal Formatting in Byte-Size Output (#3562)
# Description of Changes

Please provide a summary of the changes, including:

- **What was changed**  
  - Added `import java.util.Locale;`  
- Updated the `String.format` call in `humanReadableByteCount` to use
`Locale.US`

- **Why the change was made**  
By default, `String.format` uses the JVM’s default locale, which in some
environments (e.g., Germany) formats decimals with a comma. Tests
expected a dot (`.`) as the decimal separator (e.g., `"1.0 KB"`), so we
force `Locale.US` to ensure consistent output across all locales.


---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
2025-05-21 15:41:11 +01:00
Anthony Stirling 8bfdb2abb5 Update home.html (#3560)
# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
2025-05-20 17:42:42 +01:00
55 changed files with 3497 additions and 2753 deletions
@@ -1,320 +0,0 @@
name: PR Deployment via Comment
on:
issue_comment:
types: [created]
permissions:
contents: read
issues: write # Required for adding reactions to comments
pull-requests: read # Required for reading PR information
jobs:
check-comment:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
if: |
github.event.issue.pull_request &&
(
contains(github.event.comment.body, 'prdeploy') ||
contains(github.event.comment.body, 'deploypr')
)
&&
(
github.event.comment.user.login == 'frooodle' ||
github.event.comment.user.login == 'sf298' ||
github.event.comment.user.login == 'Ludy87' ||
github.event.comment.user.login == 'LaserKaspar' ||
github.event.comment.user.login == 'sbplat' ||
github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'ConnorYoh'
)
outputs:
pr_number: ${{ steps.get-pr.outputs.pr_number }}
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
comment_id: ${{ github.event.comment.id }}
enable_security: ${{ steps.check-security-flag.outputs.enable_security }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
# Generate GitHub App token
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Get PR data
id: get-pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const prNumber = context.payload.issue.number;
console.log(`PR Number: ${prNumber}`);
core.setOutput('pr_number', prNumber);
- name: Get PR repository and ref
id: get-pr-info
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
// For forks, use the full repository name, for internal PRs use the current repo
const repository = pr.head.repo.fork ? pr.head.repo.full_name : `${owner}/${repo}`;
console.log(`PR Repository: ${repository}`);
console.log(`PR Branch: ${pr.head.ref}`);
core.setOutput('repository', repository);
core.setOutput('ref', pr.head.ref);
- name: Check for security/login flag
id: check-security-flag
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if [[ "$COMMENT_BODY" == *"security"* ]] || [[ "$COMMENT_BODY" == *"login"* ]]; then
echo "Security flags detected in comment"
echo "enable_security=true" >> $GITHUB_OUTPUT
else
echo "No security flags detected in comment"
echo "enable_security=false" >> $GITHUB_OUTPUT
fi
- name: Add 'in_progress' reaction to comment
id: add-eyes-reaction
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`);
try {
const { data: reaction } = await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
console.log(`Added reaction with ID: ${reaction.id}`);
return { success: true, id: reaction.id };
} catch (error) {
console.error(`Failed to add reaction: ${error.message}`);
console.error(error);
return { success: false, error: error.message };
}
deploy-pr:
needs: check-comment
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: ${{ needs.check-comment.outputs.pr_repository }}
ref: ${{ needs.check-comment.outputs.pr_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: Run Gradle Command
run: |
if [ "${{ needs.check-comment.outputs.enable_security }}" == "true" ]; then
export DOCKER_ENABLE_SECURITY=true
else
export DOCKER_ENABLE_SECURITY=false
fi
./gradlew clean build
env:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push PR-specific image
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: VERSION_TAG=alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
id: deploy
run: |
# Set security settings based on flags
if [ "${{ needs.check-comment.outputs.enable_security }}" == "true" ]; then
DOCKER_SECURITY="true"
LOGIN_SECURITY="true"
SECURITY_STATUS="🔒 Security Enabled"
else
DOCKER_SECURITY="false"
LOGIN_SECURITY="false"
SECURITY_STATUS="Security Disabled"
fi
# First create the docker-compose content locally
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf:
container_name: stirling-pdf-pr-${{ needs.check-comment.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
ports:
- "${{ needs.check-comment.outputs.pr_number }}:8080"
volumes:
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/data:/usr/share/tessdata:rw
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/config:/configs:rw
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/logs:/logs:rw
environment:
DOCKER_ENABLE_SECURITY: "${DOCKER_SECURITY}"
SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF PR#${{ needs.check-comment.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "PR#${{ needs.check-comment.outputs.pr_number }} for Stirling-PDF Latest"
UI_APPNAMENAVBAR: "PR#${{ needs.check-comment.outputs.pr_number }}"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
restart: on-failure:5
EOF
# Then copy the file and execute commands
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
# Create PR-specific directories
mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs}
# Move docker-compose file to correct location
mv /tmp/docker-compose.yml /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/docker-compose.yml
# Start or restart the container
cd /stirling/PR-${{ needs.check-comment.outputs.pr_number }}
docker-compose pull
docker-compose up -d
ENDSSH
# Set output for use in PR comment
echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV
- name: Add success reaction to comment
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
try {
const { data: reaction } = await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: ${{ needs.check-comment.outputs.comment_id }},
content: 'rocket'
});
console.log(`Added rocket reaction with ID: ${reaction.id}`);
} catch (error) {
console.error(`Failed to add reaction: ${error.message}`);
console.error(error);
}
- name: Add failure reaction to comment
if: failure()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
try {
const { data: reaction } = await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: ${{ needs.check-comment.outputs.comment_id }},
content: '-1'
});
console.log(`Added -1 reaction with ID: ${reaction.id}`);
} catch (error) {
console.error(`Failed to add reaction: ${error.message}`);
console.error(error);
}
- name: Post deployment URL to PR
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const { GITHUB_REPOSITORY } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
const securityStatus = process.env.security_status || "Security Disabled";
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
const commentBody = `## 🚀 PR Test Deployment\n\n` +
`Your PR has been deployed for testing!\n\n` +
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
`${securityStatus}\n\n` +
`This deployment will be automatically cleaned up when the PR is closed.\n\n`;
await github.rest.issues.createComment({
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
body: commentBody
});
-59
View File
@@ -1,59 +0,0 @@
name: PR Deployment cleanup
on:
pull_request:
types: [opened, synchronize, reopened, closed]
permissions:
contents: read
env:
SERVER_IP: ${{ secrets.VPS_IP }} # Add this to your GitHub secrets
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: github.event.action == 'closed'
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup PR deployment
id: cleanup
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
echo "Found PR directory, proceeding with cleanup..."
# Stop and remove containers
cd /stirling/PR-${{ github.event.pull_request.number }}
docker-compose down || true
# Go back to root before removal
cd /
# Remove PR-specific directories
rm -rf /stirling/PR-${{ github.event.pull_request.number }}
# Remove the Docker image
docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ github.event.pull_request.number }} || true
echo "PERFORMED_CLEANUP"
else
echo "PR directory not found, nothing to clean up"
echo "NO_CLEANUP_NEEDED"
fi
ENDSSH
-27
View File
@@ -1,27 +0,0 @@
name: "Pull Request Labeler"
on:
pull_request_target:
types: [opened, synchronize]
permissions:
contents: read
jobs:
labeler:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Apply Labels
uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler-config.yml
sync-labels: true
-145
View File
@@ -1,145 +0,0 @@
name: Build repo
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
actions: read
security-events: write
strategy:
fail-fast: false
matrix:
jdk-version: [17, 21]
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Build with Gradle and no spring security
run: ./gradlew clean build
env:
DOCKER_ENABLE_SECURITY: false
- name: Build with Gradle and with spring security
run: ./gradlew clean build
env:
DOCKER_ENABLE_SECURITY: true
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-reports-jdk-${{ matrix.jdk-version }}
path: |
build/reports/tests/
build/test-results/
build/reports/problems/
retention-days: 3
check-licence:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "adopt"
- name: check the licenses for compatibility
run: ./gradlew clean checkLicense
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependencies-without-allowed-license.json
path: |
build/reports/dependency-license/dependencies-without-allowed-license.json
retention-days: 3
docker-compose-tests:
# if: github.event_name == 'push' && github.ref == 'refs/heads/main' ||
# (github.event_name == 'pull_request' &&
# contains(github.event.pull_request.labels.*.name, 'licenses') == false &&
# (
# contains(github.event.pull_request.labels.*.name, 'Front End') ||
# contains(github.event.pull_request.labels.*.name, 'Java') ||
# contains(github.event.pull_request.labels.*.name, 'Back End') ||
# contains(github.event.pull_request.labels.*.name, 'Security') ||
# contains(github.event.pull_request.labels.*.name, 'API') ||
# contains(github.event.pull_request.labels.*.name, 'Docker') ||
# contains(github.event.pull_request.labels.*.name, 'Test')
# )
# )
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Java 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "adopt"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Install Docker Compose
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
- name: Pip requirements
run: |
pip install --require-hashes -r ./testing/cucumber/requirements.txt
- name: Run Docker Compose Tests
run: |
chmod +x ./testing/test_webpages.sh
chmod +x ./testing/test.sh
chmod +x ./testing/test_disabledEndpoints.sh
./testing/test.sh
-250
View File
@@ -1,250 +0,0 @@
name: Check Properties Files on PR
on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "src/main/resources/messages_*.properties"
permissions:
contents: read # Allow read access to repository content
jobs:
check-files:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
permissions:
issues: write # Allow posting comments on issues/PRs
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout main branch first
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Get PR data
id: get-pr-data
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const prNumber = context.payload.pull_request.number;
const repoOwner = context.payload.repository.owner.login;
const repoName = context.payload.repository.name;
const branch = context.payload.pull_request.head.ref;
console.log(`PR Number: ${prNumber}`);
console.log(`Repo Owner: ${repoOwner}`);
console.log(`Repo Name: ${repoName}`);
console.log(`Branch: ${branch}`);
core.setOutput("pr_number", prNumber);
core.setOutput("repo_owner", repoOwner);
core.setOutput("repo_name", repoName);
core.setOutput("branch", branch);
continue-on-error: true
- name: Fetch PR changed files
id: fetch-pr-changes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Fetching PR changed files..."
echo "Getting list of changed files from PR..."
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt # Filter only matching property files
- name: Determine reference file test
id: determine-file
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const fs = require("fs");
const path = require("path");
const prNumber = ${{ steps.get-pr-data.outputs.pr_number }};
const repoOwner = "${{ steps.get-pr-data.outputs.repo_owner }}";
const repoName = "${{ steps.get-pr-data.outputs.repo_name }}";
const prRepoOwner = "${{ github.event.pull_request.head.repo.owner.login }}";
const prRepoName = "${{ github.event.pull_request.head.repo.name }}";
const branch = "${{ steps.get-pr-data.outputs.branch }}";
console.log(`Determining reference file for PR #${prNumber}`);
// Validate inputs
const validateInput = (input, regex, name) => {
if (!regex.test(input)) {
throw new Error(`Invalid ${name}: ${input}`);
}
};
validateInput(repoOwner, /^[a-zA-Z0-9_-]+$/, "repository owner");
validateInput(repoName, /^[a-zA-Z0-9._-]+$/, "repository name");
validateInput(branch, /^[a-zA-Z0-9._/-]+$/, "branch name");
// Get the list of changed files in the PR
const { data: files } = await github.rest.pulls.listFiles({
owner: repoOwner,
repo: repoName,
pull_number: prNumber,
});
// Filter for relevant files based on the PR changes
const changedFiles = files
.map(file => file.filename)
.filter(file => /^src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file));
console.log("Changed files:", changedFiles);
// Create a temporary directory for PR files
const tempDir = "pr-branch";
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// Download and save each changed file
for (const file of changedFiles) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: file,
ref: branch,
});
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
const filePath = path.join(tempDir, file);
const dirPath = path.dirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(filePath, content);
console.log(`Saved file: ${filePath}`);
}
// Output the list of changed files for further processing
const fileList = changedFiles.join(" ");
core.exportVariable("FILES_LIST", fileList);
console.log("Files saved and listed in FILES_LIST.");
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("src/main/resources/messages_en_GB.properties")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "src/main/resources/messages_en_GB.properties",
ref: branch,
});
referenceFilePath = "pr-branch-messages_en_GB.properties";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
} else {
console.log("Using main branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "src/main/resources/messages_en_GB.properties",
ref: "main",
});
referenceFilePath = "main-branch-messages_en_GB.properties";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
}
console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check files..."
python .github/scripts/check_language_properties.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \
--files "${FILES_LIST[@]}" > result.txt
continue-on-error: true # Continue the job even if this step fails
- name: Capture output
id: capture-output
run: |
if [ -f result.txt ] && [ -s result.txt ]; then
echo "Test, capturing output..."
SCRIPT_OUTPUT=$(cat result.txt)
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
echo "${SCRIPT_OUTPUT}"
# Determine job failure based on script output
if [[ "$SCRIPT_OUTPUT" == *"❌"* ]]; then
echo "FAIL_JOB=true" >> $GITHUB_ENV
else
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
else
echo "No update found."
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
- name: Post comment on PR
if: env.SCRIPT_OUTPUT != ''
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { GITHUB_REPOSITORY, SCRIPT_OUTPUT } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
const issueNumber = context.issue.number;
// Find existing comment
const comments = await github.rest.issues.listComments({
owner: repoOwner,
repo: repoName,
issue_number: issueNumber
});
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
// Only update or create comments by the action user
const expectedActor = "github-actions[bot]";
if (comment && comment.user.login === expectedActor) {
// Update existing comment
await github.rest.issues.updateComment({
owner: repoOwner,
repo: repoName,
comment_id: comment.id,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Updated existing comment.");
} else if (!comment) {
// Create new comment if no existing comment is found
await github.rest.issues.createComment({
owner: repoOwner,
repo: repoName,
issue_number: issueNumber,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Created new comment.");
} else {
console.log("Comment update attempt denied. Actor does not match.");
}
- name: Fail job if errors found
if: env.FAIL_JOB == 'true'
run: |
echo "Failing the job because errors were detected."
exit 1
-79
View File
@@ -1,79 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
#disable for now
#on:
# push:
# branches: ["main"]
# pull_request:
# The branches below must be a subset of the branches above
# branches: ["main"]
# schedule:
# - cron: "0 0 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["java"]
# CodeQL supports [ $supported-codeql-languages ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
category: "/language:${{matrix.language}}"
-27
View File
@@ -1,27 +0,0 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: "Dependency Review"
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout Repository"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: "Dependency Review"
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
-92
View File
@@ -1,92 +0,0 @@
name: License Report Workflow
on:
push:
branches:
- main
paths:
- "build.gradle"
permissions:
contents: read
jobs:
generate-license-report:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "adopt"
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
- name: check the licenses for compatibility
run: ./gradlew clean checkLicense
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependencies-without-allowed-license.json
path: |
build/reports/dependency-license/dependencies-without-allowed-license.json
retention-days: 3
- name: Move and Rename License File
run: |
mv build/reports/dependency-license/index.json src/main/resources/static/3rdPartyLicenses.json
- name: Set up git config
run: |
git config --global user.name "stirlingbot[bot]"
git config --global user.email "1113334+stirlingbot[bot]@users.noreply.github.com"
- name: Run git add
run: |
git add src/main/resources/static/3rdPartyLicenses.json
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Create Pull Request
id: cpr
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: "Update 3rd Party Licenses"
committer: "stirlingbot[bot] <1113334+stirlingbot[bot]@users.noreply.github.com>"
author: "stirlingbot[bot] <1113334+stirlingbot[bot]@users.noreply.github.com>"
signoff: true
branch: update-3rd-party-licenses
title: "Update 3rd Party Licenses"
body: |
Auto-generated by StirlingBot
labels: licenses,github-actions
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge
if: steps.cpr.outputs.pull-request-operation == 'created'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
-30
View File
@@ -1,30 +0,0 @@
name: Manage labels
on:
schedule:
- cron: "30 20 * * *"
permissions:
contents: read
jobs:
labeler:
name: Labeler
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
yaml-file: .github/labels.yml
skip-delete: true
-314
View File
@@ -1,314 +0,0 @@
name: Test Installers Build
on:
workflow_dispatch:
inputs:
test_mode:
description: "Run in test mode (skip release step)"
required: false
default: "false"
release:
types: [created]
permissions:
contents: read
jobs:
read_versions:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Get version number
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Get version number mac
id: versionNumberMac
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
CURRENT_YEAR=$(date +'%Y')
IFS='.' read -r -a VERSION_PARTS <<< "$VERSION"
MAC_VERSION="$CURRENT_YEAR.${VERSION_PARTS[1]:-0}.${VERSION_PARTS[2]:-0}"
echo "versionNumberMac=$MAC_VERSION" >> $GITHUB_OUTPUT
build-portable:
needs: read_versions
runs-on: ubuntu-latest
strategy:
matrix:
enable_security: [true, false]
include:
- enable_security: true
file_suffix: "-with-login"
- enable_security: false
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
with:
gradle-version: 8.14
- name: Generate jar (With Security=${{ matrix.enable_security }})
run: ./gradlew clean createExe
env:
DOCKER_ENABLE_SECURITY: ${{ matrix.enable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Rename binaries
run: |
mkdir ./binaries
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
mv ./build/libs/Stirling-PDF-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: stirling${{ matrix.file_suffix }}-binaries
path: |
./binaries/*
sign_verify-portable:
needs: [build-portable, read_versions]
runs-on: ubuntu-latest
strategy:
matrix:
enable_security: [true, false]
include:
- enable_security: true
file_suffix: "with-login-"
- enable_security: false
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: stirling-${{ matrix.file_suffix }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: stirling-${{ matrix.file_suffix }}signed
path: |
./*
!cosign.*
build-installers:
needs: read_versions
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
with:
gradle-version: 8.14
# Install Windows dependencies
- name: Install WiX Toolset
if: matrix.os == 'windows-latest'
run: |
curl -L -o wix.exe https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe
.\wix.exe /install /quiet
# Build installer
- name: Build Installer
run: ./gradlew build jpackage -x test --info
env:
DOCKER_ENABLE_SECURITY: false
STIRLING_PDF_DESKTOP_UI: true
BROWSER_OPEN: true
- name: Set up JDK (x86_64)
if: matrix.os == 'macos-latest'
run: |
curl -L -o jdk.tar.gz https://cdn.azul.com/zulu/bin/zulu17.56.15-ca-jdk17.0.14-macosx_x64.tar.gz
mkdir -p zulu17
tar -xzf jdk.tar.gz -C zulu17 --strip-components=1
echo "JAVA_HOME=$PWD/zulu17" >> $GITHUB_ENV
echo "$PWD/zulu17/bin" >> $GITHUB_PATH
- name: Verify JDK architecture
if: matrix.os == 'macos-latest'
run: file $JAVA_HOME/bin/java
- name: Build project and run jpackage (x86_64)
if: matrix.os == 'macos-latest'
run: arch -x86_64 ./gradlew jpackageMacX64
# Rename and collect artifacts based on OS
- name: Prepare artifacts
id: prepare
shell: bash
run: |
ls -lah ./build/jpackage/
mkdir ./binaries
if [ "${{ matrix.os }}" = "windows-latest" ]; then
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.version }}.exe" "./binaries/Stirling-PDF-win-installer.exe"
elif [ "${{ matrix.os }}" = "macos-latest" ]; then
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-installer.dmg"
mv "./build/jpackage/x86_64/Stirling PDF (x86_64)-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-x86_64-installer.dmg"
else
mv "./build/jpackage/stirling-pdf_${{ needs.read_versions.outputs.version }}-1_amd64.deb" "./binaries/Stirling-PDF-linux-installer.deb"
fi
- name: Display structure of downloaded files
run: ls -R ./binaries
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}binaries
path: |
./binaries/*
sign_verify:
needs: [read_versions, build-installers]
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: ${{ matrix.platform }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Generate key pair
if: matrix.os == 'windows-latest'
run: cosign generate-key-pair
- name: Sign and generate attestations
if: matrix.os == 'windows-latest'
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
./Stirling-PDF-win-installer.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}signed
path: |
./Stirling-PDF-${{ matrix.platform }}installer.*
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
!cosign.*
create-release:
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
needs: [read_versions, sign_verify, sign_verify-portable]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- name: Display structure of downloaded files
run: ls -R
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
with:
tag_name: v${{ needs.read_versions.outputs.version }}
generate_release_notes: true
files: |
./*signed/*
-80
View File
@@ -1,80 +0,0 @@
name: Pre-commit
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 1"
permissions:
contents: read
jobs:
pre-commit:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Get GitHub App User ID
id: get-user-id
run: echo "user-id=$(gh api "/users/${{ steps.generate-token.outputs.app-slug }}[bot]" --jq .id)" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
- id: committer
run: |
echo "string=${{ steps.generate-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.generate-token.outputs.app-slug }}[bot]@users.noreply.github.com>" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: 3.12
cache: 'pip' # caching pip dependencies
- name: Run Pre-Commit Hooks
run: |
pip install --require-hashes -r ./.github/scripts/requirements_pre_commit.txt
- run: pre-commit run --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up git config
run: |
git config --global user.name ${{ steps.generate-token.outputs.app-slug }}[bot]
git config --global user.email "${{ steps.get-user-id.outputs.user-id }}+${{ steps.generate-token.outputs.app-slug }}[bot]@users.noreply.github.com"
- name: git add
run: |
git add .
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Create Pull Request
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: ":file_folder: pre-commit"
committer: ${{ steps.committer.outputs.string }}
author: ${{ steps.committer.outputs.string }}
signoff: true
branch: pre-commit
title: "🤖 format everything with pre-commit by <${{ steps.generate-token.outputs.app-slug }}>"
body: |
Auto-generated by [create-pull-request][1] with **${{ steps.generate-token.outputs.app-slug }}**
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
-195
View File
@@ -1,195 +0,0 @@
name: Push Docker Image with VersionNumber
on:
workflow_dispatch:
push:
branches:
- master
- main
permissions:
contents: read
jobs:
push:
runs-on: ubuntu-latest
permissions:
packages: write
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
with:
gradle-version: 8.14
- name: Run Gradle Command
run: ./gradlew clean build
env:
DOCKER_ENABLE_SECURITY: false
STIRLING_PDF_DESKTOP_UI: false
- name: Install cosign
if: github.ref == 'refs/heads/master'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
with:
cosign-release: "v2.4.1"
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push main Dockerfile
id: build-push-regular
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign regular images
if: github.ref == 'refs/heads/master'
env:
DIGEST: ${{ steps.build-push-regular.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes \
--key env://COSIGN_PRIVATE_KEY \
"${tag}@${DIGEST}"
done
- name: Generate tags ultra-lite
id: meta2
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
if: github.ref != 'refs/heads/main'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
- name: Build and push Dockerfile-ultra-lite
id: build-push-lite
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
if: github.ref != 'refs/heads/main'
with:
context: .
file: ./Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta2.outputs.tags }}
labels: ${{ steps.meta2.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
if: github.ref != 'refs/heads/main'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
- name: Build and push main Dockerfile fat
id: build-push-fat
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
if: github.ref != 'refs/heads/main'
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta3.outputs.tags }}
labels: ${{ steps.meta3.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign fat images
if: github.ref == 'refs/heads/master'
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta3.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
-180
View File
@@ -1,180 +0,0 @@
name: Release Artifacts
on:
workflow_dispatch:
release:
types: [created]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
enable_security: [true, false]
include:
- enable_security: true
file_suffix: "-with-login"
- enable_security: false
file_suffix: ""
outputs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
with:
gradle-version: 8.14
- name: Generate jar (With Security=${{ matrix.enable_security }})
run: ./gradlew clean createExe
env:
DOCKER_ENABLE_SECURITY: ${{ matrix.enable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Rename binaries
run: |
mv ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
mv ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Debug build artifacts
run: |
echo "Current Directory: $(pwd)"
ls -R ./build/libs
ls -R ./build/launch4j
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: binaries${{ matrix.file_suffix }}
path: |
./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
./build/libs/Stirling-PDF${{ matrix.file_suffix }}.*
sign_verify:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
enable_security: [true, false]
include:
- enable_security: true
file_suffix: "-with-login"
- enable_security: false
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: binaries${{ matrix.file_suffix }}
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Generate key pair
run: cosign generate-key-pair
- name: Sign and generate attestations
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.intoto.jsonl \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign verify-blob \
--key ./cosign.pub \
--signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.intoto.jsonl \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: signed${{ matrix.file_suffix }}
path: |
./libs/Stirling-PDF${{ matrix.file_suffix }}.*
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
release:
needs: [build, sign_verify]
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
matrix:
enable_security: [true, false]
include:
- enable_security: true
file_suffix: "-with-login"
- enable_security: false
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: signed${{ matrix.file_suffix }}
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
with:
tag_name: v${{ needs.build.outputs.version }}
generate_release_notes: true
files: |
./libs/Stirling-PDF*
./launch4j/Stirling-PDF-Server*
-79
View File
@@ -1,79 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "20 7 * * 2"
push:
branches: ["main"]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
contents: read
actions: read
# To allow GraphQL ListCommits to work
issues: read
pull-requests: read
# To detect SAST tools
checks: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecards on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
sarif_file: results.sarif
-63
View File
@@ -1,63 +0,0 @@
name: Run Sonarqube
on:
push:
branches:
- master
pull_request_target:
branches:
- main
workflow_dispatch:
permissions:
pull-requests: read
actions: read
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Gradle
uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
- name: Build and analyze with Gradle
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
DOCKER_ENABLE_SECURITY: true
STIRLING_PDF_DESKTOP_UI: true
run: |
./gradlew clean build sonar \
-Dsonar.projectKey=Stirling-Tools_Stirling-PDF \
-Dsonar.organization=stirling-tools \
-Dsonar.host.url=https://sonarcloud.io \
-Dsonar.login=${SONAR_TOKEN} \
-Dsonar.log.level=DEBUG \
--info
- name: Upload Problems Report on Failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: gradle-problems-report
path: build/reports/problems/problems-report.html
retention-days: 7
- name: Upload Sonar Logs on Failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sonar-logs
path: |
.scannerwork/report-task.txt
build/sonar/
retention-days: 7
-40
View File
@@ -1,40 +0,0 @@
name: Close stale issues
on:
schedule:
- cron: "30 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 30 days stale issues
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
days-before-close: 7
stale-issue-message: >
This issue has been automatically marked as stale because it has had no recent activity.
It will be closed if no further activity occurs. Thank you for your contributions.
close-issue-message: >
This issue has been automatically closed because it has had no recent activity after being marked as stale.
Please reopen if you need further assistance.
stale-issue-label: "Stale"
remove-stale-when-updated: true
only-issue-labels: "more-info-needed"
days-before-pr-stale: -1 # Prevents PRs from being marked as stale
days-before-pr-close: -1 # Prevents PRs from being closed
start-date: "2024-07-06T00:00:00Z" # ISO 8601 Format
-49
View File
@@ -1,49 +0,0 @@
name: Update Swagger
on:
workflow_dispatch:
push:
branches:
- master
permissions:
contents: read
jobs:
push:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
- name: Generate Swagger documentation
run: ./gradlew generateOpenApiDocs
- name: Upload Swagger Documentation to SwaggerHub
run: ./gradlew swaggerhubUpload
env:
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
SWAGGERHUB_USER: "Frooodle"
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Set API version as published and default on SwaggerHub
run: |
curl -X PUT -H "Authorization: ${SWAGGERHUB_API_KEY}" "https://api.swaggerhub.com/apis/${SWAGGERHUB_USER}/Stirling-PDF/${{ steps.versionNumber.outputs.versionNumber }}/settings/lifecycle" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"published\":true,\"default\":true}"
env:
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
SWAGGERHUB_USER: "Frooodle"
-145
View File
@@ -1,145 +0,0 @@
name: Sync Files
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "build.gradle"
- "README.md"
- "src/main/resources/messages_*.properties"
- "src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
permissions:
contents: read
jobs:
read_bot_entries:
runs-on: ubuntu-latest
outputs:
userName: ${{ steps.get-user-id.outputs.user_name }}
userEmail: ${{ steps.get-user-id.outputs.user_email }}
committer: ${{ steps.committer.outputs.committer }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Get GitHub App User ID
id: get-user-id
run: |
USER_NAME="${{ steps.generate-token.outputs.app-slug }}[bot]"
USER_ID=$(gh api "/users/$USER_NAME" --jq .id)
USER_EMAIL="$USER_ID+$USER_NAME@users.noreply.github.com"
echo "user_name=$USER_NAME" >> "$GITHUB_OUTPUT"
echo "user_email=$USER_EMAIL" >> "$GITHUB_OUTPUT"
echo "user-id=$USER_ID" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
- id: committer
run: |
COMMITTER="${{ steps.get-user-id.outputs.user_name }} <${{ steps.get-user-id.outputs.user_email }}>"
echo "committer=$COMMITTER" >> "$GITHUB_OUTPUT"
sync-files:
needs: ["read_bot_entries"]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ vars.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
- name: Sync translation property files
run: |
python .github/scripts/check_language_properties.py --reference-file "src/main/resources/messages_en_GB.properties" --branch main
- name: Set up git config
run: |
git config --global user.name ${{ needs.read_bot_entries.outputs.userName }}
git config --global user.email ${{ needs.read_bot_entries.outputs.userEmail }}
- name: Run git add
run: |
git add src/main/resources/messages_*.properties
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "no changes"
- name: Install dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation.py
- name: Run git add
run: |
git add README.md
git diff --staged --quiet || git commit -m ":memo: Sync README.md" || echo "no changes"
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: Update files
committer: ${{ needs.read_bot_entries.outputs.committer }}
author: ${{ needs.read_bot_entries.outputs.committer }}
signoff: true
branch: sync_readme
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
src/main/resources/messages_*.properties
-154
View File
@@ -1,154 +0,0 @@
name: UI test with TestDriverAI
on:
push:
branches: ["master", "UITest", "testdriver"]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Gradle
run: ./gradlew clean build
env:
DOCKER_ENABLE_SECURITY: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push test image
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
run: |
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf:
container_name: stirling-pdf-test-${{ github.sha }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
ports:
- "1337:8080"
volumes:
- /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
- /stirling/test-${{ github.sha }}/config:/configs:rw
- /stirling/test-${{ github.sha }}/logs:/logs:rw
environment:
DOCKER_ENABLE_SECURITY: "false"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF Test"
UI_HOMEDESCRIPTION: "Test Deployment"
UI_APPNAMENAVBAR: "Test"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SYSTEM_ENABLEANALYTICS: "false"
restart: on-failure:5
EOF
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
cd /stirling/test-${{ github.sha }}
docker-compose pull
docker-compose up -d
EOF
test:
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run TestDriver.ai
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
with:
key: ${{secrets.TESTDRIVER_API_KEY}}
prerun: |
npm install
npm run build
npm install dashcam-chrome --save
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.VPS_HOST }}:1337"
Start-Sleep -Seconds 20
prompt: |
1. /run testing/testdriver/test.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORCE_COLOR: "3"
cleanup:
needs: [deploy, test]
runs-on: ubuntu-latest
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup deployment
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
cd /stirling/test-${{ github.sha }}
docker-compose down
cd /stirling
rm -rf test-${{ github.sha }}
EOF
+1 -1
View File
@@ -92,4 +92,4 @@ EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["java", "-Dfile.encoding=UTF-8", "-jar", "/app.jar"]
+1 -1
View File
@@ -101,4 +101,4 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar"]
+250
View File
@@ -0,0 +1,250 @@
# UnoServer Configuration Guide
## Overview
Stirling-PDF supports multiple UnoServer instances to improve concurrent document conversion performance. This document explains how to configure and use this feature.
The UnoServer component in Stirling-PDF is now conditional, meaning it will only be enabled if the required executables are available on your system. This allows Stirling-PDF to run in environments without LibreOffice/UnoServer while gracefully disabling office document conversion functionality.
## Configuration Options
### Multiple Local Instances
You can configure Stirling-PDF to start multiple UnoServer instances locally. Each instance will run on its own port starting from the base port.
In `settings.yml`:
```yaml
processExecutor:
sessionLimit:
libreOfficeSessionLimit: 4 # Set to desired number of instances
baseUnoconvPort: 2003 # Base port for UnoServer instances
useExternalUnoconvServers: false # Set to false to use local instances
```
### External UnoServer Instances
For more advanced setups or when running in a clustered environment, you can configure Stirling-PDF to use external UnoServer instances running on different hosts:
```yaml
processExecutor:
useExternalUnoconvServers: true
unoconvServers:
- "192.168.1.100:2003" # Format is host:port
- "192.168.1.101:2003"
- "unoserver-host:2003"
```
## Installation
### Docker
The easiest way to use UnoServer with Stirling-PDF is to use the "fat" Docker image, which includes all required dependencies:
```bash
docker pull frooodle/s-pdf:latest-fat
```
### Manual Installation
If you want to install UnoServer manually:
1. Install LibreOffice:
```bash
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y libreoffice
# CentOS/RHEL
sudo yum install -y libreoffice
# macOS
brew install libreoffice
```
2. Install UnoServer using pip:
```bash
pip install unoserver
```
3. Verify installation:
```bash
unoserver --version
unoconvert --version
```
### Installation Location
Stirling-PDF will automatically detect UnoServer in these locations:
- In the same directory as the unoconvert executable
- At `/opt/venv/bin/unoserver` (Docker default)
- In standard system paths (`/usr/bin/unoserver`, `/usr/local/bin/unoserver`)
- In your PATH environment variable
## Advanced Features
### Health Checks
The system performs automatic health checks on all UnoServer instances every 60 seconds. These checks:
- Verify that each server is reachable and operational
- Automatically restart local instances that have failed
- Log the health status of all instances
- Update the circuit breaker status for each instance
Health checks are logged at INFO level and show the number of healthy and unhealthy instances.
### Circuit Breaker
For fault tolerance, each UnoServer instance implements a circuit breaker pattern that:
1. Tracks conversion failures for each instance
2. After 3 consecutive failures, marks the instance as unavailable (circuit open)
3. Waits for a cooldown period (30 seconds) before attempting to use the instance again
4. Automatically routes requests to healthy instances
The circuit breaker helps prevent cascading failures and provides automatic recovery.
### Performance Metrics
The UnoServerManager records and logs detailed metrics about UnoServer usage:
- Total number of conversions
- Success/failure rate
- Conversions per server instance
- Average conversion time per instance
These metrics are logged periodically and on application shutdown, helping you monitor and optimize performance.
Example metrics log:
```
UnoServer metrics - Total: 120, Failed: 5, Success Rate: 95.83%
Conversions per instance:
[0] 127.0.0.1:2003 - Count: 32 (26.67%), Avg Time: 1250.45ms
[1] 127.0.0.1:2004 - Count: 30 (25.00%), Avg Time: 1187.33ms
[2] 127.0.0.1:2005 - Count: 29 (24.17%), Avg Time: 1345.78ms
[3] 127.0.0.1:2006 - Count: 29 (24.17%), Avg Time: 1290.12ms
```
## Testing Multiple Instances
To test that multiple UnoServer instances are working correctly:
1. Set `libreOfficeSessionLimit` to a value greater than 1 (e.g., 4)
2. Start the application
3. Check logs for messages like:
```
Initializing UnoServerManager with maxInstances: 4, useExternal: false
Starting UnoServer on 127.0.0.1:2003
Starting UnoServer on 127.0.0.1:2004
Starting UnoServer on 127.0.0.1:2005
Starting UnoServer on 127.0.0.1:2006
```
4. Submit multiple file conversion requests simultaneously
5. Observe in logs that different server instances are being used in a round-robin manner
6. Check the metrics logs to verify the load distribution across instances
## Performance Considerations
- Each UnoServer instance requires additional memory (typically 100-200 MB)
- Set the `libreOfficeSessionLimit` according to your server's available resources
- For most use cases, a value between 2-4 provides a good balance
- Larger values may improve concurrency but increase memory usage
- The circuit breaker pattern helps maintain system stability under high load
## Queue Management
Stirling-PDF now includes a queue management system for office document conversions:
### Queue Status API
The system exposes REST endpoints for checking conversion queue status:
- `GET /api/v1/queue/status` - Get status of all process queues
- `GET /api/v1/queue/unoserver` - Get detailed information about UnoServer instances and active tasks
- `GET /api/v1/queue/task/{taskId}` - Get status of a specific task by ID
Example response from `/api/v1/queue/unoserver`:
```json
{
"instanceCount": 4,
"activeTaskCount": 2,
"instances": [
{
"id": 0,
"host": "127.0.0.1",
"port": 2003,
"managed": true,
"running": true,
"available": true,
"failureCount": 0,
"averageConversionTimeMs": 1523.45,
"lastConversionTimeMs": 1498
},
...
],
"activeTasks": [
{
"id": "office-123",
"name": "Convert document.docx to PDF",
"status": "RUNNING",
"queuePosition": 0,
"queueTimeMs": 0,
"processTimeMs": 542,
"totalTimeMs": 542,
"errorMessage": null
},
...
]
}
```
### UI Integration
The system includes a built-in UI for monitoring conversion status:
1. A status indicator appears when a document is being converted
2. The indicator shows the current status, position in queue, and estimated wait time
3. For pages that use office conversions, a "Check Office Conversion Status" button provides detailed information about server instances and active conversions
## Troubleshooting
If you encounter issues with UnoServer:
1. Check logs for any error messages related to UnoServer startup
2. Look for health check logs to identify problematic instances
3. Verify ports are not already in use by other applications
4. Ensure LibreOffice is correctly installed
5. Check that UnoServer is properly installed and in your PATH:
```
which unoserver
which unoconvert
```
6. Try running a single UnoServer instance manually to check if it works:
```
/opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1
```
7. Use the queue status API to check the status of UnoServer instances:
```
curl http://localhost:8080/api/v1/queue/unoserver
```
8. For external servers, verify network connectivity from the Stirling-PDF server
### Common Error Messages
| Error Message | Possible Cause | Solution |
|---------------|----------------|----------|
| "UnoServer is not available" | UnoServer executable not found | Install UnoServer or use the fat Docker image |
| "Failed to start UnoServer instance" | Port in use or LibreOffice issues | Check ports, restart application, or verify LibreOffice installation |
| "Circuit breaker opened for UnoServer instance" | Multiple conversion failures | Check logs for specific errors, verify UnoServer is working correctly |
| "No UnoServer instances available" | All instances are down or in circuit-open state | Restart application or check for resource issues |
## Environment Variables
When using Docker, you can configure UnoServer instances using environment variables:
- `LIBREOFFICE_SESSION_LIMIT`: Number of UnoServer instances to start
- `BASE_UNOCONV_PORT`: Base port number for UnoServer instances
- `USE_EXTERNAL_UNOCONVSERVERS`: Set to "true" to use external servers
@@ -202,6 +202,11 @@ public class AppConfig {
public boolean disablePixel() {
return Boolean.getBoolean(env.getProperty("DISABLE_PIXEL"));
}
@Bean(name = "showQueueStatus")
public boolean showQueueStatus() {
return applicationProperties.getUi().isQueueStatusEnabled();
}
@Bean(name = "machineType")
public String determineMachineType() {
@@ -0,0 +1,19 @@
package stirling.software.SPDF.config;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Conditional;
/**
* Annotation to conditionally enable components based on the availability of UnoServer. Components
* annotated with this will only be created if UnoServer is available on the system.
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(UnoServerAvailableCondition.class)
public @interface ConditionalOnUnoServerAvailable {}
@@ -0,0 +1,193 @@
package stirling.software.SPDF.config;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;
import lombok.extern.slf4j.Slf4j;
/**
* Condition that checks if UnoServer is available on the system. This condition will pass if: 1.
* The unoserver executable is found via RuntimePathConfig (from unoConvertPath) 2. The unoserver
* executable is found at /opt/venv/bin/unoserver (Docker path) 3. The unoserver executable is found
* in PATH 4. The unoserver executable is found in any common installation directories
*/
@Slf4j
public class UnoServerAvailableCondition implements Condition {
// Common installation paths to check
private static final String[] COMMON_UNOSERVER_PATHS = {
"/opt/venv/bin/unoserver", // Docker path
"/usr/bin/unoserver", // Linux system path
"/usr/local/bin/unoserver", // Linux local path
"/opt/homebrew/bin/unoserver", // Mac Homebrew path
"/opt/libreoffice/program/unoserver" // Custom LibreOffice path
};
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
log.info("Checking if UnoServer is available on the system...");
// Collect all paths to check
List<String> pathsToCheck = new ArrayList<>();
// Check for Docker environment
boolean isDocker = Files.exists(Path.of("/.dockerenv"));
log.debug("Docker environment detected: {}", isDocker);
// Try to get unoserver path from RuntimePathConfig first (highest priority)
String unoserverFromRuntimeConfig = getUnoServerPathFromRuntimeConfig(context);
if (unoserverFromRuntimeConfig != null) {
pathsToCheck.add(unoserverFromRuntimeConfig);
}
// Add common installation paths
for (String path : COMMON_UNOSERVER_PATHS) {
pathsToCheck.add(path);
}
// Add "unoserver" to check in PATH
pathsToCheck.add("unoserver");
// Try all paths one by one
for (String path : pathsToCheck) {
log.debug("Checking for UnoServer at: {}", path);
if (isExecutableAvailable(path)) {
log.info("UnoServer found at: {}, enabling UnoServerManager", path);
return true;
}
}
// If we get here, we didn't find unoserver anywhere
log.warn(
"UnoServer not found in any of the expected locations. UnoServerManager will be disabled.");
log.info(
"To enable Office document conversions, please install UnoServer or use the 'fat' Docker image variant.");
return false;
}
/**
* Attempts to get the unoserver path from RuntimePathConfig by checking the parent directory of
* unoConvertPath.
*
* @param context The condition context
* @return The unoserver path if found, null otherwise
*/
private String getUnoServerPathFromRuntimeConfig(ConditionContext context) {
try {
RuntimePathConfig runtimePathConfig =
context.getBeanFactory().getBean(RuntimePathConfig.class);
if (runtimePathConfig != null) {
String unoConvertPath = runtimePathConfig.getUnoConvertPath();
log.debug("UnoConvert path from RuntimePathConfig: {}", unoConvertPath);
if (unoConvertPath != null && !unoConvertPath.isEmpty()) {
// First check if unoConvertPath itself exists
File unoConvertFile = new File(unoConvertPath);
if (!unoConvertFile.exists() || !unoConvertFile.canExecute()) {
log.info("UnoConvert not found at path: {}", unoConvertPath);
return null;
}
// If unoConvertPath exists, check for unoserver in the same directory
Path unoConvertDir = Paths.get(unoConvertPath).getParent();
if (unoConvertDir != null) {
Path potentialUnoServerPath = unoConvertDir.resolve("unoserver");
File unoServerFile = potentialUnoServerPath.toFile();
if (unoServerFile.exists() && unoServerFile.canExecute()) {
log.debug("UnoServer found at: {}", potentialUnoServerPath);
return potentialUnoServerPath.toString();
} else {
log.debug(
"UnoServer not found at expected path: {}",
potentialUnoServerPath);
// Continue checking other paths
}
}
}
}
} catch (Exception e) {
log.debug(
"RuntimePathConfig not available yet, falling back to default checks: {}",
e.getMessage());
}
return null;
}
/**
* Comprehensive check if an executable is available in the system
*
* @param executableName The name or path of the executable to check
* @return true if the executable is found and executable, false otherwise
*/
private boolean isExecutableAvailable(String executableName) {
// First, check if it's an absolute path and the file exists
if (executableName.startsWith("/") || executableName.contains(":\\")) {
File file = new File(executableName);
boolean exists = file.exists() && file.canExecute();
log.debug(
"Checking executable at absolute path {}: {}",
executableName,
exists ? "Found" : "Not found");
return exists;
}
// Next, try to execute the command with --version to verify it works
try {
ProcessBuilder pb = new ProcessBuilder(executableName, "--version");
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode == 0) {
log.debug("Executable {} exists in PATH (--version returned 0)", executableName);
return true;
} else {
// Try with --help as a fallback
pb = new ProcessBuilder(executableName, "--help");
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
process = pb.start();
exitCode = process.waitFor();
if (exitCode == 0) {
log.debug("Executable {} exists in PATH (--help returned 0)", executableName);
return true;
}
}
} catch (Exception e) {
log.debug("Error checking for executable {}: {}", executableName, e.getMessage());
}
// Finally, check each directory in PATH for the executable file
if (!executableName.contains("/") && !executableName.contains("\\")) {
String pathEnv = System.getenv("PATH");
if (pathEnv != null) {
String[] pathDirs = pathEnv.split(File.pathSeparator);
for (String pathDir : pathDirs) {
File file = new File(pathDir, executableName);
if (file.exists() && file.canExecute()) {
log.debug(
"Found executable {} in PATH directory {}",
executableName,
pathDir);
return true;
}
}
}
}
log.debug("Executable {} not found", executableName);
return false;
}
}
@@ -0,0 +1,887 @@
package stirling.software.SPDF.config;
import java.io.File;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import io.github.pixee.security.SystemCommand;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.utils.ConversionTask;
/**
* UnoServerManager is responsible for managing multiple instances of unoserver based on application
* configuration.
*
* <p>This component is only created if UnoServer is available on the system.
*/
@Slf4j
@Component
@ConditionalOnUnoServerAvailable
public class UnoServerManager {
private static final int INSTANCE_CHECK_TIMEOUT_MS = 1000;
private static final long INSTANCE_STARTUP_TIMEOUT_MS = 30000;
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // Health check every minute
@Autowired private ApplicationProperties properties;
@Autowired private RuntimePathConfig runtimePathConfig;
@Getter private List<ServerInstance> instances = new ArrayList<>();
private AtomicInteger currentInstanceIndex = new AtomicInteger(0);
private ScheduledExecutorService healthCheckExecutor;
// The path to the UnoServer executable that was found during initialization
private String detectedUnoServerPath;
// Circuit breaker settings for external servers
private static final int FAILURE_THRESHOLD = 3; // Number of failures before circuit opens
private static final long CIRCUIT_RESET_TIME_MS = 30000; // Time before retrying a failed server
// Performance metrics
private final AtomicInteger totalConversions = new AtomicInteger(0);
private final AtomicInteger failedConversions = new AtomicInteger(0);
private final Map<Integer, AtomicInteger> conversionsPerInstance = new ConcurrentHashMap<>();
// Queue tracking
private final ConcurrentHashMap<String, ConversionTask> activeTasks = new ConcurrentHashMap<>();
private final AtomicInteger taskIdCounter = new AtomicInteger(0);
private final Map<Integer, AtomicInteger> activeTasksPerInstance = new ConcurrentHashMap<>();
@PostConstruct
public void initialize() {
try {
int maxInstances =
properties.getProcessExecutor().getSessionLimit().getLibreOfficeSessionLimit();
boolean useExternal = properties.getProcessExecutor().isUseExternalUnoconvServers();
List<String> externalServers = properties.getProcessExecutor().getUnoconvServers();
int basePort = properties.getProcessExecutor().getBaseUnoconvPort();
boolean manageUnoServer = properties.getProcessExecutor().isManageUnoServer();
log.info(
"Initializing UnoServerManager with maxInstances: {}, useExternal: {}, externalServers: {}, unoConvertPath: {}, manageUnoServer: {}",
maxInstances,
useExternal,
externalServers,
runtimePathConfig.getUnoConvertPath(),
manageUnoServer);
// Get valid UnoServer executable path
String unoServerPath = findValidUnoServerPath();
if (unoServerPath == null) {
log.warn("UnoServer executable not found. Office conversions will be disabled.");
return;
}
log.info("Using UnoServer at: {}", unoServerPath);
// Store the path for use by server instances
this.detectedUnoServerPath = unoServerPath;
if (useExternal && !externalServers.isEmpty()) {
// Configure for external servers
for (String serverAddress : externalServers) {
String[] parts = serverAddress.split(":");
String host = parts[0];
int port = parts.length > 1 ? Integer.parseInt(parts[1]) : basePort;
ServerInstance instance = new ServerInstance(host, port, false);
instances.add(instance);
conversionsPerInstance.put(instances.size() - 1, new AtomicInteger(0));
activeTasksPerInstance.put(instances.size() - 1, new AtomicInteger(0));
}
log.info("Configured {} external UnoServer instances", instances.size());
} else if (manageUnoServer) {
// Configure for local instances only if manageUnoServer is true
boolean anyInstanceStarted = false;
for (int i = 0; i < maxInstances; i++) {
int port = basePort + i;
ServerInstance instance = new ServerInstance("127.0.0.1", port, true);
instances.add(instance);
conversionsPerInstance.put(i, new AtomicInteger(0));
activeTasksPerInstance.put(i, new AtomicInteger(0));
try {
instance.start();
anyInstanceStarted = true;
} catch (IOException e) {
log.warn(
"Failed to start UnoServer instance on port {}: {}",
port,
e.getMessage());
}
}
if (!anyInstanceStarted) {
log.warn(
"Failed to start any UnoServer instances. Office conversions may be affected.");
}
log.info("Started {} local UnoServer instances", instances.size());
} else {
log.info(
"Application is configured to not manage UnoServer instances. Assuming external management.");
}
// Start the health check scheduler
startHealthCheck();
// Log initial health status
logHealthStatus();
} catch (Exception e) {
log.warn("Failed to initialize UnoServerManager: {}", e.getMessage(), e);
}
}
/**
* Scans multiple locations to find a valid UnoServer executable
*
* @return Path to UnoServer if found, null otherwise
*/
private String findValidUnoServerPath() {
// Common paths to check for UnoServer
List<String> pathsToCheck = new ArrayList<>();
// Try to derive the path from unoConvertPath first (highest priority)
String unoConvertPath = runtimePathConfig.getUnoConvertPath();
if (unoConvertPath != null && !unoConvertPath.isEmpty()) {
File unoConvertFile = new File(unoConvertPath);
if (unoConvertFile.exists() && unoConvertFile.canExecute()) {
Path unoConvertDir = Paths.get(unoConvertPath).getParent();
if (unoConvertDir != null) {
Path potentialUnoServerPath = unoConvertDir.resolve("unoserver");
pathsToCheck.add(potentialUnoServerPath.toString());
}
} else {
log.warn("UnoConvert not found at configured path: {}", unoConvertPath);
}
}
// Add common installation paths
pathsToCheck.add("/opt/venv/bin/unoserver"); // Docker path
pathsToCheck.add("/usr/bin/unoserver"); // Linux system path
pathsToCheck.add("/usr/local/bin/unoserver"); // Linux local path
pathsToCheck.add("/opt/homebrew/bin/unoserver"); // Mac Homebrew path
pathsToCheck.add("/opt/libreoffice/program/unoserver"); // Custom LibreOffice path
// Check each path
for (String path : pathsToCheck) {
File file = new File(path);
if (file.exists() && file.canExecute()) {
log.info("Found valid UnoServer at: {}", path);
return path;
}
}
// If no absolute path works, try to find it in PATH
String pathEnv = System.getenv("PATH");
if (pathEnv != null) {
String[] pathDirs = pathEnv.split(File.pathSeparator);
for (String pathDir : pathDirs) {
File file = new File(pathDir, "unoserver");
if (file.exists() && file.canExecute()) {
log.info("Found UnoServer in PATH at: {}", file.getAbsolutePath());
return file.getAbsolutePath();
}
}
}
log.warn("UnoServer executable not found in any standard location");
return null;
}
/** Starts periodic health checks for all instances */
private void startHealthCheck() {
healthCheckExecutor = Executors.newSingleThreadScheduledExecutor();
healthCheckExecutor.scheduleAtFixedRate(
this::performHealthCheck,
HEALTH_CHECK_INTERVAL_MS,
HEALTH_CHECK_INTERVAL_MS,
TimeUnit.MILLISECONDS);
}
/** Perform health check on all instances */
private void performHealthCheck() {
log.debug("Running UnoServer health check for {} instances", instances.size());
int healthy = 0;
int unhealthy = 0;
for (int i = 0; i < instances.size(); i++) {
ServerInstance instance = instances.get(i);
boolean isRunning = instance.isRunning();
if (isRunning) {
healthy++;
instance.resetFailureCount(); // Reset failure count for healthy instance
} else {
unhealthy++;
log.warn(
"UnoServer instance {}:{} is not running",
instance.getHost(),
instance.getPort());
// For managed instances, try to restart if needed
if (instance.isManaged()) {
try {
instance.restartIfNeeded();
} catch (Exception e) {
log.error(
"Failed to restart UnoServer instance {}:{}",
instance.getHost(),
instance.getPort(),
e);
}
}
}
}
log.info("UnoServer health check: {} healthy, {} unhealthy instances", healthy, unhealthy);
// Log metrics periodically
logMetrics();
}
/** Logs the current health status of all instances */
private void logHealthStatus() {
StringBuilder status = new StringBuilder("UnoServer Instances Status:\n");
for (int i = 0; i < instances.size(); i++) {
ServerInstance instance = instances.get(i);
boolean isRunning = instance.isRunning();
int convCount = conversionsPerInstance.get(i).get();
status.append(
String.format(
" [%d] %s:%d - Status: %s, Managed: %s, Conversions: %d, Failures: %d\n",
i,
instance.getHost(),
instance.getPort(),
isRunning ? "RUNNING" : "DOWN",
instance.isManaged() ? "YES" : "NO",
convCount,
instance.getFailureCount()));
}
log.info(status.toString());
}
/** Logs performance metrics for UnoServer conversions */
private void logMetrics() {
int total = totalConversions.get();
int failed = failedConversions.get();
float successRate = total > 0 ? (float) (total - failed) / total * 100 : 0;
log.info(
"UnoServer metrics - Total: {}, Failed: {}, Success Rate: {:.2f}%",
total, failed, successRate);
// Log per-instance metrics
StringBuilder instanceMetrics = new StringBuilder("Conversions per instance:\n");
for (int i = 0; i < instances.size(); i++) {
ServerInstance instance = instances.get(i);
int count = conversionsPerInstance.get(i).get();
float percentage = total > 0 ? (float) count / total * 100 : 0;
instanceMetrics.append(
String.format(
" [%d] %s:%d - Count: %d (%.2f%%), Avg Time: %.2fms\n",
i,
instance.getHost(),
instance.getPort(),
count,
percentage,
instance.getAverageConversionTime()));
}
log.info(instanceMetrics.toString());
}
@PreDestroy
public void cleanup() {
log.info("Shutting down UnoServer instances and health check scheduler");
// Shutdown health check scheduler
if (healthCheckExecutor != null) {
healthCheckExecutor.shutdownNow();
}
// Shutdown all instances
for (ServerInstance instance : instances) {
instance.stop();
}
// Log final metrics
logMetrics();
}
/**
* Gets the next available server instance using load-balancing and circuit breaker pattern for
* fault tolerance
*
* @return The next UnoServer instance to use
*/
public ServerInstance getNextInstance() {
if (instances.isEmpty()) {
throw new IllegalStateException("No UnoServer instances available");
}
// First try to find a healthy instance with the least active tasks
int minActiveTasks = Integer.MAX_VALUE;
int selectedIndex = -1;
for (int i = 0; i < instances.size(); i++) {
ServerInstance instance = instances.get(i);
// Check if instance is available (not in circuit-open state)
if (instance.isAvailable() && instance.isRunning()) {
int activeTasks = activeTasksPerInstance.get(i).get();
// If this instance has fewer active tasks, select it
if (activeTasks < minActiveTasks) {
minActiveTasks = activeTasks;
selectedIndex = i;
// If we found an instance with no active tasks, use it immediately
if (minActiveTasks == 0) {
break;
}
}
}
}
// If we found a suitable instance, use it
if (selectedIndex >= 0) {
ServerInstance instance = instances.get(selectedIndex);
// Track this instance being selected
conversionsPerInstance.get(selectedIndex).incrementAndGet();
activeTasksPerInstance.get(selectedIndex).incrementAndGet();
totalConversions.incrementAndGet();
log.debug(
"Selected UnoServer instance {}:{} with {} active tasks",
instance.getHost(),
instance.getPort(),
minActiveTasks);
return instance;
}
// If all healthy instances are busy or no healthy instances found, use round-robin as
// fallback
log.warn(
"No available UnoServer instances found with good health. Using round-robin fallback.");
// Try to find any available instance using round-robin
for (int attempt = 0; attempt < instances.size(); attempt++) {
int index = currentInstanceIndex.getAndIncrement() % instances.size();
ServerInstance instance = instances.get(index);
// Check if the instance is available (circuit closed)
if (instance.isAvailable()) {
// Track this instance being selected
conversionsPerInstance.get(index).incrementAndGet();
activeTasksPerInstance.get(index).incrementAndGet();
totalConversions.incrementAndGet();
log.debug(
"Selected UnoServer instance {}:{} using round-robin fallback",
instance.getHost(),
instance.getPort());
return instance;
}
}
// Last resort - if all circuits are open, use the next instance anyway
int index = currentInstanceIndex.get() % instances.size();
ServerInstance instance = instances.get(index);
log.warn(
"All UnoServer instances are in circuit-open state. Using instance at {}:{} as fallback.",
instance.getHost(),
instance.getPort());
// Track metrics even for fallback case
conversionsPerInstance.get(index).incrementAndGet();
activeTasksPerInstance.get(index).incrementAndGet();
totalConversions.incrementAndGet();
return instance;
}
/**
* Creates a new task for tracking office conversions
*
* @param taskName A descriptive name for the task
* @param instance The server instance that will handle this task
* @return A unique task ID for tracking
*/
public String createTask(String taskName, ServerInstance instance) {
String taskId = "office-" + taskIdCounter.incrementAndGet();
ConversionTask task = new ConversionTask(taskName, taskId);
// Calculate queue position based on number of active tasks across all instances
int runningTasks = 0;
int availableInstances = 0;
for (int i = 0; i < instances.size(); i++) {
if (instances.get(i).isRunning() && instances.get(i).isAvailable()) {
availableInstances++;
runningTasks += activeTasksPerInstance.get(i).get();
}
}
// If all instances are busy, set a queue position
if (runningTasks >= availableInstances && availableInstances > 0) {
int queuePosition = runningTasks - availableInstances + 1;
task.setQueuePosition(queuePosition);
}
// Store the task in our tracking map
activeTasks.put(taskId, task);
// Find the instance index for updating metrics
for (int i = 0; i < instances.size(); i++) {
if (instances.get(i) == instance) {
activeTasksPerInstance.get(i).incrementAndGet();
break;
}
}
log.debug("Created task {} with ID {}", taskName, taskId);
return taskId;
}
/**
* Completes a task, updating metrics and removing it from active tasks
*
* @param taskId The task ID to complete
* @param instance The server instance that handled this task
* @param durationMs The time taken to complete the task in milliseconds
*/
public void completeTask(String taskId, ServerInstance instance, long durationMs) {
ConversionTask task = activeTasks.remove(taskId);
if (task != null) {
task.complete();
}
// Find the instance index for updating metrics
for (int i = 0; i < instances.size(); i++) {
if (instances.get(i) == instance) {
activeTasksPerInstance.get(i).decrementAndGet();
break;
}
}
// Record the success for circuit breaker and metrics
recordSuccess(instance, durationMs);
log.debug("Completed task with ID {}, duration: {}ms", taskId, durationMs);
}
/**
* Fails a task, updating metrics and removing it from active tasks
*
* @param taskId The task ID to fail
* @param instance The server instance that handled this task
* @param errorMessage The error message explaining the failure
*/
public void failTask(String taskId, ServerInstance instance, String errorMessage) {
ConversionTask task = activeTasks.remove(taskId);
if (task != null) {
task.fail(errorMessage);
}
// Find the instance index for updating metrics
for (int i = 0; i < instances.size(); i++) {
if (instances.get(i) == instance) {
activeTasksPerInstance.get(i).decrementAndGet();
break;
}
}
// Record the failure for circuit breaker and metrics
recordFailure(instance);
log.warn("Failed task with ID {}: {}", taskId, errorMessage);
}
/**
* Gets all active conversion tasks
*
* @return A map of task IDs to ConversionTask objects
*/
public Map<String, ConversionTask> getActiveTasks() {
return new HashMap<>(activeTasks);
}
/**
* Records a successful conversion for metrics
*
* @param instance The server instance that succeeded
* @param durationMs The time taken for the conversion in milliseconds
*/
public void recordSuccess(ServerInstance instance, long durationMs) {
instance.recordSuccess(durationMs);
}
/**
* Records a failed conversion for metrics and circuit breaker
*
* @param instance The server instance that failed
*/
public void recordFailure(ServerInstance instance) {
failedConversions.incrementAndGet();
instance.recordFailure();
}
/** Represents a single UnoServer instance with circuit breaker functionality */
public class ServerInstance {
@Getter private final String host;
@Getter private final int port;
@Getter private final boolean managed;
private ExecutorService executorService;
private Process process;
private boolean running = false;
// Circuit breaker state
private final AtomicInteger failureCount = new AtomicInteger(0);
private volatile Instant lastFailureTime = null;
private volatile boolean circuitOpen = false;
// Performance metrics
private final AtomicLong totalConversionTimeMs = new AtomicLong(0);
private final AtomicInteger conversionCount = new AtomicInteger(0);
private final AtomicLong lastConversionDuration = new AtomicLong(0);
public ServerInstance(String host, int port, boolean managed) {
this.host = host;
this.port = port;
this.managed = managed;
if (!managed) {
// For external servers, we assume they're running initially
this.running = true;
}
}
/** Gets the number of failures for circuit breaker */
public int getFailureCount() {
return failureCount.get();
}
/** Resets the failure count for circuit breaker */
public void resetFailureCount() {
failureCount.set(0);
circuitOpen = false;
}
/**
* Records a successful conversion
*
* @param durationMs The duration of the conversion in milliseconds
*/
public void recordSuccess(long durationMs) {
conversionCount.incrementAndGet();
totalConversionTimeMs.addAndGet(durationMs);
lastConversionDuration.set(durationMs);
// Reset failure count on success
resetFailureCount();
}
/** Records a conversion failure */
public void recordFailure() {
int currentFailures = failureCount.incrementAndGet();
lastFailureTime = Instant.now();
// Open circuit if threshold reached
if (currentFailures >= FAILURE_THRESHOLD) {
log.warn(
"Circuit breaker opened for UnoServer instance {}:{} after {} failures",
host,
port,
currentFailures);
circuitOpen = true;
}
}
/**
* Checks if this instance is available based on circuit breaker status
*
* @return true if available, false if circuit is open
*/
public boolean isAvailable() {
// If circuit is closed, instance is available
if (!circuitOpen) {
return true;
}
// If circuit is open but reset time has passed, try half-open state
if (lastFailureTime != null
&& Duration.between(lastFailureTime, Instant.now()).toMillis()
> CIRCUIT_RESET_TIME_MS) {
log.info("Circuit breaker half-open for UnoServer instance {}:{}", host, port);
circuitOpen = false;
return true;
}
// Circuit is open
return false;
}
/**
* Gets the average conversion time in milliseconds
*
* @return The average conversion time or 0 if no conversions yet
*/
public double getAverageConversionTime() {
int count = conversionCount.get();
return count > 0 ? (double) totalConversionTimeMs.get() / count : 0;
}
/**
* Gets the last conversion duration in milliseconds
*
* @return The last conversion duration
*/
public long getLastConversionDuration() {
return lastConversionDuration.get();
}
/**
* Checks if the UnoServer instance is running
*
* @return true if the server is accessible, false otherwise
*/
public boolean isRunning() {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), INSTANCE_CHECK_TIMEOUT_MS);
return true;
} catch (Exception e) {
return false;
}
}
/**
* Starts the UnoServer if it's a managed instance
*
* @throws IOException if the server fails to start
*/
public synchronized void start() throws IOException {
if (!managed
|| (process != null && process.isAlive())
|| !properties.getProcessExecutor().isManageUnoServer()) {
return;
}
log.info("Starting UnoServer on {}:{}", host, port);
try {
// Use the detected UnoServer path from parent class
String unoServerPath = UnoServerManager.this.detectedUnoServerPath;
// If not available (shouldn't happen), try to determine it
if (unoServerPath == null || unoServerPath.isEmpty()) {
log.warn(
"detectedUnoServerPath is null, attempting to find unoserver executable");
unoServerPath = findUnoServerExecutable();
if (unoServerPath == null) {
throw new IOException(
"UnoServer executable not found. Cannot start server instance.");
}
}
log.debug("Using UnoServer executable: {}", unoServerPath);
// Create the command with the correct path and options
String command =
String.format("%s --port %d --interface %s", unoServerPath, port, host);
// Final verification that the executable exists and is executable
File executableFile = new File(unoServerPath);
if (!executableFile.exists() || !executableFile.canExecute()) {
throw new IOException(
"UnoServer executable not found or not executable at: "
+ executableFile.getAbsolutePath());
}
// Run the command
log.debug("Executing command: {}", command);
process = SystemCommand.runCommand(Runtime.getRuntime(), command);
// Start a background thread to monitor the process
executorService = Executors.newSingleThreadExecutor();
executorService.submit(
() -> {
try {
int exitCode = process.waitFor();
log.info(
"UnoServer process on port {} exited with code {}",
port,
exitCode);
running = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("UnoServer monitoring thread was interrupted", e);
}
});
// Wait for the server to start up with timeout
long startTime = System.currentTimeMillis();
boolean startupSuccess = false;
while (System.currentTimeMillis() - startTime < INSTANCE_STARTUP_TIMEOUT_MS) {
if (isRunning()) {
running = true;
startupSuccess = true;
log.info("UnoServer started successfully on {}:{}", host, port);
break;
}
// Check if process is still alive
if (process == null || !process.isAlive()) {
int exitCode = process != null ? process.exitValue() : -1;
log.warn(
"UnoServer process terminated prematurely with exit code: {}, continuing without it",
exitCode);
return;
}
try {
Thread.sleep(1000); // Check every second
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("Interrupted while waiting for UnoServer to start", e);
return;
}
}
if (!startupSuccess) {
// Timeout occurred, clean up and log warning
if (process != null && process.isAlive()) {
process.destroy();
}
log.warn(
"Failed to start UnoServer within timeout period of {} seconds, continuing without it",
(INSTANCE_STARTUP_TIMEOUT_MS / 1000));
}
} catch (IOException e) {
log.warn("Failed to start UnoServer: {}, continuing without it", e.getMessage());
// Don't rethrow - continue without the server
}
}
/**
* Helper method to find the UnoServer executable
*
* @return Path to UnoServer executable or null if not found
*/
private String findUnoServerExecutable() {
// Try to derive from unoConvertPath first
String unoConvertPath = UnoServerManager.this.runtimePathConfig.getUnoConvertPath();
if (unoConvertPath != null && !unoConvertPath.isEmpty()) {
Path unoConvertDir = Paths.get(unoConvertPath).getParent();
if (unoConvertDir != null) {
Path potentialUnoServerPath = unoConvertDir.resolve("unoserver");
File unoServerFile = potentialUnoServerPath.toFile();
if (unoServerFile.exists() && unoServerFile.canExecute()) {
return potentialUnoServerPath.toString();
}
}
}
// Check common paths
String[] commonPaths = {
"/opt/venv/bin/unoserver", "/usr/bin/unoserver", "/usr/local/bin/unoserver"
};
for (String path : commonPaths) {
File file = new File(path);
if (file.exists() && file.canExecute()) {
return path;
}
}
return null;
}
/** Stops the UnoServer if it's a managed instance */
public synchronized void stop() {
if (!managed) {
return;
}
// Stop the monitoring thread
if (executorService != null) {
executorService.shutdownNow();
}
// Stop the server process
if (process != null && process.isAlive()) {
log.info("Stopping UnoServer on port {}", port);
process.destroy();
}
running = false;
}
/**
* Restarts the UnoServer if it's a managed instance and not running
*
* @return true if restart succeeded or wasn't needed, false otherwise
*/
public synchronized boolean restartIfNeeded() {
if (!managed || running || !properties.getProcessExecutor().isManageUnoServer()) {
return true;
}
try {
log.info("Attempting to restart UnoServer on {}:{}", host, port);
start();
return true;
} catch (IOException e) {
log.warn("Failed to restart UnoServer on port {}, continuing without it", port, e);
return false;
}
}
/**
* Gets the connection string for this instance
*
* @return A connection string in the format host:port
*/
public String getConnectionString() {
return host + ":" + port;
}
}
}
@@ -0,0 +1,69 @@
package stirling.software.SPDF.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.utils.ConversionTask;
/**
* Fallback configuration for when UnoServerManager is not available. This will provide friendly
* error messages when users try to use LibreOffice conversion features without having UnoServer
* installed.
*/
@Configuration
@Slf4j
public class UnoServerManagerFallback {
/**
* Creates a bean that provides a friendly error message when LibreOffice conversion is
* attempted but UnoServer is not available.
*/
@Bean
@ConditionalOnMissingBean(UnoServerManager.class)
public UnoServerNotAvailableHandler unoServerNotAvailableHandler(
RuntimePathConfig runtimePathConfig) {
log.info("UnoServer is not available. Office document conversions will be disabled.");
log.info("If you need Office document conversions, please install UnoServer.");
log.info("For Docker users, use the 'fat' image variant which includes UnoServer.");
// Log the path where we would expect to find unoconvert
if (runtimePathConfig != null) {
log.info("Expected unoconvert path: {}", runtimePathConfig.getUnoConvertPath());
}
return new UnoServerNotAvailableHandler();
}
/**
* Handler that provides friendly error messages when LibreOffice conversion is attempted but
* UnoServer is not available.
*/
public static class UnoServerNotAvailableHandler {
/** Method that throws a friendly exception when office conversions are attempted. */
public void throwUnoServerNotAvailableException() {
throw new UnoServerNotAvailableException(
"UnoServer (LibreOffice) is not available. Office document conversions are disabled. "
+ "To enable this feature, please install UnoServer or use the 'fat' Docker image variant.");
}
/** Creates a failed conversion task with a friendly error message. */
public ConversionTask createFailedTask(String taskName) {
ConversionTask task = new ConversionTask(taskName, (String) null);
task.fail(
"UnoServer (LibreOffice) is not available. Office document conversions are disabled.");
return task;
}
}
/** Exception thrown when UnoServer features are used but UnoServer is not available. */
public static class UnoServerNotAvailableException extends RuntimeException {
private static final long serialVersionUID = 1L;
public UnoServerNotAvailableException(String message) {
super(message);
}
}
}
@@ -37,8 +37,21 @@ public class EmailService {
*/
@Async
public void sendEmailWithAttachment(Email email) throws MessagingException {
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
MultipartFile file = email.getFileInput();
// 1) Validate recipient email address
if (email.getTo() == null || email.getTo().trim().isEmpty()) {
throw new MessagingException("Invalid Addresses");
}
// 2) Validate attachment
if (file == null
|| file.isEmpty()
|| file.getOriginalFilename() == null
|| file.getOriginalFilename().isEmpty()) {
throw new MessagingException("An attachment is required to send the email.");
}
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
// Creates a MimeMessage to represent the email
MimeMessage message = mailSender.createMimeMessage();
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mail.MailSendException;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -53,6 +54,11 @@ public class EmailController {
// Calls the service to send the email with attachment
emailService.sendEmailWithAttachment(email);
return ResponseEntity.ok("Email sent successfully");
} catch (MailSendException ex) {
// handles your "Invalid Addresses" case
String errorMsg = ex.getMessage();
log.error("MailSendException: {}", errorMsg, ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorMsg);
} catch (MessagingException e) {
// Catches any messaging exception (e.g., invalid email address, SMTP server issues)
String errorMsg = "Failed to send email: " + e.getMessage();
@@ -0,0 +1,221 @@
package stirling.software.SPDF.controller.api;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.UnoServerManager;
import stirling.software.SPDF.config.UnoServerManager.ServerInstance;
import stirling.software.SPDF.utils.ConversionTask;
import stirling.software.SPDF.utils.ProcessExecutor;
import stirling.software.SPDF.utils.ProcessExecutor.Processes;
/** Controller for checking status of process queues */
@RestController
@RequestMapping("/api/v1/queue")
@Slf4j
public class QueueStatusController {
@Autowired(required = false)
private UnoServerManager unoServerManager;
/**
* Get the status of all process queues
*
* @return Map of queue statuses by process type
*/
@GetMapping("/status")
public ResponseEntity<Map<String, QueueStatus>> getAllQueueStatuses() {
Map<String, QueueStatus> statuses = new HashMap<>();
// Add statuses for all ProcessExecutor process types
for (Processes processType : Processes.values()) {
ProcessExecutor executor = ProcessExecutor.getInstance(processType);
QueueStatus status = new QueueStatus();
status.setProcessType(processType.name());
status.setActiveCount(executor.getActiveTaskCount());
status.setQueuedCount(executor.getQueueLength());
statuses.put(processType.name(), status);
}
// Add UnoServer status if available
if (unoServerManager != null) {
QueueStatus status = new QueueStatus();
status.setProcessType("UNOSERVER");
// Get active tasks from UnoServerManager
Map<String, ConversionTask> activeTasks = unoServerManager.getActiveTasks();
status.setActiveCount(activeTasks.size());
status.setQueuedCount(0); // UnoServer tasks are immediately processed
statuses.put("UNOSERVER", status);
}
return ResponseEntity.ok(statuses);
}
/**
* Get the status of a specific process queue
*
* @param processType The process type
* @return Queue status for the specified process
*/
@GetMapping("/status/{processType}")
public ResponseEntity<QueueStatus> getQueueStatus(@PathVariable String processType) {
try {
Processes process = Processes.valueOf(processType.toUpperCase());
ProcessExecutor executor = ProcessExecutor.getInstance(process);
QueueStatus status = new QueueStatus();
status.setProcessType(process.name());
status.setActiveCount(executor.getActiveTaskCount());
status.setQueuedCount(executor.getQueueLength());
return ResponseEntity.ok(status);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
/**
* Get status of a specific task
*
* @param taskId The task ID
* @return Task status or 404 if not found
*/
@GetMapping("/task/{taskId}")
public ResponseEntity<TaskInfo> getTaskStatus(@PathVariable String taskId) {
// Try to find the task in any process executor
for (Processes processType : Processes.values()) {
ProcessExecutor executor = ProcessExecutor.getInstance(processType);
ConversionTask task = executor.getTask(taskId);
if (task != null) {
return ResponseEntity.ok(convertToTaskInfo(task));
}
}
// Check UnoServer tasks if available
if (unoServerManager != null && taskId.startsWith("office-")) {
Map<String, ConversionTask> unoTasks = unoServerManager.getActiveTasks();
ConversionTask task = unoTasks.get(taskId);
if (task != null) {
// Calculate queue position for UnoServer tasks
if (task.getStatus() == ConversionTask.TaskStatus.QUEUED) {
int queuePosition = 0;
for (ConversionTask otherTask : unoTasks.values()) {
if (otherTask.getStatus() == ConversionTask.TaskStatus.QUEUED
&& otherTask.getCreatedTime().isBefore(task.getCreatedTime())) {
queuePosition++;
}
}
// Set queue position
task.setQueuePosition(queuePosition + 1);
}
return ResponseEntity.ok(convertToTaskInfo(task));
}
}
return ResponseEntity.notFound().build();
}
/**
* Get queue status for a specific client ID
*
* @param clientId The client-generated ID for the task
* @return Queue status with position for the specific client task
*/
@GetMapping("/status/client/{clientId}")
public ResponseEntity<Map<String, QueueStatus>> getQueueStatusForClient(@PathVariable String clientId) {
Map<String, QueueStatus> result = new HashMap<>();
boolean foundMatch = false;
// Check each process type for the client ID
for (Processes processType : Processes.values()) {
ProcessExecutor executor = ProcessExecutor.getInstance(processType);
List<ConversionTask> queuedTasks = executor.getQueuedTasks();
// Find the position of the client's task in this queue
for (ConversionTask task : queuedTasks) {
// If we find a match for this client's task
if (task.getId().equals(clientId)) {
QueueStatus status = new QueueStatus();
status.setProcessType(processType.name());
status.setActiveCount(executor.getActiveTaskCount());
status.setQueuedCount(task.getQueuePosition());
result.put(processType.name(), status);
foundMatch = true;
break; // Exit loop once found - we only need one match
}
}
if (foundMatch) break; // Exit process type loop if we've found the task
}
// If no matching task found in process executors, check UnoServer
if (!foundMatch && unoServerManager != null) {
Map<String, ConversionTask> unoTasks = unoServerManager.getActiveTasks();
ConversionTask task = unoTasks.get(clientId);
if (task != null) {
QueueStatus status = new QueueStatus();
status.setProcessType("UNOSERVER");
status.setActiveCount(unoTasks.size());
status.setQueuedCount(0); // UnoServer tasks are immediately processed
result.put("UNOSERVER", status);
}
}
return ResponseEntity.ok(result);
}
/** Convert a ConversionTask to TaskInfo DTO */
private TaskInfo convertToTaskInfo(ConversionTask task) {
TaskInfo info = new TaskInfo();
info.setId(task.getId());
info.setStatus(task.getStatus().name());
info.setQueuePosition(task.getQueuePosition());
return info;
}
/** DTO for queue status */
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class QueueStatus {
private String processType;
private int activeCount;
private int queuedCount;
}
/** DTO for task information */
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class TaskInfo {
private String id;
private String status;
private int queuePosition;
}
}
@@ -171,16 +171,19 @@ public class UserController {
* Updates the user settings based on the provided JSON payload.
*
* @param updates A map containing the settings to update. The expected structure is:
* <ul>
* <li><b>emailNotifications</b> (optional): "true" or "false" - Enable or disable email notifications.</li>
* <li><b>theme</b> (optional): "light" or "dark" - Set the user's preferred theme.</li>
* <li><b>language</b> (optional): A string representing the preferred language (e.g., "en", "fr").</li>
* </ul>
* Keys not listed above will be ignored.
* <ul>
* <li><b>emailNotifications</b> (optional): "true" or "false" - Enable or disable email
* notifications.
* <li><b>theme</b> (optional): "light" or "dark" - Set the user's preferred theme.
* <li><b>language</b> (optional): A string representing the preferred language (e.g.,
* "en", "fr").
* </ul>
* Keys not listed above will be ignored.
* @param principal The currently authenticated user.
* @return A redirect string to the account page after updating the settings.
* @throws SQLException If a database error occurs.
* @throws UnsupportedProviderException If the operation is not supported for the user's provider.
* @throws UnsupportedProviderException If the operation is not supported for the user's
* provider.
*/
public String updateUserSettings(@RequestBody Map<String, String> updates, Principal principal)
throws SQLException, UnsupportedProviderException {
@@ -10,6 +10,7 @@ import java.util.List;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@@ -21,25 +22,62 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.RuntimePathConfig;
import stirling.software.SPDF.config.UnoServerManager;
import stirling.software.SPDF.config.UnoServerManager.ServerInstance;
import stirling.software.SPDF.config.UnoServerManagerFallback;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.api.GeneralFile;
import stirling.software.SPDF.service.CustomPDFDocumentFactory;
import stirling.software.SPDF.utils.ConversionTask;
import stirling.software.SPDF.utils.ProcessExecutor;
import stirling.software.SPDF.utils.ProcessExecutor.ProcessExecutorResult;
import stirling.software.SPDF.utils.WebResponseUtils;
@Slf4j
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertOfficeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final UnoServerManager unoServerManager;
private final ApplicationProperties applicationProperties;
@Autowired
public ConvertOfficeController(
CustomPDFDocumentFactory pdfDocumentFactory,
RuntimePathConfig runtimePathConfig,
ApplicationProperties applicationProperties,
@Autowired(required = false) UnoServerManager unoServerManager,
@Autowired(required = false)
UnoServerManagerFallback.UnoServerNotAvailableHandler
unoServerNotAvailableHandler) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.runtimePathConfig = runtimePathConfig;
this.unoServerManager = unoServerManager;
this.applicationProperties = applicationProperties;
// Log appropriate message based on UnoServer availability
if (unoServerManager == null) {
log.warn("UnoServer is not available. Office document conversions will be disabled.");
if (unoServerNotAvailableHandler == null) {
log.error("UnoServerNotAvailableHandler is also missing! This should not happen.");
}
} else {
log.info("UnoServer is available. Office document conversions are enabled.");
}
}
public File convertToPdf(MultipartFile inputFile) throws IOException, InterruptedException {
return convertToPdf(inputFile, null);
}
public File convertToPdf(MultipartFile inputFile, String[] taskIdHolder)
throws IOException, InterruptedException {
// Check for valid file extension
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null
@@ -47,6 +85,13 @@ public class ConvertOfficeController {
throw new IllegalArgumentException("Invalid file extension");
}
// Check if UnoServer is available
if (unoServerManager == null) {
throw new UnoServerManagerFallback.UnoServerNotAvailableException(
"UnoServer (LibreOffice) is not available. Office document conversions are disabled. "
+ "To enable this feature, please install UnoServer or use the 'fat' Docker image variant.");
}
// Save the uploaded file to a temporary location
Path tempInputFile =
Files.createTempFile("input_", "." + FilenameUtils.getExtension(originalFilename));
@@ -55,24 +100,95 @@ public class ConvertOfficeController {
// Prepare the output file path
Path tempOutputFile = Files.createTempFile("output_", ".pdf");
// Get the next available UnoServer instance
ServerInstance serverInstance = unoServerManager.getNextInstance();
// Create a task for tracking this conversion
String taskName = "Convert " + originalFilename + " to PDF";
String taskId = unoServerManager.createTask(taskName, serverInstance);
// Store the task ID for the caller if requested
if (taskIdHolder != null) {
taskIdHolder[0] = taskId;
}
log.info(
"Converting file {} using UnoServer instance at {}:{} (taskId: {})",
originalFilename,
serverInstance.getHost(),
serverInstance.getPort(),
taskId);
long startTime = System.currentTimeMillis();
try {
// Run the LibreOffice command
// If it's a managed instance and not running, try to restart it
if (!serverInstance.isRunning()) {
log.warn(
"UnoServer instance at {}:{} is not running, attempting restart",
serverInstance.getHost(),
serverInstance.getPort());
if (!serverInstance.restartIfNeeded()) {
unoServerManager.failTask(
taskId, serverInstance, "Failed to start UnoServer instance");
throw new IOException("Failed to start UnoServer instance for conversion");
}
}
// Run the LibreOffice command with the selected server
List<String> command =
new ArrayList<>(
Arrays.asList(
runtimePathConfig.getUnoConvertPath(),
"--port",
"2003",
String.valueOf(serverInstance.getPort()),
"--host",
serverInstance.getHost(),
"--convert-to",
"pdf",
tempInputFile.toString(),
tempOutputFile.toString()));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
// Read the converted PDF file
return tempOutputFile.toFile();
log.debug("Running command: {}", String.join(" ", command));
try {
// Execute the command with a named task
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithTask(command, "Convert " + originalFilename + " to PDF");
// Calculate duration and mark task as complete
long duration = System.currentTimeMillis() - startTime;
unoServerManager.completeTask(taskId, serverInstance, duration);
log.info(
"Successfully converted file {} using UnoServer instance {}:{} in {}ms (taskId: {})",
originalFilename,
serverInstance.getHost(),
serverInstance.getPort(),
duration,
taskId);
// Read the converted PDF file
return tempOutputFile.toFile();
} catch (IOException | InterruptedException e) {
// Mark task as failed
unoServerManager.failTask(taskId, serverInstance, e.getMessage());
log.error(
"Failed to convert file {} using UnoServer instance {}:{}: {}",
originalFilename,
serverInstance.getHost(),
serverInstance.getPort(),
e.getMessage());
throw e;
}
} catch (Exception e) {
// Mark task as failed if any other exception occurs
unoServerManager.failTask(taskId, serverInstance, e.getMessage());
throw e;
} finally {
// Clean up the temporary files
if (tempInputFile != null) Files.deleteIfExists(tempInputFile);
@@ -92,19 +208,72 @@ public class ConvertOfficeController {
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> processFileToPDF(@ModelAttribute GeneralFile generalFile)
throws Exception {
// Check if UnoServer is available first to provide a friendly error message
if (unoServerManager == null) {
return WebResponseUtils.errorResponseWithMessage(
"UnoServer (LibreOffice) is not available. Office document conversions are disabled. "
+ "To enable this feature, please install UnoServer or use the 'fat' Docker image variant.");
}
MultipartFile inputFile = generalFile.getFileInput();
// unused but can start server instance if startup time is to long
// LibreOfficeListener.getInstance().start();
File file = null;
String[] taskIdHolder = new String[1]; // Holder for task ID
try {
file = convertToPdf(inputFile);
// Call the conversion method to do the actual conversion
file = convertToPdf(inputFile, taskIdHolder);
PDDocument doc = pdfDocumentFactory.load(file);
return WebResponseUtils.pdfDocToWebResponse(
doc,
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_convertedToPDF.pdf");
// Get the ProcessExecutorResult to extract the task ID
String processTaskId = null;
if (file != null && file.exists()) {
// Extract any ProcessExecutor task ID that might have been created
// This is a bit of a hack but will work for demonstration
ProcessExecutor processExecutor = ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE);
List<ConversionTask> activeTasks = processExecutor.getActiveTasks();
List<ConversionTask> queuedTasks = processExecutor.getQueuedTasks();
// Look for a task that matches our file name
String filename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
for (ConversionTask task : activeTasks) {
if (task.getTaskName().contains(filename)) {
processTaskId = task.getId();
break;
}
}
if (processTaskId == null) {
for (ConversionTask task : queuedTasks) {
if (task.getTaskName().contains(filename)) {
processTaskId = task.getId();
break;
}
}
}
}
// Get the response builder from WebResponseUtils
ResponseEntity.BodyBuilder responseBuilder =
WebResponseUtils.getResponseBuilder(
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_convertedToPDF.pdf");
// Add headers for task tracking
if (taskIdHolder[0] != null) {
responseBuilder.header("X-Task-Id", taskIdHolder[0]);
}
if (processTaskId != null) {
responseBuilder.header("X-Process-Task-Id", processTaskId);
}
// Return the response with all available headers
return responseBuilder.body(WebResponseUtils.getBytesFromPDDocument(doc));
} catch (UnoServerManagerFallback.UnoServerNotAvailableException e) {
return WebResponseUtils.errorResponseWithMessage(e.getMessage());
} finally {
if (file != null) file.delete();
}
@@ -221,7 +221,8 @@ public class PipelineProcessor {
return result;
}
/* package */ ResponseEntity<byte[]> sendWebRequest(String url, MultiValueMap<String, Object> body) {
/* package */ ResponseEntity<byte[]> sendWebRequest(
String url, MultiValueMap<String, Object> body) {
RestTemplate restTemplate = new RestTemplate();
// Set up headers, including API key
HttpHeaders headers = new HttpHeaders();
@@ -1,5 +1,6 @@
package stirling.software.SPDF.controller.web;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
@@ -52,6 +53,6 @@ public class UploadLimitService {
if (bytes < 1024) return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(1024));
String pre = "KMGTPE".charAt(exp - 1) + "B";
return String.format("%.1f %s", bytes / Math.pow(1024, exp), pre);
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024, exp), pre);
}
}
@@ -359,6 +359,7 @@ public class ApplicationProperties {
private String homeDescription;
private String appNameNavbar;
private List<String> languages;
private Boolean showQueueStatus;
public String getAppName() {
return appName != null && appName.trim().length() > 0 ? appName : null;
@@ -375,6 +376,10 @@ public class ApplicationProperties {
? appNameNavbar
: null;
}
public boolean isQueueStatusEnabled() {
return showQueueStatus == null || showQueueStatus; // Default to true if not specified
}
}
@Data
@@ -501,6 +506,10 @@ public class ApplicationProperties {
public static class ProcessExecutor {
private SessionLimit sessionLimit = new SessionLimit();
private TimeoutMinutes timeoutMinutes = new TimeoutMinutes();
private List<String> unoconvServers = new ArrayList<>();
private boolean useExternalUnoconvServers = false;
private int baseUnoconvPort = 2003;
private boolean manageUnoServer = true;
@Data
public static class SessionLimit {
@@ -0,0 +1,188 @@
package stirling.software.SPDF.utils;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
/**
* Represents a task being processed by the ProcessExecutor. Used for tracking queue position and
* execution status.
*/
@Getter
public class ConversionTask {
public enum TaskStatus {
QUEUED,
RUNNING,
COMPLETED,
FAILED,
CANCELLED
}
private final String id;
private final String taskName;
private final Instant createdTime;
private final ProcessExecutor.Processes processType;
@Setter private volatile int queuePosition;
private volatile Instant startTime;
private volatile Instant endTime;
private volatile TaskStatus status;
private volatile String errorMessage;
private volatile Thread executingThread;
/**
* Creates a new conversion task
*
* @param taskName A descriptive name for the task
* @param processType The type of process executing the task
*/
public ConversionTask(String taskName, ProcessExecutor.Processes processType) {
this.id = UUID.randomUUID().toString();
this.taskName = taskName;
this.processType = processType;
this.createdTime = Instant.now();
this.status = TaskStatus.QUEUED;
}
/**
* Creates a new conversion task with a custom ID
*
* @param taskName A descriptive name for the task
* @param customId A custom ID for the task (can be null to generate a random UUID)
*/
public ConversionTask(String taskName, String customId) {
this.id = (customId != null) ? customId : UUID.randomUUID().toString();
this.taskName = taskName;
this.processType = null; // No process type for custom tasks
this.createdTime = Instant.now();
this.status = TaskStatus.QUEUED;
}
/** Marks the task as running */
public void start(Thread executingThread) {
this.startTime = Instant.now();
this.status = TaskStatus.RUNNING;
this.executingThread = executingThread;
}
/** Marks the task as completed */
public void complete() {
this.endTime = Instant.now();
this.status = TaskStatus.COMPLETED;
this.executingThread = null;
}
/**
* Marks the task as failed
*
* @param errorMessage The error message
*/
public void fail(String errorMessage) {
this.endTime = Instant.now();
this.status = TaskStatus.FAILED;
this.errorMessage = errorMessage;
this.executingThread = null;
}
/** Marks the task as cancelled */
public void cancel() {
this.endTime = Instant.now();
this.status = TaskStatus.CANCELLED;
this.executingThread = null;
}
/** Attempts to cancel the task if it's running */
public void attemptCancel() {
if (this.status == TaskStatus.RUNNING && executingThread != null) {
executingThread.interrupt();
} else {
cancel();
}
}
/**
* Gets the time spent in queue
*
* @return Queue time in milliseconds
*/
public long getQueueTimeMs() {
if (startTime == null) {
return Duration.between(createdTime, Instant.now()).toMillis();
}
return Duration.between(createdTime, startTime).toMillis();
}
/**
* Gets the processing time
*
* @return Processing time in milliseconds
*/
public long getProcessingTimeMs() {
if (startTime == null) {
return 0;
}
if (endTime == null) {
return Duration.between(startTime, Instant.now()).toMillis();
}
return Duration.between(startTime, endTime).toMillis();
}
/**
* Gets the total time from task creation to completion or now
*
* @return Total time in milliseconds
*/
public long getTotalTimeMs() {
if (endTime == null) {
return Duration.between(createdTime, Instant.now()).toMillis();
}
return Duration.between(createdTime, endTime).toMillis();
}
/**
* Gets a formatted string of queue time
*
* @return Formatted time
*/
public String getFormattedQueueTime() {
return formatDuration(getQueueTimeMs());
}
/**
* Gets a formatted string of processing time
*
* @return Formatted time
*/
public String getFormattedProcessingTime() {
return formatDuration(getProcessingTimeMs());
}
/**
* Gets a formatted string of total time
*
* @return Formatted time
*/
public String getFormattedTotalTime() {
return formatDuration(getTotalTimeMs());
}
/**
* Formats milliseconds as a readable duration
*
* @param ms Milliseconds
* @return Formatted string
*/
private String formatDuration(long ms) {
if (ms < 1000) {
return ms + "ms";
}
if (ms < 60000) {
return String.format("%.1fs", ms / 1000.0);
}
return String.format("%dm %ds", ms / 60000, (ms % 60000) / 1000);
}
}
@@ -7,145 +7,419 @@ import java.io.InputStreamReader;
import java.io.InterruptedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import io.github.pixee.security.BoundedLineReader;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties;
@Slf4j
@Component
public class ProcessExecutor {
private static final Map<Processes, ProcessExecutor> instances = new ConcurrentHashMap<>();
private static ApplicationProperties applicationProperties = new ApplicationProperties();
private final Semaphore semaphore;
private final boolean liveUpdates;
private long timeoutDuration;
private static ApplicationProperties applicationProperties;
private Semaphore semaphore;
private boolean liveUpdates = true;
private long timeoutDuration = 10; // Default timeout of 10 minutes
private ProcessExecutor(int semaphoreLimit, boolean liveUpdates, long timeout) {
@Autowired
public void setApplicationProperties(ApplicationProperties applicationProperties) {
ProcessExecutor.applicationProperties = applicationProperties;
// Initialize instances if not already done
initializeExecutorInstances();
}
/**
* Initialize all executor instances with the application properties This ensures that the
* static instances are correctly configured after application startup
*/
private void initializeExecutorInstances() {
if (applicationProperties != null) {
// Pre-initialize all process types
for (Processes type : Processes.values()) {
getInstance(type);
}
log.info("Initialized ProcessExecutor instances for all process types");
}
}
@Autowired
public ProcessExecutor() {
this.processType = null; // This instance is just for Spring DI
this.semaphore = new Semaphore(1); // Default to 1 permit
}
private ProcessExecutor(
Processes processType, int semaphoreLimit, boolean liveUpdates, long timeout) {
this.processType = processType;
this.semaphore = new Semaphore(semaphoreLimit);
this.liveUpdates = liveUpdates;
this.timeoutDuration = timeout;
}
// Task tracking
private Processes processType;
private final Queue<ConversionTask> queuedTasks = new ConcurrentLinkedQueue<>();
private final Map<String, ConversionTask> activeTasks = new ConcurrentHashMap<>();
private final Map<String, ConversionTask> completedTasks = new ConcurrentHashMap<>();
private static final int MAX_COMPLETED_TASKS = 100; // Maximum number of completed tasks to keep
// Metrics
private final AtomicInteger totalTasksProcessed = new AtomicInteger(0);
private final AtomicInteger failedTasks = new AtomicInteger(0);
private final AtomicInteger totalQueueTime = new AtomicInteger(0);
private final AtomicInteger totalProcessTime = new AtomicInteger(0);
// For testing - allows injecting a mock
private static ProcessExecutor mockInstance;
public static void setStaticMockInstance(ProcessExecutor mock) {
mockInstance = mock;
}
public static ProcessExecutor getInstance(Processes processType) {
return getInstance(processType, true);
}
public static ProcessExecutor getInstance(Processes processType, boolean liveUpdates) {
// For testing - return the mock if set
if (mockInstance != null) {
return mockInstance;
}
return instances.computeIfAbsent(
processType,
key -> {
int semaphoreLimit =
switch (key) {
case LIBRE_OFFICE ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getLibreOfficeSessionLimit();
case PDFTOHTML ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPdfToHtmlSessionLimit();
case PYTHON_OPENCV ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPythonOpenCvSessionLimit();
case WEASYPRINT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getWeasyPrintSessionLimit();
case INSTALL_APP ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getInstallAppSessionLimit();
case TESSERACT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getTesseractSessionLimit();
case QPDF ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getQpdfSessionLimit();
case CALIBRE ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getCalibreSessionLimit();
};
int semaphoreLimit = 1; // Default if applicationProperties is null
long timeoutMinutes = 10; // Default if applicationProperties is null
long timeoutMinutes =
switch (key) {
case LIBRE_OFFICE ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getLibreOfficeTimeoutMinutes();
case PDFTOHTML ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPdfToHtmlTimeoutMinutes();
case PYTHON_OPENCV ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPythonOpenCvTimeoutMinutes();
case WEASYPRINT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getWeasyPrintTimeoutMinutes();
case INSTALL_APP ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getInstallAppTimeoutMinutes();
case TESSERACT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getTesseractTimeoutMinutes();
case QPDF ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getQpdfTimeoutMinutes();
case CALIBRE ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getCalibreTimeoutMinutes();
};
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
if (applicationProperties != null) {
semaphoreLimit =
switch (key) {
case LIBRE_OFFICE ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getLibreOfficeSessionLimit();
case PDFTOHTML ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPdfToHtmlSessionLimit();
case PYTHON_OPENCV ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPythonOpenCvSessionLimit();
case WEASYPRINT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getWeasyPrintSessionLimit();
case INSTALL_APP ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getInstallAppSessionLimit();
case TESSERACT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getTesseractSessionLimit();
case QPDF ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getQpdfSessionLimit();
case CALIBRE ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getCalibreSessionLimit();
};
timeoutMinutes =
switch (key) {
case LIBRE_OFFICE ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getLibreOfficeTimeoutMinutes();
case PDFTOHTML ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPdfToHtmlTimeoutMinutes();
case PYTHON_OPENCV ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPythonOpenCvTimeoutMinutes();
case WEASYPRINT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getWeasyPrintTimeoutMinutes();
case INSTALL_APP ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getInstallAppTimeoutMinutes();
case TESSERACT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getTesseractTimeoutMinutes();
case QPDF ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getQpdfTimeoutMinutes();
case CALIBRE ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getCalibreTimeoutMinutes();
};
}
return new ProcessExecutor(key, semaphoreLimit, liveUpdates, timeoutMinutes);
});
}
/**
* Creates a new conversion task and adds it to the queue
*
* @param taskName A descriptive name for the task
* @return The created conversion task
*/
public ConversionTask createTask(String taskName) {
ConversionTask task = new ConversionTask(taskName, this.processType);
queuedTasks.add(task);
updateQueuePositions();
log.debug(
"Created new task {} for {} process, queue position: {}",
task.getId(),
processType,
task.getQueuePosition());
return task;
}
/**
* Gets a task by its ID
*
* @param taskId The task ID
* @return The task or null if not found
*/
public ConversionTask getTask(String taskId) {
// Check active tasks first
ConversionTask task = activeTasks.get(taskId);
if (task != null) {
return task;
}
// Check queued tasks
for (ConversionTask queuedTask : queuedTasks) {
if (queuedTask.getId().equals(taskId)) {
return queuedTask;
}
}
// Check completed tasks
return completedTasks.get(taskId);
}
/**
* Gets all tasks for this process type
*
* @return List of all tasks
*/
public List<ConversionTask> getAllTasks() {
List<ConversionTask> allTasks = new ArrayList<>();
allTasks.addAll(queuedTasks);
allTasks.addAll(activeTasks.values());
allTasks.addAll(completedTasks.values());
return allTasks;
}
/**
* Gets all active tasks for this process type
*
* @return List of active tasks
*/
public List<ConversionTask> getActiveTasks() {
return new ArrayList<>(activeTasks.values());
}
/**
* Gets all queued tasks for this process type
*
* @return List of queued tasks
*/
public List<ConversionTask> getQueuedTasks() {
return new ArrayList<>(queuedTasks);
}
/**
* Gets the current queue length
*
* @return Number of tasks in queue
*/
public int getQueueLength() {
return queuedTasks.size();
}
/**
* Gets the number of active tasks
*
* @return Number of tasks currently running
*/
public int getActiveTaskCount() {
return activeTasks.size();
}
/**
* Gets the maximum number of concurrent tasks
*
* @return Maximum concurrent tasks
*/
public int getMaxConcurrentTasks() {
return semaphore.availablePermits() + semaphore.getQueueLength();
}
/**
* Gets the estimated wait time based on current queue and average processing time
*
* @return Estimated wait time in milliseconds
*/
public long getEstimatedWaitTimeMs() {
if (queuedTasks.isEmpty()) {
return 0;
}
int processed = totalTasksProcessed.get();
if (processed == 0) {
return 30000; // Default 30 seconds if no data
}
double avgProcessTime = totalProcessTime.get() / (double) processed;
int activeCount = activeTasks.size();
int maxConcurrent = semaphore.availablePermits() + semaphore.getQueueLength();
int queueLength = queuedTasks.size();
// Calculate how many queue cycles are needed
double cycles = Math.ceil(queueLength / (double) maxConcurrent);
// Estimate wait time
return (long) (avgProcessTime * cycles);
}
/** Updates the queue positions for all queued tasks */
private synchronized void updateQueuePositions() {
int position = 0;
for (ConversionTask task : queuedTasks) {
task.setQueuePosition(++position);
}
}
/**
* Run a command with a task for queue tracking
*
* @param command The command to execute
* @param taskName A descriptive name for the task
* @return The result of the execution
*/
public ProcessExecutorResult runCommandWithTask(List<String> command, String taskName)
throws IOException, InterruptedException {
return runCommandWithTask(command, null, taskName);
}
/**
* Run a command without creating a task (direct execution)
*
* @param command The command to execute
* @return The result of the execution
*/
public ProcessExecutorResult runCommand(List<String> command)
throws IOException, InterruptedException {
return runCommandWithTask(command, "Unnamed command");
}
/**
* Run a command with a task for queue tracking
*
* @param command The command to execute
* @param workingDirectory The working directory
* @param taskName A descriptive name for the task
* @return The result of the execution
*/
public ProcessExecutorResult runCommandWithTask(
List<String> command, File workingDirectory, String taskName)
throws IOException, InterruptedException {
// Create and track the task
ConversionTask task = createTask(taskName);
try {
return runCommandWithOutputHandling(command, workingDirectory, task);
} catch (Exception e) {
task.fail(e.getMessage());
throw e;
}
}
/** Legacy method for backwards compatibility */
public ProcessExecutorResult runCommandWithOutputHandling(List<String> command)
throws IOException, InterruptedException {
return runCommandWithOutputHandling(command, null);
}
/** Legacy method for backwards compatibility */
public ProcessExecutorResult runCommandWithOutputHandling(
List<String> command, File workingDirectory) throws IOException, InterruptedException {
return runCommandWithOutputHandling(command, workingDirectory, null);
}
/** Main method to run a command and handle its output */
private ProcessExecutorResult runCommandWithOutputHandling(
List<String> command, File workingDirectory, ConversionTask task)
throws IOException, InterruptedException {
String messages = "";
int exitCode = 1;
semaphore.acquire();
try {
log.info("Running command: " + String.join(" ", command));
// If no task was provided, create an anonymous one
boolean createdTask = false;
if (task == null) {
task = createTask("Anonymous " + processType + " task");
createdTask = true;
}
// Wait for a permit from the semaphore (this is where queuing happens)
semaphore.acquire();
// Task is now running
task.start(Thread.currentThread());
queuedTasks.remove(task);
activeTasks.put(task.getId(), task);
updateQueuePositions(); // Update queue positions for remaining tasks
try {
log.info("Running command for task {}: {}", task.getId(), String.join(" ", command));
ProcessBuilder processBuilder = new ProcessBuilder(command);
// Use the working directory if it's set
@@ -264,10 +538,92 @@ public class ProcessExecutor {
+ messages);
}
}
// Task completed successfully
task.complete();
totalTasksProcessed.incrementAndGet();
totalProcessTime.addAndGet((int) task.getProcessingTimeMs());
totalQueueTime.addAndGet((int) task.getQueueTimeMs());
// Move from active to completed
activeTasks.remove(task.getId());
addToCompletedTasks(task);
log.debug(
"Task {} completed in {}ms (queue: {}ms, processing: {}ms)",
task.getId(),
task.getTotalTimeMs(),
task.getQueueTimeMs(),
task.getProcessingTimeMs());
} catch (Exception e) {
// Task failed
task.fail(e.getMessage());
failedTasks.incrementAndGet();
// Move from active to completed
activeTasks.remove(task.getId());
addToCompletedTasks(task);
log.error(
"Task {} failed after {}ms (queue: {}ms, processing: {}ms): {}",
task.getId(),
task.getTotalTimeMs(),
task.getQueueTimeMs(),
task.getProcessingTimeMs(),
e.getMessage());
throw e;
} finally {
semaphore.release();
// For anonymous tasks, don't keep them in completed tasks
if (createdTask) {
completedTasks.remove(task.getId());
}
}
return new ProcessExecutorResult(exitCode, messages, task.getId());
}
/** Adds a task to the completed tasks map, maintaining size limit */
private synchronized void addToCompletedTasks(ConversionTask task) {
// Add the task to completed tasks
completedTasks.put(task.getId(), task);
// If we exceed the limit, remove oldest completed tasks
if (completedTasks.size() > MAX_COMPLETED_TASKS) {
List<ConversionTask> oldestTasks =
completedTasks.values().stream()
.sorted(Comparator.comparing(ConversionTask::getEndTime))
.limit(completedTasks.size() - MAX_COMPLETED_TASKS)
.collect(Collectors.toList());
for (ConversionTask oldTask : oldestTasks) {
completedTasks.remove(oldTask.getId());
}
}
}
/** Periodically log queue statistics (once per minute) */
@Scheduled(fixedRate = 60000)
public void logQueueStatistics() {
if (!queuedTasks.isEmpty() || !activeTasks.isEmpty()) {
int total = totalTasksProcessed.get();
int failed = failedTasks.get();
float successRate = total > 0 ? (float) (total - failed) / total * 100 : 0;
float avgQueueTime = total > 0 ? (float) totalQueueTime.get() / total : 0;
float avgProcessTime = total > 0 ? (float) totalProcessTime.get() / total : 0;
log.info(
"{} queue status: Active={}, Queued={}, Completed={}, AvgQueue={}ms, AvgProcess={}ms, SuccessRate={:.2f}%",
processType,
activeTasks.size(),
queuedTasks.size(),
total,
avgQueueTime,
avgProcessTime,
successRate);
}
return new ProcessExecutorResult(exitCode, messages);
}
public enum Processes {
@@ -281,29 +637,20 @@ public class ProcessExecutor {
QPDF
}
@Getter
public class ProcessExecutorResult {
int rc;
String messages;
private final int rc;
private final String messages;
private final String taskId;
public ProcessExecutorResult(int rc, String messages) {
this(rc, messages, null);
}
public ProcessExecutorResult(int rc, String messages, String taskId) {
this.rc = rc;
this.messages = messages;
}
public int getRc() {
return rc;
}
public void setRc(int rc) {
this.rc = rc;
}
public String getMessages() {
return messages;
}
public void setMessages(String messages) {
this.messages = messages;
this.taskId = taskId;
}
}
}
@@ -66,4 +66,53 @@ public class WebResponseUtils {
return boasToWebResponse(baos, docName);
}
/**
* Gets a response builder with appropriate headers for the given filename
*
* @param filename The filename to use in the Content-Disposition header
* @return A ResponseEntity.BodyBuilder with appropriate headers
* @throws IOException If encoding the filename fails
*/
public static ResponseEntity.BodyBuilder getResponseBuilder(String filename)
throws IOException {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
String encodedFilename =
URLEncoder.encode(filename, StandardCharsets.UTF_8.toString())
.replaceAll("\\+", "%20");
headers.setContentDispositionFormData("attachment", encodedFilename);
return ResponseEntity.ok().headers(headers);
}
/**
* Converts a PDDocument to a byte array
*
* @param document The PDDocument to convert
* @return The document as a byte array
* @throws IOException If saving the document fails
*/
public static byte[] getBytesFromPDDocument(PDDocument document) throws IOException {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
document.save(baos);
return baos.toByteArray();
} finally {
document.close();
}
}
/**
* Creates an error response with a message
*
* @param message The error message
* @return A ResponseEntity with the error message
*/
public static ResponseEntity<byte[]> errorResponseWithMessage(String message) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String jsonError = "{\"error\":\"" + message.replace("\"", "\\\"") + "\"}";
return new ResponseEntity<>(
jsonError.getBytes(StandardCharsets.UTF_8), headers, HttpStatus.BAD_REQUEST);
}
}
+6 -6
View File
@@ -364,9 +364,9 @@ home.compressPdfs.title=Komprimieren
home.compressPdfs.desc=PDF komprimieren um die Dateigröße zu reduzieren
compressPdfs.tags=komprimieren,verkleinern,minimieren
home.unlockPDFForms.title=Unlock PDF Forms
home.unlockPDFForms.desc=Remove read-only property of form fields in a PDF document.
unlockPDFForms.tags=remove,delete,form,field,readonly
home.unlockPDFForms.title=Schreibgeschützte PDF-Formfelder entfernen
home.unlockPDFForms.desc=Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument.
unlockPDFForms.tags=entfernen,löschen,form,feld,schreibgeschützt
home.changeMetadata.title=Metadaten ändern
home.changeMetadata.desc=Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument
@@ -1197,9 +1197,9 @@ changeMetadata.selectText.5=Benutzerdefinierten Metadateneintrag hinzufügen
changeMetadata.submit=Ändern
#unlockPDFForms
unlockPDFForms.title=Remove Read-Only from Form Fields
unlockPDFForms.header=Unlock PDF Forms
unlockPDFForms.submit=Remove
unlockPDFForms.title=Entfernen Sie schreibgeschützte Formfelder
unlockPDFForms.header=Schreibgeschützte PDF-Formfelder entfernen
unlockPDFForms.submit=Entfernen
#pdfToPDFA
pdfToPDFA.title=PDF zu PDF/A
@@ -1437,3 +1437,10 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
cookieBanner.preferencesModal.analytics.title=Analytics
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
####################
# Queue Status #
####################
queue.positionInQueue=Position in queue: {0}
queue.processing=Processing your file...
queue.readyShortly=Your file will be ready shortly
+68 -68
View File
@@ -10,9 +10,9 @@ multiPdfPrompt=Выберите PDF-файлы (2+)
multiPdfDropPrompt=Выберите (или перетащите) все необходимые PDF-файлы
imgPrompt=Выберите изображение(я)
genericSubmit=Отправить
uploadLimit=Maximum file size:
uploadLimitExceededSingular=is too large. Maximum allowed size is
uploadLimitExceededPlural=are too large. Maximum allowed size is
uploadLimit=Максимальный размер файла:
uploadLimitExceededSingular=слишком велик. Максимально допустимый размер -
uploadLimitExceededPlural=слишком велики. Максимально допустимый размер -
processTimeWarning=Внимание: Данный процесс может занять до минуты в зависимости от размера файла
pageOrderPrompt=Пользовательский порядок страниц (Введите список номеров страниц через запятую или функции типа 2n+1):
pageSelectionPrompt=Выбор страниц (Введите список номеров страниц через запятую 1,5,6 или функции типа 2n+1):
@@ -86,14 +86,14 @@ loading=Загрузка...
addToDoc=Добавить в документ
reset=Сбросить
apply=Применить
noFileSelected=No file selected. Please upload one.
noFileSelected=Файл не выбран. Пожалуйста, загрузите его.
legal.privacy=Политика конфиденциальности
legal.terms=Условия использования
legal.accessibility=Доступность
legal.cookie=Политика использования файлов cookie
legal.impressum=Выходные данные
legal.showCookieBanner=Cookie Preferences
legal.showCookieBanner=Настройки файлов cookie
###############
# Pipeline #
@@ -237,7 +237,7 @@ adminUserSettings.activeUsers=Активные пользователи:
adminUserSettings.disabledUsers=Отключенные пользователи:
adminUserSettings.totalUsers=Всего пользователей:
adminUserSettings.lastRequest=Последний запрос
adminUserSettings.usage=View Usage
adminUserSettings.usage=Просмотр использования
endpointStatistics.title=Статистика конечных точек
endpointStatistics.header=Статистика конечных точек
@@ -292,18 +292,18 @@ home.desc=Ваше локальное решение для всех потре
home.searchBar=Поиск функций...
home.viewPdf.title=View/Edit PDF
home.viewPdf.title=Просмотр/Редактирование PDF
home.viewPdf.desc=Просмотр, аннотирование, добавление текста или изображений
viewPdf.tags=просмотр,чтение,аннотации,текст,изображение
home.setFavorites=Set Favourites
home.hideFavorites=Hide Favourites
home.showFavorites=Show Favourites
home.legacyHomepage=Old homepage
home.newHomePage=Try our new homepage!
home.alphabetical=Alphabetical
home.globalPopularity=Global Popularity
home.sortBy=Sort by:
home.setFavorites=Добавить в избранное
home.hideFavorites=Скрыть избранное
home.showFavorites=Показать избранное
home.legacyHomepage=Старая главная страница
home.newHomePage=Попробуйте нашу новую главную страницу!
home.alphabetical=По алфавиту
home.globalPopularity=Глобальная популярность
home.sortBy=Сортировать по:
home.multiTool.title=Мультиинструмент PDF
home.multiTool.desc=Объединение, поворот, переупорядочивание и удаление страниц
@@ -364,9 +364,9 @@ home.compressPdfs.title=Сжать
home.compressPdfs.desc=Сжимайте PDF-файлы для уменьшения их размера.
compressPdfs.tags=сжатие,маленький,крошечный
home.unlockPDFForms.title=Unlock PDF Forms
home.unlockPDFForms.desc=Remove read-only property of form fields in a PDF document.
unlockPDFForms.tags=remove,delete,form,field,readonly
home.unlockPDFForms.title=Разблокировать формы PDF
home.unlockPDFForms.desc=Удалить свойство только для чтения из полей формы в PDF-документе.
unlockPDFForms.tags=удалить,удаление,форма,поле,только для чтения
home.changeMetadata.title=Изменить метаданные
home.changeMetadata.desc=Изменить/удалить/добавить метаданные из PDF-документа
@@ -494,9 +494,9 @@ home.MarkdownToPDF.title=Markdown в PDF
home.MarkdownToPDF.desc=Преобразует любой файл Markdown в PDF
MarkdownToPDF.tags=разметка,веб-контент,преобразование,конвертация
home.PDFToMarkdown.title=PDF to Markdown
home.PDFToMarkdown.desc=Converts any PDF to Markdown
PDFToMarkdown.tags=markup,web-content,transformation,convert,md
home.PDFToMarkdown.title=PDF в Markdown
home.PDFToMarkdown.desc=Конвертирует любой PDF в Markdown
PDFToMarkdown.tags=разметка,веб-контент,преобразование,конвертировать,md
home.getPdfInfo.title=Получить ВСЮ информацию о PDF
home.getPdfInfo.desc=Собирает всю возможную информацию о PDF
@@ -609,7 +609,7 @@ login.userIsDisabled=Пользователь деактивирован, вхо
login.alreadyLoggedIn=Вы уже вошли в
login.alreadyLoggedIn2=устройств(а). Пожалуйста, выйдите из этих устройств и попробуйте снова.
login.toManySessions=У вас слишком много активных сессий
login.logoutMessage=You have been logged out.
login.logoutMessage=Вы вышли из системы.
#auto-redact
autoRedact.title=Автоматическое редактирование
@@ -648,7 +648,7 @@ redact.showAttatchments=Показать вложения
redact.showLayers=Показать слои (двойной щелчок для сброса всех слоев к состоянию по умолчанию)
redact.colourPicker=Выбор цвета
redact.findCurrentOutlineItem=Найти текущий элемент структуры
redact.applyChanges=Apply Changes
redact.applyChanges=Применить изменения
#showJS
showJS.title=Показать Javascript
@@ -686,9 +686,9 @@ MarkdownToPDF.credit=Использует WeasyPrint
#pdf-to-markdown
PDFToMarkdown.title=PDF To Markdown
PDFToMarkdown.header=PDF To Markdown
PDFToMarkdown.submit=Convert
PDFToMarkdown.title=PDF в Markdown
PDFToMarkdown.header=PDF в Markdown
PDFToMarkdown.submit=Конвертировать
#url-to-pdf
@@ -742,10 +742,10 @@ sanitizePDF.title=Очистить PDF
sanitizePDF.header=Очистить PDF-файл
sanitizePDF.selectText.1=Удалить JavaScript-действия
sanitizePDF.selectText.2=Удалить встроенные файлы
sanitizePDF.selectText.3=Remove XMP metadata
sanitizePDF.selectText.3=Удалить метаданные XMP
sanitizePDF.selectText.4=Удалить ссылки
sanitizePDF.selectText.5=Удалить шрифты
sanitizePDF.selectText.6=Remove Document Info Metadata
sanitizePDF.selectText.6=Удалить метаданные информации о документе
sanitizePDF.submit=Очистить PDF
@@ -894,8 +894,8 @@ sign.last=Последняя страница
sign.next=Следующая страница
sign.previous=Предыдущая страница
sign.maintainRatio=Переключить сохранение пропорций
sign.undo=Undo
sign.redo=Redo
sign.undo=Отменить
sign.redo=Повторить
#repair
repair.title=Восстановление
@@ -966,8 +966,8 @@ compress.title=Сжать
compress.header=Сжать PDF
compress.credit=Этот сервис использует qpdf для сжатия/оптимизации PDF.
compress.grayscale.label=Применить шкалу серого для сжатия
compress.selectText.1=Compression Settings
compress.selectText.1.1=1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality
compress.selectText.1=Настройки сжатия
compress.selectText.1.1=1-3 Сжатие PDF,</br> 4-6 легкое сжатие изображений,</br> 7-9 интенсивное сжатие изображений Существенно снижает качество изображений
compress.selectText.2=Уровень оптимизации:
compress.selectText.4=Автоматический режим - автоматически настраивает качество для получения точного размера PDF
compress.selectText.5=Ожидаемый размер PDF (например, 25MB, 10.8MB, 25KB)
@@ -1006,7 +1006,7 @@ pdfOrganiser.mode.7=Удалить первую
pdfOrganiser.mode.8=Удалить последнюю
pdfOrganiser.mode.9=Удалить первую и последнюю
pdfOrganiser.mode.10=Объединение четных-нечетных
pdfOrganiser.mode.11=Duplicate all pages
pdfOrganiser.mode.11=Дублировать все страницы
pdfOrganiser.placeholder=(например, 1,3,2 или 4-8,2,10-12 или 2n-1)
@@ -1049,7 +1049,7 @@ decrypt.success=Файл успешно расшифрован.
multiTool-advert.message=Эта функция также доступна на нашей <a href="{0}">странице мультиинструмента</a>. Попробуйте её для улучшенного постраничного интерфейса и дополнительных возможностей!
#view pdf
viewPdf.title=View/Edit PDF
viewPdf.title=Просмотр/Редактирование PDF
viewPdf.header=Просмотр PDF
#pageRemover
@@ -1191,15 +1191,15 @@ changeMetadata.keywords=Ключевые слова:
changeMetadata.modDate=Дата изменения (yyyy/MM/dd HH:mm:ss):
changeMetadata.producer=Производитель:
changeMetadata.subject=Тема:
changeMetadata.trapped=Trapped:
changeMetadata.trapped=Захвачено:
changeMetadata.selectText.4=Другие метаданные:
changeMetadata.selectText.5=Добавить пользовательскую запись метаданных
changeMetadata.submit=Изменить
#unlockPDFForms
unlockPDFForms.title=Remove Read-Only from Form Fields
unlockPDFForms.header=Unlock PDF Forms
unlockPDFForms.submit=Remove
unlockPDFForms.title=Удалить только для чтения из полей формы
unlockPDFForms.header=Разблокировать формы PDF
unlockPDFForms.submit=Удалить
#pdfToPDFA
pdfToPDFA.title=PDF в PDF/A
@@ -1319,15 +1319,15 @@ survey.please=Пожалуйста, примите участие в нашем
survey.disabled=(Всплывающее окно опроса будет отключено в следующих обновлениях, но будет доступно в нижней части страницы)
survey.button=Пройти опрос
survey.dontShowAgain=Больше не показывать
survey.meeting.1=If you're using Stirling PDF at work, we'd love to speak to you. We're offering technical support sessions in exchange for a 15 minute user discovery session.
survey.meeting.2=This is a chance to:
survey.meeting.3=Get help with deployment, integrations, or troubleshooting
survey.meeting.4=Provide direct feedback on performance, edge cases, and feature gaps
survey.meeting.5=Help us refine Stirling PDF for real-world enterprise use
survey.meeting.6=If you're interested, you can book time with our team directly. (English speaking only)
survey.meeting.7=Looking forward to digging into your use cases and making Stirling PDF even better!
survey.meeting.notInterested=Not a business and/or interested in a meeting?
survey.meeting.button=Book meeting
survey.meeting.1=Если вы используете Stirling PDF на работе, мы будем рады поговорить с вами. Мы предлагаем сеансы технической поддержки в обмен на 15-минутную сессию по изучению пользователей.
survey.meeting.2=Это возможность:
survey.meeting.3=Получить помощь с развертыванием, интеграцией или устранением неполадок
survey.meeting.4=Предоставить прямую обратную связь о производительности, крайних случаях и пробелах в функциях
survey.meeting.5=Помочь нам улучшить Stirling PDF для реального использования в корпоративной среде
survey.meeting.6=Если вы заинтересованы, вы можете записаться на встречу с нашей командой напрямую. (Только на английском языке)
survey.meeting.7=С нетерпением ждем возможности изучить ваши случаи использования и сделать Stirling PDF еще лучше!
survey.meeting.notInterested=Не являетесь бизнесом и/или не заинтересованы во встрече?
survey.meeting.button=Записаться на встречу
#error
error.sorry=Извините за неполадки!
@@ -1415,25 +1415,25 @@ validateSignature.cert.bits=бит
####################
# Cookie banner #
####################
cookieBanner.popUp.title=How we use Cookies
cookieBanner.popUp.description.1=We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.
cookieBanner.popUp.description.2=If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly.
cookieBanner.popUp.acceptAllBtn=Okay
cookieBanner.popUp.acceptNecessaryBtn=No Thanks
cookieBanner.popUp.showPreferencesBtn=Manage preferences
cookieBanner.preferencesModal.title=Consent Preferences Center
cookieBanner.preferencesModal.acceptAllBtn=Accept all
cookieBanner.preferencesModal.acceptNecessaryBtn=Reject all
cookieBanner.preferencesModal.savePreferencesBtn=Save preferences
cookieBanner.preferencesModal.closeIconLabel=Close modal
cookieBanner.preferencesModal.serviceCounterLabel=Service|Services
cookieBanner.preferencesModal.subtitle=Cookie Usage
cookieBanner.preferencesModal.description.1=Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users.
cookieBanner.preferencesModal.description.2=Stirling PDF cannot—and will never—track or access the content of the documents you use.
cookieBanner.preferencesModal.description.3=Your privacy and trust are at the core of what we do.
cookieBanner.preferencesModal.necessary.title.1=Strictly Necessary Cookies
cookieBanner.preferencesModal.necessary.title.2=Always Enabled
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off.
cookieBanner.preferencesModal.analytics.title=Analytics
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
cookieBanner.popUp.title=Как мы используем файлы cookie
cookieBanner.popUp.description.1=Мы используем файлы cookie и другие технологии, чтобы Stirling PDF работал лучше для вас — помогая нам улучшать наши инструменты и добавлять функции, которые вам понравятся.
cookieBanner.popUp.description.2=Если вы не хотите, нажав «Нет, спасибо», вы включите только основные файлы cookie, необходимые для бесперебойной работы.
cookieBanner.popUp.acceptAllBtn=Хорошо
cookieBanner.popUp.acceptNecessaryBtn=Нет, спасибо
cookieBanner.popUp.showPreferencesBtn=Управление предпочтениями
cookieBanner.preferencesModal.title=Центр управления предпочтениями
cookieBanner.preferencesModal.acceptAllBtn=Принять все
cookieBanner.preferencesModal.acceptNecessaryBtn=Отклонить все
cookieBanner.preferencesModal.savePreferencesBtn=Сохранить предпочтения
cookieBanner.preferencesModal.closeIconLabel=Закрыть окно
cookieBanner.preferencesModal.serviceCounterLabel=Сервис|Сервисы
cookieBanner.preferencesModal.subtitle=Использование файлов cookie
cookieBanner.preferencesModal.description.1=Stirling PDF использует файлы cookie и аналогичные технологии, чтобы улучшить ваш опыт и понять, как используются наши инструменты. Это помогает нам улучшать производительность, разрабатывать функции, которые важны для нашего сообщества, и предоставлять постоянную поддержку нашим пользователям.
cookieBanner.preferencesModal.description.2=Stirling PDF не может — и никогда не будет — отслеживать или получать доступ к содержимому документов, которые вы используете.
cookieBanner.preferencesModal.description.3=Ваша конфиденциальность и доверие — в основе того, что мы делаем.
cookieBanner.preferencesModal.necessary.title.1=Строго необходимые файлы cookie
cookieBanner.preferencesModal.necessary.title.2=Всегда включены
cookieBanner.preferencesModal.necessary.description=Эти файлы cookie необходимы для правильной работы веб-сайта. Они включают основные функции, такие как установка ваших предпочтений конфиденциальности, вход в систему и заполнение форм — поэтому их нельзя отключить.
cookieBanner.preferencesModal.analytics.title=Аналитика
cookieBanner.preferencesModal.analytics.description=Эти файлы cookie помогают нам понять, как используются наши инструменты, чтобы мы могли сосредоточиться на создании функций, которые ценит наше сообщество. Будьте уверены — Stirling PDF не может и никогда не будет отслеживать содержимое документов, с которыми вы работаете.
+55 -55
View File
@@ -10,9 +10,9 @@ multiPdfPrompt=Оберіть PDFи (2+)
multiPdfDropPrompt=Оберіть (або перетягніть) всі необхідні PDFи
imgPrompt=Оберіть зображення(я)
genericSubmit=Надіслати
uploadLimit=Maximum file size:
uploadLimitExceededSingular=is too large. Maximum allowed size is
uploadLimitExceededPlural=are too large. Maximum allowed size is
uploadLimit=Максимальний розмір файлу:
uploadLimitExceededSingular=занадто великий. Максимально дозволений розмір -
uploadLimitExceededPlural=занадто великі. Максимально дозволений розмір -
processTimeWarning=Увага: Цей процес може тривати до хвилини в залежності від розміру файлу.
pageOrderPrompt=Порядок сторінок (введіть список номерів сторінок через кому):
pageSelectionPrompt=Користувацький вибір сторінки (введіть список номерів сторінок через кому 1,5,6 або функції типу 2n+1) :
@@ -86,14 +86,14 @@ loading=Завантаження...
addToDoc=Додати до документу
reset=Скинути
apply=Застосувати
noFileSelected=No file selected. Please upload one.
noFileSelected=Файл не вибрано. Будь ласка, завантажте один.
legal.privacy=Політика конфіденційності
legal.terms=Правила та умови
legal.accessibility=Доступність
legal.cookie=Політика використання файлів cookie
legal.impressum=Вихідні дані
legal.showCookieBanner=Cookie Preferences
legal.showCookieBanner=Налаштування файлів cookie
###############
# Pipeline #
@@ -237,7 +237,7 @@ adminUserSettings.activeUsers=Активні користувачі:
adminUserSettings.disabledUsers=Заблоковані користувачі:
adminUserSettings.totalUsers=Всього користувачів:
adminUserSettings.lastRequest=Останній запит
adminUserSettings.usage=View Usage
adminUserSettings.usage=Переглянути використання
endpointStatistics.title=Статистика кінцевих точок
endpointStatistics.header=Статистика кінцевих точок
@@ -364,9 +364,9 @@ home.compressPdfs.title=Стиснути
home.compressPdfs.desc=Стискайте PDF-файли, щоб зменшити їх розмір.
compressPdfs.tags=стиск,маленький,крихітний
home.unlockPDFForms.title=Unlock PDF Forms
home.unlockPDFForms.desc=Remove read-only property of form fields in a PDF document.
unlockPDFForms.tags=remove,delete,form,field,readonly
home.unlockPDFForms.title=Розблокувати PDF форми
home.unlockPDFForms.desc=Видалити властивість "тільки для читання" з полів форми у PDF-документі.
unlockPDFForms.tags=видалити,розблокувати,форма,поле,тільки для читання
home.changeMetadata.title=Змінити метадані
home.changeMetadata.desc=Змінити/видалити/додати метадані з документа PDF
@@ -609,7 +609,7 @@ login.userIsDisabled=Користувач деактивовано, вхід з
login.alreadyLoggedIn=Ви вже увійшли до
login.alreadyLoggedIn2=пристроїв (а). Будь ласка, вийдіть із цих пристроїв і спробуйте знову.
login.toManySessions=У вас дуже багато активних сесій
login.logoutMessage=You have been logged out.
login.logoutMessage=Ви вийшли з системи.
#auto-redact
autoRedact.title=Автоматичне редагування
@@ -742,10 +742,10 @@ sanitizePDF.title=Дезінфекція PDF
sanitizePDF.header=Дезінфекція PDF файлу
sanitizePDF.selectText.1=Видалити JavaScript
sanitizePDF.selectText.2=Видалити вбудовані файли
sanitizePDF.selectText.3=Remove XMP metadata
sanitizePDF.selectText.3=Видалити XMP метадані
sanitizePDF.selectText.4=Видалити посилання
sanitizePDF.selectText.5=Видалити шрифти
sanitizePDF.selectText.6=Remove Document Info Metadata
sanitizePDF.selectText.6=Видалити метадані інформації про документ
sanitizePDF.submit=Дезінфекція
@@ -1071,7 +1071,7 @@ rotate.submit=Повернути
split.title=Розділити PDF
split.header=Розділити PDF
split.desc.1=Числа, які ви вибрали, це номери сторінок, на яких ви хочете зробити розділ.
split.desc.2=Таким чином, вибір 1,3,7-8 розділить 10-сторінковий документ на 6 окремих PDF-файлів з:
split.desc.2=Таким чином, вибір 1,3,7-8 розділіть 10-сторінковий документ на 6 окремих PDF-файлів з:
split.desc.3=Документ #1: Сторінка 1
split.desc.4=Документ #2: Сторінки 2 і 3
split.desc.5=Документ #3: Сторінки 4, 5 і 6
@@ -1372,68 +1372,68 @@ fileChooser.extractPDF=Видобування...
#release notes
releases.footer=Релізи
releases.title=Примечания к релизу
releases.header=Примечания к релизу
releases.current.version=Текущий релиз
releases.note=Примітка до релізу доступна тільки на англійській мові
releases.title=Примітки до релізу
releases.header=Примітки до релізу
releases.current.version=Поточний реліз
releases.note=Примітки до релізу доступні лише англійською мовою
#Validate Signature
validateSignature.title=Перевірка підписів PDF
validateSignature.header=Перевірка цифрових підписів
validateSignature.selectPDF=Виберіть підписаний PDF-файл
validateSignature.submit=Перевірити підписи
validateSignature.results=Результаты проверки
validateSignature.results=Результати перевірки
validateSignature.status=Статус
validateSignature.signer=Підписант
validateSignature.date=Дата
validateSignature.reason=Причина
validateSignature.location=Местоположение
validateSignature.noSignatures=В цьому документі не знайдено цифрових підписів
validateSignature.status.valid=Дійна
validateSignature.status.invalid=Недійсна
validateSignature.chain.invalid=Перевірка цепочки сертифікатів не удалась - неможливо перевірити особистість підписанта
validateSignature.location=Місцезнаходження
validateSignature.noSignatures=У цьому документі не знайдено цифрових підписів
validateSignature.status.valid=Дійсний
validateSignature.status.invalid=Недійсний
validateSignature.chain.invalid=Перевірка ланцюга сертифікатів не вдалася - неможливо перевірити особу підписанта
validateSignature.trust.invalid=Сертифікат відсутній у довіреному сховищі - джерело не може бути перевірено
validateSignature.cert.expired=Срок дії сертифіката істеку
validateSignature.cert.revoked=Сертифікат був отозван
validateSignature.signature.info=Інформація про підписи
validateSignature.signature=Подпись
validateSignature.signature.mathValid=Подпись математически корректна, НО:
validateSignature.selectCustomCert=Користувачський файл сертифіката X.509 (Необов'язково)
validateSignature.cert.info=Сведения про сертифікати
validateSignature.cert.issuer=Издатель
validateSignature.cert.subject=суб'єкт
validateSignature.cert.serialNumber=Серийний номер
validateSignature.cert.expired=Термін дії сертифіката закінчився
validateSignature.cert.revoked=Сертифікат було відкликано
validateSignature.signature.info=Інформація про підпис
validateSignature.signature=Підпис
validateSignature.signature.mathValid=Підпис математично коректний, АЛЕ:
validateSignature.selectCustomCert=Користувацький файл сертифіката X.509 (Необов'язково)
validateSignature.cert.info=Інформація про сертифікат
validateSignature.cert.issuer=Видавець
validateSignature.cert.subject=Суб'єкт
validateSignature.cert.serialNumber=Серійний номер
validateSignature.cert.validFrom=Дійсний з
validateSignature.cert.validUntil=Дійсний до
validateSignature.cert.algorithm=Алгоритм
validateSignature.cert.keySize=Розмір ключа
validateSignature.cert.version=Версія
validateSignature.cert.keyUsage=Використання ключа
validateSignature.cert.selfSigned=Самоподписанный
validateSignature.cert.selfSigned=Самопідписаний
validateSignature.cert.bits=біт
####################
# Cookie banner #
####################
cookieBanner.popUp.title=How we use Cookies
cookieBanner.popUp.description.1=We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.
cookieBanner.popUp.description.2=If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly.
cookieBanner.popUp.acceptAllBtn=Okay
cookieBanner.popUp.acceptNecessaryBtn=No Thanks
cookieBanner.popUp.showPreferencesBtn=Manage preferences
cookieBanner.preferencesModal.title=Consent Preferences Center
cookieBanner.preferencesModal.acceptAllBtn=Accept all
cookieBanner.preferencesModal.acceptNecessaryBtn=Reject all
cookieBanner.preferencesModal.savePreferencesBtn=Save preferences
cookieBanner.preferencesModal.closeIconLabel=Close modal
cookieBanner.preferencesModal.serviceCounterLabel=Service|Services
cookieBanner.preferencesModal.subtitle=Cookie Usage
cookieBanner.preferencesModal.description.1=Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users.
cookieBanner.preferencesModal.description.2=Stirling PDF cannot—and will never—track or access the content of the documents you use.
cookieBanner.preferencesModal.description.3=Your privacy and trust are at the core of what we do.
cookieBanner.preferencesModal.necessary.title.1=Strictly Necessary Cookies
cookieBanner.preferencesModal.necessary.title.2=Always Enabled
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off.
cookieBanner.preferencesModal.analytics.title=Analytics
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
cookieBanner.popUp.title=Як ми використовуємо файли cookie
cookieBanner.popUp.description.1=Ми використовуємо файли cookie та інші технології, щоб Stirling PDF працював краще для вас — допомагаючи нам покращувати наші інструменти та створювати функції, які вам сподобаються.
cookieBanner.popUp.description.2=Якщо ви не хочете, натискання «Ні, дякую» увімкне лише необхідні файли cookie, потрібні для безперебійної роботи.
cookieBanner.popUp.acceptAllBtn=Добре
cookieBanner.popUp.acceptNecessaryBtn=Ні, дякую
cookieBanner.popUp.showPreferencesBtn=Керувати налаштуваннями
cookieBanner.preferencesModal.title=Центр налаштувань згоди
cookieBanner.preferencesModal.acceptAllBtn=Прийняти всі
cookieBanner.preferencesModal.acceptNecessaryBtn=Відхилити всі
cookieBanner.preferencesModal.savePreferencesBtn=Зберегти налаштування
cookieBanner.preferencesModal.closeIconLabel=Закрити модальне вікно
cookieBanner.preferencesModal.serviceCounterLabel=Сервіс|Сервіси
cookieBanner.preferencesModal.subtitle=Використання файлів cookie
cookieBanner.preferencesModal.description.1=Stirling PDF використовує файли cookie та подібні технології, щоб покращити ваш досвід і зрозуміти, як використовуються наші інструменти. Це допомагає нам покращувати продуктивність, розробляти функції, які вас цікавлять, і надавати постійну підтримку нашим користувачам.
cookieBanner.preferencesModal.description.2=Stirling PDF не може — і ніколи не буде — відстежувати або отримувати доступ до вмісту документів, які ви використовуєте.
cookieBanner.preferencesModal.description.3=Ваша конфіденційність і довіра є основою того, що ми робимо.
cookieBanner.preferencesModal.necessary.title.1=Суворо необхідні файли cookie
cookieBanner.preferencesModal.necessary.title.2=Завжди увімкнені
cookieBanner.preferencesModal.necessary.description=Ці файли cookie є необхідними для правильного функціонування вебсайту. Вони забезпечують основні функції, такі як налаштування ваших уподобань конфіденційності, вхід у систему та заповнення форм — тому їх не можна вимкнути.
cookieBanner.preferencesModal.analytics.title=Аналітика
cookieBanner.preferencesModal.analytics.description=Ці файли cookie допомагають нам зрозуміти, як використовуються наші інструменти, щоб ми могли зосередитися на створенні функцій, які найбільше цінує наша спільнота. Будьте впевнені — Stirling PDF не може і ніколи не буде відстежувати вміст документів, з якими ви працюєте.
+3 -3
View File
@@ -1075,9 +1075,9 @@ split.desc.2=如选择1,3,7-9将把一个 10 页的文件分割成6个独立的P
split.desc.3=文档 #1:第 1 页
split.desc.4=文档 #2:第 2 页和第 3 页
split.desc.5=文档 #3:第 4 页、第 5 页、第 6 页和第 7 页
split.desc.6=文档 #4:第 7
split.desc.7=文档 #5:第 8
split.desc.8=文档 #6:第 9 页和第 10 页
split.desc.6=文档 #4:第 8
split.desc.7=文档 #5:第 9
split.desc.8=文档 #6:第 10 页
split.splitPages=输入要分割的页面:
split.submit=拆分
+5
View File
@@ -125,6 +125,7 @@ ui:
homeDescription: '' # short description or tagline shown on the homepage
appNameNavbar: '' # name displayed on the navigation bar
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
showQueueStatus: true # set to 'false' to disable the queue status indicator
endpoints:
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
@@ -157,3 +158,7 @@ processExecutor:
installApptimeoutMinutes: 60
calibretimeoutMinutes: 30
tesseractTimeoutMinutes: 30
unoconvServers: [] # List of external unoconv servers in the format ["hostname:port", "hostname:port"]. Leave empty to use local instances.
useExternalUnoconvServers: false # Set to true to use external servers from the list above
baseUnoconvPort: 2003 # Base port for local unoconv instances (will increment by 1 for each instance)
manageUnoServer: true # Set to true to let the application manage UnoServer instances
+421
View File
@@ -0,0 +1,421 @@
/**
* Queue Status JS
* Simple queue position indicator with continuous polling
*/
class QueueStatusTracker {
constructor() {
this.activeTaskIds = new Map();
this.basePollingInterval = 5000; // 5 seconds between polls by default
this.pollingTimeoutId = null;
this.initialized = false;
this.isPolling = false;
this.maxDisplayTime = 20 * 60 * 1000; // 20 minutes max display time
this.initialDelayMs = 1000; // 1 second delay before showing position
this.apiErrorCount = 0; // Track consecutive API errors
this.lastQueuePosition = 0; // Track last known queue position
console.log('[QueueStatusTracker] Constructor called');
}
/**
* Calculate polling interval based on queue position
* - Position <= 5: Poll every 3 seconds
* - Position <= 20: Poll every 5 seconds
* - Position > 20: Poll every 10 seconds
* @param {number} position Current queue position
* @returns {number} Polling interval in milliseconds
*/
getPollingInterval(position) {
if (position <= 5) {
return 3000; // 3 seconds for closer positions
} else if (position <= 20) {
return 5000; // 5 seconds for medium distance
} else {
return 10000; // 10 seconds for far positions
}
}
/**
* Initialize the queue status tracker
*/
init() {
if (this.initialized) return;
this.initialized = true;
console.log('[QueueStatusTracker] Initializing queue tracker');
// Add CSS to head
const style = document.createElement('style');
style.textContent = `
.queue-status-container {
margin-top: 20px;
width: 100%;
font-family: sans-serif;
}
.queue-position-info {
background-color: var(--md-sys-color-surface-container, #fff);
border: 1px solid var(--md-sys-color-outline-variant, #ddd);
border-radius: 5px;
box-shadow: var(--md-sys-elevation-1, 0 2px 5px rgba(0,0,0,0.15));
margin-top: 10px;
padding: 12px;
text-align: center;
font-weight: bold;
color: var(--md-sys-color-on-surface, #000);
border-left: 4px solid var(--md-sys-color-primary, #0060aa);
animation: queue-status-fade-in 0.3s ease-in-out;
transition: background-color 0.3s ease;
}
@keyframes queue-status-fade-in {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.queue-position-info.processing {
background-color: var(--md-sys-color-primary-container, #d0e4ff);
}
`;
document.head.appendChild(style);
console.log('[QueueStatusTracker] Styles injected into <head>');
}
/**
* Create container for queue status if it doesn't exist
*/
ensureContainer() {
// Find container or create it if not present
let container = document.getElementById('queueStatusContainer');
if (container) {
console.log('[QueueStatusTracker] Found existing queue container');
return container;
}
console.log('[QueueStatusTracker] Creating new queue container');
container = document.createElement('div');
container.id = 'queueStatusContainer';
container.className = 'queue-status-container';
container.style.display = 'none';
// Try to insert after the form
const form = document.querySelector('form[action="/api/v1/convert/file/pdf"]');
if (form) {
console.log('[QueueStatusTracker] Found form, inserting container after it');
form.parentNode.insertBefore(container, form.nextSibling);
} else {
// Fall back to appending to body
console.log('[QueueStatusTracker] No form found, appending to body');
document.body.appendChild(container);
}
return container;
}
/**
* Generate a unique client task ID
*/
generateClientTaskId() {
return 'client-' + Math.random().toString(36).substring(2, 11);
}
/**
* Track a task with polling for status
* @param {string} clientTaskId - Client-generated task ID
*/
trackTask(clientTaskId) {
console.log(`[QueueStatusTracker] Starting to track task: ${clientTaskId}`);
this.init();
// Initialize container and elements
const container = this.ensureContainer();
// Wait a short delay before showing anything
setTimeout(() => {
container.style.display = 'block';
console.log('[QueueStatusTracker] Queue status container is now visible (after delay)');
// Create or get the position info element
let positionInfo = document.getElementById('queuePositionInfo');
if (!positionInfo) {
positionInfo = document.createElement('div');
positionInfo.id = 'queuePositionInfo';
positionInfo.className = 'queue-position-info';
// Add message content with HTML
// We'll use the global i18n variables defined in common.html
const positionMessageTemplate = typeof queuePositionInQueue !== 'undefined' ?
queuePositionInQueue : 'Position in queue: {0}';
positionInfo.innerHTML = `<span id="queuePositionText">${positionMessageTemplate.replace('{0}', '<span id="queuePosition">...</span>')}</span>`;
container.appendChild(positionInfo);
console.log('[QueueStatusTracker] Created position info element');
}
}, this.initialDelayMs);
// Store the task data
this.activeTaskIds.set(clientTaskId, {
clientId: clientTaskId,
addedTime: Date.now(),
position: 0, // Initialize with unknown position
active: true
});
console.log(`[QueueStatusTracker] Added task ${clientTaskId} to active tasks`);
// Start polling to get and update the queue position
this.startPolling();
// Set maximum display time
setTimeout(() => {
console.log(`[QueueStatusTracker] Maximum display time reached for ${clientTaskId}`);
this.removeAllTasks();
}, this.maxDisplayTime);
}
/**
* Update the queue position by polling the server for only the specific client task
*/
updateTotalQueuePosition() {
console.log('[QueueStatusTracker] Updating queue position...');
// If we don't have any active task IDs, there's nothing to track
if (this.activeTaskIds.size === 0) {
console.log('[QueueStatusTracker] No active tasks to track');
return;
}
// Get the client ID of the first task (we only track one at a time)
const clientId = this.activeTaskIds.keys().next().value;
console.log(`[QueueStatusTracker] Fetching status for client task: ${clientId}`);
// Fetch queue status for only this specific client task
fetch(`/api/v1/queue/status/client/${clientId}`)
.then(response => {
if (!response.ok) {
throw new Error(`Failed to get queue status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('[QueueStatusTracker] Got client queue status:', data);
// Reset error count on successful API call
this.apiErrorCount = 0;
// Determine which queue type to use based on the form that was submitted
let queuePosition = 0;
const lastSubmittedAction = localStorage.getItem('lastSubmittedFormAction');
// Find the appropriate processor based on the form action
if (data && Object.keys(data).length > 0) {
// Just use the position from the first (and likely only) process type returned
// The backend has already filtered to just the relevant processor
const firstProcessType = Object.keys(data)[0];
queuePosition = data[firstProcessType].queuedCount || 0;
} else {
// If no data returned for our specific client task, assume it's being processed
queuePosition = 0;
}
console.log(`[QueueStatusTracker] Queue position for client task: ${queuePosition}`);
// Update last known position
this.lastQueuePosition = queuePosition;
// If position is 0, it's being processed now, show the processing message
if (queuePosition === 0) {
console.log('[QueueStatusTracker] Task is being processed (position 0), showing processing message');
// Show processing message
const processingMessage = typeof queueProcessing !== 'undefined' ?
queueProcessing : 'Processing your file...';
const positionTextElem = document.getElementById('queuePositionText');
const positionInfo = document.getElementById('queuePositionInfo');
if (positionTextElem) {
positionTextElem.textContent = processingMessage;
}
if (positionInfo) {
positionInfo.classList.add('processing');
}
// After 5 seconds, show the "ready shortly" message
setTimeout(() => {
const readyMessage = typeof queueReadyShortly !== 'undefined' ?
queueReadyShortly : 'Your file will be ready shortly';
if (positionTextElem) {
positionTextElem.textContent = readyMessage;
}
// After another 5 seconds, hide the message
setTimeout(() => {
this.removeAllTasks();
}, 5000);
}, 5000);
return;
}
// Update the UI with position in queue
const positionElem = document.getElementById('queuePosition');
if (positionElem) {
// Just update the position number
positionElem.textContent = queuePosition;
}
// Store position in the client task
const taskData = this.activeTaskIds.get(clientId);
if (taskData) {
taskData.position = queuePosition;
}
})
.catch(error => {
console.error('[QueueStatusTracker] Error getting queue status:', error);
// If we've had more than 3 consecutive failures, remove the queue indicator
this.apiErrorCount = (this.apiErrorCount || 0) + 1;
if (this.apiErrorCount > 3) {
console.warn('[QueueStatusTracker] Too many API failures, removing queue indicator');
this.removeAllTasks();
return;
}
// Otherwise just keep the previous position display
console.warn('[QueueStatusTracker] API error, keeping previous position display');
});
}
/**
* Remove all tasks and clean up
*/
removeAllTasks() {
console.log('[QueueStatusTracker] Removing all tasks');
// Clear tasks map
this.activeTaskIds.clear();
// Stop polling
this.stopPolling();
// Hide container
const container = document.getElementById('queueStatusContainer');
if (container) {
container.style.display = 'none';
console.log('[QueueStatusTracker] Hidden queue status container');
}
}
/**
* Start polling for queue status
*/
startPolling() {
if (this.isPolling) {
console.log('[QueueStatusTracker] Polling already active');
return;
}
this.isPolling = true;
console.log('[QueueStatusTracker] Starting polling');
// Poll every few seconds
const poll = () => {
if (this.activeTaskIds.size === 0) {
this.stopPolling();
return;
}
// Update queue positions
this.updateTotalQueuePosition();
// Calculate polling interval based on position
const interval = this.getPollingInterval(this.lastQueuePosition);
// Schedule next poll with dynamic interval
console.log(`[QueueStatusTracker] Next poll in ${interval/1000} seconds (position: ${this.lastQueuePosition})`);
this.pollingTimeoutId = setTimeout(poll, interval);
};
// First poll after a short delay to allow the system to process the request
setTimeout(() => {
this.updateTotalQueuePosition();
// Then start regular polling with initial base interval
this.pollingTimeoutId = setTimeout(poll, this.basePollingInterval);
}, this.initialDelayMs);
}
/**
* Stop polling
*/
stopPolling() {
if (!this.isPolling) return;
console.log('[QueueStatusTracker] Stopping polling');
this.isPolling = false;
if (this.pollingTimeoutId) {
clearTimeout(this.pollingTimeoutId);
this.pollingTimeoutId = null;
}
}
}
// Create global instance
const queueStatusTracker = new QueueStatusTracker();
console.log('[QueueStatusTracker] Global instance created');
// Add submit event handler to show queue position when form is submitted
document.addEventListener('submit', function(event) {
const form = event.target;
// Check if this is a conversion or processing form
// We need to track more API endpoints that might use the queue
if (form && (
// Main API categories
form.action.includes('/api/v1/convert') ||
form.action.includes('/api/v1/file/pdf') ||
form.action.includes('/api/v1/compress') ||
form.action.includes('/api/v1/ocr') ||
form.action.includes('/api/v1/extract') ||
form.action.includes('/api/v1/misc') ||
form.action.includes('/api/v1/pipeline') ||
// HTML/PDF conversions
form.action.includes('/api/v1/convert/html/pdf') ||
form.action.includes('/api/v1/convert/pdf/html') ||
// Image extraction
form.action.includes('/api/v1/extract/image/scans') ||
// URL and Markdown
form.action.includes('/api/v1/convert/url/pdf') ||
form.action.includes('/api/v1/convert/markdown/pdf') ||
// Office conversions
form.action.includes('/api/v1/convert/pdf/docx') ||
form.action.includes('/api/v1/convert/pdf/doc') ||
form.action.includes('/api/v1/convert/pdf/odt') ||
form.action.includes('/api/v1/convert/pdf/ppt') ||
form.action.includes('/api/v1/convert/pdf/pptx') ||
form.action.includes('/api/v1/convert/pdf/odp') ||
form.action.includes('/api/v1/convert/pdf/rtf') ||
form.action.includes('/api/v1/convert/pdf/xml') ||
form.action.includes('/api/v1/convert/pdf/pdfa') ||
// Calibre conversions
form.action.includes('/api/v1/convert/pdf/epub') ||
form.action.includes('/api/v1/convert/pdf/mobi')
)) {
console.log('[QueueStatusTracker] Form submission detected:', form.action);
// Store the form action for later use in determining queue type
localStorage.setItem('lastSubmittedFormAction', form.action);
// Generate a client task ID
const clientTaskId = queueStatusTracker.generateClientTaskId();
// Start tracking the task
queueStatusTracker.trackTask(clientTaskId);
console.log(`[QueueStatusTracker] Tracking form submission with ID: ${clientTaskId}`);
}
});
console.log('[QueueStatusTracker] Form submit event listener installed');
@@ -21,7 +21,7 @@
<meta name="msapplication-TileColor" content="#00aba9">
<meta name="theme-color" content="#ffffff">
<script>
<script th:inline="javascript">
window.stirlingPDF = window.stirlingPDF || {};
</script>
<script th:src="@{'/js/thirdParty/pdf-lib.min.js'}"></script>
@@ -86,6 +86,7 @@
<script th:src="@{'/js/tab-container.js'}"></script>
<script th:src="@{'/js/darkmode.js'}"></script>
<script th:src="@{'/js/csrf.js'}"></script>
<script th:if="${@showQueueStatus}" th:src="@{'/js/queueStatus.js'}"></script>
<script th:inline="javascript">
function UpdatePosthogConsent(){
@@ -120,6 +121,11 @@
const cookieBannerPreferencesModalNecessaryDescription = /*[[#{cookieBanner.preferencesModal.necessary.description}]]*/ "";
const cookieBannerPreferencesModalAnalyticsTitle = /*[[#{cookieBanner.preferencesModal.analytics.title}]]*/ "";
const cookieBannerPreferencesModalAnalyticsDescription = /*[[#{cookieBanner.preferencesModal.analytics.description}]]*/ "";
// Queue Status messages
const queuePositionInQueue = /*[[#{queue.positionInQueue}]]*/ "Position in queue: {0}";
const queueProcessing = /*[[#{queue.processing}]]*/ "Processing your file...";
const queueReadyShortly = /*[[#{queue.readyShortly}]]*/ "Your file will be ready shortly";
if (analyticsEnabled) {
!function (t, e) {
+2 -2
View File
@@ -138,7 +138,7 @@
<p><span>🔍</span><span th:text="#{survey.meeting.5}">Help us refine Stirling PDF for real-world enterprise use</span></p>
<p th:text="#{survey.meeting.6}">If you're interested, you can book time with our team directly.</p>
<p th:text="#{survey.meeting.7}">Looking forward to digging into your use cases and making Stirling PDF even better!</p>
<a href="https://calendly.com/d/cm4p-zz5-yy8/stirling-pdf-15-minute-group-discussion" target="_blank" class="btn btn-primary" id="takeSurvey2" th:text="#{survey.meeting.button}">Book meeting</a>
<a href="https://calendly.com/d/crsr-tz6-487" target="_blank" class="btn btn-primary" id="takeSurvey2" th:text="#{survey.meeting.button}">Book meeting</a>
</br>
</br>
<p th:text="#{survey.meeting.notInterested}">Not a business and/or interested in a meeting?</p>
@@ -232,4 +232,4 @@
</body>
</html>
</html>
@@ -1,5 +1,7 @@
package stirling.software.SPDF.config.security.mail;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
@@ -57,4 +59,111 @@ public class EmailServiceTest {
// Verify that the email was sent using mailSender
verify(mailSender).send(mimeMessage);
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForMissingFilename() throws MessagingException {
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
when(fileInput.isEmpty()).thenReturn(false);
when(fileInput.getOriginalFilename()).thenReturn("");
try {
emailService.sendEmailWithAttachment(email);
fail("Expected MessagingException to be thrown");
} catch (MessagingException e) {
assertEquals("An attachment is required to send the email.", e.getMessage());
}
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForMissingFilenameNull()
throws MessagingException {
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
when(fileInput.isEmpty()).thenReturn(false);
when(fileInput.getOriginalFilename()).thenReturn(null);
try {
emailService.sendEmailWithAttachment(email);
fail("Expected MessagingException to be thrown");
} catch (MessagingException e) {
assertEquals("An attachment is required to send the email.", e.getMessage());
}
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForMissingFile() throws MessagingException {
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
when(fileInput.isEmpty()).thenReturn(true);
try {
emailService.sendEmailWithAttachment(email);
fail("Expected MessagingException to be thrown");
} catch (MessagingException e) {
assertEquals("An attachment is required to send the email.", e.getMessage());
}
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForMissingFileNull() throws MessagingException {
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(null); // Missing file
try {
emailService.sendEmailWithAttachment(email);
fail("Expected MessagingException to be thrown");
} catch (MessagingException e) {
assertEquals("An attachment is required to send the email.", e.getMessage());
}
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForInvalidAddressNull()
throws MessagingException {
Email email = new Email();
email.setTo(null); // Invalid address
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
try {
emailService.sendEmailWithAttachment(email);
fail("Expected MailSendException to be thrown");
} catch (MessagingException e) {
assertEquals("Invalid Addresses", e.getMessage());
}
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForInvalidAddressEmpty()
throws MessagingException {
Email email = new Email();
email.setTo(""); // Invalid address
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
try {
emailService.sendEmailWithAttachment(email);
fail("Expected MailSendException to be thrown");
} catch (MessagingException e) {
assertEquals("Invalid Addresses", e.getMessage());
}
}
}
@@ -0,0 +1,54 @@
package stirling.software.SPDF.config.security.mail;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Properties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import stirling.software.SPDF.model.ApplicationProperties;
class MailConfigTest {
private ApplicationProperties.Mail mailProps;
@BeforeEach
void initMailProperties() {
mailProps = mock(ApplicationProperties.Mail.class);
when(mailProps.getHost()).thenReturn("smtp.example.com");
when(mailProps.getPort()).thenReturn(587);
when(mailProps.getUsername()).thenReturn("user@example.com");
when(mailProps.getPassword()).thenReturn("password");
}
@Test
void shouldConfigureJavaMailSenderWithCorrectProperties() {
ApplicationProperties appProps = mock(ApplicationProperties.class);
when(appProps.getMail()).thenReturn(mailProps);
MailConfig config = new MailConfig(appProps);
JavaMailSender sender = config.javaMailSender();
assertInstanceOf(JavaMailSenderImpl.class, sender);
JavaMailSenderImpl impl = (JavaMailSenderImpl) sender;
Properties props = impl.getJavaMailProperties();
assertAll(
"SMTP configuration",
() -> assertEquals("smtp.example.com", impl.getHost()),
() -> assertEquals(587, impl.getPort()),
() -> assertEquals("user@example.com", impl.getUsername()),
() -> assertEquals("password", impl.getPassword()),
() -> assertEquals("UTF-8", impl.getDefaultEncoding()),
() -> assertEquals("true", props.getProperty("mail.smtp.auth")),
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable")));
}
}
@@ -1,18 +1,25 @@
package stirling.software.SPDF.controller.api;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mail.MailSendException;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.multipart.MultipartFile;
import jakarta.mail.MessagingException;
@@ -20,7 +27,7 @@ import stirling.software.SPDF.config.security.mail.EmailService;
import stirling.software.SPDF.model.api.Email;
@ExtendWith(MockitoExtension.class)
public class EmailControllerTest {
class EmailControllerTest {
private MockMvc mockMvc;
@@ -28,59 +35,61 @@ public class EmailControllerTest {
@InjectMocks private EmailController emailController;
@Mock private MultipartFile fileInput;
@BeforeEach
void setUp() {
// Set up the MockMvc instance for testing
mockMvc = MockMvcBuilders.standaloneSetup(emailController).build();
}
@Test
void testSendEmailWithAttachmentSuccess() throws Exception {
// Create a mock Email object
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
@ParameterizedTest(name = "Case {index}: exception={0}, includeTo={1}")
@MethodSource("emailParams")
void shouldHandleEmailRequests(
Exception serviceException,
boolean includeTo,
int expectedStatus,
String expectedContent)
throws Exception {
if (serviceException == null) {
doNothing().when(emailService).sendEmailWithAttachment(any(Email.class));
} else {
doThrow(serviceException).when(emailService).sendEmailWithAttachment(any(Email.class));
}
// Mock the service to not throw any exception
doNothing().when(emailService).sendEmailWithAttachment(any(Email.class));
var request =
multipart("/api/v1/general/send-email")
.file("fileInput", "dummy-content".getBytes())
.param("subject", "Test Email")
.param("body", "This is a test email.");
// Perform the request and verify the response
mockMvc.perform(
multipart("/api/v1/general/send-email")
.file("fileInput", "dummy-content".getBytes())
.param("to", email.getTo())
.param("subject", email.getSubject())
.param("body", email.getBody()))
.andExpect(status().isOk())
.andExpect(content().string("Email sent successfully"));
if (includeTo) {
request = request.param("to", "test@example.com");
}
mockMvc.perform(request)
.andExpect(status().is(expectedStatus))
.andExpect(content().string(expectedContent));
}
@Test
void testSendEmailWithAttachmentFailure() throws Exception {
// Create a mock Email object
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
// Mock the service to throw a MessagingException
doThrow(new MessagingException("Failed to send email"))
.when(emailService)
.sendEmailWithAttachment(any(Email.class));
// Perform the request and verify the response
mockMvc.perform(
multipart("/api/v1/general/send-email")
.file("fileInput", "dummy-content".getBytes())
.param("to", email.getTo())
.param("subject", email.getSubject())
.param("body", email.getBody()))
.andExpect(status().isInternalServerError())
.andExpect(content().string("Failed to send email: Failed to send email"));
static Stream<Arguments> emailParams() {
return Stream.of(
// success case
Arguments.of(null, true, 200, "Email sent successfully"),
// generic messaging error
Arguments.of(
new MessagingException("Failed to send email"),
true,
500,
"Failed to send email: Failed to send email"),
// missing 'to' results in MailSendException
Arguments.of(
new MailSendException("Invalid Addresses"),
false,
500,
"Invalid Addresses"),
// invalid email address formatting
Arguments.of(
new MessagingException("Invalid Addresses"),
true,
500,
"Failed to send email: Invalid Addresses"));
}
}
@@ -16,6 +16,7 @@ import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import jakarta.servlet.ServletContext;
@@ -26,14 +27,11 @@ import stirling.software.SPDF.model.PipelineResult;
@ExtendWith(MockitoExtension.class)
class PipelineProcessorTest {
@Mock
ApiDocService apiDocService;
@Mock ApiDocService apiDocService;
@Mock
UserServiceInterface userService;
@Mock UserServiceInterface userService;
@Mock
ServletContext servletContext;
@Mock ServletContext servletContext;
PipelineProcessor pipelineProcessor;
@@ -50,27 +48,34 @@ class PipelineProcessorTest {
PipelineConfig config = new PipelineConfig();
config.setOperations(List.of(op));
Resource file = new ByteArrayResource("data".getBytes()) {
@Override
public String getFilename() {
return "test.pdf";
}
};
Resource file =
new ByteArrayResource("data".getBytes()) {
@Override
public String getFilename() {
return "test.pdf";
}
};
List<Resource> files = List.of(file);
when(apiDocService.isMultiInput("filter-page-count")).thenReturn(false);
when(apiDocService.getExtensionTypes(false, "filter-page-count")).thenReturn(List.of("pdf"));
when(apiDocService.getExtensionTypes(false, "filter-page-count"))
.thenReturn(List.of("pdf"));
// Mock the sendWebRequest method to return an empty response body with OK status
LinkedMultiValueMap<String, Object> expectedBody = new LinkedMultiValueMap<>();
expectedBody.add("fileInput", file);
doReturn(new ResponseEntity<>(new byte[0], HttpStatus.OK))
.when(pipelineProcessor)
.sendWebRequest(anyString(), any());
.sendWebRequest(contains("filter-page-count"), any());
PipelineResult result = pipelineProcessor.runPipelineAgainstFiles(files, config);
assertTrue(result.isFiltersApplied(), "Filter flag should be true when operation filters file");
assertTrue(
result.isFiltersApplied(),
"Filter flag should be true when operation filters file");
assertFalse(result.isHasErrors(), "No errors should occur");
assertTrue(result.getOutputFiles().isEmpty(), "Filtered file list should be empty");
}
}
@@ -0,0 +1,79 @@
package stirling.software.SPDF.controller.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import stirling.software.SPDF.model.ApplicationProperties;
class UploadLimitServiceTest {
private UploadLimitService uploadLimitService;
private ApplicationProperties applicationProperties;
private ApplicationProperties.System systemProps;
@BeforeEach
void setUp() {
applicationProperties = mock(ApplicationProperties.class);
systemProps = mock(ApplicationProperties.System.class);
when(applicationProperties.getSystem()).thenReturn(systemProps);
uploadLimitService = new UploadLimitService();
// inject mock
try {
var field = UploadLimitService.class.getDeclaredField("applicationProperties");
field.setAccessible(true);
field.set(uploadLimitService, applicationProperties);
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}
@ParameterizedTest(name = "getUploadLimit case #{index}: input={0}, expected={1}")
@MethodSource("uploadLimitParams")
void shouldComputeUploadLimitCorrectly(String input, long expected) {
when(systemProps.getFileUploadLimit()).thenReturn(input);
long result = uploadLimitService.getUploadLimit();
assertEquals(expected, result);
}
static Stream<Arguments> uploadLimitParams() {
return Stream.of(
// empty or null input yields 0
Arguments.of(null, 0L),
Arguments.of("", 0L),
// invalid formats
Arguments.of("1234MB", 0L),
Arguments.of("5TB", 0L),
// valid formats
Arguments.of("10KB", 10 * 1024L),
Arguments.of("2MB", 2 * 1024 * 1024L),
Arguments.of("1GB", 1L * 1024 * 1024 * 1024),
Arguments.of("5mb", 5 * 1024 * 1024L),
Arguments.of("0MB", 0L));
}
@ParameterizedTest(name = "getReadableUploadLimit case #{index}: rawValue={0}, expected={1}")
@MethodSource("readableLimitParams")
void shouldReturnReadableFormat(String rawValue, String expected) {
when(systemProps.getFileUploadLimit()).thenReturn(rawValue);
String result = uploadLimitService.getReadableUploadLimit();
assertEquals(expected, result);
}
static Stream<Arguments> readableLimitParams() {
return Stream.of(
Arguments.of(null, "0 B"),
Arguments.of("", "0 B"),
Arguments.of("1KB", "1.0 KB"),
Arguments.of("2MB", "2.0 MB"));
}
}
@@ -1,38 +1,14 @@
package stirling.software.SPDF.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.SPDF.model.api.converters.HTMLToPdfRequest;
@ExtendWith(MockitoExtension.class)
public class FileToPdfTest {
/**
* Test the HTML to PDF conversion. This test expects an IOException when an empty HTML input is
* provided.
*/
@Test
public void testConvertHtmlToPdf() {
HTMLToPdfRequest request = new HTMLToPdfRequest();
byte[] fileBytes = new byte[0]; // Sample file bytes (empty input)
String fileName = "test.html"; // Sample file name indicating an HTML file
boolean disableSanitize = false; // Flag to control sanitization
// Expect an IOException to be thrown due to empty input
Throwable thrown =
assertThrows(
IOException.class,
() ->
FileToPdf.convertHtmlToPdf(
"/path/", request, fileBytes, fileName, disableSanitize));
assertNotNull(thrown);
}
/**
* Test sanitizeZipFilename with null or empty input. It should return an empty string in these
* cases.
@@ -1,62 +1,31 @@
package stirling.software.SPDF.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
public class ProcessExecutorTest {
private ProcessExecutor processExecutor;
@BeforeEach
public void setUp() {
// Initialize the ProcessExecutor instance
processExecutor = ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE);
}
@Test
public void testRunCommandWithOutputHandling() throws IOException, InterruptedException {
// Mock the command to execute
List<String> command = new ArrayList<>();
command.add("java");
command.add("-version");
// Execute the command
public void testProcessExecutorResult() {
// Test the ProcessExecutorResult class
ProcessExecutor.ProcessExecutorResult result =
processExecutor.runCommandWithOutputHandling(command);
new ProcessExecutor().new ProcessExecutorResult(0, "Success message", "task-123");
// Check the exit code and output messages
assertEquals(0, result.getRc());
assertNotNull(result.getMessages()); // Check if messages are not null
}
assertEquals(0, result.getRc(), "Exit code should be 0");
assertEquals("Success message", result.getMessages(), "Messages should match");
assertEquals("task-123", result.getTaskId(), "Task ID should match");
@Test
public void testRunCommandWithOutputHandling_Error() {
// Mock the command to execute
List<String> command = new ArrayList<>();
command.add("nonexistent-command");
// Test constructor without taskId
ProcessExecutor.ProcessExecutorResult resultNoTask =
new ProcessExecutor().new ProcessExecutorResult(1, "Error message");
// Execute the command and expect an IOException
IOException thrown =
assertThrows(
IOException.class,
() -> {
processExecutor.runCommandWithOutputHandling(command);
});
// Check the exception message to ensure it indicates the command was not found
String errorMessage = thrown.getMessage();
assertTrue(
errorMessage.contains("error=2")
|| errorMessage.contains("No such file or directory"),
"Unexpected error message: " + errorMessage);
assertEquals(1, resultNoTask.getRc(), "Exit code should be 1");
assertEquals("Error message", resultNoTask.getMessages(), "Messages should match");
assertTrue(resultNoTask.getTaskId() == null, "Task ID should be null");
}
}
+9 -7
View File
@@ -6,7 +6,6 @@
# ___) || | | || _ <| |___ | || |\ | |_| |_____| __/| |_| | _| #
# |____/ |_| |___|_| \_\_____|___|_| \_|\____| |_| |____/|_| #
# #
# Custom setting.yml file with all endpoints disabled to only be used for testing purposes #
# Do not comment out any entry, it will be removed on next startup #
# If you want to override with environment parameter follow parameter naming SECURITY_INITIALLOGIN_USERNAME #
#############################################################################################################
@@ -67,10 +66,10 @@ premium:
proFeatures:
SSOAutoLogin: false
CustomMetadata:
autoUpdateMetadata: false # set to 'true' to automatically update metadata with below values
author: username # supports text such as 'John Doe' or types such as username to autopopulate with user's username
creator: Stirling-PDF # supports text such as 'Company-PDF'
producer: Stirling-PDF # supports text such as 'Company-PDF'
autoUpdateMetadata: false
author: username
creator: Stirling-PDF
producer: Stirling-PDF
googleDrive:
enabled: false
clientId: ''
@@ -127,7 +126,7 @@ ui:
appNameNavbar: '' # name displayed on the navigation bar
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints: # All the possible endpoints are disabled
endpoints:
toRemove: [crop, merge-pdfs, multi-page-layout, overlay-pdfs, pdf-to-single-page, rearrange-pages, remove-image-pdf, remove-pages, rotate-pdf, scale-pages, split-by-size-or-count, split-pages, split-pdf-by-chapters, split-pdf-by-sections, add-password, add-watermark, auto-redact, cert-sign, get-info-on-pdf, redact, remove-cert-sign, remove-password, sanitize-pdf, validate-signature, file-to-pdf, html-to-pdf, img-to-pdf, markdown-to-pdf, pdf-to-csv, pdf-to-html, pdf-to-img, pdf-to-markdown, pdf-to-pdfa, pdf-to-presentation, pdf-to-text, pdf-to-word, pdf-to-xml, url-to-pdf, add-image, add-page-numbers, add-stamp, auto-rename, auto-split-pdf, compress-pdf, decompress-pdf, extract-image-scans, extract-images, flatten, ocr-pdf, remove-blanks, repair, replace-invert-pdf, show-javascript, update-metadata, filter-contains-image, filter-contains-text, filter-file-size, filter-page-count, filter-page-rotation, filter-page-size] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
@@ -138,7 +137,7 @@ metrics:
AutomaticallyGenerated:
key: cbb81c0f-50b1-450c-a2b5-89ae527776eb
UUID: 10dd4fba-01fa-4717-9b78-3dc4f54e398a
appVersion: 0.44.3
appVersion: 0.46.2
processExecutor:
sessionLimit: # Process executor instances limits
@@ -158,3 +157,6 @@ processExecutor:
installApptimeoutMinutes: 60
calibretimeoutMinutes: 30
tesseractTimeoutMinutes: 30
unoconvServers: [] # List of external unoconv servers in the format ["hostname:port", "hostname:port"]. Leave empty to use local instances.
useExternalUnoconvServers: false # Set to true to use external servers from the list above
baseUnoconvPort: 2003 # Base port for local unoconv instances (will increment by 1 for each instance)