Compare commits

..

11 Commits

Author SHA1 Message Date
Anthony Stirling
c2062c19ab Merge remote-tracking branch 'origin/main' into bug/remember-me 2024-11-15 21:30:14 +00:00
Anthony Stirling
6760fddd6f Merge branch 'bug/remember-me' of
git@github.com:Stirling-Tools/Stirling-PDF.git into bug/remember-me
2024-11-15 21:26:51 +00:00
a
20a75d577d Merge branch 'bug/remember-me' of git@github.com:Stirling-Tools/Stirling-PDF.git into bug/remember-me 2024-11-15 13:13:34 +00:00
Anthony Stirling
559581c59d Merge branch 'bug/remember-me' of
git@github.com:Stirling-Tools/Stirling-PDF.git into bug/remember-me
2024-11-15 13:12:20 +00:00
Anthony Stirling
e7356a1d38 Merge branch 'main' into bug/remember-me 2024-11-07 20:39:19 +00:00
a
1405e4f5ee Merge branch 'bug/remember-me' of git@github.com:Stirling-Tools/Stirling-PDF.git into bug/remember-me 2024-11-07 20:38:54 +00:00
Anthony Stirling
322e7dee0d exe no longer disable CLI 2024-11-07 20:38:32 +00:00
github-actions[bot]
918e977c6a Update translation files (#2185)
Signed-off-by: GitHub Action <action@github.com>
Co-authored-by: GitHub Action <action@github.com>
2024-11-05 14:26:24 +00:00
Anthony Stirling
a31633e5b8 Merge branch 'main' into bug/remember-me 2024-11-05 14:24:44 +00:00
Anthony Stirling
ed551cec91 remove uselss comment 2024-11-05 14:24:28 +00:00
Anthony Stirling
3f14e77725 fix remmeber me 2024-11-05 14:22:55 +00:00
270 changed files with 5427 additions and 41955 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1,2 +1,2 @@
# All PRs to V1 must be approved by Frooodle
* @Frooodle @reecebrowne @Ludy87 @DarioGii
* @Frooodle

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: 💬 Discord Server
url: https://discord.gg/HYmhKj45pU
url: https://discord.gg/Cn8pWhQRxZ
about: You can join our Discord server for real time discussion and support

View File

@@ -11,13 +11,7 @@ updates:
interval: "weekly"
open-pull-requests-limit: 10
rebase-strategy: "auto"
- package-ecosystem: "docker"
directory: "/" # Location of Dockerfile
schedule:
interval: "weekly"
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

4
.github/release.yml vendored
View File

@@ -9,7 +9,7 @@ changelog:
- title: Bug Fixes
labels:
- Bug
- title: Enhancements
labels:
- enhancement
@@ -26,7 +26,7 @@ changelog:
- title: Translation Changes
labels:
- Translation
- title: Other Changes
labels:
- "*"

View File

@@ -9,9 +9,8 @@ The script also provides functionality to update the translation files to match
adjusting the format.
Usage:
python check_language_properties.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
python script_name.py --reference-file <path_to_reference_file> --branch <branch_name> [--files <list_of_changed_files>]
"""
import copy
import glob
import os
@@ -19,10 +18,6 @@ import argparse
import re
# Maximum size for properties files (e.g., 200 KB)
MAX_FILE_SIZE = 200 * 1024
def parse_properties_file(file_path):
"""Parses a .properties file and returns a list of objects (including comments, empty lines, and line numbers)."""
properties_list = []
@@ -100,7 +95,7 @@ def write_json_file(file_path, updated_properties):
def update_missing_keys(reference_file, file_list, branch=""):
reference_properties = parse_properties_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
basename_current_file = os.path.basename(branch + file_path)
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".properties")
@@ -108,7 +103,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
):
continue
current_properties = parse_properties_file(os.path.join(branch, file_path))
current_properties = parse_properties_file(branch + file_path)
updated_properties = []
for ref_entry in reference_properties:
ref_entry_copy = copy.deepcopy(ref_entry)
@@ -119,79 +114,60 @@ def update_missing_keys(reference_file, file_list, branch=""):
if ref_entry_copy["key"] == current_entry["key"]:
ref_entry_copy["value"] = current_entry["value"]
updated_properties.append(ref_entry_copy)
write_json_file(os.path.join(branch, file_path), updated_properties)
write_json_file(branch + file_path, updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
update_missing_keys(reference_file, file_list, branch + "/")
def read_properties(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as file:
return file.read().splitlines()
return [""]
with open(file_path, "r", encoding="utf-8") as file:
return file.read().splitlines()
def check_for_differences(reference_file, file_list, branch, actor):
def check_for_differences(reference_file, file_list, branch):
reference_branch = reference_file.split("/")[0]
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
report.append(
f"### 📋 Checking with the file `{basename_reference_file}` from the `{reference_branch}` - Checking the `{branch}`"
)
reference_lines = read_properties(reference_file)
has_differences = False
only_reference_file = True
file_arr = file_list
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(os.path.join(os.getcwd(), "src", "main", "resources"))
for file_path in file_arr:
absolute_path = os.path.abspath(file_path)
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_path}")
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_path)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_path} is too large and could pose a security risk."
)
basename_current_file = os.path.basename(os.path.join(branch, file_path))
for file_path in file_list:
basename_current_file = os.path.basename(branch + "/" + file_path)
if (
basename_current_file == basename_reference_file
or not file_path.startswith(
os.path.join("src", "main", "resources", "messages_")
)
or not file_path.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{basename_current_file}`")
current_lines = read_properties(os.path.join(branch, file_path))
report.append(f"#### 🗂️ **Checking File:** `{basename_current_file}`...")
current_lines = read_properties(branch + "/" + file_path)
reference_line_count = len(reference_lines)
current_line_count = len(current_lines)
if reference_line_count != current_line_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append("- **Test 1 Status:** ❌ Failed")
has_differences = True
if reference_line_count > current_line_count:
report.append(
f" - **_Mismatched line count_**: {reference_line_count} (reference) vs {current_line_count} (current). Comments, empty lines, or translation strings are missing."
f" - **Issue:** Missing lines! Comments, empty lines, or translation strings are missing. Details: {reference_line_count} (reference) vs {current_line_count} (current)."
)
elif reference_line_count < current_line_count:
report.append(
f" - **_Too many lines_**: {reference_line_count} (reference) vs {current_line_count} (current). Please verify if there is an additional line that needs to be removed."
f" - **Issue:** Too many lines! Check your translation files! Details: {reference_line_count} (reference) vs {current_line_count} (current)."
)
# update_missing_keys(reference_file, [file_path], branch + "/")
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
report.append("- **Test 1 Status:** ✅ Passed")
# Check for missing or extra keys
current_keys = []
@@ -216,42 +192,32 @@ def check_for_differences(reference_file, file_list, branch, actor):
has_differences = True
missing_keys_str = "`, `".join(missing_keys_list)
extra_keys_str = "`, `".join(extra_keys_list)
report.append("2. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append("- **Test 2 Status:** ❌ Failed")
if missing_keys_list:
spaces_keys_list = []
for key in missing_keys_list:
if " " in key:
spaces_keys_list.append(key)
if spaces_keys_list:
spaces_keys_str = "`, `".join(spaces_keys_list)
report.append(
f" - **_Keys containing unnecessary spaces_**: `{spaces_keys_str}`!"
)
report.append(
f" - **_Extra keys in `{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
f" - **Issue:** There are keys in ***{basename_current_file}*** `{missing_keys_str}` that are not present in ***{basename_reference_file}***!"
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{basename_reference_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_current_file}`_**."
f" - **Issue:** There are keys in ***{basename_reference_file}*** `{extra_keys_str}` that are not present in ***{basename_current_file}***!"
)
# update_missing_keys(reference_file, [file_path], branch + "/")
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
report.append("- **Test 2 Status:** ✅ Passed")
# if has_differences:
# report.append("")
# report.append(f"#### 🚧 ***{basename_current_file}*** will be corrected...")
report.append("")
report.append("---")
report.append("")
# update_file_list = glob.glob(branch + "/src/**/messages_*.properties", recursive=True)
# update_missing_keys(reference_file, update_file_list)
# report.append("---")
# report.append("")
if has_differences:
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
if not only_reference_file:
print("\n".join(report))
@@ -259,11 +225,6 @@ def check_for_differences(reference_file, file_list, branch, actor):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser.add_argument(
"--actor",
required=False,
help="Actor from PR.",
)
parser.add_argument(
"--reference-file",
required=True,
@@ -283,21 +244,11 @@ if __name__ == "__main__":
)
args = parser.parse_args()
# Sanitize --actor input to avoid injection attacks
if args.actor:
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
# Sanitize --branch input to avoid injection attacks
if args.branch:
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
file_list = args.files
if file_list is None:
file_list = glob.glob(
os.path.join(
os.getcwd(), "src", "main", "resources", "messages_*.properties"
)
os.getcwd() + "/src/**/messages_*.properties", recursive=True
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
check_for_differences(args.reference_file, file_list, args.branch)

View File

@@ -1,189 +0,0 @@
name: PR Deployment via Comment
on:
issue_comment:
types: [created]
jobs:
check-comment:
runs-on: ubuntu-latest
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'
)
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 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- 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);
deploy-pr:
needs: check-comment
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- 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@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
with:
java-version: '17'
distribution: 'temurin'
- name: Run Gradle Command
run: ./gradlew clean build
env:
DOCKER_ENABLE_SECURITY: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.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@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push PR-specific image
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
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: |
# 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: "false"
SECURITY_ENABLELOGIN: "false"
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
- name: Post deployment URL to PR
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { GITHUB_REPOSITORY } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
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\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
});

View File

@@ -1,84 +0,0 @@
name: PR Deployment cleanup
on:
pull_request:
types: [opened, synchronize, reopened, closed]
permissions: read-all
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@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
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: |
CLEANUP_STATUS=$(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
)
if [[ $CLEANUP_STATUS == *"PERFORMED_CLEANUP"* ]]; then
echo "cleanup_performed=true" >> $GITHUB_OUTPUT
else
echo "cleanup_performed=false" >> $GITHUB_OUTPUT
fi
- name: Post cleanup notice to PR
if: steps.cleanup.outputs.cleanup_performed == 'true'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { GITHUB_REPOSITORY } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
const prNumber = context.issue.number;
const commentBody = `## 🧹 Deployment Cleanup\n\n` +
`The test deployment for this PR has been cleaned up.`;
await github.rest.issues.createComment({
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
body: commentBody
});

View File

@@ -3,23 +3,17 @@ on:
pull_request_target:
types: [opened, synchronize]
permissions: read-all
jobs:
labeler:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@v4
- name: Apply Labels
uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler-config.yml

View File

@@ -6,13 +6,13 @@ on:
pull_request:
branches: ["main"]
permissions: read-all
jobs:
build:
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
@@ -21,22 +21,17 @@ jobs:
jdk-version: [17, 21]
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: 8.7
@@ -61,22 +56,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "adopt"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0
uses: docker/setup-buildx-action@v3
- name: Install Docker Compose
run: |
@@ -84,9 +74,9 @@ jobs:
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.7"
- name: Pip requirements
run: |

View File

@@ -1,42 +1,40 @@
name: Check Properties Files on PR
name: Check Properties Files
on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "src/main/resources/messages_*.properties"
push:
paths:
- "src/main/resources/messages_en_GB.properties"
permissions: read-all
permissions:
contents: write
pull-requests: write
jobs:
check-files:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
- name: Checkout PR branch
uses: actions/checkout@v4
with:
egress-policy: audit
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
path: pr-branch
fetch-depth: 0
- name: Checkout main branch first
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Checkout main branch
uses: actions/checkout@v4
with:
ref: main
path: main-branch
fetch-depth: 0
- name: Checkout PR branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
env:
PULL_REQUEST_REF: ${{ github.event.pull_request.head.ref }}
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: $PULL_REQUEST_REF
path: pr-branch
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
uses: actions/setup-python@v5
with:
python-version: "3.x"
@@ -51,73 +49,56 @@ jobs:
echo "Fetching PR changed files..."
cd pr-branch
gh repo set-default ${{ github.repository }}
# Store files in a safe way, only allowing valid properties files
echo "Getting list of changed files from PR..."
gh pr view ${{ github.event.pull_request.number }} --json files -q ".files[].path" | grep -E '^src/main/resources/messages_[a-zA-Z_]+\.properties$' > ../changed_files.txt
gh pr view ${{ github.event.pull_request.number }} --json files -q ".files[].path" > ../changed_files.txt
cd ..
echo "Processing changed files..."
mapfile -t CHANGED_FILES < changed_files.txt
CHANGED_FILES_STR="${CHANGED_FILES[*]}"
echo "CHANGED_FILES=${CHANGED_FILES_STR}" >> $GITHUB_ENV
echo "Changed files: ${CHANGED_FILES_STR}"
echo $(cat changed_files.txt)
BRANCH_PATH="pr-branch"
echo "BRANCH_PATH=${BRANCH_PATH}" >> $GITHUB_ENV
CHANGED_FILES=$(cat changed_files.txt | tr '\n' ' ')
echo "CHANGED_FILES=${CHANGED_FILES}" >> $GITHUB_ENV
echo "Changed files: ${CHANGED_FILES}"
echo "Branch: ${BRANCH_PATH}"
- name: Determine reference file
id: determine-file
run: |
echo "Determining reference file..."
if grep -Fxq "src/main/resources/messages_en_GB.properties" changed_files.txt; then
echo "Using PR branch reference file"
if echo "${{ env.CHANGED_FILES }}" | grep -q 'src/main/resources/messages_en_GB.properties'; then
echo "REFERENCE_FILE=pr-branch/src/main/resources/messages_en_GB.properties" >> $GITHUB_ENV
else
echo "Using main branch reference file"
echo "REFERENCE_FILE=main-branch/src/main/resources/messages_en_GB.properties" >> $GITHUB_ENV
fi
echo "REFERENCE_FILE=${{ env.REFERENCE_FILE }}"
- name: Show REFERENCE_FILE
run: echo "Reference file is set to ${REFERENCE_FILE}"
run: echo "Reference file is set to ${{ env.REFERENCE_FILE }}"
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check files..."
python main-branch/.github/scripts/check_language_properties.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch pr-branch \
--files "${CHANGED_FILES[@]}" > result.txt || true
python main-branch/.github/scripts/check_language_properties.py --reference-file ${{ env.REFERENCE_FILE }} --branch ${{ env.BRANCH_PATH }} --files ${{ env.CHANGED_FILES }} > failure.txt || true
- 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
if [ -f failure.txt ] && [ -s failure.txt ]; then
echo "Test failed, capturing output..."
ERROR_OUTPUT=$(cat failure.txt)
echo "ERROR_OUTPUT<<EOF" >> $GITHUB_ENV
echo "$ERROR_OUTPUT" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
echo "${SCRIPT_OUTPUT}"
# Set FAIL_JOB to true if SCRIPT_OUTPUT contains ❌
if [[ "$SCRIPT_OUTPUT" == *"❌"* ]]; then
echo "FAIL_JOB=true" >> $GITHUB_ENV
else
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
echo $ERROR_OUTPUT
else
echo "No update found."
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
echo "FAIL_JOB=false" >> $GITHUB_ENV
echo "No errors found."
echo "ERROR_OUTPUT=" >> $GITHUB_ENV
fi
- name: Post comment on PR
if: env.SCRIPT_OUTPUT != ''
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
if: env.ERROR_OUTPUT != ''
uses: actions/github-script@v7
with:
script: |
const { GITHUB_REPOSITORY, SCRIPT_OUTPUT } = process.env;
const { GITHUB_REPOSITORY, ERROR_OUTPUT } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
const prNumber = context.issue.number;
@@ -139,7 +120,7 @@ jobs:
owner: repoOwner,
repo: repoName,
comment_id: comment.id,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
body: `## 🚀 Translation Verification Summary\n\n\n${ERROR_OUTPUT}\n`
});
console.log("Updated existing comment.");
} else if (!comment) {
@@ -148,15 +129,74 @@ jobs:
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
body: `## 🚀 Translation Verification Summary\n\n\n${ERROR_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'
# - name: Set up git config
# run: |
# git config --global user.name "github-actions[bot]"
# git config --global user.email "github-actions[bot]@users.noreply.github.com"
# - name: Add translation keys
# run: |
# cd ${{ env.BRANCH_PATH }}
# git add src/main/resources/messages_*.properties
# git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
# git commit -m "Update translation files" || echo "No changes to commit"
# - name: Push
# if: env.CHANGES_DETECTED == 'true'
# run: |
# cd pr-branch
# git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.event.pull_request.head.repo.full_name }}.git
# git push origin ${{ github.head_ref }} || echo "Push failed: possibly no changes to push"
update-translations-main:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Run Python script to check files
id: run-check
run: |
echo "Failing the job because errors were detected."
exit 1
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 "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Add translation keys
run: |
git add src/main/resources/messages_*.properties
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@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "Update translation files"
committer: GitHub Action <action@github.com>
author: GitHub Action <action@github.com>
signoff: true
branch: update_translation_files
title: "Update translation files"
body: |
Auto-generated by [create-pull-request][1]
[1]: https://github.com/peter-evans/create-pull-request
labels: Translation
draft: false
delete-branch: true

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@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
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}}"

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@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0

View File

@@ -7,30 +7,25 @@ on:
paths:
- "build.gradle"
permissions: read-all
permissions:
contents: write
pull-requests: write
jobs:
generate-license-report:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "adopt"
- uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
- uses: gradle/actions/setup-gradle@v4
- name: Run Gradle Command
run: ./gradlew clean generateLicenseReport
@@ -52,7 +47,7 @@ jobs:
- name: Create Pull Request
id: cpr
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "Update 3rd Party Licenses"
@@ -77,7 +72,7 @@ jobs:
- name: Enable auto-merge
if: steps.cpr.outputs.pull-request-operation == 'created'
uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3.0.0
uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}

View File

@@ -4,26 +4,21 @@ on:
schedule:
- cron: "30 20 * * *"
permissions: read-all
permissions:
contents: read
issues: write
jobs:
labeler:
name: Labeler
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@b54af0c25861143e7c8813d7cbbf46d2c341680c # v5.1.0
uses: crazy-max/ghaction-github-labeler@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
yaml-file: .github/labels.yml
skip-delete: true
skip-delete: true

View File

@@ -1,104 +0,0 @@
name: Test Installers Build
on:
workflow_dispatch:
release:
types: [created]
permissions: read-all
jobs:
build-installers:
strategy:
matrix:
include:
- os: windows-latest
platform: win
ext: exe
#- os: macos-latest
# platform: mac
# ext: dmg
#- os: ubuntu-latest
# platform: linux
# ext: deb
runs-on: ${{ matrix.os }}
permissions:
contents: write
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
with:
gradle-version: 8.7
# 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
# Install Linux dependencies
- name: Install Linux Dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y fakeroot rpm
# Get version number
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
shell: bash
- name: Get version number mac
id: versionNumberMac
run: echo "versionNumberMac=$(./gradlew printMacVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
shell: bash
# Build installer
- name: Build Installer
run: ./gradlew build jpackage -x test --info
env:
DOCKER_ENABLE_SECURITY: false
STIRLING_PDF_DESKTOP_UI: true
# Rename and collect artifacts based on OS
- name: Prepare artifacts
id: prepare
shell: bash
run: |
if [ "${{ matrix.os }}" = "windows-latest" ]; then
mv "build/jpackage/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.exe" "Stirling-PDF-${{ matrix.platform }}-installer.${{ matrix.ext }}"
elif [ "${{ matrix.os }}" = "macos-latest" ]; then
mv "build/jpackage/Stirling-PDF-${{ steps.versionNumberMac.outputs.versionNumberMac }}.dmg" "Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}-${{ matrix.platform }}.${{ matrix.ext }}"
else
mv "build/jpackage/stirling-pdf_${{ steps.versionNumber.outputs.versionNumber }}-1_amd64.deb" "Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}-${{ matrix.platform }}.${{ matrix.ext }}"
fi
# Upload installer as artifact for testing
- name: Upload Installer Artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
with:
name: Stirling-PDF-${{ matrix.platform }}-installer.${{ matrix.ext }}
path: Stirling-PDF-${{ matrix.platform }}-installer.${{ matrix.ext }}
retention-days: 1
if-no-files-found: error
- name: Upload binaries to release
uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
with:
files: ./Stirling-PDF-${{ matrix.platform }}-installer.${{ matrix.ext }}

View File

@@ -10,28 +10,20 @@ on:
permissions:
contents: read
packages: write
id-token: write
jobs:
push:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: 8.7
@@ -40,34 +32,29 @@ jobs:
env:
DOCKER_ENABLE_SECURITY: false
- name: Install cosign
uses: sigstore/cosign-installer@v3.7.0
with:
cosign-release: 'v2.4.1'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0
uses: docker/setup-buildx-action@v3
- 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@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0
uses: docker/setup-qemu-action@v3
- name: Convert repository owner to lowercase
id: repoowner
@@ -75,12 +62,11 @@ jobs:
- name: Generate tags
id: meta
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1
uses: docker/metadata-action@v5
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' }}
@@ -88,8 +74,7 @@ jobs:
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@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
uses: docker/build-push-action@v6
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -101,46 +86,22 @@ jobs:
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
env:
DIGEST: ${{ steps.build-push-regular.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
run: |
# Always sign images regardless of branch
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes "${tag}@${DIGEST}"
done
# For alpha builds specifically, we want to ensure they're marked as development builds
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "Signing alpha build with development attestation"
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
if [[ $tag == *":alpha" ]]; then
cosign attest --predicate <(echo '{"type":"development"}') --yes "${tag}@${DIGEST}"
fi
done
fi
- name: Generate tags ultra-lite
id: meta2
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1
uses: docker/metadata-action@v5
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@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
uses: docker/build-push-action@v6
if: github.ref != 'refs/heads/main'
with:
context: .
@@ -152,36 +113,22 @@ jobs:
labels: ${{ steps.meta2.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign ultra-lite images
if: github.ref != 'refs/heads/main'
env:
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
TAGS: ${{ steps.meta2.outputs.tags }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes "${tag}@${DIGEST}"
done
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1
uses: docker/metadata-action@v5
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@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
uses: docker/build-push-action@v6
if: github.ref != 'refs/heads/main'
with:
builder: ${{ steps.buildx.outputs.name }}
@@ -194,15 +141,3 @@ jobs:
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/main'
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta3.outputs.tags }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes "${tag}@${DIGEST}"
done

View File

@@ -4,15 +4,12 @@ on:
workflow_dispatch:
release:
types: [created]
permissions: read-all
permissions:
contents: write
packages: write
jobs:
push:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
strategy:
matrix:
enable_security: [true, false]
@@ -22,20 +19,15 @@ jobs:
- enable_security: false
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: 8.7
@@ -43,34 +35,33 @@ jobs:
run: ./gradlew clean createExe
env:
DOCKER_ENABLE_SECURITY: ${{ matrix.enable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Rename binarie
run: cp ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
if: matrix.file_suffix != ''
run: cp ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF${{ matrix.file_suffix }}.exe
- name: Upload Assets binarie
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@v4
with:
path: ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
name: Stirling-PDF-Server${{ matrix.file_suffix }}.exe
path: ./build/launch4j/Stirling-PDF${{ matrix.file_suffix }}.exe
name: Stirling-PDF${{ matrix.file_suffix }}.exe
overwrite: true
retention-days: 1
if-no-files-found: error
- name: Upload binaries to release
uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
uses: softprops/action-gh-release@v2
with:
files: ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
files: ./build/launch4j/Stirling-PDF${{ matrix.file_suffix }}.exe
- name: Rename jar binaries
run: cp ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Upload Assets jar binaries
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@v4
with:
path: ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
name: Stirling-PDF${{ matrix.file_suffix }}.jar
@@ -79,6 +70,6 @@ jobs:
if-no-files-found: error
- name: Upload jar binaries to release
uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
uses: softprops/action-gh-release@v2
with:
files: ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar

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@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
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@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
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@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
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@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
sarif_file: results.sarif

View File

@@ -5,8 +5,6 @@ on:
- cron: "30 0 * * *"
workflow_dispatch:
permissions: read-all
jobs:
stale:
runs-on: ubuntu-latest
@@ -14,13 +12,8 @@ jobs:
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: 30 days stale issues
uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30

View File

@@ -6,26 +6,19 @@ on:
branches:
- master
permissions: read-all
jobs:
push:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
- uses: gradle/actions/setup-gradle@v4
- name: Generate Swagger documentation
run: ./gradlew generateOpenApiDocs

View File

@@ -9,23 +9,17 @@ on:
- "src/main/resources/messages_*.properties"
- "scripts/ignore_translation.toml"
permissions: read-all
permissions:
contents: write
pull-requests: write
jobs:
sync-readme:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
@@ -42,7 +36,7 @@ jobs:
git diff --staged --quiet || git commit -m ":memo: Sync README
> Made via sync_files.yml" || echo "no changes"
- name: Create Pull Request
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Update files

View File

@@ -1,71 +0,0 @@
name: Update Translations
on:
push:
branches: ["main"]
paths:
- "src/main/resources/messages_en_GB.properties"
permissions: read-all
jobs:
update-translations-main:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.x"
- 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 \
--reference-file src/main/resources/messages_en_GB.properties \
--branch main
- name: Set up git config
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Add translation keys
run: |
git add src/main/resources/messages_*.properties
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@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7.0.5
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "Update translation files"
committer: GitHub Action <action@github.com>
author: GitHub Action <action@github.com>
signoff: true
branch: update_translation_files
title: "Update translation files"
add-paths: |
src/main/resources/messages_*.properties
body: |
Auto-generated by [create-pull-request][1]
[1]: https://github.com/peter-evans/create-pull-request
labels: Translation
draft: false
delete-branch: true
sign-commits: true

1
.gitignore vendored
View File

@@ -161,4 +161,3 @@ out/
.pytest_cache
.ipynb_checkpoints
**/jcef-bundle/

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
rev: v0.2.1
hooks:
- id: ruff
args:
@@ -12,7 +12,7 @@ repos:
files: ^((.github/scripts|scripts)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
rev: v2.2.6
hooks:
- id: codespell
args:
@@ -21,25 +21,6 @@ repos:
- --quiet-level=2
files: \.(properties|html|css|js|py|md)$
exclude: (.vscode|.devcontainer|src/main/resources|Dockerfile)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.22.0
hooks:
- id: gitleaks
- repo: https://github.com/jumanjihouse/pre-commit-hooks
rev: 3.0.0
hooks:
- id: shellcheck
files: ^.*(\.bash|\.sh|\.ksh|\.zsh)$
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js$)
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js$)
- repo: local
hooks:
- id: check-duplicate-properties-keys
@@ -47,11 +28,12 @@ repos:
entry: python .github/scripts/check_duplicates.py
language: python
files: ^(src)/.+\.properties$
- repo: local
hooks:
- id: check-html-tabs
name: Check HTML for tabs
description: Ensures HTML/CSS/JS files do not contain tab characters
# args: ["--replace_with= "]
entry: python .github/scripts/check_tabulator.py
language: python
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js$)
files: ^.*(\.html|\.css|\.js)$
exclude: ^(src/main/resources/static/pdfjs|src/main/resources/static/pdfjs-legacy)
files: ^.*(\.html|\.css|\.js)$

View File

@@ -49,7 +49,5 @@
"editor.indentSize": "tabSize",
"editor.stickyScroll.enabled": false,
"editor.minimap.enabled": false,
"editor.formatOnSave": true,
"java.format.settings.google.mode": "jar-file",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports"
"editor.formatOnSave": true
}

View File

@@ -1,5 +1,12 @@
# New Database Backup and Import Functionality
> [!IMPORTANT]
> **Full activation will take place on approximately January 5th, 2025!**
Why is the waiting time six months?
There are users who only install updates sporadically; if they skip the preparation, it can/will lead to data loss in the database.
## Functionality Overview
The newly introduced feature enhances the application with robust database backup and import capabilities. This feature is designed to ensure data integrity and provide a straightforward way to manage database backups. Here's how it works:

View File

@@ -11,7 +11,7 @@ Stirling-PDF is built using:
- Spring Boot + Thymeleaf
- PDFBox
- LibreOffice
- qpdf
- OcrMyPdf
- HTML, CSS, JavaScript
- Docker
- PDF.js
@@ -114,7 +114,7 @@ These files provide pre-configured setups for different scenarios. For example,
services:
stirling-pdf:
container_name: Stirling-PDF-Security
image: stirlingtools/stirling-pdf:latest
image: frooodle/s-pdf:latest
deploy:
resources:
limits:
@@ -173,20 +173,20 @@ Stirling-PDF uses different Docker images for various configurations. The build
For the latest version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t frooodle/s-pdf:latest -f ./Dockerfile .
```
For the ultra-lite version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile-ultra-lite .
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t frooodle/s-pdf:latest-ultra-lite -f ./Dockerfile-ultra-lite .
```
For the fat version (with security enabled):
```bash
export DOCKER_ENABLE_SECURITY=true
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile-fat .
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t frooodle/s-pdf:latest-fat -f ./Dockerfile-fat .
```
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
@@ -243,7 +243,7 @@ To run Stirling-PDF locally:
Important notes:
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
- Local testing doesn't include features that depend on external tools like OCRmyPDF, LibreOffice, or Python scripts.
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.

View File

@@ -1,5 +1,5 @@
# Main stage
FROM alpine:3.20.3@sha256:1e42bbe2508154c9126d48c2b8a75420c3544343bf86fd041fb7527e017a4b4a
FROM alpine:3.20.3
# Copy necessary files
COPY scripts /scripts
@@ -30,7 +30,6 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
tini \
bash \
curl \
qpdf \
shadow \
su-exec \
openssl \
@@ -41,6 +40,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced features)
ocrmypdf \
tesseract-ocr-data-eng \
# CV
py3-opencv \

View File

@@ -1,5 +1,5 @@
# Build the application
FROM gradle:8.12-jdk17 AS build
FROM gradle:8.7-jdk17 AS build
# Set the working directory
WORKDIR /app
@@ -55,7 +55,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced featues)
qpdf \
ocrmypdf \
tesseract-ocr-data-eng \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra \
# CV

View File

@@ -1,5 +1,5 @@
# use alpine
FROM alpine:3.21.0
FROM alpine:3.20.3
ARG VERSION_TAG

View File

@@ -1,4 +1,4 @@
| Operation | PageOps | Convert | Security | Other | CLI | Python | OpenCV | LibreOffice | qpdf | Java | Javascript | Unoconv | tesseract |
| Operation | PageOps | Convert | Security | Other | CLI | Python | OpenCV | LibreOffice | OCRmyPDF | Java | Javascript | Unoconv | Ghostscript |
| ------------------- | ------- | ------- | -------- | ----- | --- | ------ | ------ | ----------- | -------- | ---- | ---------- | ------- | ----------- |
| adjust-contrast | ✔️ | | | | | | | | | | ✔️ | | |
| auto-split-pdf | ✔️ | | | | | | | | | ✔️ | | | |
@@ -16,7 +16,7 @@
| img-to-pdf | | ✔️ | | | | | | | | ✔️ | | | |
| pdf-to-html | | ✔️ | | | ✔️ | | | ✔️ | | | | | |
| pdf-to-img | | ✔️ | | | | ✔️ | | | | ✔️ | | | |
| pdf-to-pdfa | | ✔️ | | | ✔️ | | | | ✔️ | | | | |
| pdf-to-pdfa | | ✔️ | | | ✔️ | | | | ✔️ | | | | ✔️ |
| pdf-to-markdown | | ✔️ | | | | | | | | ✔️ | | | |
| pdf-to-presentation | | ✔️ | | | ✔️ | | | ✔️ | | | | | |
| pdf-to-text | | ✔️ | | | ✔️ | | | ✔️ | | | | | |
@@ -34,13 +34,13 @@
| auto-rename | | | | ✔️ | | | | | | ✔️ | | | |
| change-metadata | | | | ✔️ | | | | | | ✔️ | | | |
| compare | | | | ✔️ | | | | | | | ✔️ | | |
| compress-pdf | | | | ✔️ | ✔️ | | | | ✔️ | | | | |
| compress-pdf | | | | ✔️ | ✔️ | | | | ✔️ | | | | ✔️ |
| extract-image-scans | | | | ✔️ | ✔️ | ✔️ | ✔️ | | | | | | |
| extract-images | | | | ✔️ | | | | | | ✔️ | | | |
| flatten | | | | ✔️ | | | | | | | ✔️ | | |
| get-info-on-pdf | | | | ✔️ | | | | | | ✔️ | | | |
| ocr-pdf | | | | ✔️ | ✔️ | | | | | | | | |
| ocr-pdf | | | | ✔️ | ✔️ | | | | ✔️ | | | | |
| remove-blanks | | | | ✔️ | ✔️ | ✔️ | ✔️ | | | | | | |
| repair | | | | ✔️ | ✔️ | | | ✔️ | | | | | |
| repair | | | | ✔️ | ✔️ | | | ✔️ | | | | | ✔️ |
| show-javascript | | | | ✔️ | | | | | | | ✔️ | | |
| sign | | | | ✔️ | | | | | | | ✔️ | | |

View File

@@ -8,7 +8,7 @@ The paths have changed for the tessdata locations on new Docker images. Please u
## How does the OCR Work
Stirling-PDF uses Tesseract for its text recognition. All credit goes to them for this awesome work!
Stirling-PDF uses [OCRmyPDF](https://github.com/ocrmypdf/OCRmyPDF), which in turn uses Tesseract for its text recognition. All credit goes to them for this awesome work!
## Language Packs
@@ -52,6 +52,8 @@ Add the following to your existing Docker run command:
### Non-Docker Setup
If you are not using Docker, you need to install the OCR components, including the `ocrmypdf` app. You can see the [OCRmyPDF install guide](https://ocrmypdf.readthedocs.io/en/latest/installation.html).
For Debian-based systems, install languages with this command:
```bash
@@ -81,7 +83,8 @@ rpm -qa | grep tesseract-langpack | sed 's/tesseract-langpack-//g'
For Windows:
You must ensure tesseract is installed
Ensure ocrmypdf in installed with
``pip install ocrmypdf``
Additional languages must be downloaded manually:
Download desired .traineddata files from tessdata or tessdata_fast

45
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,45 @@
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'chmod 755 gradlew'
sh './gradlew build'
}
}
stage('Docker Build') {
steps {
script {
def appVersion = sh(returnStdout: true, script: './gradlew printVersion -q').trim()
def image = "frooodle/s-pdf:$appVersion"
sh "docker build -t $image ."
}
}
}
stage('Docker Push') {
steps {
script {
def appVersion = sh(returnStdout: true, script: './gradlew printVersion -q').trim()
def image = "frooodle/s-pdf:$appVersion"
withCredentials([string(credentialsId: 'docker_hub_access_token', variable: 'DOCKER_HUB_ACCESS_TOKEN')]) {
sh "docker login --username frooodle --password $DOCKER_HUB_ACCESS_TOKEN"
sh "docker push $image"
}
}
}
}
stage('Helm Push') {
steps {
script {
//TODO: Read chartVersion from Chart.yaml
def chartVersion = '1.0.0'
withCredentials([string(credentialsId: 'docker_hub_access_token', variable: 'DOCKER_HUB_ACCESS_TOKEN')]) {
sh "docker login --username frooodle --password $DOCKER_HUB_ACCESS_TOKEN"
sh "helm package chart/stirling-pdf"
sh "helm push stirling-pdf-chart-1.0.0.tgz oci://registry-1.docker.io/frooodle"
}
}
}
}
}
}

View File

@@ -68,7 +68,7 @@ nix-env -iA nixpkgs.jbig2enc
### Step 3: Install Additional Software
Next we need to install LibreOffice for conversions, qpdf for OCR, and OpenCV for pattern recognition functionality.
Next we need to install LibreOffice for conversions, ocrmypdf for OCR, and OpenCV for pattern recognition functionality.
Install the following software:
@@ -81,27 +81,27 @@ Install the following software:
- unoconv
- pngquant
- unpaper
- qpdf
- ocrmypdf
- opencv-python-headless
For Debian-based systems, you can use the following command:
```bash
sudo apt-get install -y libreoffice-writer libreoffice-calc libreoffice-impress unpaper qpdf
sudo apt-get install -y libreoffice-writer libreoffice-calc libreoffice-impress unpaper ocrmypdf
pip3 install uno opencv-python-headless unoconv pngquant WeasyPrint --break-system-packages
```
For Fedora:
```bash
sudo dnf install -y libreoffice-writer libreoffice-calc libreoffice-impress unpaper qpdf
sudo dnf install -y libreoffice-writer libreoffice-calc libreoffice-impress unpaper ocrmypdf
pip3 install uno opencv-python-headless unoconv pngquant WeasyPrint
```
For Nix:
```bash
nix-env -iA nixpkgs.unpaper nixpkgs.libreoffice nixpkgs.qpdf nixpkgs.poppler_utils
nix-env -iA nixpkgs.unpaper nixpkgs.libreoffice nixpkgs.ocrmypdf nixpkgs.poppler_utils
pip3 install uno opencv-python-headless unoconv pngquant WeasyPrint
```
@@ -146,6 +146,7 @@ The easiest method is to use the language packs provided by your repositories. S
1. Download the desired language pack(s) by selecting the `.traineddata` file(s) for the language(s) you need.
2. Place the `.traineddata` files in the Tesseract tessdata directory: `/usr/share/tessdata`
3. Please view [OCRmyPDF install guide](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for more info.
**IMPORTANT:** DO NOT REMOVE EXISTING `eng.traineddata`, IT'S REQUIRED.

101
README.md
View File

@@ -2,22 +2,23 @@
<h1 align="center">Stirling-PDF</h1>
[![Docker Pulls](https://img.shields.io/docker/pulls/frooodle/s-pdf)](https://hub.docker.com/r/frooodle/s-pdf)
[![Discord](https://img.shields.io/discord/1068636748814483718?label=Discord)](https://discord.gg/HYmhKj45pU)
[![Discord](https://img.shields.io/discord/1068636748814483718?label=Discord)](https://discord.gg/Cn8pWhQRxZ)
[![Docker Image Version (tag latest semver)](https://img.shields.io/docker/v/frooodle/s-pdf/latest)](https://github.com/Stirling-Tools/Stirling-PDF/)
[![GitHub Repo stars](https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social)](https://github.com/Stirling-Tools/stirling-pdf)
<a href="https://www.producthunt.com/posts/stirling-pdf?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-stirling&#0045;pdf" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=641239&theme=light" alt="Stirling&#0032;PDF - Open&#0032;source&#0032;locally&#0032;hosted&#0032;web&#0032;PDF&#0032;editor | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Stirling-Tools/Stirling-PDF/tree/digitalOcean&refcode=c3210994b1af)
[Stirling-PDF](https://www.stirlingpdf.com) is a robust, locally hosted web-based PDF manipulation tool using Docker. It enables you to carry out various operations on PDF files, including splitting, merging, converting, reorganizing, adding images, rotating, compressing, and more. This locally hosted web application has evolved to encompass a comprehensive set of features, addressing all your PDF requirements.
Stirling-PDF does not initiate any outbound calls for record-keeping or tracking purposes.
All files and PDFs exist either exclusively on the client side, reside in server memory only during task execution, or temporarily reside in a file solely for the execution of the task. Any file downloaded by the user will have been deleted from the server by that point.
![stirling-home](images/stirling-home.jpg)
## Features
- Enterprise features like SSO Check [here](https://docs.stirlingpdf.com/Enterprise%20Edition)
- Enterprise features like SSO Check [here](https://docs.stirlingpdf.com/Enterprise%20Edition)
- Dark mode support
- Custom download options
- Parallel file processing and downloads
@@ -77,15 +78,15 @@ All files and PDFs exist either exclusively on the client side, reside in server
- Detect and remove blank pages
- Compare two PDFs and show differences in text
- Add images to PDFs
- Compress PDFs to decrease their filesize (using qpdf)
- Compress PDFs to decrease their filesize (using OCRMyPDF)
- Extract images from PDF
- Remove images from PDF
- Extract images from scans
- Remove annotations
- Add page numbers
- Auto rename file by detecting PDF header text
- OCR on PDF (using tesseract)
- PDF/A conversion (using libreoffice)
- OCR on PDF (using OCRMyPDF)
- PDF/A conversion (using OCRMyPDF)
- Edit metadata
- Flatten PDFs
- Get all information on a PDF to view or export as JSON
@@ -100,7 +101,7 @@ A demo of the app is available [here](https://stirlingpdf.io).
- Spring Boot + Thymeleaf
- [PDFBox](https://github.com/apache/pdfbox/tree/trunk)
- [LibreOffice](https://www.libreoffice.org/discover/libreoffice/) for advanced conversions
- [qpdf](https://github.com/qpdf/qpdf)
- [OcrMyPdf](https://github.com/ocrmypdf/OCRmyPDF)
- HTML, CSS, JavaScript
- Docker
- [PDF.js](https://github.com/mozilla/pdf.js)
@@ -119,13 +120,13 @@ Please view the [LocalRunGuide](https://github.com/Stirling-Tools/Stirling-PDF/b
### Docker / Podman
> [!NOTE]
> <https://hub.docker.com/r/stirlingtools/stirling-pdf>
> <https://hub.docker.com/r/frooodle/s-pdf>
Stirling-PDF has three different versions: a full version, an ultra-lite version, and a 'fat' version. Depending on the types of features you use, you may want a smaller image to save on space. To see what the different versions offer, please look at our [version mapping](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/Version-groups.md). For people that don't mind space optimization, just use the latest tag.
![Docker Image Size (tag)](https://img.shields.io/docker/image-size/stirlingtools/stirling-pdf/latest?label=Stirling-PDF%20Full)
![Docker Image Size (tag)](https://img.shields.io/docker/image-size/stirlingtools/stirling-pdf/latest-ultra-lite?label=Stirling-PDF%20Ultra-Lite)
![Docker Image Size (tag)](https://img.shields.io/docker/image-size/stirlingtools/stirling-pdf/latest-fat?label=Stirling-PDF%20Fat)
![Docker Image Size (tag)](https://img.shields.io/docker/image-size/frooodle/s-pdf/latest?label=Stirling-PDF%20Full)
![Docker Image Size (tag)](https://img.shields.io/docker/image-size/frooodle/s-pdf/latest-ultra-lite?label=Stirling-PDF%20Ultra-Lite)
![Docker Image Size (tag)](https://img.shields.io/docker/image-size/frooodle/s-pdf/latest-fat?label=Stirling-PDF%20Fat)
Please note in the examples below, you may need to change the volume paths as needed, e.g., `./extraConfigs:/configs` to `/opt/stirlingpdf/extraConfigs:/configs`.
@@ -143,7 +144,7 @@ docker run -d \
-e INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
-e LANGS=en_GB \
--name stirling-pdf \
stirlingtools/stirling-pdf:latest
frooodle/s-pdf:latest
```
### Docker Compose
@@ -152,7 +153,7 @@ docker run -d \
version: '3.3'
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:latest
image: frooodle/s-pdf:latest
ports:
- '8080:8080'
volumes:
@@ -185,48 +186,46 @@ Certain functionality like `Sign` supports pre-saved files stored at `/customFil
## Supported Languages
Stirling-PDF currently supports 38 languages!
Stirling-PDF currently supports 36 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![94%](https://geps.dev/progress/94) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![92%](https://geps.dev/progress/92) |
| Basque (Euskara) (eu_ES) | ![53%](https://geps.dev/progress/53) |
| Bulgarian (Български) (bg_BG) | ![89%](https://geps.dev/progress/89) |
| Catalan (Català) (ca_CA) | ![84%](https://geps.dev/progress/84) |
| Croatian (Hrvatski) (hr_HR) | ![91%](https://geps.dev/progress/91) |
| Czech (Česky) (cs_CZ) | ![90%](https://geps.dev/progress/90) |
| Danish (Dansk) (da_DK) | ![89%](https://geps.dev/progress/89) |
| Dutch (Nederlands) (nl_NL) | ![89%](https://geps.dev/progress/89) |
| Arabic (العربية) (ar_AR) | ![98%](https://geps.dev/progress/98) |
| Basque (Euskara) (eu_ES) | ![55%](https://geps.dev/progress/55) |
| Bulgarian (Български) (bg_BG) | ![97%](https://geps.dev/progress/97) |
| Catalan (Català) (ca_CA) | ![90%](https://geps.dev/progress/90) |
| Croatian (Hrvatski) (hr_HR) | ![98%](https://geps.dev/progress/98) |
| Czech (Česky) (cs_CZ) | ![98%](https://geps.dev/progress/98) |
| Danish (Dansk) (da_DK) | ![97%](https://geps.dev/progress/97) |
| Dutch (Nederlands) (nl_NL) | ![96%](https://geps.dev/progress/96) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![92%](https://geps.dev/progress/92) |
| German (Deutsch) (de_DE) | ![100%](https://geps.dev/progress/100) |
| Greek (Ελληνικά) (el_GR) | ![90%](https://geps.dev/progress/90) |
| Hindi (हिंदी) (hi_IN) | ![88%](https://geps.dev/progress/88) |
| Hungarian (Magyar) (hu_HU) | ![91%](https://geps.dev/progress/91) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![90%](https://geps.dev/progress/90) |
| Irish (Gaeilge) (ga_IE) | ![82%](https://geps.dev/progress/82) |
| Italian (Italiano) (it_IT) | ![99%](https://geps.dev/progress/99) |
| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) |
| Korean (한국어) (ko_KR) | ![89%](https://geps.dev/progress/89) |
| Norwegian (Norsk) (no_NB) | ![82%](https://geps.dev/progress/82) |
| Persian (فارسی) (fa_IR) | ![99%](https://geps.dev/progress/99) |
| Polish (Polski) (pl_PL) | ![90%](https://geps.dev/progress/90) |
| Portuguese (Português) (pt_PT) | ![90%](https://geps.dev/progress/90) |
| French (Français) (fr_FR) | ![97%](https://geps.dev/progress/97) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![98%](https://geps.dev/progress/98) |
| Hindi (हिंदी) (hi_IN) | ![95%](https://geps.dev/progress/95) |
| Hungarian (Magyar) (hu_HU) | ![98%](https://geps.dev/progress/98) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![98%](https://geps.dev/progress/98) |
| Irish (Gaeilge) (ga_IE) | ![88%](https://geps.dev/progress/88) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![86%](https://geps.dev/progress/86) |
| Korean (한국어) (ko_KR) | ![96%](https://geps.dev/progress/96) |
| Norwegian (Norsk) (no_NB) | ![88%](https://geps.dev/progress/88) |
| Polish (Polski) (pl_PL) | ![97%](https://geps.dev/progress/97) |
| Portuguese (Português) (pt_PT) | ![98%](https://geps.dev/progress/98) |
| Portuguese Brazilian (Português) (pt_BR) | ![98%](https://geps.dev/progress/98) |
| Romanian (Română) (ro_RO) | ![84%](https://geps.dev/progress/84) |
| Russian (Русский) (ru_RU) | ![90%](https://geps.dev/progress/90) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![67%](https://geps.dev/progress/67) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) |
| Slovakian (Slovensky) (sk_SK) | ![78%](https://geps.dev/progress/78) |
| Spanish (Español) (es_ES) | ![91%](https://geps.dev/progress/91) |
| Swedish (Svenska) (sv_SE) | ![90%](https://geps.dev/progress/90) |
| Thai (ไทย) (th_TH) | ![90%](https://geps.dev/progress/90) |
| Traditional Chinese (繁體中文) (zh_TW) | ![99%](https://geps.dev/progress/99) |
| Turkish (Türkçe) (tr_TR) | ![86%](https://geps.dev/progress/86) |
| Ukrainian (Українська) (uk_UA) | ![76%](https://geps.dev/progress/76) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![83%](https://geps.dev/progress/83) |
| Romanian (Română) (ro_RO) | ![90%](https://geps.dev/progress/90) |
| Russian (Русский) (ru_RU) | ![97%](https://geps.dev/progress/97) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![70%](https://geps.dev/progress/70) |
| Simplified Chinese (简体中文) (zh_CN) | ![91%](https://geps.dev/progress/91) |
| Slovakian (Slovensky) (sk_SK) | ![82%](https://geps.dev/progress/82) |
| Spanish (Español) (es_ES) | ![98%](https://geps.dev/progress/98) |
| Swedish (Svenska) (sv_SE) | ![97%](https://geps.dev/progress/97) |
| Thai (ไทย) (th_TH) | ![96%](https://geps.dev/progress/96) |
| Traditional Chinese (繁體中文) (zh_TW) | ![98%](https://geps.dev/progress/98) |
| Turkish (Türkçe) (tr_TR) | ![92%](https://geps.dev/progress/92) |
| Ukrainian (Українська) (uk_UA) | ![80%](https://geps.dev/progress/80) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![89%](https://geps.dev/progress/89) |
## Contributing (Creating Issues, Translations, Fixing Bugs, etc.)
@@ -239,7 +238,7 @@ Stirling PDF offers a Enterprise edition of its software, This is the same great
### Whats included
- Prioritised Support tickets via support@stirlingpdf.com to reach directly to Stirling-PDF team for support and 1:1 meetings where applicable (Provided they come from same email domain registered with us)
- Prioritised Enhancements to Stirling-PDF where applicable
- Prioritised Enhancements to Stirling-PDF where applicable
- Base SSO support
- Advanced SSO such as automated login handling (Coming very soon)
- SAML SSO (Coming very soon)
@@ -403,7 +402,7 @@ To access your account settings, go to Account Settings in the settings cog menu
To add new users, go to the bottom of Account Settings and hit 'Admin Settings'. Here you can add new users. The different roles mentioned within this are for rate limiting. This is a work in progress and will be expanded on more in the future.
For API usage, you must provide a header with `X-API-KEY` and the associated API key for that user.
For API usage, you must provide a header with `X-API-Key` and the associated API key for that user.
## FAQ

View File

@@ -1,63 +0,0 @@
# Security Policy
## Reporting a Vulnerability
The Stirling-PDF team takes security vulnerabilities seriously. We appreciate your efforts to responsibly disclose your findings.
### How to Report
You can report security vulnerabilities through two channels:
1. **GitHub Security Advisory**:
- Navigate to the [Security tab](https://github.com/Stirling-Tools/Stirling-PDF/security) in our repository
- Click on "Report a vulnerability"
- Provide a detailed description of the vulnerability
2. **Direct Email**:
- Send your report to security@stirlingpdf.com
- Please include as much information as possible about the vulnerability
### What to Include
When reporting a vulnerability, please provide:
- A clear description of the vulnerability
- Steps to reproduce the issue
- Any potential impact
- If possible, suggestions for addressing the vulnerability
- Your contact information for follow-up questions
### Response Time
We aim to acknowledge receipt of your vulnerability report within 48 hours
### Process
1. Submit your report through one of the channels above
2. Receive an acknowledgment from our team
3. Our team will investigate and validate the issue
4. We will work on a fix and keep you updated on our progress
5. Once resolved, we will publish the fix and acknowledge your contribution (if desired)
### Bug Bounty
At this time, we do not offer a bug bounty program. However, we greatly appreciate your efforts in making Stirling-PDF more secure and will acknowledge your contribution in our release notes (unless you prefer to remain anonymous).
## Supported Versions
Only the latest version of Stirling-PDF is supported for security updates. We do not backport security fixes to older versions.
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| Older | :x: |
**Please note:** Before reporting a security issue, ensure you are using the latest version of Stirling-PDF. Security reports for older versions will not be accepted.
## Security Best Practices
When deploying Stirling-PDF:
1. Always use the latest version
2. Follow our deployment guidelines
3. Regularly check for and apply updates

View File

@@ -8,7 +8,7 @@ The 'Fat' container contains all those found in 'Full' with security jar along w
| Libre | | ✔️ |
| Python | | ✔️ |
| OpenCV | | ✔️ |
| qpdf | | ✔️ |
| OCRmyPDF | | ✔️ |
| Operation | Ultra-Lite | Full |
| ---------------------- | ---------- | ---- |

View File

@@ -1,34 +1,28 @@
plugins {
id "java"
id "org.springframework.boot" version "3.4.1"
id "io.spring.dependency-management" version "1.1.7"
id "org.springframework.boot" version "3.3.5"
id "io.spring.dependency-management" version "1.1.6"
id "org.springdoc.openapi-gradle-plugin" version "1.8.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "edu.sc.seis.launch4j" version "3.0.6"
id "com.diffplug.spotless" version "6.25.0"
id "com.github.jk1.dependency-license-report" version "2.9"
//id "nebula.lint" version "19.0.3"
id("org.panteleyev.jpackageplugin") version "1.6.0"
}
import com.github.jk1.license.render.*
ext {
springBootVersion = "3.4.1"
springBootVersion = "3.3.5"
pdfboxVersion = "3.0.3"
logbackVersion = "1.5.7"
imageioVersion = "3.12.0"
lombokVersion = "1.18.36"
bouncycastleVersion = "1.79"
springSecuritySamlVersion = "6.4.2"
openSamlVersion = "4.3.2"
lombokVersion = "1.18.34"
bouncycastleVersion = "1.78.1"
}
group = "stirling.software"
version = "0.36.5"
version = "0.32.0"
java {
// 17 is lowest but we support and recommend 21
@@ -38,9 +32,10 @@ java {
repositories {
mavenCentral()
maven { url "https://jitpack.io" }
maven { url "https://build.shibboleth.net/maven/releases" }
maven { url "https://maven.pkg.github.com/jcefmaven/jcefmaven" }
maven { url "https://build.shibboleth.net/nexus/content/repositories/releases/" }
maven {
url 'https://build.shibboleth.net/maven/releases'
}
}
licenseReport {
@@ -64,12 +59,6 @@ sourceSets {
exclude "stirling/software/SPDF/model/User.java"
exclude "stirling/software/SPDF/repository/**"
}
if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") {
exclude "stirling/software/SPDF/UI/impl/**"
}
}
}
}
@@ -80,153 +69,16 @@ openApi {
outputFileName = "SwaggerDoc.json"
}
//0.11.5 to 2024.11.5
def getMacVersion(String version) {
def currentYear = java.time.Year.now().getValue()
def versionParts = version.split("\\.", 2)
return "${currentYear}.${versionParts.length > 1 ? versionParts[1] : versionParts[0]}"
}
jpackage {
input = "build/libs"
appName = "Stirling-PDF"
appVersion = project.version
vendor = "Stirling-Software"
appDescription = "Stirling PDF - Your Local PDF Editor"
mainJar = "Stirling-PDF-${project.version}.jar"
mainClass = "org.springframework.boot.loader.launch.JarLauncher"
icon = "src/main/resources/static/favicon.ico"
// JVM Options
javaOptions = [
"-DBROWSER_OPEN=true",
"-DSTIRLING_PDF_DESKTOP_UI=true",
"-Djava.awt.headless=false",
"-Dapple.awt.UIElement=true",
"--add-opens", "java.base/java.lang=ALL-UNNAMED",
"--add-opens", "java.desktop/java.awt.event=ALL-UNNAMED",
"--add-opens", "java.desktop/sun.awt=ALL-UNNAMED"
]
verbose = true
destination = "${projectDir}/build/jpackage"
// Windows-specific configuration
windows {
launcherAsService = false
appVersion = project.version
winConsole = false
winDirChooser = true
winMenu = true
winShortcut = true
winPerUserInstall = true
winMenuGroup = "Stirling Software"
winUpgradeUuid = "2a43ed0c-b8c2-40cf-89e1-751129b87641" // Unique identifier for updates
winHelpUrl = "https://github.com/Stirling-Tools/Stirling-PDF"
winUpdateUrl = "https://github.com/Stirling-Tools/Stirling-PDF/releases"
type = "exe"
installDir = "C:/Program Files/Stirling-PDF"
}
// macOS-specific configuration
mac {
appVersion = getMacVersion(project.version.toString())
icon = "src/main/resources/static/favicon.icns"
type = "dmg"
macPackageIdentifier = "com.stirling.software.pdf"
macPackageName = "Stirling-PDF"
macAppCategory = "public.app-category.productivity"
macSign = false // Enable signing
macAppStore = false // Not targeting App Store initially
//installDir = "Applications"
// Add license and other documentation to DMG
/*macDmgContent = [
"README.md",
"LICENSE",
"CHANGELOG.md"
]*/
// Enable Mac-specific entitlements
//macEntitlements = "entitlements.plist" // You'll need to create this file
}
// Linux-specific configuration
linux {
appVersion = project.version
icon = "src/main/resources/static/favicon.png"
type = "deb" // Can also use "rpm" for Red Hat-based systems
// Debian package configuration
//linuxPackageName = "stirlingpdf"
linuxDebMaintainer = "support@stirlingpdf.com"
linuxMenuGroup = "Office;PDF;Productivity"
linuxAppCategory = "Office"
linuxAppRelease = "1"
linuxPackageDeps = true
installDir = "/opt/Stirling-PDF"
// RPM-specific settings
//linuxRpmLicenseType = "MIT"
}
// Common additional options
//jLinkOptions = [
// "--strip-debug",
// "--compress=2",
// "--no-header-files",
// "--no-man-pages"
//]
// Add any additional modules required
/*addModules = [
"java.base",
"java.desktop",
"java.logging",
"java.sql",
"java.xml",
"jdk.crypto.ec"
]*/
// Add copyright and license information
copyright = "Copyright © 2024 Stirling Software"
licenseFile = "LICENSE"
}
launch4j {
icon = "${projectDir}/src/main/resources/static/favicon.ico"
outfile="Stirling-PDF.exe"
if(System.getenv("STIRLING_PDF_DESKTOP_UI") == 'true') {
headerType = "gui"
} else {
headerType = "console"
}
headerType="console"
jarTask = tasks.bootJar
errTitle="Encountered error, Do you have Java 21?"
downloadUrl="https://download.oracle.com/java/21/latest/jdk-21_windows-x64_bin.exe"
if(System.getenv("STIRLING_PDF_DESKTOP_UI") == 'true') {
variables=["BROWSER_OPEN=true", "STIRLING_PDF_DESKTOP_UI=true"]
} else {
variables=["BROWSER_OPEN=true"]
}
variables=["BROWSER_OPEN=true"]
jreMinVersion="17"
mutexName="Stirling-PDF"
@@ -243,7 +95,7 @@ spotless {
java {
target project.fileTree('src/main/java')
googleJavaFormat("1.25.2").aosp().reorderImports(false)
googleJavaFormat("1.22.0").aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io")
toggleOffOn()
@@ -266,17 +118,10 @@ configurations.all {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
}
dependencies {
if (System.getenv("STIRLING_PDF_DESKTOP_UI") != "false") {
implementation "me.friwi:jcefmaven:127.3.1"
implementation "org.openjfx:javafx-controls:21"
implementation "org.openjfx:javafx-swing:21"
}
//security updates
implementation "org.springframework:spring-webmvc:6.2.1"
implementation "org.springframework:spring-webmvc:6.1.14"
implementation("io.github.pixee:java-security-toolkit:1.2.1")
implementation("io.github.pixee:java-security-toolkit:1.2.0")
// implementation "org.yaml:snakeyaml:2.2"
implementation 'com.github.Carleslc.Simple-YAML:Simple-Yaml:1.8.4'
@@ -292,22 +137,22 @@ dependencies {
if (System.getenv("DOCKER_ENABLE_SECURITY") != "false") {
implementation "org.springframework.boot:spring-boot-starter-security:$springBootVersion"
implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE"
implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.2.RELEASE"
implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion"
implementation "org.springframework.boot:spring-boot-starter-oauth2-client:$springBootVersion"
implementation "org.springframework.session:spring-session-core:$springBootVersion"
implementation 'org.springframework.security:spring-security-saml2-service-provider:6.3.4'
implementation 'com.unboundid.product.scim2:scim2-sdk-client:2.3.5'
// Don't upgrade h2database
runtimeOnly "com.h2database:h2:2.3.232"
//2.2.x requires rebuild of DB file.. need migration path
runtimeOnly "com.h2database:h2:2.1.214"
// implementation "com.h2database:h2:2.2.224"
constraints {
implementation "org.opensaml:opensaml-core:$openSamlVersion"
implementation "org.opensaml:opensaml-saml-api:$openSamlVersion"
implementation "org.opensaml:opensaml-saml-impl:$openSamlVersion"
implementation "org.opensaml:opensaml-core"
implementation "org.opensaml:opensaml-saml-api"
implementation "org.opensaml:opensaml-saml-impl"
}
implementation "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
// implementation 'org.springframework.security:spring-security-core:$springSecuritySamlVersion'
implementation "org.springframework.security:spring-security-saml2-service-provider"
implementation 'com.coveo:saml-client:5.0.0'
@@ -339,7 +184,7 @@ dependencies {
// Image metadata extractor
implementation "com.drewnoakes:metadata-extractor:2.19.0"
implementation "commons-io:commons-io:2.18.0"
implementation "commons-io:commons-io:2.17.0"
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0"
//general PDF
@@ -356,19 +201,12 @@ dependencies {
exclude group: "commons-logging", module: "commons-logging"
}
// https://mvnrepository.com/artifact/technology.tabula/tabula
implementation ('technology.tabula:tabula:1.0.5') {
exclude group: "org.slf4j", module: "slf4j-simple"
exclude group: "org.bouncycastle", module: "bcprov-jdk15on"
exclude group: "com.google.code.gson", module: "gson"
}
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation "org.springframework.boot:spring-boot-starter-actuator:$springBootVersion"
implementation "io.micrometer:micrometer-core:1.14.2"
implementation "io.micrometer:micrometer-core:1.13.6"
implementation group: "com.google.zxing", name: "core", version: "3.5.3"
// https://mvnrepository.com/artifact/org.commonmark/commonmark
implementation "org.commonmark:commonmark:0.24.0"
@@ -421,14 +259,7 @@ jar {
tasks.named("test") {
useJUnitPlatform()
}
task printVersion {
doLast {
println project.version
}
}
task printMacVersion {
doLast {
println getMacVersion(project.version.toString())
}
task printVersion {
println project.version
}

View File

@@ -48,6 +48,24 @@ Feature: API Validation
And the response status code should be 200
@ocr @negative
Scenario: Process PDF with text and OCR with type normal
Given I generate a PDF file as "fileInput"
And the pdf contains 3 pages with random text
And the request data includes
| parameter | value |
| languages | eng |
| sidecar | false |
| deskew | true |
| clean | true |
| cleanFinal | true |
| ocrType | Normal |
| ocrRenderType | hocr |
| removeImagesAfter| false |
When I send the API request to the endpoint "/api/v1/misc/ocr-pdf"
Then the response status code should be 500
@ocr @positive
Scenario: Process PDF with OCR
Given I generate a PDF file as "fileInput"
@@ -65,6 +83,26 @@ Feature: API Validation
Then the response content type should be "application/pdf"
And the response file should have size greater than 0
And the response status code should be 200
@ocr @positive
Scenario: Process PDF with OCR with sidecar
Given I generate a PDF file as "fileInput"
And the request data includes
| parameter | value |
| languages | eng |
| sidecar | true |
| deskew | true |
| clean | true |
| cleanFinal | true |
| ocrType | Force |
| ocrRenderType | hocr |
| removeImagesAfter| false |
When I send the API request to the endpoint "/api/v1/misc/ocr-pdf"
Then the response content type should be "application/octet-stream"
And the response file should have extension ".zip"
And the response ZIP should contain 2 files
And the response file should have size greater than 0
And the response status code should be 200
@libre @positive
@@ -107,7 +145,7 @@ Feature: API Validation
And the response file should have extension ".pdf"
And the response file should have size greater than 100
@compress @qpdf @positive
@compress @ghostscript @positive
Scenario: Compress
Given I use an example file at "exampleFiles/ghost3.pdf" as parameter "fileInput"
And the request data includes
@@ -118,7 +156,7 @@ Feature: API Validation
And the response file should have extension ".pdf"
And the response file should have size greater than 100
@compress @qpdf @positive
@compress @ghostscript @positive
Scenario: Compress
Given I use an example file at "exampleFiles/ghost2.pdf" as parameter "fileInput"
And the request data includes
@@ -131,7 +169,7 @@ Feature: API Validation
And the response file should have size greater than 100
@compress @qpdf @positive
@compress @ghostscript @positive
Scenario: Compress
Given I use an example file at "exampleFiles/ghost1.pdf" as parameter "fileInput"
And the request data includes

View File

@@ -15,10 +15,6 @@ import shutil
import re
from PIL import Image, ImageDraw
API_HEADERS = {
'X-API-KEY': '123456789'
}
#########
# GIVEN #
#########
@@ -231,7 +227,7 @@ def save_generated_pdf(context, filename):
def step_send_get_request(context, endpoint):
base_url = "http://localhost:8080"
full_url = f"{base_url}{endpoint}"
response = requests.get(full_url, headers=API_HEADERS)
response = requests.get(full_url)
context.response = response
@when('I send a GET request to "{endpoint}" with parameters')
@@ -239,7 +235,7 @@ def step_send_get_request_with_params(context, endpoint):
base_url = "http://localhost:8080"
params = {row['parameter']: row['value'] for row in context.table}
full_url = f"{base_url}{endpoint}"
response = requests.get(full_url, params=params, headers=API_HEADERS)
response = requests.get(full_url, params=params)
context.response = response
@when('I send the API request to the endpoint "{endpoint}"')
@@ -260,7 +256,7 @@ def step_send_api_request(context, endpoint):
print(f"form_data {file.name} with {mime_type}")
form_data.append((key, (file.name, file, mime_type)))
response = requests.post(url, files=form_data, headers=API_HEADERS)
response = requests.post(url, files=form_data)
context.response = response
########

View File

@@ -1,7 +1,7 @@
services:
stirling-pdf:
container_name: Stirling-PDF-Security-Fat
image: stirlingtools/stirling-pdf:latest-fat
image: frooodle/s-pdf:latest-fat
deploy:
resources:
limits:

View File

@@ -1,7 +1,7 @@
services:
stirling-pdf:
container_name: Stirling-PDF-Security
image: stirlingtools/stirling-pdf:latest
image: frooodle/s-pdf:latest
deploy:
resources:
limits:

View File

@@ -1,7 +1,7 @@
services:
stirling-pdf:
container_name: Stirling-PDF-Security
image: stirlingtools/stirling-pdf:latest
image: frooodle/s-pdf:latest
deploy:
resources:
limits:

View File

@@ -1,7 +1,7 @@
services:
stirling-pdf:
container_name: Stirling-PDF-Ultra-Lite-Security
image: stirlingtools/stirling-pdf:latest-ultra-lite
image: frooodle/s-pdf:latest-ultra-lite
deploy:
resources:
limits:

View File

@@ -1,7 +1,7 @@
services:
stirling-pdf:
container_name: Stirling-PDF-Ultra-Lite
image: stirlingtools/stirling-pdf:latest-ultra-lite
image: frooodle/s-pdf:latest-ultra-lite
deploy:
resources:
limits:

View File

@@ -1,7 +1,7 @@
services:
stirling-pdf:
container_name: Stirling-PDF
image: stirlingtools/stirling-pdf:latest
image: frooodle/s-pdf:latest
deploy:
resources:
limits:

View File

@@ -1,34 +0,0 @@
services:
stirling-pdf:
container_name: Stirling-PDF-Security-Fat
image: stirlingtools/stirling-pdf:latest-fat
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f -H 'X-API-KEY: 123456789' http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- 8080:8080
volumes:
- /stirling/latest/data:/usr/share/tessdata:rw
- /stirling/latest/config:/configs:rw
- /stirling/latest/logs:/logs:rw
environment:
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
UMASK: "022"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest-fat with Security
UI_APPNAMENAVBAR: Stirling-PDF Latest-fat
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SECURITY_CUSTOMGLOBALAPIKEY: "123456789"
restart: on-failure:5

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -3,11 +3,6 @@ ignore = [
'language.direction',
]
[az_AZ]
ignore = [
'language.direction',
]
[bg_BG]
ignore = [
'language.direction',
@@ -37,25 +32,21 @@ ignore = [
ignore = [
'AddStampRequest.alphabet',
'AddStampRequest.position',
'home.pipeline.title'
'PDFToBook.selectText.1',
'PDFToText.tags',
'addPageNumbers.selectText.3',
'alphabet',
'certSign.name',
'fileChooser.dragAndDrop',
'home.pipeline.title',
'language.direction',
'legal.impressum',
'licenses.version',
'pipeline.title',
'pipelineOptions.pipelineHeader',
'pro',
'sponsor',
'text',
'validateSignature.cert.bits',
'validateSignature.cert.version',
'validateSignature.status',
'watermark.type.1',
'certSign.name',
]
[el_GR]
@@ -66,6 +57,7 @@ ignore = [
[es_ES]
ignore = [
'adminUserSettings.roles',
'color',
'error',
'language.direction',
'no',
@@ -77,11 +69,6 @@ ignore = [
'language.direction',
]
[fa_IR]
ignore = [
'language.direction',
]
[fr_FR]
ignore = [
'AddStampRequest.alphabet',

View File

@@ -16,10 +16,10 @@ fi
# Check if TESSERACT_LANGS environment variable is set and is not empty
if [[ -n "$TESSERACT_LANGS" ]]; then
# Convert comma-separated values to a space-separated list
SPACE_SEPARATED_LANGS=$(echo $TESSERACT_LANGS | tr ',' ' ')
LANGS=$(echo $TESSERACT_LANGS | tr ',' ' ')
pattern='^[a-zA-Z]{2,4}(_[a-zA-Z]{2,4})?$'
# Install each language pack
for LANG in $SPACE_SEPARATED_LANGS; do
for LANG in $LANGS; do
if [[ $LANG =~ $pattern ]]; then
apk add --no-cache "tesseract-ocr-data-$LANG"
else

View File

@@ -1,8 +1,8 @@
#!/bin/bash
translation_key="pdfToPDFA.credit"
old_value="qpdf"
new_value="liibreoffice"
old_value="OCRmyPDF"
new_value="ghostscript"
for file in ../src/main/resources/messages_*.properties; do
sed -i "/^$translation_key=/s/$old_value/$new_value/" "$file"

View File

@@ -3,14 +3,13 @@ package stirling.software.SPDF.EE;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Lazy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@Lazy
@Slf4j
public class EEAppConfig {

View File

@@ -25,10 +25,9 @@ public class LicenseKeyChecker {
KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties) {
this.licenseService = licenseService;
this.applicationProperties = applicationProperties;
this.checkLicense();
}
@Scheduled(initialDelay = 604800000, fixedRate = 604800000) // 7 days in milliseconds
@Scheduled(fixedRate = 604800000, initialDelay = 1000) // 7 days in milliseconds
public void checkLicensePeriodically() {
checkLicense();
}

View File

@@ -6,6 +6,9 @@ import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.pixee.security.SystemCommand;
import lombok.extern.slf4j.Slf4j;
@@ -13,6 +16,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LibreOfficeListener {
private static final Logger logger = LoggerFactory.getLogger(LibreOfficeListener.class);
private static final long ACTIVITY_TIMEOUT = 20L * 60 * 1000; // 20 minutes
private static final LibreOfficeListener INSTANCE = new LibreOfficeListener();
@@ -83,7 +87,7 @@ public class LibreOfficeListener {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("exception", e);
logger.error("exception", e);
} // Check every 1 second
}
}

View File

@@ -1,6 +1,5 @@
package stirling.software.SPDF;
import java.awt.*;
import java.io.IOException;
import java.net.ServerSocket;
import java.nio.file.Files;
@@ -9,10 +8,9 @@ import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import javax.swing.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
@@ -23,17 +21,15 @@ import org.springframework.scheduling.annotation.EnableScheduling;
import io.github.pixee.security.SystemCommand;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.UI.WebBrowser;
import stirling.software.SPDF.config.ConfigInitializer;
import stirling.software.SPDF.model.ApplicationProperties;
@SpringBootApplication
@EnableScheduling
@Slf4j
public class SPdfApplication {
private static final Logger logger = LoggerFactory.getLogger(SPdfApplication.class);
@Autowired private Environment env;
@Autowired ApplicationProperties applicationProperties;
@@ -71,19 +67,36 @@ public class SPdfApplication {
}
}
@PostConstruct
public void init() {
baseUrlStatic = this.baseUrl;
// Check if the BROWSER_OPEN environment variable is set to true
String browserOpenEnv = env.getProperty("BROWSER_OPEN");
boolean browserOpen = browserOpenEnv != null && "true".equalsIgnoreCase(browserOpenEnv);
if (browserOpen) {
try {
String url = baseUrl + ":" + getStaticPort();
String os = System.getProperty("os.name").toLowerCase();
Runtime rt = Runtime.getRuntime();
if (os.contains("win")) {
// For Windows
SystemCommand.runCommand(rt, "rundll32 url.dll,FileProtocolHandler " + url);
} else if (os.contains("mac")) {
SystemCommand.runCommand(rt, "open " + url);
} else if (os.contains("nix") || os.contains("nux")) {
SystemCommand.runCommand(rt, "xdg-open " + url);
}
} catch (Exception e) {
logger.error("Error opening browser: {}", e.getMessage());
}
}
logger.info("Running configs {}", applicationProperties.toString());
}
public static void main(String[] args) throws IOException, InterruptedException {
SpringApplication app = new SpringApplication(SPdfApplication.class);
Properties props = new Properties();
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
System.setProperty("java.awt.headless", "false");
app.setHeadless(false);
props.put("java.awt.headless", "false");
props.put("spring.main.web-application-type", "servlet");
}
app.setAdditionalProfiles("default");
app.addInitializers(new ConfigInitializer());
Map<String, String> propertyFiles = new HashMap<>();
@@ -92,7 +105,7 @@ public class SPdfApplication {
if (Files.exists(Paths.get("configs/settings.yml"))) {
propertyFiles.put("spring.config.additional-location", "file:configs/settings.yml");
} else {
log.warn("External configuration file 'configs/settings.yml' does not exist.");
logger.warn("External configuration file 'configs/settings.yml' does not exist.");
}
if (Files.exists(Paths.get("configs/custom_settings.yml"))) {
@@ -105,22 +118,16 @@ public class SPdfApplication {
"spring.config.additional-location",
existingLocation + "file:configs/custom_settings.yml");
} else {
log.warn("Custom configuration file 'configs/custom_settings.yml' does not exist.");
logger.warn("Custom configuration file 'configs/custom_settings.yml' does not exist.");
}
Properties finalProps = new Properties();
if (!propertyFiles.isEmpty()) {
finalProps.putAll(
app.setDefaultProperties(
Collections.singletonMap(
"spring.config.additional-location",
propertyFiles.get("spring.config.additional-location")));
}
if (!props.isEmpty()) {
finalProps.putAll(props);
}
app.setDefaultProperties(finalProps);
app.run(args);
// Ensure directories are created
@@ -128,56 +135,16 @@ public class SPdfApplication {
Files.createDirectories(Path.of("customFiles/static/"));
Files.createDirectories(Path.of("customFiles/templates/"));
} catch (Exception e) {
log.error("Error creating directories: {}", e.getMessage());
logger.error("Error creating directories: {}", e.getMessage());
}
printStartupLogs();
}
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
logger.info("Stirling-PDF Started.");
String url = baseUrlStatic + ":" + getStaticPort();
log.info("Navigate to {}", url);
}
@Autowired(required = false)
private WebBrowser webBrowser;
@PostConstruct
public void init() {
baseUrlStatic = this.baseUrl;
String url = baseUrl + ":" + getStaticPort();
if (webBrowser != null
&& Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
webBrowser.initWebUI(url);
} else {
String browserOpenEnv = env.getProperty("BROWSER_OPEN");
boolean browserOpen = browserOpenEnv != null && "true".equalsIgnoreCase(browserOpenEnv);
if (browserOpen) {
try {
String os = System.getProperty("os.name").toLowerCase();
Runtime rt = Runtime.getRuntime();
if (os.contains("win")) {
// For Windows
SystemCommand.runCommand(rt, "rundll32 url.dll,FileProtocolHandler " + url);
} else if (os.contains("mac")) {
SystemCommand.runCommand(rt, "open " + url);
} else if (os.contains("nix") || os.contains("nux")) {
SystemCommand.runCommand(rt, "xdg-open " + url);
}
} catch (Exception e) {
log.error("Error opening browser: {}", e.getMessage());
}
}
}
log.info("Running configs {}", applicationProperties.toString());
}
@PreDestroy
public void cleanup() {
if (webBrowser != null) {
webBrowser.cleanup();
}
logger.info("Navigate to {}", url);
}
public static String getStaticBaseUrl() {

View File

@@ -1,7 +0,0 @@
package stirling.software.SPDF.UI;
public interface WebBrowser {
void initWebUI(String url);
void cleanup();
}

View File

@@ -1,354 +0,0 @@
package stirling.software.SPDF.UI.impl;
import java.awt.AWTException;
import java.awt.BorderLayout;
import java.awt.Frame;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.event.WindowEvent;
import java.awt.event.WindowStateListener;
import java.io.File;
import java.io.InputStream;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import org.cef.CefApp;
import org.cef.CefClient;
import org.cef.CefSettings;
import org.cef.browser.CefBrowser;
import org.cef.callback.CefBeforeDownloadCallback;
import org.cef.callback.CefDownloadItem;
import org.cef.callback.CefDownloadItemCallback;
import org.cef.handler.CefDownloadHandlerAdapter;
import org.cef.handler.CefLoadHandlerAdapter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import me.friwi.jcefmaven.CefAppBuilder;
import me.friwi.jcefmaven.EnumProgress;
import me.friwi.jcefmaven.MavenCefAppHandlerAdapter;
import me.friwi.jcefmaven.impl.progress.ConsoleProgressHandler;
import stirling.software.SPDF.UI.WebBrowser;
@Component
@Slf4j
@ConditionalOnProperty(
name = "STIRLING_PDF_DESKTOP_UI",
havingValue = "true",
matchIfMissing = false)
public class DesktopBrowser implements WebBrowser {
private static CefApp cefApp;
private static CefClient client;
private static CefBrowser browser;
private static JFrame frame;
private static LoadingWindow loadingWindow;
private static volatile boolean browserInitialized = false;
private static TrayIcon trayIcon;
private static SystemTray systemTray;
public DesktopBrowser() {
SwingUtilities.invokeLater(
() -> {
loadingWindow = new LoadingWindow(null, "Initializing...");
loadingWindow.setVisible(true);
});
}
public void initWebUI(String url) {
CompletableFuture.runAsync(
() -> {
try {
CefAppBuilder builder = new CefAppBuilder();
configureCefSettings(builder);
builder.setProgressHandler(createProgressHandler());
// Build and initialize CEF
cefApp = builder.build();
client = cefApp.createClient();
// Set up download handler
setupDownloadHandler();
// Create browser and frame on EDT
SwingUtilities.invokeAndWait(
() -> {
browser = client.createBrowser(url, false, false);
setupMainFrame();
setupLoadHandler();
// Show the frame immediately but transparent
frame.setVisible(true);
});
} catch (Exception e) {
log.error("Error initializing JCEF browser: ", e);
cleanup();
}
});
}
private void configureCefSettings(CefAppBuilder builder) {
CefSettings settings = builder.getCefSettings();
settings.cache_path = new File("jcef-bundle").getAbsolutePath();
settings.root_cache_path = new File("jcef-bundle").getAbsolutePath();
settings.persist_session_cookies = true;
settings.windowless_rendering_enabled = false;
settings.log_severity = CefSettings.LogSeverity.LOGSEVERITY_INFO;
builder.setAppHandler(
new MavenCefAppHandlerAdapter() {
@Override
public void stateHasChanged(org.cef.CefApp.CefAppState state) {
log.info("CEF state changed: " + state);
if (state == CefApp.CefAppState.TERMINATED) {
System.exit(0);
}
}
});
}
private void setupDownloadHandler() {
client.addDownloadHandler(
new CefDownloadHandlerAdapter() {
@Override
public boolean onBeforeDownload(
CefBrowser browser,
CefDownloadItem downloadItem,
String suggestedName,
CefBeforeDownloadCallback callback) {
callback.Continue("", true);
return true;
}
@Override
public void onDownloadUpdated(
CefBrowser browser,
CefDownloadItem downloadItem,
CefDownloadItemCallback callback) {
if (downloadItem.isComplete()) {
log.info("Download completed: " + downloadItem.getFullPath());
} else if (downloadItem.isCanceled()) {
log.info("Download canceled: " + downloadItem.getFullPath());
}
}
});
}
private ConsoleProgressHandler createProgressHandler() {
return new ConsoleProgressHandler() {
@Override
public void handleProgress(EnumProgress state, float percent) {
Objects.requireNonNull(state, "state cannot be null");
SwingUtilities.invokeLater(
() -> {
if (loadingWindow != null) {
switch (state) {
case LOCATING:
loadingWindow.setStatus("Locating Files...");
loadingWindow.setProgress(0);
break;
case DOWNLOADING:
if (percent >= 0) {
loadingWindow.setStatus(
String.format(
"Downloading additional files: %.0f%%",
percent));
loadingWindow.setProgress((int) percent);
}
break;
case EXTRACTING:
loadingWindow.setStatus("Extracting files...");
loadingWindow.setProgress(60);
break;
case INITIALIZING:
loadingWindow.setStatus("Initializing UI...");
loadingWindow.setProgress(80);
break;
case INITIALIZED:
loadingWindow.setStatus("Finalising startup...");
loadingWindow.setProgress(90);
break;
}
}
});
}
};
}
private void setupMainFrame() {
frame = new JFrame("Stirling-PDF");
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
frame.setUndecorated(true);
frame.setOpacity(0.0f);
JPanel contentPane = new JPanel(new BorderLayout());
contentPane.setDoubleBuffered(true);
contentPane.add(browser.getUIComponent(), BorderLayout.CENTER);
frame.setContentPane(contentPane);
frame.addWindowListener(
new java.awt.event.WindowAdapter() {
@Override
public void windowClosing(java.awt.event.WindowEvent windowEvent) {
cleanup();
System.exit(0);
}
});
frame.setSize(1280, 768);
frame.setLocationRelativeTo(null);
loadIcon();
}
private void setupLoadHandler() {
client.addLoadHandler(
new CefLoadHandlerAdapter() {
@Override
public void onLoadingStateChange(
CefBrowser browser,
boolean isLoading,
boolean canGoBack,
boolean canGoForward) {
if (!isLoading && !browserInitialized) {
browserInitialized = true;
SwingUtilities.invokeLater(
() -> {
if (loadingWindow != null) {
Timer timer =
new Timer(
500,
e -> {
loadingWindow.dispose();
loadingWindow = null;
frame.dispose();
frame.setOpacity(1.0f);
frame.setUndecorated(false);
frame.pack();
frame.setSize(1280, 800);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
frame.requestFocus();
frame.toFront();
browser.getUIComponent()
.requestFocus();
});
timer.setRepeats(false);
timer.start();
}
});
}
}
});
}
private void setupTrayIcon(Image icon) {
if (!SystemTray.isSupported()) {
log.warn("System tray is not supported");
return;
}
try {
systemTray = SystemTray.getSystemTray();
// Create popup menu
PopupMenu popup = new PopupMenu();
// Create menu items
MenuItem showItem = new MenuItem("Show");
showItem.addActionListener(
e -> {
frame.setVisible(true);
frame.setState(Frame.NORMAL);
});
MenuItem exitItem = new MenuItem("Exit");
exitItem.addActionListener(
e -> {
cleanup();
System.exit(0);
});
// Add menu items to popup menu
popup.add(showItem);
popup.addSeparator();
popup.add(exitItem);
// Create tray icon
trayIcon = new TrayIcon(icon, "Stirling-PDF", popup);
trayIcon.setImageAutoSize(true);
// Add double-click behavior
trayIcon.addActionListener(
e -> {
frame.setVisible(true);
frame.setState(Frame.NORMAL);
});
// Add tray icon to system tray
systemTray.add(trayIcon);
// Modify frame behavior to minimize to tray
frame.addWindowStateListener(
new WindowStateListener() {
public void windowStateChanged(WindowEvent e) {
if (e.getNewState() == Frame.ICONIFIED) {
frame.setVisible(false);
}
}
});
} catch (AWTException e) {
log.error("Error setting up system tray icon", e);
}
}
private void loadIcon() {
try {
Image icon = null;
String[] iconPaths = {"/static/favicon.ico"};
for (String path : iconPaths) {
if (icon != null) break;
try {
try (InputStream is = getClass().getResourceAsStream(path)) {
if (is != null) {
icon = ImageIO.read(is);
break;
}
}
} catch (Exception e) {
log.debug("Could not load icon from " + path, e);
}
}
if (icon != null) {
frame.setIconImage(icon);
setupTrayIcon(icon);
} else {
log.warn("Could not load icon from any source");
}
} catch (Exception e) {
log.error("Error loading icon", e);
}
}
@PreDestroy
public void cleanup() {
if (browser != null) browser.close(true);
if (client != null) client.dispose();
if (cefApp != null) cefApp.dispose();
if (loadingWindow != null) loadingWindow.dispose();
}
}

View File

@@ -1,114 +0,0 @@
package stirling.software.SPDF.UI.impl;
import java.awt.*;
import java.io.InputStream;
import javax.imageio.ImageIO;
import javax.swing.*;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LoadingWindow extends JDialog {
private final JProgressBar progressBar;
private final JLabel statusLabel;
private final JPanel mainPanel;
private final JLabel brandLabel;
public LoadingWindow(Frame parent, String initialUrl) {
super(parent, "Initializing Stirling-PDF", true);
// Initialize components
mainPanel = new JPanel();
mainPanel.setBackground(Color.WHITE);
mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 30, 20, 30));
mainPanel.setLayout(new GridBagLayout());
GridBagConstraints gbc = new GridBagConstraints();
// Configure GridBagConstraints
gbc.gridwidth = GridBagConstraints.REMAINDER;
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.insets = new Insets(5, 5, 5, 5);
gbc.weightx = 1.0; // Add horizontal weight
gbc.weighty = 0.0; // Add vertical weight
// Add icon
try {
try (InputStream is = getClass().getResourceAsStream("/static/favicon.ico")) {
if (is != null) {
Image img = ImageIO.read(is);
if (img != null) {
Image scaledImg = img.getScaledInstance(48, 48, Image.SCALE_SMOOTH);
JLabel iconLabel = new JLabel(new ImageIcon(scaledImg));
iconLabel.setHorizontalAlignment(SwingConstants.CENTER);
gbc.gridy = 0;
mainPanel.add(iconLabel, gbc);
}
}
}
} catch (Exception e) {
log.error("Failed to load icon", e);
}
// URL Label with explicit size
brandLabel = new JLabel(initialUrl);
brandLabel.setHorizontalAlignment(SwingConstants.CENTER);
brandLabel.setPreferredSize(new Dimension(300, 25));
brandLabel.setText("Stirling-PDF");
gbc.gridy = 1;
mainPanel.add(brandLabel, gbc);
// Status label with explicit size
statusLabel = new JLabel("Initializing...");
statusLabel.setHorizontalAlignment(SwingConstants.CENTER);
statusLabel.setPreferredSize(new Dimension(300, 25));
gbc.gridy = 2;
mainPanel.add(statusLabel, gbc);
// Progress bar with explicit size
progressBar = new JProgressBar(0, 100);
progressBar.setStringPainted(true);
progressBar.setPreferredSize(new Dimension(300, 25));
gbc.gridy = 3;
mainPanel.add(progressBar, gbc);
// Set dialog properties
setContentPane(mainPanel);
setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
setResizable(false);
setUndecorated(false);
// Set size and position
setSize(400, 200);
setLocationRelativeTo(parent);
setAlwaysOnTop(true);
setProgress(0);
setStatus("Starting...");
}
public void setProgress(final int progress) {
SwingUtilities.invokeLater(
() -> {
try {
progressBar.setValue(Math.min(Math.max(progress, 0), 100));
progressBar.setString(progress + "%");
mainPanel.revalidate();
mainPanel.repaint();
} catch (Exception e) {
log.error("Error updating progress", e);
}
});
}
public void setStatus(final String status) {
log.info(status);
SwingUtilities.invokeLater(
() -> {
try {
statusLabel.setText(status != null ? status : "");
mainPanel.revalidate();
mainPanel.repaint();
} catch (Exception e) {
log.error("Error updating status", e);
}
});
}
}

View File

@@ -7,6 +7,8 @@ import java.nio.file.Paths;
import java.util.Properties;
import java.util.function.Predicate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -19,14 +21,14 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.thymeleaf.spring6.SpringTemplateEngine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties;
@Configuration
@Lazy
@Slf4j
public class AppConfig {
private static final Logger logger = LoggerFactory.getLogger(AppConfig.class);
@Autowired ApplicationProperties applicationProperties;
@Bean
@@ -59,7 +61,7 @@ public class AppConfig {
props.load(resource.getInputStream());
return props.getProperty("version");
} catch (IOException e) {
log.error("exception", e);
logger.error("exception", e);
}
return "0.0.0";
}
@@ -99,27 +101,6 @@ public class AppConfig {
return Files.exists(Paths.get("/.dockerenv"));
}
@Bean(name = "configDirMounted")
public boolean isRunningInDockerWithConfig() {
Path dockerEnv = Paths.get("/.dockerenv");
// default to true if not docker
if (!Files.exists(dockerEnv)) {
return true;
}
Path mountInfo = Paths.get("/proc/1/mountinfo");
// this should always exist, if not some unknown usecase
if (!Files.exists(mountInfo)) {
return true;
}
try {
return Files.lines(mountInfo).anyMatch(line -> line.contains(" /configs "));
} catch (IOException e) {
return false;
}
}
@Bean(name = "bookAndHtmlFormatsInstalled")
public boolean bookAndHtmlFormatsInstalled() {
String installOps = System.getProperty("INSTALL_BOOK_AND_ADVANCED_HTML_OPS");

View File

@@ -16,15 +16,16 @@ import org.simpleyaml.configuration.comments.CommentType;
import org.simpleyaml.configuration.file.YamlFile;
import org.simpleyaml.configuration.implementation.SimpleYamlImplementation;
import org.simpleyaml.configuration.implementation.snakeyaml.lib.DumperOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ConfigInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final Logger logger = LoggerFactory.getLogger(ConfigInitializer.class);
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
try {
@@ -148,7 +149,7 @@ public class ConfigInitializer
.commentSide(settingsTemplateFile.getComment(path, CommentType.SIDE));
} else {
// Log if the key is not found in both YAML files
log.info("Key not found in both YAML files: " + path);
logger.info("Key not found in both YAML files: " + path);
}
}
}

View File

@@ -7,19 +7,19 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties;
@Service
@Slf4j
@DependsOn({"bookAndHtmlFormatsInstalled"})
public class EndpointConfiguration {
private static final Logger logger = LoggerFactory.getLogger(EndpointConfiguration.class);
private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
@@ -43,7 +43,7 @@ public class EndpointConfiguration {
public void disableEndpoint(String endpoint) {
if (!endpointStatuses.containsKey(endpoint) || endpointStatuses.get(endpoint) != false) {
log.debug("Disabling {}", endpoint);
logger.debug("Disabling {}", endpoint);
endpointStatuses.put(endpoint, false);
}
}
@@ -87,7 +87,7 @@ public class EndpointConfiguration {
.collect(Collectors.toList());
if (!disabledList.isEmpty()) {
log.info(
logger.info(
"Total disabled endpoints: {}. Disabled endpoints: {}",
disabledList.size(),
String.join(", ", disabledList));
@@ -188,7 +188,7 @@ public class EndpointConfiguration {
addEndpointToGroup("OpenCV", "extract-image-scans");
// LibreOffice
addEndpointToGroup("qpdf", "repair");
addEndpointToGroup("LibreOffice", "repair");
addEndpointToGroup("LibreOffice", "file-to-pdf");
addEndpointToGroup("LibreOffice", "pdf-to-word");
addEndpointToGroup("LibreOffice", "pdf-to-presentation");
@@ -199,11 +199,10 @@ public class EndpointConfiguration {
// Unoconv
addEndpointToGroup("Unoconv", "file-to-pdf");
// qpdf
addEndpointToGroup("qpdf", "compress-pdf");
addEndpointToGroup("qpdf", "pdf-to-pdfa");
addEndpointToGroup("tesseract", "ocr-pdf");
// OCRmyPDF
addEndpointToGroup("OCRmyPDF", "compress-pdf");
addEndpointToGroup("OCRmyPDF", "pdf-to-pdfa");
addEndpointToGroup("OCRmyPDF", "ocr-pdf");
// Java
addEndpointToGroup("Java", "merge-pdfs");
@@ -249,10 +248,10 @@ public class EndpointConfiguration {
addEndpointToGroup("Javascript", "compare");
addEndpointToGroup("Javascript", "adjust-contrast");
// qpdf dependent endpoints
addEndpointToGroup("qpdf", "compress-pdf");
addEndpointToGroup("qpdf", "pdf-to-pdfa");
addEndpointToGroup("qpdf", "repair");
// Ghostscript dependent endpoints
addEndpointToGroup("Ghostscript", "compress-pdf");
addEndpointToGroup("Ghostscript", "pdf-to-pdfa");
addEndpointToGroup("Ghostscript", "repair");
// Weasyprint dependent endpoints
addEndpointToGroup("Weasyprint", "html-to-pdf");
@@ -260,9 +259,6 @@ public class EndpointConfiguration {
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
// disabled for now while we resolve issues
disableEndpoint("pdf-to-pdfa");
}
private void processEnvironmentConfigs() {

View File

@@ -37,12 +37,12 @@ public class ExternalAppDepConfig {
private final Map<String, List<String>> commandToGroupMapping =
new HashMap<>() {
{
put("gs", List.of("Ghostscript"));
put("soffice", List.of("LibreOffice"));
put("ocrmypdf", List.of("OCRmyPDF"));
put("weasyprint", List.of("Weasyprint"));
put("pdftohtml", List.of("Pdftohtml"));
put("unoconv", List.of("Unoconv"));
put("qpdf", List.of("qpdf"));
put("tesseract", List.of("tesseract"));
}
};
@@ -97,9 +97,9 @@ public class ExternalAppDepConfig {
public void checkDependencies() {
// Check core dependencies
checkDependencyAndDisableGroup("tesseract");
checkDependencyAndDisableGroup("gs");
checkDependencyAndDisableGroup("soffice");
checkDependencyAndDisableGroup("qpdf");
checkDependencyAndDisableGroup("ocrmypdf");
checkDependencyAndDisableGroup("weasyprint");
checkDependencyAndDisableGroup("pdftohtml");
checkDependencyAndDisableGroup("unoconv");

View File

@@ -1,14 +1,11 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import java.util.Properties;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import io.micrometer.common.util.StringUtils;
@@ -26,18 +23,6 @@ public class InitialSetup {
@Autowired private ApplicationProperties applicationProperties;
@PostConstruct
public void init() throws IOException {
initUUIDKey();
initSecretKey();
initEnableCSRFSecurity();
initLegalUrls();
initSetAppVersion();
}
public void initUUIDKey() throws IOException {
String uuid = applicationProperties.getAutomaticallyGenerated().getUUID();
if (!GeneralUtils.isValidUUID(uuid)) {
@@ -47,6 +32,7 @@ public class InitialSetup {
}
}
@PostConstruct
public void initSecretKey() throws IOException {
String secretKey = applicationProperties.getAutomaticallyGenerated().getKey();
if (!GeneralUtils.isValidUUID(secretKey)) {
@@ -56,24 +42,13 @@ public class InitialSetup {
}
}
public void initEnableCSRFSecurity() throws IOException {
if (GeneralUtils.isVersionHigher(
"0.36.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
if (!csrf) {
GeneralUtils.saveKeyToConfig("security.csrfDisabled", false, false);
GeneralUtils.saveKeyToConfig("system.enableAnalytics", "true", false);
applicationProperties.getSecurity().setCsrfDisabled(false);
}
}
}
@PostConstruct
public void initLegalUrls() throws IOException {
// Initialize Terms and Conditions
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
if (StringUtils.isEmpty(termsUrl)) {
String defaultTermsUrl = "https://www.stirlingpdf.com/terms-and-conditions";
GeneralUtils.saveKeyToConfig("legal.termsAndConditions", defaultTermsUrl, false);
GeneralUtils.saveKeyToConfig("legal.termsAndConditions", defaultTermsUrl);
applicationProperties.getLegal().setTermsAndConditions(defaultTermsUrl);
}
@@ -81,23 +56,8 @@ public class InitialSetup {
String privacyUrl = applicationProperties.getLegal().getPrivacyPolicy();
if (StringUtils.isEmpty(privacyUrl)) {
String defaultPrivacyUrl = "https://www.stirlingpdf.com/privacy-policy";
GeneralUtils.saveKeyToConfig("legal.privacyPolicy", defaultPrivacyUrl, false);
GeneralUtils.saveKeyToConfig("legal.privacyPolicy", defaultPrivacyUrl);
applicationProperties.getLegal().setPrivacyPolicy(defaultPrivacyUrl);
}
}
public void initSetAppVersion() throws IOException {
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
try {
props.load(resource.getInputStream());
appVersion = props.getProperty("version");
} catch (Exception e) {
}
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
GeneralUtils.saveKeyToConfig("AutomaticallyGenerated.appVersion", appVersion, false);
}
}

View File

@@ -6,7 +6,6 @@ import java.util.List;
import stirling.software.SPDF.utils.FileInfo;
public interface DatabaseBackupInterface {
void exportDatabase() throws IOException;
boolean importDatabase();

View File

@@ -30,7 +30,6 @@ public class InitialSecuritySetup {
initializeAdminUser();
} else {
databaseBackupHelper.exportDatabase();
userService.migrateOauth2ToSSO();
}
initializeInternalApiUser();
}
@@ -75,6 +74,5 @@ public class InitialSecuritySetup {
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
log.info("Internal API user created: " + Role.INTERNAL_API_USER.getRoleId());
}
userService.syncCustomApiUser(applicationProperties.getSecurity().getCustomGlobalAPIKey());
}
}

View File

@@ -3,16 +3,14 @@ package stirling.software.SPDF.config.security;
import java.security.cert.X509Certificate;
import java.util.*;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -34,8 +32,7 @@ import org.springframework.security.saml2.provider.service.authentication.OpenSa
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
@@ -44,7 +41,6 @@ import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.SPDF.config.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
@@ -68,7 +64,6 @@ import stirling.software.SPDF.repository.JPATokenRepositoryImpl;
@EnableWebSecurity
@EnableMethodSecurity
@Slf4j
@DependsOn("runningEE")
public class SecurityConfiguration {
@Autowired private CustomUserDetailsService userDetailsService;
@@ -84,10 +79,6 @@ public class SecurityConfiguration {
@Qualifier("loginEnabled")
public boolean loginEnabledValue;
@Autowired
@Qualifier("runningEE")
public boolean runningEE;
@Autowired ApplicationProperties applicationProperties;
@Autowired private UserAuthenticationFilter userAuthenticationFilter;
@@ -99,14 +90,13 @@ public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) {
http.csrf(csrf -> csrf.disable());
}
if (loginEnabledValue) {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
if (!applicationProperties.getSecurity().getCsrfDisabled()) {
if (applicationProperties.getSecurity().getCsrfDisabled()) {
http.csrf(csrf -> csrf.disable());
} else {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
@@ -116,7 +106,7 @@ public class SecurityConfiguration {
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String apiKey = request.getHeader("X-API-KEY");
String apiKey = request.getHeader("X-API-Key");
// If there's no API key, don't ignore CSRF
// (return false)
@@ -255,22 +245,12 @@ public class SecurityConfiguration {
}
// Handle SAML
if (applicationProperties.getSecurity().isSaml2Activ()) { // && runningEE
// Configure the authentication provider
OpenSaml4AuthenticationProvider authenticationProvider =
new OpenSaml4AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(
new CustomSaml2ResponseAuthenticationConverter(userService));
http.authenticationProvider(authenticationProvider)
.saml2Login(
saml2 -> {
try {
if (applicationProperties.getSecurity().isSaml2Activ()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()) {
http.authenticationProvider(samlAuthenticationProvider());
http.saml2Login(
saml2 ->
saml2.loginPage("/saml2")
.relyingPartyRegistrationRepository(
relyingPartyRegistrations())
.authenticationManager(
new ProviderManager(authenticationProvider))
.successHandler(
new CustomSaml2AuthenticationSuccessHandler(
loginAttemptService,
@@ -278,34 +258,44 @@ public class SecurityConfiguration {
userService))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
authenticationRequestResolver(
relyingPartyRegistrations()));
} catch (Exception e) {
log.error("Error configuring SAML2 login", e);
throw new RuntimeException(e);
}
});
.permitAll())
.addFilterBefore(
userAuthenticationFilter, Saml2WebSsoAuthenticationFilter.class);
}
} else {
// if (!applicationProperties.getSecurity().getCsrfDisabled()) {
// CookieCsrfTokenRepository cookieRepo =
// CookieCsrfTokenRepository.withHttpOnlyFalse();
// CsrfTokenRequestAttributeHandler requestHandler =
// new CsrfTokenRequestAttributeHandler();
// requestHandler.setCsrfRequestAttributeName(null);
// http.csrf(
// csrf ->
// csrf.csrfTokenRepository(cookieRepo)
// .csrfTokenRequestHandler(requestHandler));
// }
if (applicationProperties.getSecurity().getCsrfDisabled()) {
http.csrf(csrf -> csrf.disable());
} else {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf ->
csrf.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler));
}
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
}
return http.build();
}
@Bean
@ConditionalOnProperty(
name = "security.saml2.enabled",
havingValue = "true",
matchIfMissing = false)
public AuthenticationProvider samlAuthenticationProvider() {
OpenSaml4AuthenticationProvider authenticationProvider =
new OpenSaml4AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(
new CustomSaml2ResponseAuthenticationConverter(userService));
return authenticationProvider;
}
// Client Registration Repository for OAUTH2 OIDC Login
@Bean
@ConditionalOnProperty(
value = "security.oauth2.enabled",
@@ -442,12 +432,11 @@ public class SecurityConfiguration {
havingValue = "true",
matchIfMissing = false)
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getidpCert());
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert();
Saml2X509Credential signingCredential =
@@ -456,97 +445,26 @@ public class SecurityConfiguration {
CertificateUtils.readCertificate(certificateResource),
Saml2X509CredentialType.SIGNING);
X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getidpCert());
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential))
.assertingPartyMetadata(
metadata ->
metadata.entityId(samlConf.getIdpIssuer())
.signingX509Credentials((c) -> c.add(signingCredential))
.assertingPartyDetails(
(details) ->
details.entityId(samlConf.getIdpIssuer())
.singleSignOnServiceLocation(
samlConf.getIdpSingleLoginUrl())
.verificationX509Credentials(
c -> c.add(verificationCredential))
.singleSignOnServiceBinding(
Saml2MessageBinding.POST)
(c) -> c.add(verificationCredential))
.wantAuthnRequestsSigned(true))
.build();
return new InMemoryRelyingPartyRegistrationRepository(rp);
}
@Bean
@ConditionalOnProperty(
name = "security.saml2.enabled",
havingValue = "true",
matchIfMissing = false)
public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver(
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
OpenSaml4AuthenticationRequestResolver resolver =
new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository);
resolver.setAuthnRequestCustomizer(
customizer -> {
log.debug("Customizing SAML Authentication request");
AuthnRequest authnRequest = customizer.getAuthnRequest();
log.debug("AuthnRequest ID: {}", authnRequest.getID());
if (authnRequest.getID() == null) {
authnRequest.setID("ARQ" + UUID.randomUUID().toString());
}
log.debug("AuthnRequest new ID after set: {}", authnRequest.getID());
log.debug("AuthnRequest IssueInstant: {}", authnRequest.getIssueInstant());
log.debug(
"AuthnRequest Issuer: {}",
authnRequest.getIssuer() != null
? authnRequest.getIssuer().getValue()
: "null");
HttpServletRequest request = customizer.getRequest();
// Log HTTP request details
log.debug("HTTP Request Method: {}", request.getMethod());
log.debug("Request URI: {}", request.getRequestURI());
log.debug("Request URL: {}", request.getRequestURL().toString());
log.debug("Query String: {}", request.getQueryString());
log.debug("Remote Address: {}", request.getRemoteAddr());
// Log headers
Collections.list(request.getHeaderNames())
.forEach(
headerName -> {
log.debug(
"Header - {}: {}",
headerName,
request.getHeader(headerName));
});
// Log SAML specific parameters
log.debug("SAML Request Parameters:");
log.debug("SAMLRequest: {}", request.getParameter("SAMLRequest"));
log.debug("RelayState: {}", request.getParameter("RelayState"));
// Log session debugrmation if exists
if (request.getSession(false) != null) {
log.debug("Session ID: {}", request.getSession().getId());
}
// Log any assertions consumer service details if present
if (authnRequest.getAssertionConsumerServiceURL() != null) {
log.debug(
"AssertionConsumerServiceURL: {}",
authnRequest.getAssertionConsumerServiceURL());
}
// Log NameID policy if present
if (authnRequest.getNameIDPolicy() != null) {
log.debug(
"NameIDPolicy Format: {}",
authnRequest.getNameIDPolicy().getFormat());
}
});
return resolver;
}
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);

View File

@@ -71,7 +71,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
// Check for API key in the request headers if no authentication exists
if (authentication == null || !authentication.isAuthenticated()) {
String apiKey = request.getHeader("X-API-KEY");
String apiKey = request.getHeader("X-API-Key");
if (apiKey != null && !apiKey.trim().isEmpty()) {
try {
// Use API key to authenticate. This requires you to have an authentication

View File

@@ -59,7 +59,7 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
String identifier = null;
// Check for API key in the request headers
String apiKey = request.getHeader("X-API-KEY");
String apiKey = request.getHeader("X-API-Key");
if (apiKey != null && !apiKey.trim().isEmpty()) {
identifier =
"API_KEY_" + apiKey; // Prefix to distinguish between API keys and usernames
@@ -79,7 +79,7 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
Role userRole =
getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication());
if (request.getHeader("X-API-KEY") != null) {
if (request.getHeader("X-API-Key") != null) {
// It's an API call
processRequest(
userRole.getApiCallsPerDay(),

View File

@@ -18,7 +18,6 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.interfaces.DatabaseBackupInterface;
@@ -51,19 +50,8 @@ public class UserService implements UserServiceInterface {
@Autowired ApplicationProperties applicationProperties;
@Transactional
public void migrateOauth2ToSSO() {
userRepository
.findByAuthenticationTypeIgnoreCase("OAUTH2")
.forEach(
user -> {
user.setAuthenticationType(AuthenticationType.SSO);
userRepository.save(user);
});
}
// Handle OAUTH2 login and user auto creation.
public boolean processSSOPostLogin(String username, boolean autoCreateUser)
public boolean processOAuth2PostLogin(String username, boolean autoCreateUser)
throws IllegalArgumentException, IOException {
if (!isUsernameValid(username)) {
return false;
@@ -73,7 +61,7 @@ public class UserService implements UserServiceInterface {
return true;
}
if (autoCreateUser) {
saveUser(username, AuthenticationType.SSO);
saveUser(username, AuthenticationType.OAUTH2);
return true;
}
return false;
@@ -390,37 +378,6 @@ public class UserService implements UserServiceInterface {
}
}
@Transactional
public void syncCustomApiUser(String customApiKey) throws IOException {
if (customApiKey == null || customApiKey.trim().length() == 0) {
return;
}
String username = "CUSTOM_API_USER";
Optional<User> existingUser = findByUsernameIgnoreCase(username);
if (!existingUser.isPresent()) {
// Create new user with API role
User user = new User();
user.setUsername(username);
user.setPassword(UUID.randomUUID().toString());
user.setEnabled(true);
user.setFirstLogin(false);
user.setAuthenticationType(AuthenticationType.WEB);
user.setApiKey(customApiKey);
user.addAuthority(new Authority(Role.INTERNAL_API_USER.getRoleId(), user));
userRepository.save(user);
databaseBackupHelper.exportDatabase();
} else {
// Update API key if it has changed
User user = existingUser.get();
if (!customApiKey.equals(user.getApiKey())) {
user.setApiKey(customApiKey);
userRepository.save(user);
databaseBackupHelper.exportDatabase();
}
}
}
@Override
public long getTotalUsersCount() {
return userRepository.count();

View File

@@ -34,12 +34,6 @@ public class DatabaseBackupHelper implements DatabaseBackupInterface {
@Value("${spring.datasource.url}")
private String url;
@Value("${spring.datasource.username}")
private String databaseUsername;
@Value("${spring.datasource.password}")
private String databasePassword;
private Path backupPath = Paths.get("configs/db/backup/");
@Override
@@ -140,8 +134,7 @@ public class DatabaseBackupHelper implements DatabaseBackupInterface {
this.getBackupFilePath("backup_" + dateNow.format(myFormatObj) + ".sql");
String query = "SCRIPT SIMPLE COLUMNS DROP to ?;";
try (Connection conn =
DriverManager.getConnection(url, databaseUsername, databasePassword);
try (Connection conn = DriverManager.getConnection(url, "sa", "");
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, insertOutputFilePath.toString());
stmt.execute();
@@ -154,8 +147,7 @@ public class DatabaseBackupHelper implements DatabaseBackupInterface {
// Retrieves the H2 database version.
public String getH2Version() {
String version = "Unknown";
try (Connection conn =
DriverManager.getConnection(url, databaseUsername, databasePassword)) {
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
try (Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT H2VERSION() AS version")) {
if (rs.next()) {
@@ -197,8 +189,7 @@ public class DatabaseBackupHelper implements DatabaseBackupInterface {
private boolean executeDatabaseScript(Path scriptPath) {
String query = "RUNSCRIPT from ?;";
try (Connection conn =
DriverManager.getConnection(url, databaseUsername, databasePassword);
try (Connection conn = DriverManager.getConnection(url, "sa", "");
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, scriptPath.toString());
stmt.execute();

View File

@@ -82,7 +82,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
}
if (userService.usernameExistsIgnoreCase(username)
&& userService.hasPassword(username)
&& !userService.isAuthenticationTypeByUsername(username, AuthenticationType.SSO)
&& !userService.isAuthenticationTypeByUsername(
username, AuthenticationType.OAUTH2)
&& oAuth.getAutoCreateUser()) {
response.sendRedirect(contextPath + "/logout?oauth2AuthenticationErrorWeb=true");
return;
@@ -94,7 +95,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
return;
}
if (principal instanceof OAuth2User) {
userService.processSSOPostLogin(username, oAuth.getAutoCreateUser());
userService.processOAuth2PostLogin(username, oAuth.getAutoCreateUser());
}
response.sendRedirect(contextPath + "/");
return;

View File

@@ -2,6 +2,8 @@ package stirling.software.SPDF.config.security.oauth2;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
@@ -11,7 +13,6 @@ import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.LoginAttemptService;
import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.model.ApplicationProperties;
@@ -19,7 +20,6 @@ import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2.Client;
import stirling.software.SPDF.model.User;
@Slf4j
public class CustomOAuth2UserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
private final OidcUserService delegate = new OidcUserService();
@@ -30,6 +30,8 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
private ApplicationProperties applicationProperties;
private static final Logger logger = LoggerFactory.getLogger(CustomOAuth2UserService.class);
public CustomOAuth2UserService(
ApplicationProperties applicationProperties,
UserService userService,
@@ -80,10 +82,10 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
user.getUserInfo(),
usernameAttribute);
} catch (IllegalArgumentException e) {
log.error("Error loading OIDC user: {}", e.getMessage());
logger.error("Error loading OIDC user: {}", e.getMessage());
throw new OAuth2AuthenticationException(new OAuth2Error(e.getMessage()), e);
} catch (Exception e) {
log.error("Unexpected error loading OIDC user", e);
logger.error("Unexpected error loading OIDC user", e);
throw new OAuth2AuthenticationException("Unexpected error during authentication");
}
}

View File

@@ -1,20 +1,25 @@
package stirling.software.SPDF.config.security.saml2;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Scanner;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemReader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CertificateUtils {
public static X509Certificate readCertificate(Resource certificateResource) throws Exception {
@@ -30,26 +35,95 @@ public class CertificateUtils {
}
public static RSAPrivateKey readPrivateKey(Resource privateKeyResource) throws Exception {
try (PEMParser pemParser =
new PEMParser(
try (PemReader pemReader =
new PemReader(
new InputStreamReader(
privateKeyResource.getInputStream(), StandardCharsets.UTF_8))) {
Object object = pemParser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
if (object instanceof PEMKeyPair) {
// Handle traditional RSA private key format
PEMKeyPair keypair = (PEMKeyPair) object;
return (RSAPrivateKey) converter.getPrivateKey(keypair.getPrivateKeyInfo());
} else if (object instanceof PrivateKeyInfo) {
// Handle PKCS#8 format
return (RSAPrivateKey) converter.getPrivateKey((PrivateKeyInfo) object);
} else {
throw new IllegalArgumentException(
"Unsupported key format: "
+ (object != null ? object.getClass().getName() : "null"));
}
PemObject pemObject = pemReader.readPemObject();
byte[] decodedKey = pemObject.getContent();
return (RSAPrivateKey)
KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(decodedKey));
}
}
public static X509Certificate getIdPCertificate(Resource certificateResource) throws Exception {
if (certificateResource instanceof UrlResource) {
return extractCertificateFromMetadata(certificateResource);
} else {
// Treat as file resource
return readCertificate(certificateResource);
}
}
private static X509Certificate extractCertificateFromMetadata(Resource metadataResource) throws Exception {
log.info("Attempting to extract certificate from metadata resource: {}", metadataResource.getDescription());
try (InputStream is = metadataResource.getInputStream()) {
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
log.info("Retrieved metadata content, length: {}", content.length());
// Find the certificate data
int startIndex = content.indexOf("<ds:X509Certificate>");
int endIndex = content.indexOf("</ds:X509Certificate>");
if (startIndex == -1 || endIndex == -1) {
log.error("Certificate tags not found in metadata");
throw new Exception("Certificate tags not found in metadata");
}
// Extract certificate data
String certData = content.substring(
startIndex + "<ds:X509Certificate>".length(),
endIndex
).trim();
log.info("Found certificate data, length: {}", certData.length());
// Remove any whitespace and newlines from cert data
certData = certData.replaceAll("\\s+", "");
// Reconstruct PEM format with proper line breaks
StringBuilder pemBuilder = new StringBuilder();
pemBuilder.append("-----BEGIN CERTIFICATE-----\n");
// Insert line breaks every 64 characters
int lineLength = 64;
for (int i = 0; i < certData.length(); i += lineLength) {
int end = Math.min(i + lineLength, certData.length());
pemBuilder.append(certData, i, end).append('\n');
}
pemBuilder.append("-----END CERTIFICATE-----");
String pemCert = pemBuilder.toString();
log.debug("Reconstructed PEM certificate:\n{}", pemCert);
try {
ByteArrayInputStream pemStream = new ByteArrayInputStream(pemCert.getBytes(StandardCharsets.UTF_8));
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(pemStream);
log.info("Successfully parsed certificate. Subject: {}", cert.getSubjectX500Principal());
// Optional: check validity dates
cert.checkValidity(); // Throws CertificateExpiredException if expired
log.info("Certificate is valid (not expired)");
return cert;
} catch (Exception e) {
log.error("Failed to parse certificate", e);
throw new Exception("Failed to parse X509 certificate from metadata", e);
}
} catch (Exception e) {
log.error("Error processing metadata resource", e);
throw e;
}
}
}

View File

@@ -16,23 +16,35 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
if (exception instanceof Saml2AuthenticationException) {
Saml2Error error = ((Saml2AuthenticationException) exception).getSaml2Error();
getRedirectStrategy()
.sendRedirect(request, response, "/login?erroroauth=" + error.getErrorCode());
} else if (exception instanceof ProviderNotFoundException) {
getRedirectStrategy()
.sendRedirect(
request,
response,
"/login?erroroauth=not_authentication_provider_found");
}
log.error("AuthenticationException: " + exception);
}
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
if (exception instanceof Saml2AuthenticationException saml2Exception) {
Saml2Error error = saml2Exception.getSaml2Error();
// Log detailed information about the SAML error
log.error("SAML Authentication failed with error code: {}", error.getErrorCode());
log.error("Error description: {}", error.getDescription());
// Redirect to login with specific error code
getRedirectStrategy()
.sendRedirect(request, response, "/login?erroroauth=" + error.getErrorCode());
} else if (exception instanceof ProviderNotFoundException) {
log.error("Authentication failed: No authentication provider found");
getRedirectStrategy()
.sendRedirect(
request,
response,
"/login?erroroauth=not_authentication_provider_found");
} else {
log.error("Unknown AuthenticationException: {}", exception.getMessage());
getRedirectStrategy().sendRedirect(request, response, "/login?erroroauth=unknown_error");
}
}
}

View File

@@ -12,7 +12,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.LoginAttemptService;
import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.model.ApplicationProperties;
@@ -21,11 +20,11 @@ import stirling.software.SPDF.model.AuthenticationType;
import stirling.software.SPDF.utils.RequestUriUtils;
@AllArgsConstructor
@Slf4j
public class CustomSaml2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private LoginAttemptService loginAttemptService;
private ApplicationProperties applicationProperties;
private UserService userService;
@@ -35,12 +34,10 @@ public class CustomSaml2AuthenticationSuccessHandler
throws ServletException, IOException {
Object principal = authentication.getPrincipal();
log.debug("Starting SAML2 authentication success handling");
if (principal instanceof CustomSaml2AuthenticatedPrincipal) {
String username = ((CustomSaml2AuthenticatedPrincipal) principal).getName();
log.debug("Authenticated principal found for user: {}", username);
// Get the saved request
HttpSession session = request.getSession(false);
String contextPath = request.getContextPath();
SavedRequest savedRequest =
@@ -48,77 +45,46 @@ public class CustomSaml2AuthenticationSuccessHandler
? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST")
: null;
log.debug(
"Session exists: {}, Saved request exists: {}",
session != null,
savedRequest != null);
if (savedRequest != null
&& !RequestUriUtils.isStaticResource(
contextPath, savedRequest.getRedirectUrl())) {
log.debug(
"Valid saved request found, redirecting to original destination: {}",
savedRequest.getRedirectUrl());
// Redirect to the original destination
super.onAuthenticationSuccess(request, response, authentication);
} else {
SAML2 saml2 = applicationProperties.getSecurity().getSaml2();
log.debug(
"Processing SAML2 authentication with autoCreateUser: {}",
saml2.getAutoCreateUser());
if (loginAttemptService.isBlocked(username)) {
log.debug("User {} is blocked due to too many login attempts", username);
if (session != null) {
session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST");
}
throw new LockedException(
"Your account has been locked due to too many failed login attempts.");
}
boolean userExists = userService.usernameExistsIgnoreCase(username);
boolean hasPassword = userExists && userService.hasPassword(username);
boolean isSSOUser =
userExists
&& userService.isAuthenticationTypeByUsername(
username, AuthenticationType.SSO);
log.debug(
"User status - Exists: {}, Has password: {}, Is SSO user: {}",
userExists,
hasPassword,
isSSOUser);
if (userExists && hasPassword && !isSSOUser && saml2.getAutoCreateUser()) {
log.debug(
"User {} exists with password but is not SSO user, redirecting to logout",
username);
if (userService.usernameExistsIgnoreCase(username)
&& userService.hasPassword(username)
&& !userService.isAuthenticationTypeByUsername(
username, AuthenticationType.OAUTH2)
&& saml2.getAutoCreateUser()) {
response.sendRedirect(
contextPath + "/logout?oauth2AuthenticationErrorWeb=true");
return;
}
try {
if (saml2.getBlockRegistration() && !userExists) {
log.debug("Registration blocked for new user: {}", username);
if (saml2.getBlockRegistration()
&& !userService.usernameExistsIgnoreCase(username)) {
response.sendRedirect(
contextPath + "/login?erroroauth=oauth2_admin_blocked_user");
return;
}
log.debug("Processing SSO post-login for user: {}", username);
userService.processSSOPostLogin(username, saml2.getAutoCreateUser());
log.debug("Successfully processed authentication for user: {}", username);
userService.processOAuth2PostLogin(username, saml2.getAutoCreateUser());
response.sendRedirect(contextPath + "/");
return;
} catch (IllegalArgumentException e) {
log.debug(
"Invalid username detected for user: {}, redirecting to logout",
username);
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
return;
}
}
} else {
log.debug("Non-SAML2 principal detected, delegating to parent handler");
super.onAuthenticationSuccess(request, response, authentication);
}
}

View File

@@ -3,6 +3,8 @@ package stirling.software.SPDF.config.security.saml2;
import java.util.*;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.schema.XSBoolean;
import org.opensaml.core.xml.schema.XSString;
import org.opensaml.saml.saml2.core.Assertion;
import org.opensaml.saml.saml2.core.Attribute;
import org.opensaml.saml.saml2.core.AttributeStatement;
@@ -28,60 +30,15 @@ public class CustomSaml2ResponseAuthenticationConverter
this.userService = userService;
}
private Map<String, List<Object>> extractAttributes(Assertion assertion) {
Map<String, List<Object>> attributes = new HashMap<>();
for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) {
for (Attribute attribute : attributeStatement.getAttributes()) {
String attributeName = attribute.getName();
List<Object> values = new ArrayList<>();
for (XMLObject xmlObject : attribute.getAttributeValues()) {
// Get the text content directly
String value = xmlObject.getDOM().getTextContent();
if (value != null && !value.trim().isEmpty()) {
values.add(value);
}
}
if (!values.isEmpty()) {
// Store with both full URI and last part of the URI
attributes.put(attributeName, values);
String shortName = attributeName.substring(attributeName.lastIndexOf('/') + 1);
attributes.put(shortName, values);
}
}
}
return attributes;
}
@Override
public Saml2Authentication convert(ResponseToken responseToken) {
// Extract the assertion from the response
Assertion assertion = responseToken.getResponse().getAssertions().get(0);
Map<String, List<Object>> attributes = extractAttributes(assertion);
// Debug log with actual values
log.debug("Extracted SAML Attributes: " + attributes);
// Extract the NameID
String nameId = assertion.getSubject().getNameID().getValue();
// Try to get username/identifier in order of preference
String userIdentifier = null;
if (hasAttribute(attributes, "username")) {
userIdentifier = getFirstAttributeValue(attributes, "username");
} else if (hasAttribute(attributes, "emailaddress")) {
userIdentifier = getFirstAttributeValue(attributes, "emailaddress");
} else if (hasAttribute(attributes, "name")) {
userIdentifier = getFirstAttributeValue(attributes, "name");
} else if (hasAttribute(attributes, "upn")) {
userIdentifier = getFirstAttributeValue(attributes, "upn");
} else if (hasAttribute(attributes, "uid")) {
userIdentifier = getFirstAttributeValue(attributes, "uid");
} else {
userIdentifier = assertion.getSubject().getNameID().getValue();
}
// Rest of your existing code...
Optional<User> userOpt = userService.findByUsernameIgnoreCase(userIdentifier);
Optional<User> userOpt = userService.findByUsernameIgnoreCase(nameId);
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_USER");
if (userOpt.isPresent()) {
User user = userOpt.get();
@@ -91,27 +48,39 @@ public class CustomSaml2ResponseAuthenticationConverter
}
}
// Extract the SessionIndexes
List<String> sessionIndexes = new ArrayList<>();
for (AuthnStatement authnStatement : assertion.getAuthnStatements()) {
sessionIndexes.add(authnStatement.getSessionIndex());
}
CustomSaml2AuthenticatedPrincipal principal =
new CustomSaml2AuthenticatedPrincipal(
userIdentifier, attributes, userIdentifier, sessionIndexes);
// Extract the Attributes
Map<String, List<Object>> attributes = extractAttributes(assertion);
// Create the custom principal
CustomSaml2AuthenticatedPrincipal principal =
new CustomSaml2AuthenticatedPrincipal(nameId, attributes, nameId, sessionIndexes);
// Create the Saml2Authentication
return new Saml2Authentication(
principal,
responseToken.getToken().getSaml2Response(),
Collections.singletonList(simpleGrantedAuthority));
}
private boolean hasAttribute(Map<String, List<Object>> attributes, String name) {
return attributes.containsKey(name) && !attributes.get(name).isEmpty();
}
private String getFirstAttributeValue(Map<String, List<Object>> attributes, String name) {
List<Object> values = attributes.get(name);
return values != null && !values.isEmpty() ? values.get(0).toString() : null;
private Map<String, List<Object>> extractAttributes(Assertion assertion) {
Map<String, List<Object>> attributes = new HashMap<>();
for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) {
for (Attribute attribute : attributeStatement.getAttributes()) {
String attributeName = attribute.getName();
List<Object> values = new ArrayList<>();
for (XMLObject xmlObject : attribute.getAttributeValues()) {
log.info("BOOL: " + ((XSBoolean) xmlObject).getValue());
values.add(((XSString) xmlObject).getValue());
}
attributes.put(attributeName, values);
}
}
return attributes;
}
}

View File

@@ -0,0 +1,67 @@
package stirling.software.SPDF.config.security.saml2;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.saml2.core.Saml2ErrorCodes;
public class LoggingSamlAuthenticationProvider implements AuthenticationProvider {
private static final Logger log = LoggerFactory.getLogger(LoggingSamlAuthenticationProvider.class);
private final OpenSaml4AuthenticationProvider delegate;
public LoggingSamlAuthenticationProvider(OpenSaml4AuthenticationProvider delegate) {
this.delegate = delegate;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (authentication instanceof Saml2AuthenticationToken token) {
String samlResponse = token.getSaml2Response();
// Log the raw SAML response
log.info("Raw SAML Response (Base64): {}", samlResponse);
// Decode and log the SAML response XML
try {
String decodedResponse = new String(Base64.getDecoder().decode(samlResponse), StandardCharsets.UTF_8);
log.info("Decoded SAML Response XML:\n{}", decodedResponse);
} catch (IllegalArgumentException e) {
// If decoding fails, its likely already plain XML
log.warn("SAML Response appears to be different format, not Base64-encoded.");
log.debug("SAML Response XML:\n{}", samlResponse);
}
// Delegate the actual authentication to the wrapped OpenSaml4AuthenticationProvider
try {
return delegate.authenticate(authentication);
} catch (Saml2AuthenticationException e) {
log.error("SAML authentication failed: {}");
log.error("Detailed error message: {}", e);
throw e;
}
}
return null;
}
@Override
public boolean supports(Class<?> authentication) {
// Only support Saml2AuthenticationToken
return Saml2AuthenticationToken.class.isAssignableFrom(authentication);
}
}

View File

@@ -11,6 +11,8 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -31,6 +33,8 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@Tag(name = "General", description = "General APIs")
public class CropController {
private static final Logger logger = LoggerFactory.getLogger(CropController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
private final PostHogService postHogService;

View File

@@ -25,7 +25,6 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
@@ -35,28 +34,24 @@ import stirling.software.SPDF.config.security.database.DatabaseBackupHelper;
@Controller
@RequestMapping("/api/v1/database")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Tag(name = "Database", description = "Database APIs for backup, import, and management")
@Tag(name = "Database", description = "Database APIs")
public class DatabaseController {
@Autowired DatabaseBackupHelper databaseBackupHelper;
@Operation(
summary = "Import a database backup file",
description = "Uploads and imports a database backup SQL file.")
@Hidden
@PostMapping(consumes = "multipart/form-data", value = "import-database")
@Operation(
summary = "Import database backup",
description = "This endpoint imports a database backup from a SQL file.")
public String importDatabase(
@Parameter(description = "SQL file to import", required = true)
@RequestParam("fileInput")
MultipartFile file,
RedirectAttributes redirectAttributes)
throws IOException {
@RequestParam("fileInput") MultipartFile file, RedirectAttributes redirectAttributes)
throws IllegalArgumentException, IOException {
if (file == null || file.isEmpty()) {
redirectAttributes.addAttribute("error", "fileNullOrEmpty");
return "redirect:/database";
}
log.info("Received file: {}", file.getOriginalFilename());
Path tempTemplatePath = Files.createTempFile("backup_", ".sql");
try (InputStream in = file.getInputStream()) {
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
@@ -74,15 +69,9 @@ public class DatabaseController {
}
@Hidden
@Operation(
summary = "Import database backup by filename",
description = "Imports a database backup file from the server using its file name.")
@GetMapping("/import-database-file/{fileName}")
public String importDatabaseFromBackupUI(
@Parameter(description = "Name of the file to import", required = true) @PathVariable
String fileName)
throws IOException {
public String importDatabaseFromBackupUI(@PathVariable String fileName)
throws IllegalArgumentException, IOException {
if (fileName == null || fileName.isEmpty()) {
return "redirect:/database?error=fileNullOrEmpty";
}
@@ -96,7 +85,6 @@ public class DatabaseController {
return "redirect:/database?error=fileNotFound";
}
log.info("Received file: {}", fileName);
if (databaseBackupHelper.importDatabaseFromUI(fileName)) {
log.info("File {} imported to database", fileName);
return "redirect:/database?infoMessage=importIntoDatabaseSuccessed";
@@ -105,14 +93,12 @@ public class DatabaseController {
}
@Hidden
@GetMapping("/delete/{fileName}")
@Operation(
summary = "Delete a database backup file",
description = "Deletes a specified database backup file from the server.")
@GetMapping("/delete/{fileName}")
public String deleteFile(
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
String fileName) {
description =
"This endpoint deletes a database backup file with the specified file name.")
public String deleteFile(@PathVariable String fileName) {
if (fileName == null || fileName.isEmpty()) {
throw new IllegalArgumentException("File must not be null or empty");
}
@@ -131,13 +117,12 @@ public class DatabaseController {
}
@Hidden
@GetMapping("/download/{fileName}")
@Operation(
summary = "Download a database backup file",
description = "Downloads the specified database backup file from the server.")
@GetMapping("/download/{fileName}")
public ResponseEntity<?> downloadFile(
@Parameter(description = "Name of the file to download", required = true) @PathVariable
String fileName) {
description =
"This endpoint downloads a database backup file with the specified file name.")
public ResponseEntity<?> downloadFile(@PathVariable String fileName) {
if (fileName == null || fileName.isEmpty()) {
throw new IllegalArgumentException("File must not be null or empty");
}
@@ -156,22 +141,4 @@ public class DatabaseController {
.build();
}
}
@Operation(
summary = "Create a database backup",
description =
"This endpoint triggers the creation of a database backup and redirects to the"
+ " database management page.")
@GetMapping("/createDatabaseBackup")
public String createDatabaseBackup() {
try {
log.info("Starting database backup creation...");
databaseBackupHelper.exportDatabase();
log.info("Database backup successfully created.");
} catch (IOException e) {
log.error("Error creating database backup: {}", e.getMessage(), e);
return "redirect:/database?error=" + e.getMessage();
}
return "redirect:/database?infoMessage=backupCreated";
}
}

View File

@@ -20,6 +20,8 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -31,18 +33,18 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.SPDF.service.CustomPDDocumentFactory;
import stirling.software.SPDF.utils.GeneralUtils;
import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@Slf4j
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
public class MergeController {
private static final Logger logger = LoggerFactory.getLogger(MergeController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -182,7 +184,7 @@ public class MergeController {
baos.toByteArray(), mergedFileName); // Return the modified PDF
} catch (Exception ex) {
log.error("Error in merge pdf process", ex);
logger.error("Error in merge pdf process", ex);
throw ex;
} finally {
for (File file : filesToDelete) {

View File

@@ -12,6 +12,8 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -33,6 +35,8 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@Tag(name = "General", description = "General APIs")
public class MultiPageLayoutController {
private static final Logger logger = LoggerFactory.getLogger(MultiPageLayoutController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired

View File

@@ -8,6 +8,8 @@ import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -20,7 +22,6 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SortTypes;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.model.api.general.RearrangePagesRequest;
@@ -30,10 +31,11 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
public class RearrangePagesPDFController {
private static final Logger logger = LoggerFactory.getLogger(RearrangePagesPDFController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -200,7 +202,7 @@ public class RearrangePagesPDFController {
throw new IllegalArgumentException("Unsupported custom mode");
}
} catch (IllegalArgumentException e) {
log.error("Unsupported custom mode", e);
logger.error("Unsupported custom mode", e);
return null;
}
}
@@ -228,8 +230,8 @@ public class RearrangePagesPDFController {
} else {
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
}
log.info("newPageOrder = " + newPageOrder);
log.info("totalPages = " + totalPages);
logger.info("newPageOrder = " + newPageOrder);
logger.info("totalPages = " + totalPages);
// Create a new list to hold the pages in the new order
List<PDPage> newPages = new ArrayList<>();
for (int i = 0; i < newPageOrder.size(); i++) {
@@ -252,7 +254,7 @@ public class RearrangePagesPDFController {
.replaceFirst("[.][^.]+$", "")
+ "_rearranged.pdf");
} catch (IOException e) {
log.error("Failed rearranging documents", e);
logger.error("Failed rearranging documents", e);
return null;
}
}

View File

@@ -5,6 +5,8 @@ import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -26,6 +28,8 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@Tag(name = "General", description = "General APIs")
public class RotationController {
private static final Logger logger = LoggerFactory.getLogger(RotationController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired

View File

@@ -13,6 +13,8 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -34,6 +36,8 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@Tag(name = "General", description = "General APIs")
public class ScalePagesController {
private static final Logger logger = LoggerFactory.getLogger(ScalePagesController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired

View File

@@ -13,6 +13,8 @@ import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -26,17 +28,16 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.service.CustomPDDocumentFactory;
import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
public class SplitPDFController {
private static final Logger logger = LoggerFactory.getLogger(SplitPDFController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -51,114 +52,84 @@ public class SplitPDFController {
"This endpoint splits a given PDF file into separate documents based on the specified page numbers or ranges. Users can specify pages using individual numbers, ranges, or 'all' for every page. Input:PDF Output:PDF Type:SIMO")
public ResponseEntity<byte[]> splitPdf(@ModelAttribute PDFWithPageNums request)
throws IOException {
MultipartFile file = request.getFileInput();
String pages = request.getPageNumbers();
// open the pdf document
PDDocument document = null;
Path zipFile = null;
PDDocument document = Loader.loadPDF(file.getBytes());
// PdfMetadata metadata = PdfMetadataService.extractMetadataFromPdf(document);
int totalPages = document.getNumberOfPages();
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
if (!pageNumbers.contains(totalPages - 1)) {
// Create a mutable ArrayList so we can add to it
pageNumbers = new ArrayList<>(pageNumbers);
pageNumbers.add(totalPages - 1);
}
logger.info(
"Splitting PDF into pages: {}",
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
// split the document
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
try {
MultipartFile file = request.getFileInput();
String pages = request.getPageNumbers();
// open the pdf document
document = Loader.loadPDF(file.getBytes());
// PdfMetadata metadata = PdfMetadataService.extractMetadataFromPdf(document);
int totalPages = document.getNumberOfPages();
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
if (!pageNumbers.contains(totalPages - 1)) {
// Create a mutable ArrayList so we can add to it
pageNumbers = new ArrayList<>(pageNumbers);
pageNumbers.add(totalPages - 1);
}
log.info(
"Splitting PDF into pages: {}",
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
// split the document
splitDocumentsBoas = new ArrayList<>();
int previousPageNumber = 0;
for (int splitPoint : pageNumbers) {
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
PDPage page = document.getPage(i);
splitDocument.addPage(page);
log.info("Adding page {} to split document", i);
}
previousPageNumber = splitPoint + 1;
// Transfer metadata to split pdf
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
} catch (Exception e) {
log.error("Failed splitting documents and saving them", e);
throw e;
int previousPageNumber = 0;
for (int splitPoint : pageNumbers) {
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
PDPage page = document.getPage(i);
splitDocument.addPage(page);
logger.info("Adding page {} to split document", i);
}
}
previousPageNumber = splitPoint + 1;
// closing the original document
document.close();
// Transfer metadata to split pdf
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
zipFile = Files.createTempFile("split_documents", ".zip");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
splitDocument.save(baos);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
// loop through the split documents and write them to the zip file
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
String fileName = filename + "_" + (i + 1) + ".pdf";
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
byte[] pdf = baos.toByteArray();
// Add PDF file to the zip
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
log.info("Wrote split document {} to zip file", fileName);
}
splitDocumentsBoas.add(baos);
} catch (Exception e) {
log.error("Failed writing to zip", e);
logger.error("Failed splitting documents and saving them", e);
throw e;
}
log.info("Successfully created zip file with split documents: {}", zipFile.toString());
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
// return the Resource in the response
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
try {
// Close the main document
if (document != null) {
document.close();
}
// Close all ByteArrayOutputStreams
for (ByteArrayOutputStream baos : splitDocumentsBoas) {
if (baos != null) {
baos.close();
}
}
// Delete temporary zip file
if (zipFile != null) {
Files.deleteIfExists(zipFile);
}
} catch (Exception e) {
log.error("Error while cleaning up resources", e);
}
}
// closing the original document
document.close();
Path zipFile = Files.createTempFile("split_documents", ".zip");
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
// loop through the split documents and write them to the zip file
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
String fileName = filename + "_" + (i + 1) + ".pdf";
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
byte[] pdf = baos.toByteArray();
// Add PDF file to the zip
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
logger.info("Wrote split document {} to zip file", fileName);
}
} catch (Exception e) {
logger.error("Failed writing to zip", e);
throw e;
}
logger.info("Successfully created zip file with split documents: {}", zipFile.toString());
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
// return the Resource in the response
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
}

View File

@@ -13,6 +13,8 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -30,7 +32,6 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.PdfMetadata;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.SPDF.service.PdfMetadataService;
@@ -38,10 +39,12 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
public class SplitPdfByChaptersController {
private static final Logger logger =
LoggerFactory.getLogger(SplitPdfByChaptersController.class);
private final PdfMetadataService pdfMetadataService;
@Autowired
@@ -56,86 +59,70 @@ public class SplitPdfByChaptersController {
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfByChaptersRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
PDDocument sourceDocument = null;
Path zipFile = null;
try {
boolean includeMetadata = request.getIncludeMetadata();
Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) {
return ResponseEntity.badRequest().body("Invalid bookmark level".getBytes());
}
sourceDocument = Loader.loadPDF(file.getBytes());
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline();
if (outline == null) {
log.warn("No outline found for {}", file.getOriginalFilename());
return ResponseEntity.badRequest().body("No outline found".getBytes());
}
List<Bookmark> bookmarks = new ArrayList<>();
try {
bookmarks =
extractOutlineItems(
sourceDocument,
outline.getFirstChild(),
bookmarks,
outline.getFirstChild().getNextSibling(),
0,
bookmarkLevel);
// to handle last page edge case
bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages());
Bookmark lastBookmark = bookmarks.get(bookmarks.size() - 1);
} catch (Exception e) {
log.error("Unable to extract outline items", e);
return ResponseEntity.internalServerError()
.body("Unable to extract outline items".getBytes());
}
boolean allowDuplicates = request.getAllowDuplicates();
if (!allowDuplicates) {
/*
duplicates are generated when multiple bookmarks correspond to the same page,
if the user doesn't want duplicates mergeBookmarksThatCorrespondToSamePage() method will merge the titles of all
the bookmarks that correspond to the same page, and treat them as a single bookmark
*/
bookmarks = mergeBookmarksThatCorrespondToSamePage(bookmarks);
}
for (Bookmark bookmark : bookmarks) {
log.info(
"{}::::{} to {}",
bookmark.getTitle(),
bookmark.getStartPage(),
bookmark.getEndPage());
}
List<ByteArrayOutputStream> splitDocumentsBoas =
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
zipFile = createZipFile(bookmarks, splitDocumentsBoas);
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
sourceDocument.close();
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
try {
if (sourceDocument != null) {
sourceDocument.close();
}
if (zipFile != null) {
Files.deleteIfExists(zipFile);
}
} catch (Exception e) {
log.error("Error while cleaning up resources", e);
}
boolean includeMetadata = request.getIncludeMetadata();
Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) {
return ResponseEntity.badRequest().body("Invalid bookmark level".getBytes());
}
PDDocument sourceDocument = Loader.loadPDF(file.getBytes());
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline();
if (outline == null) {
logger.warn("No outline found for {}", file.getOriginalFilename());
return ResponseEntity.badRequest().body("No outline found".getBytes());
}
List<Bookmark> bookmarks = new ArrayList<>();
try {
bookmarks =
extractOutlineItems(
sourceDocument,
outline.getFirstChild(),
bookmarks,
outline.getFirstChild().getNextSibling(),
0,
bookmarkLevel);
// to handle last page edge case
bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages());
Bookmark lastBookmark = bookmarks.get(bookmarks.size() - 1);
} catch (Exception e) {
logger.error("Unable to extract outline items", e);
return ResponseEntity.internalServerError()
.body("Unable to extract outline items".getBytes());
}
boolean allowDuplicates = request.getAllowDuplicates();
if (!allowDuplicates) {
/*
duplicates are generated when multiple bookmarks correspond to the same page,
if the user doesn't want duplicates mergeBookmarksThatCorrespondToSamePage() method will merge the titles of all
the bookmarks that correspond to the same page, and treat them as a single bookmark
*/
bookmarks = mergeBookmarksThatCorrespondToSamePage(bookmarks);
}
for (Bookmark bookmark : bookmarks) {
logger.info(
"{}::::{} to {}",
bookmark.getTitle(),
bookmark.getStartPage(),
bookmark.getEndPage());
}
List<ByteArrayOutputStream> splitDocumentsBoas =
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
Path zipFile = createZipFile(bookmarks, splitDocumentsBoas);
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
sourceDocument.close();
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
private List<Bookmark> mergeBookmarksThatCorrespondToSamePage(List<Bookmark> bookmarks) {
@@ -253,14 +240,14 @@ public class SplitPdfByChaptersController {
zipOut.write(pdf);
zipOut.closeEntry();
log.info("Wrote split document {} to zip file", fileName);
logger.info("Wrote split document {} to zip file", fileName);
}
} catch (Exception e) {
log.error("Failed writing to zip", e);
logger.error("Failed writing to zip", e);
throw e;
}
log.info("Successfully created zip file with split documents: {}", zipFile);
logger.info("Successfully created zip file with split documents: {}", zipFile);
return zipFile;
}
@@ -281,7 +268,7 @@ public class SplitPdfByChaptersController {
i++) {
PDPage page = sourceDocument.getPage(i);
splitDocument.addPage(page);
log.info("Adding page {} to split document", i);
logger.info("Adding page {} to split document", i);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (includeMetadata) {
@@ -292,7 +279,7 @@ public class SplitPdfByChaptersController {
splitDocumentsBoas.add(baos);
} catch (Exception e) {
log.error("Failed splitting documents and saving them", e);
logger.error("Failed splitting documents and saving them", e);
throw e;
}
}

View File

@@ -18,6 +18,8 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -40,6 +42,9 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@Tag(name = "General", description = "General APIs")
public class SplitPdfBySectionsController {
private static final Logger logger =
LoggerFactory.getLogger(SplitPdfBySectionsController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -100,13 +105,15 @@ public class SplitPdfBySectionsController {
if (sectionNum == horiz * verti) pageNum++;
}
data = Files.readAllBytes(zipFile);
return WebResponseUtils.bytesToWebResponse(
data, filename + "_split.zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
logger.error("exception", e);
} finally {
data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
}
return WebResponseUtils.bytesToWebResponse(
data, filename + "_split.zip", MediaType.APPLICATION_OCTET_STREAM);
}
public List<PDDocument> splitPdfPages(

View File

@@ -10,6 +10,8 @@ import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -23,7 +25,6 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
import stirling.software.SPDF.service.CustomPDDocumentFactory;
import stirling.software.SPDF.utils.GeneralUtils;
@@ -31,10 +32,10 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
public class SplitPdfBySizeController {
private static final Logger logger = LoggerFactory.getLogger(SplitPdfBySizeController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -77,7 +78,7 @@ public class SplitPdfBySizeController {
}
} catch (Exception e) {
log.error("exception", e);
logger.error("exception", e);
} finally {
data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);

View File

@@ -11,6 +11,8 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -30,6 +32,8 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@Tag(name = "General", description = "General APIs")
public class ToSinglePageController {
private static final Logger logger = LoggerFactory.getLogger(ToSinglePageController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired

View File

@@ -244,8 +244,8 @@ public class UserController {
return new RedirectView("/addUsers?messageType=invalidRole", true);
}
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
userService.saveUser(username, AuthenticationType.SSO, role);
if (authType.equalsIgnoreCase(AuthenticationType.OAUTH2.toString())) {
userService.saveUser(username, AuthenticationType.OAUTH2, role);
} else {
if (password.isBlank()) {
return new RedirectView("/addUsers?messageType=invalidPassword", true);

View File

@@ -14,6 +14,8 @@ import java.util.zip.ZipOutputStream;
import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.rendering.ImageType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -27,7 +29,6 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.SPDF.service.CustomPDDocumentFactory;
@@ -39,10 +40,11 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Slf4j
@Tag(name = "Convert", description = "Convert APIs")
public class ConvertImgPDFController {
private static final Logger logger = LoggerFactory.getLogger(ConvertImgPDFController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -63,137 +65,112 @@ public class ConvertImgPDFController {
String colorType = request.getColorType();
String dpi = request.getDpi();
Path tempFile = null;
Path tempOutputDir = null;
Path tempPdfPath = null;
byte[] pdfBytes = file.getBytes();
ImageType colorTypeResult = ImageType.RGB;
if ("greyscale".equals(colorType)) {
colorTypeResult = ImageType.GRAY;
} else if ("blackwhite".equals(colorType)) {
colorTypeResult = ImageType.BINARY;
}
// returns bytes for image
boolean singleImage = "single".equals(singleOrMultiple);
byte[] result = null;
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
try {
byte[] pdfBytes = file.getBytes();
ImageType colorTypeResult = ImageType.RGB;
if ("greyscale".equals(colorType)) {
colorTypeResult = ImageType.GRAY;
} else if ("blackwhite".equals(colorType)) {
colorTypeResult = ImageType.BINARY;
}
// returns bytes for image
boolean singleImage = "single".equals(singleOrMultiple);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
result =
PdfUtils.convertFromPdf(
pdfBytes,
"webp".equalsIgnoreCase(imageFormat)
? "png"
: imageFormat.toUpperCase(),
colorTypeResult,
singleImage,
Integer.valueOf(dpi),
filename);
if (result == null || result.length == 0) {
log.error("resultant bytes for {} is null, error converting ", filename);
}
if ("webp".equalsIgnoreCase(imageFormat) && !CheckProgramInstall.isPythonAvailable()) {
throw new IOException("Python is not installed. Required for WebP conversion.");
} else if ("webp".equalsIgnoreCase(imageFormat)
&& CheckProgramInstall.isPythonAvailable()) {
// Write the output stream to a temp file
tempFile = Files.createTempFile("temp_png", ".png");
try (FileOutputStream fos = new FileOutputStream(tempFile.toFile())) {
fos.write(result);
fos.flush();
}
String pythonVersion = CheckProgramInstall.getAvailablePythonCommand();
List<String> command = new ArrayList<>();
command.add(pythonVersion);
command.add("./scripts/png_to_webp.py"); // Python script to handle the conversion
// Create a temporary directory for the output WebP files
tempOutputDir = Files.createTempDirectory("webp_output");
if (singleImage) {
// Run the Python script to convert PNG to WebP
command.add(tempFile.toString());
command.add(tempOutputDir.toString());
command.add("--single");
} else {
// Save the uploaded PDF to a temporary file
tempPdfPath = Files.createTempFile("temp_pdf", ".pdf");
file.transferTo(tempPdfPath.toFile());
// Run the Python script to convert PDF to WebP
command.add(tempPdfPath.toString());
command.add(tempOutputDir.toString());
}
command.add("--dpi");
command.add(dpi);
ProcessExecutorResult resultProcess =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV)
.runCommandWithOutputHandling(command);
// Find all WebP files in the output directory
List<Path> webpFiles =
Files.walk(tempOutputDir)
.filter(path -> path.toString().endsWith(".webp"))
.collect(Collectors.toList());
if (webpFiles.isEmpty()) {
log.error("No WebP files were created in: {}", tempOutputDir.toString());
throw new IOException(
"No WebP files were created. " + resultProcess.getMessages());
}
byte[] bodyBytes = new byte[0];
if (webpFiles.size() == 1) {
// Return the single WebP file directly
Path webpFilePath = webpFiles.get(0);
bodyBytes = Files.readAllBytes(webpFilePath);
} else {
// Create a ZIP file containing all WebP images
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
for (Path webpFile : webpFiles) {
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
Files.copy(webpFile, zos);
zos.closeEntry();
}
}
bodyBytes = zipOutputStream.toByteArray();
}
// Clean up the temporary files
Files.deleteIfExists(tempFile);
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
result = bodyBytes;
result =
PdfUtils.convertFromPdf(
pdfBytes,
"webp".equalsIgnoreCase(imageFormat) ? "png" : imageFormat.toUpperCase(),
colorTypeResult,
singleImage,
Integer.valueOf(dpi),
filename);
if (result == null || result.length == 0) {
logger.error("resultant bytes for {} is null, error converting ", filename);
}
if ("webp".equalsIgnoreCase(imageFormat) && !CheckProgramInstall.isPythonAvailable()) {
throw new IOException("Python is not installed. Required for WebP conversion.");
} else if ("webp".equalsIgnoreCase(imageFormat)
&& CheckProgramInstall.isPythonAvailable()) {
// Write the output stream to a temp file
Path tempFile = Files.createTempFile("temp_png", ".png");
try (FileOutputStream fos = new FileOutputStream(tempFile.toFile())) {
fos.write(result);
fos.flush();
}
String pythonVersion = CheckProgramInstall.getAvailablePythonCommand();
List<String> command = new ArrayList<>();
command.add(pythonVersion);
command.add("./scripts/png_to_webp.py"); // Python script to handle the conversion
// Create a temporary directory for the output WebP files
Path tempOutputDir = Files.createTempDirectory("webp_output");
if (singleImage) {
String docName = filename + "." + imageFormat;
MediaType mediaType = MediaType.parseMediaType(getMediaType(imageFormat));
return WebResponseUtils.bytesToWebResponse(result, docName, mediaType);
// Run the Python script to convert PNG to WebP
command.add(tempFile.toString());
command.add(tempOutputDir.toString());
command.add("--single");
} else {
String zipFilename = filename + "_convertedToImages.zip";
return WebResponseUtils.bytesToWebResponse(
result, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
// Save the uploaded PDF to a temporary file
Path tempPdfPath = Files.createTempFile("temp_pdf", ".pdf");
file.transferTo(tempPdfPath.toFile());
// Run the Python script to convert PDF to WebP
command.add(tempPdfPath.toString());
command.add(tempOutputDir.toString());
}
command.add("--dpi");
command.add(dpi);
ProcessExecutorResult resultProcess =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV)
.runCommandWithOutputHandling(command);
// Find all WebP files in the output directory
List<Path> webpFiles =
Files.walk(tempOutputDir)
.filter(path -> path.toString().endsWith(".webp"))
.collect(Collectors.toList());
if (webpFiles.isEmpty()) {
logger.error("No WebP files were created in: {}", tempOutputDir.toString());
throw new IOException("No WebP files were created. " + resultProcess.getMessages());
}
} finally {
try {
// Clean up temporary files
if (tempFile != null) {
Files.deleteIfExists(tempFile);
byte[] bodyBytes = new byte[0];
if (webpFiles.size() == 1) {
// Return the single WebP file directly
Path webpFilePath = webpFiles.get(0);
bodyBytes = Files.readAllBytes(webpFilePath);
} else {
// Create a ZIP file containing all WebP images
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
for (Path webpFile : webpFiles) {
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
Files.copy(webpFile, zos);
zos.closeEntry();
}
}
if (tempPdfPath != null) {
Files.deleteIfExists(tempPdfPath);
}
if (tempOutputDir != null) {
FileUtils.deleteDirectory(tempOutputDir.toFile());
}
} catch (Exception e) {
log.error("Error cleaning up temporary files", e);
bodyBytes = zipOutputStream.toByteArray();
}
// Clean up the temporary files
Files.deleteIfExists(tempFile);
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
result = bodyBytes;
}
if (singleImage) {
String docName = filename + "." + imageFormat;
MediaType mediaType = MediaType.parseMediaType(getMediaType(imageFormat));
return WebResponseUtils.bytesToWebResponse(result, docName, mediaType);
} else {
String zipFilename = filename + "_convertedToImages.zip";
return WebResponseUtils.bytesToWebResponse(
result, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
}
}
@@ -208,13 +185,7 @@ public class ConvertImgPDFController {
String fitOption = request.getFitOption();
String colorType = request.getColorType();
boolean autoRotate = request.isAutoRotate();
// Handle Null entries for formdata
if (colorType == null || colorType.isBlank()) {
colorType = "color";
}
if (fitOption == null || fitOption.isEmpty()) {
fitOption = "fillPage";
}
// Convert the file to PDF and get the resulting bytes
byte[] bytes =
PdfUtils.imageToPdf(file, fitOption, autoRotate, colorType, pdfDocumentFactory);

View File

@@ -1,13 +1,14 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -20,7 +21,6 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.SPDF.utils.ProcessExecutor;
import stirling.software.SPDF.utils.ProcessExecutor.ProcessExecutorResult;
@@ -28,98 +28,68 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Slf4j
@Tag(name = "Convert", description = "Convert APIs")
public class ConvertPDFToPDFA {
private static final Logger logger = LoggerFactory.getLogger(ConvertPDFToPDFA.class);
@PostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
@Operation(
summary = "Convert a PDF to a PDF/A",
description =
"This endpoint converts a PDF file to a PDF/A file using LibreOffice. PDF/A is a format designed for long-term archiving of digital documents. Input:PDF Output:PDF Type:SISO")
"This endpoint converts a PDF file to a PDF/A file. PDF/A is a format designed for long-term archiving of digital documents. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> pdfToPdfA(@ModelAttribute PdfToPdfARequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
String outputFormat = request.getOutputFormat();
// Validate input file type
if (!"application/pdf".equals(inputFile.getContentType())) {
log.error("Invalid input file type: {}", inputFile.getContentType());
throw new IllegalArgumentException("Input file must be a PDF");
// Convert MultipartFile to byte[]
byte[] pdfBytes = inputFile.getBytes();
// Save the uploaded file to a temporary location
Path tempInputFile = Files.createTempFile("input_", ".pdf");
try (OutputStream outputStream = new FileOutputStream(tempInputFile.toFile())) {
outputStream.write(pdfBytes);
}
// Get the original filename without extension
String originalFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFileName == null || originalFileName.trim().isEmpty()) {
originalFileName = "output.pdf";
}
String baseFileName =
originalFileName.contains(".")
? originalFileName.substring(0, originalFileName.lastIndexOf('.'))
: originalFileName;
// Prepare the output file path
Path tempOutputFile = Files.createTempFile("output_", ".pdf");
Path tempInputFile = null;
Path tempOutputDir = null;
byte[] fileBytes;
// Prepare the ghostscript command
List<String> command = new ArrayList<>();
command.add("gs");
command.add("-dPDFA=" + ("pdfa".equals(outputFormat) ? "2" : "1"));
command.add("-dNOPAUSE");
command.add("-dBATCH");
command.add("-sColorConversionStrategy=sRGB");
command.add("-sDEVICE=pdfwrite");
command.add("-dPDFACompatibilityPolicy=2");
command.add("-o");
command.add(tempOutputFile.toString());
command.add(tempInputFile.toString());
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
if (returnCode.getRc() != 0) {
logger.info(
outputFormat + " conversion failed with return code: " + returnCode.getRc());
}
try {
// Save uploaded file to temp location
tempInputFile = Files.createTempFile("input_", ".pdf");
inputFile.transferTo(tempInputFile);
// Create temp output directory
tempOutputDir = Files.createTempDirectory("output_");
// Determine PDF/A filter based on requested format
String pdfFilter =
"pdfa".equals(outputFormat)
? "writer_pdf_Export:{'SelectPdfVersion':{'Value':'2'}}:writer_pdf_Export"
: "writer_pdf_Export:{'SelectPdfVersion':{'Value':'1'}}:writer_pdf_Export";
// Prepare LibreOffice command
List<String> command =
new ArrayList<>(
Arrays.asList(
"soffice",
"--headless",
"--nologo",
"--convert-to",
"pdf:" + pdfFilter,
"--outdir",
tempOutputDir.toString(),
tempInputFile.toString()));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
if (returnCode.getRc() != 0) {
log.error("PDF/A conversion failed with return code: {}", returnCode.getRc());
throw new RuntimeException("PDF/A conversion failed");
}
// Get the output file
File[] outputFiles = tempOutputDir.toFile().listFiles();
if (outputFiles == null || outputFiles.length != 1) {
throw new RuntimeException(
"Expected exactly one output file but found "
+ (outputFiles == null ? "none" : outputFiles.length));
}
fileBytes = FileUtils.readFileToByteArray(outputFiles[0]);
String outputFilename = baseFileName + "_PDFA.pdf";
byte[] pdfBytesOutput = Files.readAllBytes(tempOutputFile);
// Return the optimized PDF as a response
String outputFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_PDFA.pdf";
return WebResponseUtils.bytesToWebResponse(
fileBytes, outputFilename, MediaType.APPLICATION_PDF);
pdfBytesOutput, outputFilename, MediaType.APPLICATION_PDF);
} finally {
// Clean up temporary files
if (tempInputFile != null) {
Files.deleteIfExists(tempInputFile);
}
if (tempOutputDir != null) {
FileUtils.deleteDirectory(tempOutputDir.toFile());
}
// Clean up the temporary files
Files.deleteIfExists(tempInputFile);
Files.deleteIfExists(tempOutputFile);
}
}
}

View File

@@ -7,6 +7,8 @@ import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -17,7 +19,6 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
import stirling.software.SPDF.service.CustomPDDocumentFactory;
import stirling.software.SPDF.utils.GeneralUtils;
@@ -27,10 +28,11 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@Slf4j
@RequestMapping("/api/v1/convert")
public class ConvertWebsiteToPDF {
private static final Logger logger = LoggerFactory.getLogger(ConvertWebsiteToPDF.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -86,7 +88,7 @@ public class ConvertWebsiteToPDF {
try {
Files.deleteIfExists(tempOutputFile);
} catch (IOException e) {
log.error("Error deleting temporary output file", e);
logger.error("Error deleting temporary output file", e);
}
}
}

View File

@@ -1,12 +1,14 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.QuoteMode;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -16,38 +18,79 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.opencsv.CSVWriter;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import stirling.software.SPDF.controller.api.CropController;
import stirling.software.SPDF.controller.api.strippers.PDFTableStripper;
import stirling.software.SPDF.model.api.extract.PDFFilePage;
import stirling.software.SPDF.pdf.FlexibleCSVWriter;
import technology.tabula.ObjectExtractor;
import technology.tabula.Page;
import technology.tabula.Table;
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
import technology.tabula.writers.Writer;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
public class ExtractCSVController {
private static final Logger logger = LoggerFactory.getLogger(CropController.class);
@PostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
@Operation(
summary = "Extracts a CSV document from a PDF",
description =
"This operation takes an input PDF file and returns CSV file of whole page. Input:PDF Output:CSV Type:SISO")
public ResponseEntity<String> PdfToCsv(@ModelAttribute PDFFilePage form) throws Exception {
StringWriter writer = new StringWriter();
ArrayList<String> tableData = new ArrayList<>();
int columnsCount = 0;
try (PDDocument document = Loader.loadPDF(form.getFileInput().getBytes())) {
CSVFormat format =
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
Writer csvWriter = new FlexibleCSVWriter(format);
SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
try (ObjectExtractor extractor = new ObjectExtractor(document)) {
Page page = extractor.extract(form.getPageId());
List<Table> tables = sea.extract(page);
csvWriter.write(writer, tables);
final double res = 72; // PDF units are at 72 DPI
PDFTableStripper stripper = new PDFTableStripper();
PDPage pdPage = document.getPage(form.getPageId() - 1);
stripper.extractTable(pdPage);
columnsCount = stripper.getColumns();
for (int c = 0; c < columnsCount; ++c) {
for (int r = 0; r < stripper.getRows(); ++r) {
tableData.add(stripper.getText(r, c));
}
}
}
ArrayList<String> notEmptyColumns = new ArrayList<>();
for (String item : tableData) {
if (!item.trim().isEmpty()) {
notEmptyColumns.add(item);
} else {
columnsCount--;
}
}
List<String> fullTable =
notEmptyColumns.stream()
.map(
(entity) ->
entity.replace('\n', ' ')
.replace('\r', ' ')
.trim()
.replaceAll("\\s{2,}", "|"))
.toList();
int rowsCount = fullTable.get(0).split("\\|").length;
ArrayList<String> headersList = getTableHeaders(columnsCount, fullTable);
ArrayList<String> recordList = getRecordsList(rowsCount, fullTable);
if (headersList.size() == 0 && recordList.size() == 0) {
throw new Exception("No table detected, no headers or records found");
}
StringWriter writer = new StringWriter();
try (CSVWriter csvWriter = new CSVWriter(writer)) {
csvWriter.writeNext(headersList.toArray(new String[0]));
for (String record : recordList) {
csvWriter.writeNext(record.split("\\|"));
}
}
@@ -64,4 +107,33 @@ public class ExtractCSVController {
return ResponseEntity.ok().headers(headers).body(writer.toString());
}
private ArrayList<String> getRecordsList(int rowsCounts, List<String> items) {
ArrayList<String> recordsList = new ArrayList<>();
for (int b = 1; b < rowsCounts; b++) {
StringBuilder strbldr = new StringBuilder();
for (int i = 0; i < items.size(); i++) {
String[] parts = items.get(i).split("\\|");
strbldr.append(parts[b]);
if (i != items.size() - 1) {
strbldr.append("|");
}
}
recordsList.add(strbldr.toString());
}
return recordsList;
}
private ArrayList<String> getTableHeaders(int columnsCount, List<String> items) {
ArrayList<String> resultList = new ArrayList<>();
for (int i = 0; i < columnsCount; i++) {
String[] parts = items.get(i).split("\\|");
resultList.add(parts[0]);
}
return resultList;
}
}

View File

@@ -9,6 +9,8 @@ import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@@ -20,16 +22,16 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.ExtractHeaderRequest;
import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
public class AutoRenameController {
private static final Logger logger = LoggerFactory.getLogger(AutoRenameController.class);
private static final float TITLE_FONT_SIZE_THRESHOLD = 20.0f;
private static final int LINE_LIMIT = 200;
@@ -131,7 +133,7 @@ public class AutoRenameController {
header = header.replaceAll("[/\\\\?%*:|\"<>]", "").trim();
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
} else {
log.info("File has no good title to be found");
logger.info("File has no good title to be found");
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
}

View File

@@ -14,6 +14,8 @@ import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -35,17 +37,16 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.AutoSplitPdfRequest;
import stirling.software.SPDF.service.CustomPDDocumentFactory;
import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
public class AutoSplitPdfController {
private static final Logger logger = LoggerFactory.getLogger(AutoSplitPdfController.class);
private static final String QR_CONTENT = "https://github.com/Stirling-Tools/Stirling-PDF";
private static final String QR_CONTENT_OLD = "https://github.com/Frooodle/Stirling-PDF";
@@ -133,7 +134,7 @@ public class AutoSplitPdfController {
try {
document.close();
} catch (IOException e) {
log.error("Error closing main PDDocument", e);
logger.error("Error closing main PDDocument", e);
}
}
@@ -141,7 +142,7 @@ public class AutoSplitPdfController {
try {
splitDoc.close();
} catch (IOException e) {
log.error("Error closing split PDDocument", e);
logger.error("Error closing split PDDocument", e);
}
}
@@ -149,7 +150,7 @@ public class AutoSplitPdfController {
try {
Files.deleteIfExists(zipFile);
} catch (IOException e) {
log.error("Error deleting temporary zip file", e);
logger.error("Error deleting temporary zip file", e);
}
}
}

View File

@@ -14,6 +14,8 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -28,7 +30,6 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.RemoveBlankPagesRequest;
import stirling.software.SPDF.service.CustomPDDocumentFactory;
import stirling.software.SPDF.utils.PdfUtils;
@@ -36,10 +37,11 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
public class BlankPageController {
private static final Logger logger = LoggerFactory.getLogger(BlankPageController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -69,7 +71,7 @@ public class BlankPageController {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (PDPage page : pages) {
log.info("checking page {}", pageIndex);
logger.info("checking page {}", pageIndex);
textStripper.setStartPage(pageIndex + 1);
textStripper.setEndPage(pageIndex + 1);
String pageText = textStripper.getText(document);
@@ -77,12 +79,12 @@ public class BlankPageController {
boolean blank = true;
if (hasText) {
log.info("page {} has text, not blank", pageIndex);
logger.info("page {} has text, not blank", pageIndex);
blank = false;
} else {
boolean hasImages = PdfUtils.hasImagesOnPage(page);
if (hasImages) {
log.info("page {} has image, running blank detection", pageIndex);
logger.info("page {} has image, running blank detection", pageIndex);
// Render image and save as temp file
BufferedImage image = pdfRenderer.renderImageWithDPI(pageIndex, 30);
blank = isBlankImage(image, threshold, whitePercent, threshold);
@@ -90,10 +92,10 @@ public class BlankPageController {
}
if (blank) {
log.info("Skipping, Image was blank for page #{}", pageIndex);
logger.info("Skipping, Image was blank for page #{}", pageIndex);
blankPages.add(page);
} else {
log.info("page {} has image which is not blank", pageIndex);
logger.info("page {} has image which is not blank", pageIndex);
nonBlankPages.add(page);
}
@@ -119,12 +121,12 @@ public class BlankPageController {
zos.close();
log.info("Returning ZIP file: {}", filename + "_processed.zip");
logger.info("Returning ZIP file: {}", filename + "_processed.zip");
return WebResponseUtils.boasToWebResponse(
baos, filename + "_processed.zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (IOException e) {
log.error("exception", e);
logger.error("exception", e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@@ -147,7 +149,7 @@ public class BlankPageController {
public static boolean isBlankImage(
BufferedImage image, int threshold, double whitePercent, int blurSize) {
if (image == null) {
log.info("Error: Image is null");
logger.info("Error: Image is null");
return false;
}
@@ -165,7 +167,7 @@ public class BlankPageController {
}
double whitePixelPercentage = (whitePixels / (double) totalPixels) * 100;
log.info(String.format("Page has white pixel percent of %.2f%%", whitePixelPercentage));
logger.info(String.format("Page has white pixel percent of %.2f%%", whitePixelPercentage));
return whitePixelPercentage >= whitePercent;
}

View File

@@ -10,6 +10,7 @@ import java.util.List;
import javax.imageio.ImageIO;
import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -17,6 +18,8 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -29,7 +32,6 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.SPDF.service.CustomPDDocumentFactory;
import stirling.software.SPDF.utils.GeneralUtils;
@@ -39,10 +41,11 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
public class CompressController {
private static final Logger logger = LoggerFactory.getLogger(CompressController.class);
private final CustomPDDocumentFactory pdfDocumentFactory;
@Autowired
@@ -50,54 +53,6 @@ public class CompressController {
this.pdfDocumentFactory = pdfDocumentFactory;
}
private void compressImagesInPDF(Path pdfFile, double initialScaleFactor) throws Exception {
byte[] fileBytes = Files.readAllBytes(pdfFile);
try (PDDocument doc = Loader.loadPDF(fileBytes)) {
double scaleFactor = initialScaleFactor;
for (PDPage page : doc.getPages()) {
PDResources res = page.getResources();
if (res != null && res.getXObjectNames() != null) {
for (COSName name : res.getXObjectNames()) {
PDXObject xobj = res.getXObject(name);
if (xobj instanceof PDImageXObject) {
PDImageXObject image = (PDImageXObject) xobj;
BufferedImage bufferedImage = image.getImage();
int newWidth = (int) (bufferedImage.getWidth() * scaleFactor);
int newHeight = (int) (bufferedImage.getHeight() * scaleFactor);
if (newWidth == 0 || newHeight == 0) {
continue;
}
Image scaledImage =
bufferedImage.getScaledInstance(
newWidth, newHeight, Image.SCALE_SMOOTH);
BufferedImage scaledBufferedImage =
new BufferedImage(
newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
scaledBufferedImage.getGraphics().drawImage(scaledImage, 0, 0, null);
ByteArrayOutputStream compressedImageStream =
new ByteArrayOutputStream();
ImageIO.write(scaledBufferedImage, "jpeg", compressedImageStream);
byte[] imageBytes = compressedImageStream.toByteArray();
compressedImageStream.close();
PDImageXObject compressedImage =
PDImageXObject.createFromByteArray(
doc, imageBytes, image.getCOSObject().toString());
res.put(name, compressedImage);
}
}
}
}
doc.save(pdfFile.toString());
}
}
@PostMapping(consumes = "multipart/form-data", value = "/compress-pdf")
@Operation(
summary = "Optimize PDF file",
@@ -120,92 +75,209 @@ public class CompressController {
autoMode = true;
}
// Save the uploaded file to a temporary location
Path tempInputFile = Files.createTempFile("input_", ".pdf");
inputFile.transferTo(tempInputFile.toFile());
long inputFileSize = Files.size(tempInputFile);
// Prepare the output file path
Path tempOutputFile = null;
byte[] pdfBytes;
try {
tempOutputFile = Files.createTempFile("output_", ".pdf");
// Determine initial optimization level based on expected size reduction, only if in
// autoMode
if (autoMode) {
double sizeReductionRatio = expectedOutputSize / (double) inputFileSize;
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
if (sizeReductionRatio > 0.7) {
optimizeLevel = 1;
} else if (sizeReductionRatio > 0.5) {
optimizeLevel = 2;
} else if (sizeReductionRatio > 0.35) {
optimizeLevel = 3;
} else {
optimizeLevel = 3;
}
}
boolean sizeMet = false;
while (!sizeMet && optimizeLevel <= 9) {
// Apply additional image compression for levels 6-9
if (optimizeLevel >= 6) {
// Calculate scale factor based on optimization level
double scaleFactor =
switch (optimizeLevel) {
case 6 -> 0.9; // 90% of original size
case 7 -> 0.8; // 80% of original size
case 8 -> 0.65; // 70% of original size
case 9 -> 0.5; // 60% of original size
default -> 1.0;
};
compressImagesInPDF(tempInputFile, scaleFactor);
}
// Run QPDF optimization
while (!sizeMet && optimizeLevel <= 4) {
// Prepare the Ghostscript command
List<String> command = new ArrayList<>();
command.add("qpdf");
if (request.getNormalize()) {
command.add("--normalize-content=y");
command.add("gs");
command.add("-sDEVICE=pdfwrite");
command.add("-dCompatibilityLevel=1.5");
switch (optimizeLevel) {
case 1:
command.add("-dPDFSETTINGS=/prepress");
break;
case 2:
command.add("-dPDFSETTINGS=/printer");
break;
case 3:
command.add("-dPDFSETTINGS=/ebook");
break;
case 4:
command.add("-dPDFSETTINGS=/screen");
break;
default:
command.add("-dPDFSETTINGS=/default");
}
if (request.getLinearize()) {
command.add("--linearize");
}
command.add("--optimize-images");
command.add("--recompress-flate");
command.add("--compression-level=" + optimizeLevel);
command.add("--compress-streams=y");
command.add("--object-streams=generate");
command.add("-dNOPAUSE");
command.add("-dQUIET");
command.add("-dBATCH");
command.add("-sOutputFile=" + tempOutputFile.toString());
command.add(tempInputFile.toString());
command.add(tempOutputFile.toString());
ProcessExecutorResult returnCode = null;
try {
returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)
.runCommandWithOutputHandling(command);
} catch (Exception e) {
if (returnCode != null && returnCode.getRc() != 3) {
throw e;
}
}
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
// Check if file size is within expected size or not auto mode
// Check if file size is within expected size or not auto mode so instantly finish
long outputFileSize = Files.size(tempOutputFile);
if (outputFileSize <= expectedOutputSize || !autoMode) {
sizeMet = true;
} else {
optimizeLevel =
incrementOptimizeLevel(
optimizeLevel, outputFileSize, expectedOutputSize);
if (autoMode && optimizeLevel > 9) {
log.info("Maximum compression level reached in auto mode");
// Increase optimization level for next iteration
optimizeLevel++;
if (autoMode && optimizeLevel > 4) {
logger.info("Skipping level 5 due to bad results in auto mode");
sizeMet = true;
} else {
logger.info(
"Increasing ghostscript optimisation level to " + optimizeLevel);
}
}
}
if (expectedOutputSize != null && autoMode) {
long outputFileSize = Files.size(tempOutputFile);
byte[] fileBytes = Files.readAllBytes(tempOutputFile);
if (outputFileSize > expectedOutputSize) {
try (PDDocument doc = Loader.loadPDF(fileBytes)) {
long previousFileSize = 0;
double scaleFactorConst = 0.9f;
double scaleFactor = 0.9f;
while (true) {
for (PDPage page : doc.getPages()) {
PDResources res = page.getResources();
if (res != null && res.getXObjectNames() != null) {
for (COSName name : res.getXObjectNames()) {
PDXObject xobj = res.getXObject(name);
if (xobj != null && xobj instanceof PDImageXObject) {
PDImageXObject image = (PDImageXObject) xobj;
// Get the image in BufferedImage format
BufferedImage bufferedImage = image.getImage();
// Calculate the new dimensions
int newWidth =
(int)
(bufferedImage.getWidth()
* scaleFactorConst);
int newHeight =
(int)
(bufferedImage.getHeight()
* scaleFactorConst);
// If the new dimensions are zero, skip this iteration
if (newWidth == 0 || newHeight == 0) {
continue;
}
// Otherwise, proceed with the scaling
Image scaledImage =
bufferedImage.getScaledInstance(
newWidth,
newHeight,
Image.SCALE_SMOOTH);
// Convert the scaled image back to a BufferedImage
BufferedImage scaledBufferedImage =
new BufferedImage(
newWidth,
newHeight,
BufferedImage.TYPE_INT_RGB);
scaledBufferedImage
.getGraphics()
.drawImage(scaledImage, 0, 0, null);
// Compress the scaled image
ByteArrayOutputStream compressedImageStream =
new ByteArrayOutputStream();
ImageIO.write(
scaledBufferedImage,
"jpeg",
compressedImageStream);
byte[] imageBytes = compressedImageStream.toByteArray();
compressedImageStream.close();
PDImageXObject compressedImage =
PDImageXObject.createFromByteArray(
doc,
imageBytes,
image.getCOSObject().toString());
// Replace the image in the resources with the
// compressed
// version
res.put(name, compressedImage);
}
}
}
}
// save the document to tempOutputFile again
doc.save(tempOutputFile.toString());
long currentSize = Files.size(tempOutputFile);
// Check if the overall PDF size is still larger than expectedOutputSize
if (currentSize > expectedOutputSize) {
// Log the current file size and scaleFactor
logger.info(
"Current file size: "
+ FileUtils.byteCountToDisplaySize(currentSize));
logger.info("Current scale factor: " + scaleFactor);
// The file is still too large, reduce scaleFactor and try again
scaleFactor *= 0.9f; // reduce scaleFactor by 10%
// Avoid scaleFactor being too small, causing the image to shrink to
// 0
if (scaleFactor < 0.2f || previousFileSize == currentSize) {
throw new RuntimeException(
"Could not reach the desired size without excessively degrading image quality, lowest size recommended is "
+ FileUtils.byteCountToDisplaySize(currentSize)
+ ", "
+ currentSize
+ " bytes");
}
previousFileSize = currentSize;
} else {
// The file is small enough, break the loop
break;
}
}
}
}
}
// Read the optimized PDF file
pdfBytes = Files.readAllBytes(tempOutputFile);
Path finalFile = tempOutputFile;
// Check if optimized file is larger than the original
if (pdfBytes.length > inputFileSize) {
log.warn(
// Log the occurrence
logger.warn(
"Optimized file is larger than the original. Returning the original file instead.");
// Read the original file again
finalFile = tempInputFile;
}
// Return the optimized PDF as a response
String outputFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
@@ -214,31 +286,10 @@ public class CompressController {
pdfDocumentFactory.load(finalFile.toFile()), outputFilename);
} finally {
// Clean up the temporary files
// deleted by multipart file handler deu to transferTo?
// Files.deleteIfExists(tempInputFile);
Files.deleteIfExists(tempOutputFile);
}
}
private int determineOptimizeLevel(double sizeReductionRatio) {
if (sizeReductionRatio > 0.9) return 1;
if (sizeReductionRatio > 0.8) return 2;
if (sizeReductionRatio > 0.7) return 3;
if (sizeReductionRatio > 0.6) return 4;
if (sizeReductionRatio > 0.5) return 5;
if (sizeReductionRatio > 0.4) return 6;
if (sizeReductionRatio > 0.3) return 7;
if (sizeReductionRatio > 0.2) return 8;
return 9;
}
private int incrementOptimizeLevel(int currentLevel, long currentSize, long targetSize) {
double currentRatio = currentSize / (double) targetSize;
log.info("Current compression ratio: {}", String.format("%.2f", currentRatio));
if (currentRatio > 2.0) {
return Math.min(9, currentLevel + 3);
} else if (currentRatio > 1.5) {
return Math.min(9, currentLevel + 2);
}
return Math.min(9, currentLevel + 1);
}
}

View File

@@ -17,6 +17,8 @@ import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@@ -29,7 +31,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.ExtractImageScansRequest;
import stirling.software.SPDF.utils.CheckProgramInstall;
import stirling.software.SPDF.utils.ProcessExecutor;
@@ -38,10 +39,11 @@ import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
public class ExtractImageScansController {
private static final Logger logger = LoggerFactory.getLogger(ExtractImageScansController.class);
@PostMapping(consumes = "multipart/form-data", value = "/extract-image-scans")
@Operation(
summary = "Extract image scans from an input file",
@@ -199,7 +201,7 @@ public class ExtractImageScansController {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.error("Failed to delete temporary image file: " + path, e);
logger.error("Failed to delete temporary image file: " + path, e);
}
});
@@ -207,7 +209,7 @@ public class ExtractImageScansController {
try {
Files.deleteIfExists(tempZipFile);
} catch (IOException e) {
log.error("Failed to delete temporary zip file: " + tempZipFile, e);
logger.error("Failed to delete temporary zip file: " + tempZipFile, e);
}
}
@@ -216,7 +218,7 @@ public class ExtractImageScansController {
try {
FileUtils.deleteDirectory(dir.toFile());
} catch (IOException e) {
log.error("Failed to delete temporary directory: " + dir, e);
logger.error("Failed to delete temporary directory: " + dir, e);
}
});
}

View File

@@ -25,6 +25,8 @@ import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -37,17 +39,17 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFExtractImagesRequest;
import stirling.software.SPDF.utils.ImageProcessingUtils;
import stirling.software.SPDF.utils.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
public class ExtractImagesController {
private static final Logger logger = LoggerFactory.getLogger(ExtractImagesController.class);
@PostMapping(consumes = "multipart/form-data", value = "/extract-images")
@Operation(
summary = "Extract images from a PDF file",
@@ -105,7 +107,7 @@ public class ExtractImagesController {
allowDuplicates);
} catch (IOException e) {
// Log the error and continue processing other pages
log.error(
logger.error(
"Error extracting images from page {}: {}",
pageNum,
e.getMessage());
@@ -165,7 +167,7 @@ public class ExtractImagesController {
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
log.error("MD5 algorithm not available for extractImages hash.", e);
logger.error("MD5 algorithm not available for extractImages hash.", e);
return;
}
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {

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