Merge branch 'V2' into V2-backend-ui-removal

This commit is contained in:
Anthony Stirling 2025-08-09 15:40:36 +01:00 committed by GitHub
commit 475a5be10b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
234 changed files with 20104 additions and 1715 deletions

View File

@ -6,7 +6,10 @@
"Bash(./gradlew:*)", "Bash(./gradlew:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(cat:*)", "Bash(cat:*)",
"Bash(find:*)" "Bash(find:*)",
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)"
], ],
"deny": [] "deny": []
} }

View File

@ -24,7 +24,7 @@ indent_size = 2
insert_final_newline = false insert_final_newline = false
trim_trailing_whitespace = false trim_trailing_whitespace = false
[*.js] [{*.js,*.jsx,*.ts,*.tsx}]
indent_size = 2 indent_size = 2
[*.css] [*.css]

View File

@ -48,6 +48,7 @@ jobs:
"DarioGii" "DarioGii"
"ConnorYoh" "ConnorYoh"
"EthanHealy01" "EthanHealy01"
"jbrunton96"
) )
# Check if author is in the authorized list # Check if author is in the authorized list
@ -317,6 +318,7 @@ jobs:
SYSTEM_MAXFILESIZE: "100" SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true" METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false" SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:${V2_PORT}"
restart: on-failure:5 restart: on-failure:5
stirling-pdf-v2-frontend: stirling-pdf-v2-frontend:
@ -350,10 +352,10 @@ jobs:
docker-compose up -d docker-compose up -d
# Clean up unused Docker resources to save space # Clean up unused Docker resources to save space
docker system prune -af --volumes docker system prune -af --volumes || true
# Clean up old backend/frontend images (older than 2 weeks) # Clean up old backend/frontend images (older than 2 weeks)
docker image prune -af --filter "until=336h" --filter "label!=keep=true" docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH ENDSSH
# Set port for output # Set port for output
@ -385,10 +387,12 @@ jobs:
} }
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`; const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` + const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` + `Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` +
`🔗 **V2 Test URL:** [${deploymentUrl}](${deploymentUrl})\n\n` + `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`; `🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
@ -486,7 +490,7 @@ jobs:
fi fi
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse # Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true" docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
# Note: We don't remove the commit-based images since they can be reused across PRs # Note: We don't remove the commit-based images since they can be reused across PRs
# Only remove PR-specific containers and directories # Only remove PR-specific containers and directories

View File

@ -29,6 +29,7 @@ jobs:
github.event.comment.user.login == 'reecebrowne' || github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' || github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'jbrunton96' ||
github.event.comment.user.login == 'ConnorYoh' github.event.comment.user.login == 'ConnorYoh'
) )
outputs: outputs:

View File

@ -53,7 +53,7 @@ jobs:
with: with:
gradle-version: 8.14 gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }} - name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew clean build run: ./gradlew clean build -PnoSpotless
env: env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }} DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
- name: Check Test Reports Exist - name: Check Test Reports Exist
@ -130,7 +130,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: cd frontend && npm run build run: cd frontend && npm run build
- name: Run frontend tests - name: Run frontend tests
run: cd frontend && npm test --passWithNoTests --watchAll=false || true run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts - name: Upload frontend build artifacts
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:

View File

@ -0,0 +1,188 @@
name: Auto V2 Deploy on Push
on:
push:
branches:
- V2
- deploy-on-v2-commit
permissions:
contents: read
jobs:
deploy-v2-on-push:
runs-on: ubuntu-latest
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get commit hashes for frontend and backend
id: commit-hashes
run: |
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
- name: Check if frontend image exists
id: check-frontend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Frontend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Frontend image needs to be built"
fi
- name: Check if backend image exists
id: check-backend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/backend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
chmod 600 ../private.key
- name: Deploy to VPS on port 3000
run: |
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
cat > $UNIQUE_NAME << EOF
version: '3.3'
services:
backend:
container_name: stirling-v2-backend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports:
- "13000:8080"
volumes:
- /stirling/V2/data:/usr/share/tessdata:rw
- /stirling/V2/config:/configs:rw
- /stirling/V2/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2"
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
UI_APPNAMENAVBAR: "V2 Deployment"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:3000"
restart: on-failure:5
frontend:
container_name: stirling-v2-frontend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "3000:80"
environment:
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
depends_on:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
cd /stirling/V2
docker-compose down || true
docker-compose pull
docker-compose up -d
docker system prune -af --volumes || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key

View File

@ -126,19 +126,19 @@ jobs:
commentBody = `## ❌ Frontend License Check Failed commentBody = `## ❌ Frontend License Check Failed
The frontend license check has detected compatibility warnings that require review: The frontend license check has detected compatibility warnings that require review:
${warningDetails} ${warningDetails}
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging. **Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
_This check will fail the PR until license issues are resolved._`; _This check will fail the PR until license issues are resolved._`;
} else { } else {
commentBody = `## ✅ Frontend License Check Passed commentBody = `## ✅ Frontend License Check Passed
All frontend licenses have been validated and no compatibility warnings were detected. All frontend licenses have been validated and no compatibility warnings were detected.
The frontend license report has been updated successfully.`; The frontend license report has been updated successfully.`;
} }
await github.rest.issues.createComment({ await github.rest.issues.createComment({

2
.gitignore vendored
View File

@ -27,7 +27,7 @@ clientWebUI/
!cucumber/exampleFiles/ !cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip !cucumber/exampleFiles/example_html.zip
exampleYmlFiles/stirling/ exampleYmlFiles/stirling/
stirling/ /stirling/
/testing/file_snapshots /testing/file_snapshots
SwaggerDoc.json SwaggerDoc.json

View File

@ -59,12 +59,73 @@ Frontend designed for **stateful document processing**:
Without cleanup: browser crashes with memory leaks. Without cleanup: browser crashes with memory leaks.
#### Tool Development #### Tool Development
- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation
- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection) **Architecture**: Modular hook-based system with clear separation of concerns:
- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools
- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking - **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- **Parameters**: Tool parameter handling patterns still being standardized - Coordinates all tool operations with consistent interface
- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview) - Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
- **Supporting Hooks**:
- **useToolState**: UI state management (loading, progress, error, files)
- **useToolApiCalls**: HTTP requests and file processing
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
- **Utilities**:
- **toolErrorHandler**: Standardized error extraction and i18n support
- **toolResponseProcessor**: API response handling (single/zip/custom)
- **toolOperationTracker**: FileContext integration utilities
**Three Tool Patterns**:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
```typescript
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
filePrefix: 'compressed_'
});
```
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
```typescript
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_'
});
```
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
```typescript
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
filePrefix: 'converted_'
});
```
**Benefits**:
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
- **Consistent**: All tools follow same pattern and interface
- **Maintainable**: Single responsibility hooks, easy to test and modify
- **i18n Ready**: Built-in internationalization support
- **Type Safe**: Full TypeScript support with generic interfaces
- **Memory Safe**: Automatic resource cleanup and blob URL management
## Architecture Overview ## Architecture Overview
@ -126,7 +187,10 @@ Without cleanup: browser crashes with memory leaks.
- **Core Status**: React SPA architecture complete with multi-tool workflow support - **Core Status**: React SPA architecture complete with multi-tool workflow support
- **State Management**: FileContext handles all file operations and tool navigation - **State Management**: FileContext handles all file operations and tool navigation
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+) - **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference - **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example) - **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing - **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
@ -141,7 +205,7 @@ Without cleanup: browser crashes with memory leaks.
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation - **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling - **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code - **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`) - **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes - **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation) - **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)

View File

@ -39,6 +39,11 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
String queryString = request.getQueryString(); String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) { if (queryString != null && !queryString.isEmpty()) {
String requestURI = request.getRequestURI(); String requestURI = request.getRequestURI();
if (requestURI.contains("/api/")) {
return true;
}
Map<String, String> allowedParameters = new HashMap<>(); Map<String, String> allowedParameters = new HashMap<>();
// Keep only the allowed parameters // Keep only the allowed parameters

View File

@ -10,6 +10,7 @@ import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -50,17 +51,26 @@ public class OpenApiConfig {
.url("https://www.stirlingpdf.com") .url("https://www.stirlingpdf.com")
.email("contact@stirlingpdf.com")) .email("contact@stirlingpdf.com"))
.description(DEFAULT_DESCRIPTION); .description(DEFAULT_DESCRIPTION);
OpenAPI openAPI = new OpenAPI().info(info);
// Add server configuration from environment variable
String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL");
if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) {
Server server = new Server().url(swaggerServerUrl).description("API Server");
openAPI.addServersItem(server);
}
if (!applicationProperties.getSecurity().getEnableLogin()) { if (!applicationProperties.getSecurity().getEnableLogin()) {
return new OpenAPI().components(new Components()).info(info); return openAPI.components(new Components());
} else { } else {
SecurityScheme apiKeyScheme = SecurityScheme apiKeyScheme =
new SecurityScheme() new SecurityScheme()
.type(SecurityScheme.Type.APIKEY) .type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER) .in(SecurityScheme.In.HEADER)
.name("X-API-KEY"); .name("X-API-KEY");
return new OpenAPI() return openAPI
.components(new Components().addSecuritySchemes("apiKey", apiKeyScheme)) .components(new Components().addSecuritySchemes("apiKey", apiKeyScheme))
.info(info)
.addSecurityItem(new SecurityRequirement().addList("apiKey")); .addSecurityItem(new SecurityRequirement().addList("apiKey"));
} }
} }

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.*; import java.util.*;
@ -29,7 +31,7 @@ public class AnalysisController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/page-count", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/page-count", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get PDF page count", summary = "Get PDF page count",
description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO") description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO")
@ -39,7 +41,7 @@ public class AnalysisController {
} }
} }
@PostMapping(value = "/basic-info", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/basic-info", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get basic PDF information", summary = "Get basic PDF information",
description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO") description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO")
@ -53,7 +55,7 @@ public class AnalysisController {
} }
} }
@PostMapping(value = "/document-properties", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/document-properties", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get PDF document properties", summary = "Get PDF document properties",
description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO") description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO")
@ -76,7 +78,7 @@ public class AnalysisController {
} }
} }
@PostMapping(value = "/page-dimensions", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/page-dimensions", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get page dimensions for all pages", summary = "Get page dimensions for all pages",
description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO") description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO")
@ -96,7 +98,7 @@ public class AnalysisController {
} }
} }
@PostMapping(value = "/form-fields", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/form-fields", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get form field information", summary = "Get form field information",
description = description =
@ -119,7 +121,7 @@ public class AnalysisController {
} }
} }
@PostMapping(value = "/annotation-info", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/annotation-info", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get annotation information", summary = "Get annotation information",
description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO") description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO")
@ -143,7 +145,7 @@ public class AnalysisController {
} }
} }
@PostMapping(value = "/font-info", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/font-info", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get font information", summary = "Get font information",
description = description =
@ -165,7 +167,7 @@ public class AnalysisController {
} }
} }
@PostMapping(value = "/security-info", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/security-info", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Get security information", summary = "Get security information",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -33,7 +35,7 @@ public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/crop", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/crop", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Crops a PDF document", summary = "Crops a PDF document",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@ -44,7 +46,7 @@ public class EditTableOfContentsController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@PostMapping(value = "/extract-bookmarks", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/extract-bookmarks", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Extract PDF Bookmarks", summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.") description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ -152,7 +154,7 @@ public class EditTableOfContentsController {
return bookmark; return bookmark;
} }
@PostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Edit Table of Contents", summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.") description = "Add or edit bookmarks/table of contents in a PDF document.")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -154,7 +156,7 @@ public class MergeController {
} }
} }
@PostMapping(consumes = "multipart/form-data", value = "/merge-pdfs") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/merge-pdfs")
@Operation( @Operation(
summary = "Merge multiple PDF files into one", summary = "Merge multiple PDF files into one",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -36,7 +38,7 @@ public class MultiPageLayoutController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/multi-page-layout", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Merge multiple pages of a PDF document into a single page", summary = "Merge multiple pages of a PDF document into a single page",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -46,7 +48,7 @@ public class PdfImageRemovalController {
* content type and filename. * content type and filename.
* @throws IOException If an error occurs while processing the PDF file. * @throws IOException If an error occurs while processing the PDF file.
*/ */
@PostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf")
@Operation( @Operation(
summary = "Remove images from file to reduce the file size.", summary = "Remove images from file to reduce the file size.",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -39,7 +41,7 @@ public class PdfOverlayController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Overlay PDF files in various modes", summary = "Overlay PDF files in various modes",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -38,7 +40,7 @@ public class RearrangePagesPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-pages") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-pages")
@Operation( @Operation(
summary = "Remove pages from a PDF file", summary = "Remove pages from a PDF file",
description = description =
@ -237,7 +239,7 @@ public class RearrangePagesPDFController {
} }
} }
@PostMapping(consumes = "multipart/form-data", value = "/rearrange-pages") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/rearrange-pages")
@Operation( @Operation(
summary = "Rearrange pages in a PDF file", summary = "Rearrange pages in a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -31,7 +33,7 @@ public class RotationController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/rotate-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
@Operation( @Operation(
summary = "Rotate a PDF file", summary = "Rotate a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
@ -38,7 +40,7 @@ public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/scale-pages", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/scale-pages", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Change the size of a PDF page/document", summary = "Change the size of a PDF page/document",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
@ -31,7 +33,7 @@ public class SettingsController {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private final EndpointConfiguration endpointConfiguration; private final EndpointConfiguration endpointConfiguration;
@PostMapping("/update-enable-analytics") @AutoJobPostMapping("/update-enable-analytics")
@Hidden @Hidden
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException { public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
if (applicationProperties.getSystem().getEnableAnalytics() != null) { if (applicationProperties.getSystem().getEnableAnalytics() != null) {

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -41,7 +43,7 @@ public class SplitPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/split-pages") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/split-pages")
@Operation( @Operation(
summary = "Split a PDF file into separate documents", summary = "Split a PDF file into separate documents",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -117,7 +119,7 @@ public class SplitPdfByChaptersController {
return bookmarks; return bookmarks;
} }
@PostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Split PDFs by Chapters", summary = "Split PDFs by Chapters",
description = "Splits a PDF into chapters and returns a ZIP file.") description = "Splits a PDF into chapters and returns a ZIP file.")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -43,7 +45,7 @@ public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Split PDF pages into smaller sections", summary = "Split PDF pages into smaller sections",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -39,7 +41,7 @@ public class SplitPdfBySizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Auto split PDF pages into separate documents based on size or count", summary = "Auto split PDF pages into separate documents based on size or count",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.geom.AffineTransform; import java.awt.geom.AffineTransform;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -33,7 +35,7 @@ public class ToSinglePageController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page")
@Operation( @Operation(
summary = "Convert a multi-page PDF into a single long page PDF", summary = "Convert a multi-page PDF into a single long page PDF",
description = description =

View File

@ -0,0 +1,301 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@Slf4j
@RestController
@RequestMapping("/api/v1/ui-data")
@Tag(name = "UI Data", description = "APIs for React UI data")
public class UIDataController {
private final ApplicationProperties applicationProperties;
private final SignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public UIDataController(
ApplicationProperties applicationProperties,
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
this.applicationProperties = applicationProperties;
this.signatureService = signatureService;
this.userService = userService;
this.resourceLoader = resourceLoader;
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/home")
@Operation(summary = "Get home page data")
public ResponseEntity<HomeData> getHomeData() {
String showSurvey = System.getenv("SHOW_SURVEY");
boolean showSurveyValue = showSurvey == null || "true".equalsIgnoreCase(showSurvey);
HomeData data = new HomeData();
data.setShowSurveyFromDocker(showSurveyValue);
return ResponseEntity.ok(data);
}
@GetMapping("/licenses")
@Operation(summary = "Get third-party licenses data")
public ResponseEntity<LicensesData> getLicensesData() {
LicensesData data = new LicensesData();
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
try {
InputStream is = resource.getInputStream();
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> licenseData =
mapper.readValue(json, new TypeReference<>() {});
data.setDependencies(licenseData.get("dependencies"));
} catch (IOException e) {
log.error("Failed to load licenses data", e);
data.setDependencies(Collections.emptyList());
}
return ResponseEntity.ok(data);
}
@GetMapping("/pipeline")
@Operation(summary = "Get pipeline configuration data")
public ResponseEntity<PipelineData> getPipelineData() {
PipelineData data = new PipelineData();
List<String> pipelineConfigs = new ArrayList<>();
List<Map<String, String>> pipelineConfigsWithNames = new ArrayList<>();
if (new java.io.File(runtimePathConfig.getPipelineDefaultWebUiConfigs()).exists()) {
try (Stream<Path> paths =
Files.walk(Paths.get(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) {
List<Path> jsonFiles =
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".json"))
.toList();
for (Path jsonFile : jsonFiles) {
String content = Files.readString(jsonFile, StandardCharsets.UTF_8);
pipelineConfigs.add(content);
}
for (String config : pipelineConfigs) {
Map<String, Object> jsonContent =
new ObjectMapper()
.readValue(config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) {
String filename =
jsonFiles
.get(pipelineConfigs.indexOf(config))
.getFileName()
.toString();
name = filename.substring(0, filename.lastIndexOf('.'));
}
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", config);
configWithName.put("name", name);
pipelineConfigsWithNames.add(configWithName);
}
} catch (IOException e) {
log.error("Failed to load pipeline configs", e);
}
}
if (pipelineConfigsWithNames.isEmpty()) {
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", "");
configWithName.put("name", "No preloaded configs found");
pipelineConfigsWithNames.add(configWithName);
}
data.setPipelineConfigsWithNames(pipelineConfigsWithNames);
data.setPipelineConfigs(pipelineConfigs);
return ResponseEntity.ok(data);
}
@GetMapping("/sign")
@Operation(summary = "Get signature form data")
public ResponseEntity<SignData> getSignData() {
String username = "";
if (userService != null) {
username = userService.getCurrentUsername();
}
List<SignatureFile> signatures = signatureService.getAvailableSignatures(username);
List<FontResource> fonts = getFontNames();
SignData data = new SignData();
data.setSignatures(signatures);
data.setFonts(fonts);
return ResponseEntity.ok(data);
}
@GetMapping("/ocr-pdf")
@Operation(summary = "Get OCR PDF data")
public ResponseEntity<OcrData> getOcrPdfData() {
List<String> languages = getAvailableTesseractLanguages();
OcrData data = new OcrData();
data.setLanguages(languages);
return ResponseEntity.ok(data);
}
private List<String> getAvailableTesseractLanguages() {
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
}
return Arrays.stream(files)
.filter(file -> file.getName().endsWith(".traineddata"))
.map(file -> file.getName().replace(".traineddata", ""))
.filter(lang -> !"osd".equalsIgnoreCase(lang))
.sorted()
.toList();
}
private List<FontResource> getFontNames() {
List<FontResource> fontNames = new ArrayList<>();
fontNames.addAll(getFontNamesFromLocation("classpath:static/fonts/*.woff2"));
fontNames.addAll(
getFontNamesFromLocation(
"file:"
+ InstallationPathConfig.getStaticPath()
+ "fonts"
+ java.io.File.separator
+ "*"));
return fontNames;
}
private List<FontResource> getFontNamesFromLocation(String locationPattern) {
try {
Resource[] resources =
GeneralUtils.getResourcesFromLocationPattern(locationPattern, resourceLoader);
return Arrays.stream(resources)
.map(
resource -> {
try {
String filename = resource.getFilename();
if (filename != null) {
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex != -1) {
String name = filename.substring(0, lastDotIndex);
String extension = filename.substring(lastDotIndex + 1);
return new FontResource(name, extension);
}
}
return null;
} catch (Exception e) {
throw ExceptionUtils.createRuntimeException(
"error.fontLoadingFailed",
"Error processing font file",
e);
}
})
.filter(Objects::nonNull)
.toList();
} catch (Exception e) {
throw ExceptionUtils.createRuntimeException(
"error.fontDirectoryReadFailed", "Failed to read font directory", e);
}
}
// Data classes
@Data
public static class HomeData {
private boolean showSurveyFromDocker;
}
@Data
public static class LicensesData {
private List<Dependency> dependencies;
}
@Data
public static class PipelineData {
private List<Map<String, String>> pipelineConfigsWithNames;
private List<String> pipelineConfigs;
}
@Data
public static class SignData {
private List<SignatureFile> signatures;
private List<FontResource> fonts;
}
@Data
public static class OcrData {
private List<String> languages;
}
@Data
public static class FontResource {
private String name;
private String extension;
private String type;
public FontResource(String name, String extension) {
this.name = name;
this.extension = extension;
this.type = getFormatFromExtension(extension);
}
private static String getFormatFromExtension(String extension) {
switch (extension) {
case "ttf":
return "truetype";
case "woff":
return "woff";
case "woff2":
return "woff2";
case "eot":
return "embedded-opentype";
case "svg":
return "svg";
default:
return "";
}
}
}
}

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -38,7 +40,7 @@ public class ConvertEmlToPDF {
private final RuntimePathConfig runtimePathConfig; private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/eml/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/eml/pdf")
@Operation( @Operation(
summary = "Convert EML to PDF", summary = "Convert EML to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -36,7 +38,7 @@ public class ConvertHtmlToPDF {
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/html/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@Operation( @Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF", summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@ -51,7 +53,7 @@ public class ConvertImgPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf/img") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/img")
@Operation( @Operation(
summary = "Convert PDF to image(s)", summary = "Convert PDF to image(s)",
description = description =
@ -211,7 +213,7 @@ public class ConvertImgPDFController {
} }
} }
@PostMapping(consumes = "multipart/form-data", value = "/img/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/img/pdf")
@Operation( @Operation(
summary = "Convert images to a PDF file", summary = "Convert images to a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -45,7 +47,7 @@ public class ConvertMarkdownToPdf {
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@Operation( @Operation(
summary = "Convert a Markdown file to PDF", summary = "Convert a Markdown file to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -84,7 +86,7 @@ public class ConvertOfficeController {
return fileExtension.matches(extensionPattern); return fileExtension.matches(extensionPattern);
} }
@PostMapping(consumes = "multipart/form-data", value = "/file/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/file/pdf")
@Operation( @Operation(
summary = "Convert a file to a PDF using LibreOffice", summary = "Convert a file to a PDF using LibreOffice",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -18,7 +20,7 @@ import stirling.software.common.util.PDFToFile;
@RequestMapping("/api/v1/convert") @RequestMapping("/api/v1/convert")
public class ConvertPDFToHtml { public class ConvertPDFToHtml {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/html") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/html")
@Operation( @Operation(
summary = "Convert PDF to HTML", summary = "Convert PDF to HTML",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -34,7 +36,7 @@ public class ConvertPDFToOffice {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf/presentation") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/presentation")
@Operation( @Operation(
summary = "Convert PDF to Presentation format", summary = "Convert PDF to Presentation format",
description = description =
@ -49,7 +51,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import"); return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
} }
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text")
@Operation( @Operation(
summary = "Convert PDF to Text or RTF format", summary = "Convert PDF to Text or RTF format",
description = description =
@ -77,7 +79,7 @@ public class ConvertPDFToOffice {
} }
} }
@PostMapping(consumes = "multipart/form-data", value = "/pdf/word") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/word")
@Operation( @Operation(
summary = "Convert PDF to Word document", summary = "Convert PDF to Word document",
description = description =
@ -91,7 +93,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import"); return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
} }
@PostMapping(consumes = "multipart/form-data", value = "/pdf/xml") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/xml")
@Operation( @Operation(
summary = "Convert PDF to XML", summary = "Convert PDF to XML",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.Color; import java.awt.Color;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
@ -78,7 +80,7 @@ import stirling.software.common.util.WebResponseUtils;
@Tag(name = "Convert", description = "Convert APIs") @Tag(name = "Convert", description = "Convert APIs")
public class ConvertPDFToPDFA { public class ConvertPDFToPDFA {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
@Operation( @Operation(
summary = "Convert a PDF to a PDF/A", summary = "Convert a PDF to a PDF/A",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -40,7 +42,7 @@ public class ConvertWebsiteToPDF {
private final RuntimePathConfig runtimePathConfig; private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
@PostMapping(consumes = "multipart/form-data", value = "/url/pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/url/pdf")
@Operation( @Operation(
summary = "Convert a URL to a PDF", summary = "Convert a URL to a PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.converters; package stirling.software.SPDF.controller.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.StringWriter; import java.io.StringWriter;
@ -46,7 +48,7 @@ public class ExtractCSVController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/pdf/csv", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Extracts a CSV document from a PDF", summary = "Extracts a CSV document from a PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.filters; package stirling.software.SPDF.controller.api.filters;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -37,7 +39,7 @@ public class FilterController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-text") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-text")
@Operation( @Operation(
summary = "Checks if a PDF contains set text, returns true if does", summary = "Checks if a PDF contains set text, returns true if does",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -55,7 +57,7 @@ public class FilterController {
} }
// TODO // TODO
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-image") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-image")
@Operation( @Operation(
summary = "Checks if a PDF contains an image", summary = "Checks if a PDF contains an image",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -71,7 +73,7 @@ public class FilterController {
return null; return null;
} }
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-count") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-count")
@Operation( @Operation(
summary = "Checks if a PDF is greater, less or equal to a setPageCount", summary = "Checks if a PDF is greater, less or equal to a setPageCount",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -104,7 +106,7 @@ public class FilterController {
return null; return null;
} }
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-size") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-size")
@Operation( @Operation(
summary = "Checks if a PDF is of a certain size", summary = "Checks if a PDF is of a certain size",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -147,7 +149,7 @@ public class FilterController {
return null; return null;
} }
@PostMapping(consumes = "multipart/form-data", value = "/filter-file-size") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-file-size")
@Operation( @Operation(
summary = "Checks if a PDF is a set file size", summary = "Checks if a PDF is a set file size",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")
@ -180,7 +182,7 @@ public class FilterController {
return null; return null;
} }
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation")
@Operation( @Operation(
summary = "Checks if a PDF is of a certain rotation", summary = "Checks if a PDF is of a certain rotation",
description = "Input:PDF Output:Boolean Type:SISO") description = "Input:PDF Output:Boolean Type:SISO")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@ -34,7 +36,7 @@ public class AttachmentController {
private final AttachmentServiceInterface pdfAttachmentService; private final AttachmentServiceInterface pdfAttachmentService;
@PostMapping(consumes = "multipart/form-data", value = "/add-attachments") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-attachments")
@Operation( @Operation(
summary = "Add attachments to PDF", summary = "Add attachments to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@ -38,7 +40,7 @@ public class AutoRenameController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/auto-rename") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/auto-rename")
@Operation( @Operation(
summary = "Extract header from PDF file", summary = "Extract header from PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte; import java.awt.image.DataBufferByte;
import java.awt.image.DataBufferInt; import java.awt.image.DataBufferInt;
@ -102,7 +104,7 @@ public class AutoSplitPdfController {
} }
} }
@PostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Auto split PDF pages into separate documents", summary = "Auto split PDF pages into separate documents",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -69,7 +71,7 @@ public class BlankPageController {
return whitePixelPercentage >= whitePercent; return whitePixelPercentage >= whitePercent;
} }
@PostMapping(consumes = "multipart/form-data", value = "/remove-blanks") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-blanks")
@Operation( @Operation(
summary = "Remove blank pages from a PDF file", summary = "Remove blank pages from a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@ -658,7 +660,7 @@ public class CompressController {
}; };
} }
@PostMapping(consumes = "multipart/form-data", value = "/compress-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/compress-pdf")
@Operation( @Operation(
summary = "Optimize PDF file", summary = "Optimize PDF file",
description = description =

View File

@ -110,14 +110,14 @@ public class ConfigController {
} }
@GetMapping("/endpoint-enabled") @GetMapping("/endpoint-enabled")
public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam String endpoint) { public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam(name = "endpoint") String endpoint) {
boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint); boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint);
return ResponseEntity.ok(enabled); return ResponseEntity.ok(enabled);
} }
@GetMapping("/endpoints-enabled") @GetMapping("/endpoints-enabled")
public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled( public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled(
@RequestParam String endpoints) { @RequestParam(name = "endpoints") String endpoints) {
Map<String, Boolean> result = new HashMap<>(); Map<String, Boolean> result = new HashMap<>();
String[] endpointArray = endpoints.split(","); String[] endpointArray = endpoints.split(",");
for (String endpoint : endpointArray) { for (String endpoint : endpointArray) {

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
@ -38,7 +40,7 @@ public class DecompressPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/decompress-pdf", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/decompress-pdf", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Decompress PDF streams", summary = "Decompress PDF streams",
description = "Fully decompresses all PDF streams including text content") description = "Fully decompresses all PDF streams including text content")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
@ -50,7 +52,7 @@ public class ExtractImageScansController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/extract-image-scans") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-image-scans")
@Operation( @Operation(
summary = "Extract image scans from an input file", summary = "Extract image scans from an input file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage; import java.awt.image.RenderedImage;
@ -54,7 +56,7 @@ public class ExtractImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/extract-images") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-images")
@Operation( @Operation(
summary = "Extract images from a PDF file", summary = "Extract images from a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.IOException; import java.io.IOException;
@ -38,7 +40,7 @@ public class FlattenController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/flatten") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/flatten")
@Operation( @Operation(
summary = "Flatten PDF form fields or full page", summary = "Flatten PDF form fields or full page",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.text.ParseException; import java.text.ParseException;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
@ -51,7 +53,7 @@ public class MetadataController {
binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor()); binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor());
} }
@PostMapping(consumes = "multipart/form-data", value = "/update-metadata") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/update-metadata")
@Operation( @Operation(
summary = "Update metadata of a PDF file", summary = "Update metadata of a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.*; import java.io.*;
import java.nio.file.Files; import java.nio.file.Files;
@ -76,7 +78,7 @@ public class OCRController {
.toList(); .toList();
} }
@PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
@Operation( @Operation(
summary = "Process a PDF file with OCR", summary = "Process a PDF file with OCR",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -31,7 +33,7 @@ public class OverlayImageController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/add-image") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-image")
@Operation( @Operation(
summary = "Overlay image onto a PDF file", summary = "Overlay image onto a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@ -37,7 +39,7 @@ public class PageNumbersController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/add-page-numbers", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/add-page-numbers", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Add page numbers to a PDF document", summary = "Add page numbers to a PDF document",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.print.PageFormat; import java.awt.print.PageFormat;
@ -37,7 +39,7 @@ import stirling.software.SPDF.model.api.misc.PrintFileRequest;
public class PrintFileController { public class PrintFileController {
// TODO // TODO
// @PostMapping(value = "/print-file", consumes = "multipart/form-data") // @AutoJobPostMapping(value = "/print-file", consumes = "multipart/form-data")
// @Operation( // @Operation(
// summary = "Prints PDF/Image file to a set printer", // summary = "Prints PDF/Image file to a set printer",
// description = // description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -46,7 +48,7 @@ public class RepairController {
return endpointConfiguration.isGroupEnabled("qpdf"); return endpointConfiguration.isGroupEnabled("qpdf");
} }
@PostMapping(consumes = "multipart/form-data", value = "/repair") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/repair")
@Operation( @Operation(
summary = "Repair a PDF file", summary = "Repair a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
@ -27,7 +29,7 @@ public class ReplaceAndInvertColorController {
private final ReplaceAndInvertColorService replaceAndInvertColorService; private final ReplaceAndInvertColorService replaceAndInvertColorService;
@PostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf")
@Operation( @Operation(
summary = "Replace-Invert Color PDF", summary = "Replace-Invert Color PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.Color; import java.awt.Color;
import java.awt.Graphics2D; import java.awt.Graphics2D;
import java.awt.RenderingHints; import java.awt.RenderingHints;
@ -52,7 +54,7 @@ public class ScannerEffectController {
private static final int MAX_IMAGE_HEIGHT = 8192; private static final int MAX_IMAGE_HEIGHT = 8192;
private static final long MAX_IMAGE_PIXELS = 16_777_216; // 4096x4096 private static final long MAX_IMAGE_PIXELS = 16_777_216; // 4096x4096
@PostMapping(value = "/scanner-effect", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/scanner-effect", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Apply scanner effect to PDF", summary = "Apply scanner effect to PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Map; import java.util.Map;
@ -32,7 +34,7 @@ public class ShowJavascript {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/show-javascript") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/show-javascript")
@Operation( @Operation(
summary = "Grabs all JS from a PDF and returns a single JS file with all code", summary = "Grabs all JS from a PDF and returns a single JS file with all code",
description = "desc. Input:PDF Output:JS Type:SISO") description = "desc. Input:PDF Output:JS Type:SISO")

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
@ -52,7 +54,7 @@ public class StampController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
@PostMapping(consumes = "multipart/form-data", value = "/add-stamp") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-stamp")
@Operation( @Operation(
summary = "Add stamp to a PDF file", summary = "Add stamp to a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
@ -37,7 +39,7 @@ public class UnlockPDFFormsController {
this.pdfDocumentFactory = pdfDocumentFactory; this.pdfDocumentFactory = pdfDocumentFactory;
} }
@PostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms")
@Operation( @Operation(
summary = "Remove read-only property from form fields", summary = "Remove read-only property from form fields",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.pipeline; package stirling.software.SPDF.controller.api.pipeline;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
import java.util.HashMap; import java.util.HashMap;
@ -46,7 +48,7 @@ public class PipelineController {
private final PostHogService postHogService; private final PostHogService postHogService;
@PostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request) public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
throws JsonMappingException, JsonProcessingException { throws JsonMappingException, JsonProcessingException {
MultipartFile[] files = request.getFileInput(); MultipartFile[] files = request.getFileInput();

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.beans.PropertyEditorSupport; import java.beans.PropertyEditorSupport;
import java.io.*; import java.io.*;
@ -138,7 +140,7 @@ public class CertSignController {
} }
} }
@PostMapping( @AutoJobPostMapping(
consumes = { consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE MediaType.APPLICATION_FORM_URLENCODED_VALUE

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -188,7 +190,7 @@ public class GetInfoOnPDF {
return false; return false;
} }
@PostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf")
@Operation(summary = "Summary here", description = "desc. Input:PDF Output:JSON Type:SISO") @Operation(summary = "Summary here", description = "desc. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException { public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException {
MultipartFile inputFile = request.getFileInput(); MultipartFile inputFile = request.getFileInput();

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -32,7 +34,7 @@ public class PasswordController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-password") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-password")
@Operation( @Operation(
summary = "Remove password from a PDF file", summary = "Remove password from a PDF file",
description = description =
@ -58,7 +60,7 @@ public class PasswordController {
} }
} }
@PostMapping(consumes = "multipart/form-data", value = "/add-password") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-password")
@Operation( @Operation(
summary = "Add password to a PDF file", summary = "Add password to a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -56,7 +58,7 @@ public class RedactController {
List.class, "redactions", new StringToArrayListPropertyEditor()); List.class, "redactions", new StringToArrayListPropertyEditor());
} }
@PostMapping(value = "/redact", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/redact", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Redacts areas and pages in a PDF document", summary = "Redacts areas and pages in a PDF document",
description = description =
@ -190,7 +192,7 @@ public class RedactController {
return pageNumbers; return pageNumbers;
} }
@PostMapping(value = "/auto-redact", consumes = "multipart/form-data") @AutoJobPostMapping(value = "/auto-redact", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Redacts listOfText in a PDF document", summary = "Redacts listOfText in a PDF document",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.util.List; import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -32,7 +34,7 @@ public class RemoveCertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign")
@Operation( @Operation(
summary = "Remove digital signature from PDF", summary = "Remove digital signature from PDF",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.io.IOException; import java.io.IOException;
import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSDictionary;
@ -46,7 +48,7 @@ public class SanitizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
@Operation( @Operation(
summary = "Sanitize a PDF file", summary = "Sanitize a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.beans.PropertyEditorSupport; import java.beans.PropertyEditorSupport;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
@ -69,7 +71,7 @@ public class ValidateSignatureController {
description = description =
"Validates the digital signatures in a PDF file against default or custom" "Validates the digital signatures in a PDF file against default or custom"
+ " certificates. Input:PDF Output:JSON Type:SISO") + " certificates. Input:PDF Output:JSON Type:SISO")
@PostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @AutoJobPostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<SignatureValidationResult>> validateSignature( public ResponseEntity<List<SignatureValidationResult>> validateSignature(
@ModelAttribute SignatureValidationRequest request) throws IOException { @ModelAttribute SignatureValidationRequest request) throws IOException {
List<SignatureValidationResult> results = new ArrayList<>(); List<SignatureValidationResult> results = new ArrayList<>();

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import stirling.software.common.annotations.AutoJobPostMapping;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.beans.PropertyEditorSupport; import java.beans.PropertyEditorSupport;
@ -64,7 +66,7 @@ public class WatermarkController {
}); });
} }
@PostMapping(consumes = "multipart/form-data", value = "/add-watermark") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-watermark")
@Operation( @Operation(
summary = "Add watermark to a PDF file", summary = "Add watermark to a PDF file",
description = description =

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.model.api.converters; package stirling.software.SPDF.model.api.converters;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -18,7 +20,7 @@ import stirling.software.common.util.PDFToFile;
@RequestMapping("/api/v1/convert") @RequestMapping("/api/v1/convert")
public class ConvertPDFToMarkdown { public class ConvertPDFToMarkdown {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/markdown") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/markdown")
@Operation( @Operation(
summary = "Convert PDF to Markdown", summary = "Convert PDF to Markdown",
description = description =

View File

@ -0,0 +1,484 @@
package stirling.software.proprietary.controller.api;
import static stirling.software.common.util.ProviderUtils.validateProvider;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2.Client;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.FileInfo;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/ui-data")
@Tag(name = "Proprietary UI Data", description = "APIs for React UI data (Proprietary features)")
@EnterpriseEndpoint
public class ProprietaryUIDataController {
private final ApplicationProperties applicationProperties;
private final AuditConfigurationProperties auditConfig;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final UserRepository userRepository;
private final TeamRepository teamRepository;
private final SessionRepository sessionRepository;
private final DatabaseService databaseService;
private final boolean runningEE;
private final ObjectMapper objectMapper;
public ProprietaryUIDataController(
ApplicationProperties applicationProperties,
AuditConfigurationProperties auditConfig,
SessionPersistentRegistry sessionPersistentRegistry,
UserRepository userRepository,
TeamRepository teamRepository,
SessionRepository sessionRepository,
DatabaseService databaseService,
ObjectMapper objectMapper,
@Qualifier("runningEE") boolean runningEE) {
this.applicationProperties = applicationProperties;
this.auditConfig = auditConfig;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.userRepository = userRepository;
this.teamRepository = teamRepository;
this.sessionRepository = sessionRepository;
this.databaseService = databaseService;
this.objectMapper = objectMapper;
this.runningEE = runningEE;
}
@GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get audit dashboard data")
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
AuditDashboardData data = new AuditDashboardData();
data.setAuditEnabled(auditConfig.isEnabled());
data.setAuditLevel(auditConfig.getAuditLevel());
data.setAuditLevelInt(auditConfig.getLevel());
data.setRetentionDays(auditConfig.getRetentionDays());
data.setAuditLevels(AuditLevel.values());
data.setAuditEventTypes(AuditEventType.values());
return ResponseEntity.ok(data);
}
@GetMapping("/login")
@Operation(summary = "Get login page data")
public ResponseEntity<LoginData> getLoginData() {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
if (oauth.isSettingsValid()) {
String firstChar = String.valueOf(oauth.getProvider().charAt(0));
String clientName =
oauth.getProvider().replaceFirst(firstChar, firstChar.toUpperCase());
providerList.put("/oauth2/authorization/" + oauth.getProvider(), clientName);
}
Client client = oauth.getClient();
if (client != null) {
GoogleProvider google = client.getGoogle();
if (validateProvider(google)) {
providerList.put(
"/oauth2/authorization/" + google.getName(), google.getClientName());
}
GitHubProvider github = client.getGithub();
if (validateProvider(github)) {
providerList.put(
"/oauth2/authorization/" + github.getName(), github.getClientName());
}
KeycloakProvider keycloak = client.getKeycloak();
if (validateProvider(keycloak)) {
providerList.put(
"/oauth2/authorization/" + keycloak.getName(),
keycloak.getClientName());
}
}
}
SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
}
}
// Remove null entries
providerList
.entrySet()
.removeIf(entry -> entry.getKey() == null || entry.getValue() == null);
data.setProviderList(providerList);
data.setLoginMethod(securityProps.getLoginMethod());
data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin());
return ResponseEntity.ok(data);
}
@GetMapping("/admin-settings")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Operation(summary = "Get admin settings data")
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>();
int activeUsers = 0;
int disabledUsers = 0;
while (iterator.hasNext()) {
User user = iterator.next();
if (user != null) {
boolean shouldRemove = false;
// Check if user is an INTERNAL_API_USER
for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
shouldRemove = true;
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
break;
}
}
// Check if user is part of the Internal team
if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
shouldRemove = true;
}
if (shouldRemove) {
iterator.remove();
continue;
}
// Session status and last request time
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
boolean hasActiveSession = false;
Date lastRequest = null;
Optional<SessionEntity> latestSession =
sessionPersistentRegistry.findLatestSession(user.getUsername());
if (latestSession.isPresent()) {
SessionEntity sessionEntity = latestSession.get();
Date lastAccessedTime = sessionEntity.getLastRequest();
Instant now = Instant.now();
Instant expirationTime =
lastAccessedTime
.toInstant()
.plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId());
} else {
hasActiveSession = !sessionEntity.isExpired();
}
lastRequest = sessionEntity.getLastRequest();
} else {
lastRequest = new Date(0);
}
userSessions.put(user.getUsername(), hasActiveSession);
userLastRequest.put(user.getUsername(), lastRequest);
if (hasActiveSession) activeUsers++;
if (!user.isEnabled()) disabledUsers++;
}
}
// Sort users by active status and last request date
List<User> sortedUsers =
allUsers.stream()
.sorted(
(u1, u2) -> {
boolean u1Active = userSessions.get(u1.getUsername());
boolean u2Active = userSessions.get(u2.getUsername());
if (u1Active && !u2Active) return -1;
if (!u1Active && u2Active) return 1;
Date u1LastRequest =
userLastRequest.getOrDefault(
u1.getUsername(), new Date(0));
Date u2LastRequest =
userLastRequest.getOrDefault(
u2.getUsername(), new Date(0));
return u2LastRequest.compareTo(u1LastRequest);
})
.toList();
List<Team> allTeams =
teamRepository.findAll().stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.toList();
AdminSettingsData data = new AdminSettingsData();
data.setUsers(sortedUsers);
data.setCurrentUsername(authentication.getName());
data.setRoleDetails(roleDetails);
data.setUserSessions(userSessions);
data.setUserLastRequest(userLastRequest);
data.setTotalUsers(allUsers.size());
data.setActiveUsers(activeUsers);
data.setDisabledUsers(disabledUsers);
data.setTeams(allTeams);
data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers());
return ResponseEntity.ok(data);
}
@GetMapping("/account")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@Operation(summary = "Get account page data")
public ResponseEntity<AccountData> getAccountData(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(401).build();
}
Object principal = authentication.getPrincipal();
String username = null;
boolean isOAuth2Login = false;
boolean isSaml2Login = false;
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
isOAuth2Login = true;
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
username = saml2User.name();
isSaml2Login = true;
}
if (username == null) {
return ResponseEntity.status(401).build();
}
Optional<User> user = userRepository.findByUsernameIgnoreCaseWithSettings(username);
if (user.isEmpty()) {
return ResponseEntity.status(404).build();
}
String settingsJson;
try {
settingsJson = objectMapper.writeValueAsString(user.get().getSettings());
} catch (JsonProcessingException e) {
log.error("Error converting settings map", e);
return ResponseEntity.status(500).build();
}
AccountData data = new AccountData();
data.setUsername(username);
data.setRole(user.get().getRolesAsString());
data.setSettings(settingsJson);
data.setChangeCredsFlag(user.get().isFirstLogin());
data.setOAuth2Login(isOAuth2Login);
data.setSaml2Login(isSaml2Login);
return ResponseEntity.ok(data);
}
@GetMapping("/teams")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Operation(summary = "Get teams list data")
public ResponseEntity<TeamsData> getTeamsData() {
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
List<TeamWithUserCountDTO> teamsWithCounts =
allTeamsWithCounts.stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.toList();
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
Map<Long, Date> teamLastRequest = new HashMap<>();
for (Object[] result : teamActivities) {
Long teamId = (Long) result[0];
Date lastActivity = (Date) result[1];
teamLastRequest.put(teamId, lastActivity);
}
TeamsData data = new TeamsData();
data.setTeamsWithCounts(teamsWithCounts);
data.setTeamLastRequest(teamLastRequest);
return ResponseEntity.ok(data);
}
@GetMapping("/teams/{id}")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Operation(summary = "Get team details data")
public ResponseEntity<TeamDetailsData> getTeamDetailsData(@PathVariable("id") Long id) {
Team team =
teamRepository
.findById(id)
.orElseThrow(() -> new RuntimeException("Team not found"));
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(403).build();
}
List<User> teamUsers = userRepository.findAllByTeamId(id);
List<User> allUsers = userRepository.findAllWithTeam();
List<User> availableUsers =
allUsers.stream()
.filter(
user ->
(user.getTeam() == null
|| !user.getTeam().getId().equals(id))
&& (user.getTeam() == null
|| !user.getTeam()
.getName()
.equals(
TeamService
.INTERNAL_TEAM_NAME)))
.toList();
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
Map<String, Date> userLastRequest = new HashMap<>();
for (Object[] result : userSessions) {
String username = (String) result[0];
Date lastRequest = (Date) result[1];
userLastRequest.put(username, lastRequest);
}
TeamDetailsData data = new TeamDetailsData();
data.setTeam(team);
data.setTeamUsers(teamUsers);
data.setAvailableUsers(availableUsers);
data.setUserLastRequest(userLastRequest);
return ResponseEntity.ok(data);
}
@GetMapping("/database")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Operation(summary = "Get database management data")
public ResponseEntity<DatabaseData> getDatabaseData() {
List<FileInfo> backupList = databaseService.getBackupList();
String dbVersion = databaseService.getH2Version();
boolean isVersionUnknown = "Unknown".equalsIgnoreCase(dbVersion);
DatabaseData data = new DatabaseData();
data.setBackupFiles(backupList);
data.setDatabaseVersion(dbVersion);
data.setVersionUnknown(isVersionUnknown);
return ResponseEntity.ok(data);
}
// Data classes
@Data
public static class AuditDashboardData {
private boolean auditEnabled;
private AuditLevel auditLevel;
private int auditLevelInt;
private int retentionDays;
private AuditLevel[] auditLevels;
private AuditEventType[] auditEventTypes;
}
@Data
public static class LoginData {
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;
}
@Data
public static class AdminSettingsData {
private List<User> users;
private String currentUsername;
private Map<String, String> roleDetails;
private Map<String, Boolean> userSessions;
private Map<String, Date> userLastRequest;
private int totalUsers;
private int activeUsers;
private int disabledUsers;
private List<Team> teams;
private int maxPaidUsers;
}
@Data
public static class AccountData {
private String username;
private String role;
private String settings;
private boolean changeCredsFlag;
private boolean oAuth2Login;
private boolean saml2Login;
}
@Data
public static class TeamsData {
private List<TeamWithUserCountDTO> teamsWithCounts;
private Map<Long, Date> teamLastRequest;
}
@Data
public static class TeamDetailsData {
private Team team;
private List<User> teamUsers;
private List<User> availableUsers;
private Map<String, Date> userLastRequest;
}
@Data
public static class DatabaseData {
private List<FileInfo> backupFiles;
private String databaseVersion;
private boolean versionUnknown;
}
}

View File

@ -1,5 +1,7 @@
package stirling.software.proprietary.security.controller.api; package stirling.software.proprietary.security.controller.api;
import stirling.software.common.annotations.AutoJobPostMapping;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -42,7 +44,7 @@ public class EmailController {
* attachment. * attachment.
* @return ResponseEntity with success or error message. * @return ResponseEntity with success or error message.
*/ */
@PostMapping(consumes = "multipart/form-data", value = "/send-email") @AutoJobPostMapping(consumes = "multipart/form-data", value = "/send-email")
@Operation( @Operation(
summary = "Send an email with an attachment", summary = "Send an email with an attachment",
description = description =

View File

@ -203,9 +203,17 @@ subprojects {
tasks.withType(JavaCompile).configureEach { tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8" options.encoding = "UTF-8"
dependsOn "spotlessApply" if (!project.hasProperty("noSpotless")) {
dependsOn "spotlessApply"
}
}
gradle.taskGraph.whenReady { graph ->
if (project.hasProperty("noSpotless")) {
tasks.matching { it.name.startsWith("spotless") }.configureEach {
enabled = false
}
}
} }
licenseReport { licenseReport {
projects = [project] projects = [project]
renderers = [new JsonReportRenderer()] renderers = [new JsonReportRenderer()]

View File

@ -26,8 +26,6 @@ services:
DISABLE_ADDITIONAL_FEATURES: "false" DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "false" SECURITY_ENABLELOGIN: "false"
FAT_DOCKER: "true" FAT_DOCKER: "true"
INSTALL_BOOK_AND_ADVANCED_HTML_OPS: "false"
LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID"
SYSTEM_DEFAULTLOCALE: en-US SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Full-featured Stirling-PDF with all capabilities UI_HOMEDESCRIPTION: Full-featured Stirling-PDF with all capabilities

View File

@ -25,7 +25,6 @@ services:
environment: environment:
DISABLE_ADDITIONAL_FEATURES: "true" DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false" SECURITY_ENABLELOGIN: "false"
LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID"
SYSTEM_DEFAULTLOCALE: en-US SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest

View File

@ -52,6 +52,44 @@ http {
proxy_request_buffering off; proxy_request_buffering off;
} }
# Proxy Swagger UI to backend (including versioned paths)
location ~ ^/swagger-ui(.*)$ {
proxy_pass ${VITE_API_BASE_URL}/swagger-ui$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
}
# Proxy API docs to backend (with query parameters and sub-paths)
location ~ ^/v3/api-docs(.*)$ {
proxy_pass ${VITE_API_BASE_URL}/v3/api-docs$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Proxy v1 API docs to backend (with query parameters and sub-paths)
location ~ ^/v1/api-docs(.*)$ {
proxy_pass ${VITE_API_BASE_URL}/v1/api-docs$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Cache static assets # Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;

3
frontend/.gitignore vendored
View File

@ -22,3 +22,6 @@
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
playwright-report
test-results

View File

@ -7,12 +7,12 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Web site created using Vite" content="The Free Adobe Acrobat alternative (10M+ Downloads)"
/> />
<link rel="apple-touch-icon" href="/logo192.png" /> <link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>Vite App</title> <title>Stirling PDF</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"material-symbols": "^0.33.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"react": "^19.1.0", "react": "^19.1.0",
@ -36,7 +37,13 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"generate-licenses": "node scripts/generate-licenses.js" "generate-licenses": "node scripts/generate-licenses.js",
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@ -57,15 +64,19 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.40.0",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0", "@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^1.0.0",
"jsdom": "^23.0.0",
"license-checker": "^25.0.1", "license-checker": "^25.0.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5" "vite": "^6.3.5",
"vitest": "^1.0.0"
} }
} }

View File

@ -0,0 +1,75 @@
import { defineConfig, devices } from '@playwright/test';
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './src/tests',
testMatch: '**/*.spec.ts',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 }
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,4 @@
<svg width="146" height="157" viewBox="0 0 146 157" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#E6E6E6" fill-opacity="0.9"/>
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#E6E6E6" fill-opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@ -0,0 +1,4 @@
<svg width="146" height="146" viewBox="0 0 146 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#ACACAC" fill-opacity="0.3"/>
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#FC9999" fill-opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -347,6 +347,10 @@
"title": "Rotate", "title": "Rotate",
"desc": "Easily rotate your PDFs." "desc": "Easily rotate your PDFs."
}, },
"convert": {
"title": "Convert",
"desc": "Convert files between different formats"
},
"imageToPdf": { "imageToPdf": {
"title": "Image to PDF", "title": "Image to PDF",
"desc": "Convert a image (PNG, JPEG, GIF) to PDF." "desc": "Convert a image (PNG, JPEG, GIF) to PDF."
@ -575,6 +579,10 @@
"title": "Validate PDF Signature", "title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents" "desc": "Verify digital signatures and certificates in PDF documents"
}, },
"swagger": {
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"replaceColorPdf": { "replaceColorPdf": {
"title": "Advanced Colour options", "title": "Advanced Colour options",
"desc": "Replace colour for text and background in PDF and invert full colour of pdf to reduce file size" "desc": "Replace colour for text and background in PDF and invert full colour of pdf to reduce file size"
@ -643,6 +651,73 @@
"selectAngle": "Select rotation angle (in multiples of 90 degrees):", "selectAngle": "Select rotation angle (in multiples of 90 degrees):",
"submit": "Rotate" "submit": "Rotate"
}, },
"convert": {
"title": "Convert",
"desc": "Convert files between different formats",
"files": "Files",
"selectFilesPlaceholder": "Select files in the main view to get started",
"settings": "Settings",
"conversionCompleted": "Conversion completed",
"results": "Results",
"defaultFilename": "converted_file",
"conversionResults": "Conversion Results",
"convertFrom": "Convert from",
"convertTo": "Convert to",
"sourceFormatPlaceholder": "Source format",
"targetFormatPlaceholder": "Target format",
"selectSourceFormatFirst": "Select a source format first",
"outputOptions": "Output Options",
"pdfOptions": "PDF Options",
"imageOptions": "Image Options",
"colorType": "Colour Type",
"color": "Colour",
"greyscale": "Greyscale",
"blackwhite": "Black & White",
"dpi": "DPI",
"output": "Output",
"single": "Single",
"multiple": "Multiple",
"fitOption": "Fit Option",
"maintainAspectRatio": "Maintain Aspect Ratio",
"fitDocumentToPage": "Fit Document to Page",
"fillPage": "Fill Page",
"autoRotate": "Auto Rotate",
"autoRotateDescription": "Automatically rotate images to better fit the PDF page",
"combineImages": "Combine Images",
"combineImagesDescription": "Combine all images into one PDF, or create separate PDFs for each image",
"webOptions": "Web to PDF Options",
"zoomLevel": "Zoom Level",
"emailOptions": "Email to PDF Options",
"includeAttachments": "Include email attachments",
"maxAttachmentSize": "Maximum attachment size (MB)",
"includeAllRecipients": "Include CC and BCC recipients in header",
"downloadHtml": "Download HTML intermediate file instead of PDF",
"pdfaOptions": "PDF/A Options",
"outputFormat": "Output Format",
"pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.",
"pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step.",
"fileFormat": "File Format",
"wordDoc": "Word Document",
"wordDocExt": "Word Document (.docx)",
"odtExt": "OpenDocument Text (.odt)",
"pptExt": "PowerPoint (.pptx)",
"odpExt": "OpenDocument Presentation (.odp)",
"txtExt": "Plain Text (.txt)",
"rtfExt": "Rich Text Format (.rtf)",
"selectedFiles": "Selected files",
"noFileSelected": "No file selected. Use the file panel to add files.",
"convertFiles": "Convert Files",
"converting": "Converting...",
"downloadConverted": "Download Converted File",
"errorNoFiles": "Please select at least one file to convert.",
"errorNoFormat": "Please select both source and target formats.",
"errorNotSupported": "Conversion from {{from}} to {{to}} is not supported.",
"images": "Images",
"officeDocs": "Office Documents (Word, Excel, PowerPoint)",
"imagesExt": "Images (JPG, PNG, etc.)",
"markdown": "Markdown",
"textRtf": "Text/RTF"
},
"imageToPdf": { "imageToPdf": {
"tags": "conversion,img,jpg,picture,photo" "tags": "conversion,img,jpg,picture,photo"
}, },
@ -1521,6 +1596,12 @@
}, },
"note": "Release notes are only available in English" "note": "Release notes are only available in English"
}, },
"swagger": {
"title": "API Documentation",
"header": "API Documentation",
"desc": "View and test the Stirling PDF API endpoints",
"tags": "api,documentation,swagger,endpoints,development"
},
"cookieBanner": { "cookieBanner": {
"popUp": { "popUp": {
"title": "How we use Cookies", "title": "How we use Cookies",
@ -1572,18 +1653,6 @@
"pageEditor": "Page Editor", "pageEditor": "Page Editor",
"fileManager": "File Manager" "fileManager": "File Manager"
}, },
"fileManager": {
"dragDrop": "Drag & Drop files here",
"clickToUpload": "Click to upload files",
"selectedFiles": "Selected Files",
"clearAll": "Clear All",
"storage": "Storage",
"filesStored": "files stored",
"storageError": "Storage error occurred",
"storageLow": "Storage is running low. Consider removing old files.",
"uploadError": "Failed to upload some files.",
"supportMessage": "Powered by browser database storage for unlimited capacity"
},
"pageEditor": { "pageEditor": {
"title": "Page Editor", "title": "Page Editor",
"save": "Save Changes", "save": "Save Changes",
@ -1655,7 +1724,34 @@
"failedToLoad": "Failed to load file to active set.", "failedToLoad": "Failed to load file to active set.",
"storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.", "storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.",
"clearAll": "Clear All", "clearAll": "Clear All",
"reloadFiles": "Reload Files" "reloadFiles": "Reload Files",
"dragDrop": "Drag & Drop files here",
"clickToUpload": "Click to upload files",
"selectedFiles": "Selected Files",
"storage": "Storage",
"filesStored": "files stored",
"storageError": "Storage error occurred",
"storageLow": "Storage is running low. Consider removing old files.",
"supportMessage": "Powered by browser database storage for unlimited capacity",
"noFileSelected": "No files selected",
"searchFiles": "Search files...",
"recent": "Recent",
"localFiles": "Local Files",
"googleDrive": "Google Drive",
"googleDriveShort": "Drive",
"myFiles": "My Files",
"noRecentFiles": "No recent files found",
"dropFilesHint": "Drop files here to upload",
"googleDriveNotAvailable": "Google Drive integration not available",
"openFiles": "Open Files",
"openFile": "Open File",
"details": "File Details",
"fileName": "Name",
"fileFormat": "Format",
"fileSize": "Size",
"fileVersion": "Version",
"totalSelected": "Total Selected",
"dropFilesHere": "Drop files here"
}, },
"storage": { "storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",

View File

@ -575,6 +575,10 @@
"title": "Validate PDF Signature", "title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents" "desc": "Verify digital signatures and certificates in PDF documents"
}, },
"swagger": {
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"replaceColorPdf": { "replaceColorPdf": {
"title": "Replace and Invert Color", "title": "Replace and Invert Color",
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size" "desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
@ -1521,6 +1525,12 @@
}, },
"note": "Release notes are only available in English" "note": "Release notes are only available in English"
}, },
"swagger": {
"title": "API Documentation",
"header": "API Documentation",
"desc": "View and test the Stirling PDF API endpoints",
"tags": "api,documentation,swagger,endpoints,development"
},
"cookieBanner": { "cookieBanner": {
"popUp": { "popUp": {
"title": "How we use Cookies", "title": "How we use Cookies",
@ -1557,5 +1567,51 @@
"description": "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with." "description": "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with."
} }
} }
},
"convert": {
"files": "Files",
"selectFilesPlaceholder": "Select files in the main view to get started",
"settings": "Settings",
"conversionCompleted": "Conversion completed",
"results": "Results",
"defaultFilename": "converted_file",
"conversionResults": "Conversion Results",
"converting": "Converting...",
"convertFiles": "Convert Files",
"downloadConverted": "Download Converted File",
"convertFrom": "Convert from",
"convertTo": "Convert to",
"sourceFormatPlaceholder": "Source format",
"targetFormatPlaceholder": "Target format",
"selectSourceFormatFirst": "Select a source format first",
"imageOptions": "Image Options",
"colorType": "Color Type",
"color": "Color",
"greyscale": "Greyscale",
"blackwhite": "Black & White",
"dpi": "DPI",
"output": "Output",
"single": "Single",
"multiple": "Multiple",
"pdfOptions": "PDF Options",
"fitOption": "Fit Option",
"maintainAspectRatio": "Maintain Aspect Ratio",
"fitDocumentToPage": "Fit Document to Page",
"fillPage": "Fill Page",
"autoRotate": "Auto Rotate",
"autoRotateDescription": "Automatically rotate images to better fit the PDF page",
"combineImages": "Combine Images",
"combineImagesDescription": "Combine all images into one PDF, or create separate PDFs for each image",
"webOptions": "Web to PDF Options",
"zoomLevel": "Zoom Level",
"emailOptions": "Email to PDF Options",
"includeAttachments": "Include email attachments",
"maxAttachmentSize": "Maximum attachment size (MB)",
"includeAllRecipients": "Include CC and BCC recipients in header",
"downloadHtml": "Download HTML intermediate file instead of PDF",
"pdfaOptions": "PDF/A Options",
"outputFormat": "Output Format",
"pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.",
"pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step."
} }
} }

View File

@ -0,0 +1,29 @@
{
"convert": {
"selectSourceFormat": "Select source file format",
"selectTargetFormat": "Select target file format",
"selectFirst": "Select a source format first",
"imageOptions": "Image Options:",
"emailOptions": "Email Options:",
"colorType": "Color Type",
"dpi": "DPI",
"singleOrMultiple": "Output",
"emailNote": "Email attachments and embedded images will be included"
},
"common": {
"color": "Color",
"grayscale": "Grayscale",
"blackWhite": "Black & White",
"single": "Single Image",
"multiple": "Multiple Images"
},
"groups": {
"document": "Document",
"spreadsheet": "Spreadsheet",
"presentation": "Presentation",
"image": "Image",
"web": "Web",
"text": "Text",
"email": "Email"
}
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" fill="none">
<path d="M7.26375 95.8344L123.374 4.32822e-05L123.375 89.4987L7.26375 185.333L7.26375 95.8344Z" fill="white"/>
<path d="M68.4794 102.395L184.728 6.44717L184.728 96.052L68.4794 192L68.4794 102.395Z" fill="white" fill-opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import { FileContextProvider } from './contexts/FileContext'; import { FileContextProvider } from './contexts/FileContext';
import { FilesModalProvider } from './contexts/FilesModalContext';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
// Import global styles // Import global styles
@ -11,7 +12,9 @@ export default function App() {
return ( return (
<RainbowThemeProvider> <RainbowThemeProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}> <FileContextProvider enableUrlSync={true} enablePersistence={true}>
<HomePage /> <FilesModalProvider>
<HomePage />
</FilesModalProvider>
</FileContextProvider> </FileContextProvider>
</RainbowThemeProvider> </RainbowThemeProvider>
); );

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,168 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { FileWithUrl } from '../types/file';
import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import { Tool } from '../types/tool';
import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
interface FileManagerProps {
selectedTool?: Tool | null;
}
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
const extension = fileName.split('.').pop()?.toLowerCase();
return selectedTool.supportedFormats.includes(extension || '');
}, [selectedTool?.supportedFormats]);
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
}, [loadRecentFiles]);
const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
try {
const fileObjects = await Promise.all(
files.map(async (fileWithUrl) => {
return await convertToFile(fileWithUrl);
})
);
onFilesSelect(fileObjects);
} catch (error) {
console.error('Failed to process selected files:', error);
}
}, [convertToFile, onFilesSelect]);
const handleNewFileUpload = useCallback(async (files: File[]) => {
if (files.length > 0) {
try {
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
onFilesSelect(files);
await refreshRecentFiles();
} catch (error) {
console.error('Failed to process dropped files:', error);
}
}
}, [onFilesSelect, refreshRecentFiles]);
const handleRemoveFileByIndex = useCallback(async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
}, [handleRemoveFile, recentFiles]);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (isFilesModalOpen) {
refreshRecentFiles();
} else {
// Reset state when modal is closed
setIsDragging(false);
}
}, [isFilesModalOpen, refreshRecentFiles]);
// Cleanup any blob URLs when component unmounts
useEffect(() => {
return () => {
// Clean up blob URLs from recent files
recentFiles.forEach(file => {
if (file.url && file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url);
}
});
};
}, [recentFiles]);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
const modalWidth = isMobile ? '100%' : '80vw';
const modalMaxWidth = isMobile ? '100%' : '1200px';
const modalMaxHeight = '1200px';
const modalMinWidth = isMobile ? '320px' : '800px';
return (
<Modal
opened={isFilesModalOpen}
onClose={closeFilesModal}
size={isMobile ? "100%" : "auto"}
centered
radius={30}
className="overflow-hidden p-0"
withCloseButton={false}
styles={{
content: {
position: 'relative',
margin: isMobile ? '1rem' : '2rem'
},
body: { padding: 0 },
header: { display: 'none' }
}}
>
<div style={{
position: 'relative',
height: modalHeight,
width: modalWidth,
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
margin: '0 auto',
overflow: 'hidden'
}}>
<Dropzone
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
accept={["*/*"]}
multiple={true}
activateOnClick={false}
style={{
height: '100%',
width: '100%',
border: 'none',
borderRadius: '30px',
backgroundColor: 'var(--bg-file-manager)'
}}
styles={{
inner: { pointerEvents: 'all' }
}}
>
<FileManagerProvider
recentFiles={recentFiles}
onFilesSelected={handleFilesSelected}
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
storeFile={storeFile}
refreshRecentFiles={refreshRecentFiles}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</FileManagerProvider>
</Dropzone>
<DragOverlay isVisible={isDragging} />
</div>
</Modal>
);
};
export default FileManager;

View File

@ -7,10 +7,12 @@ import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile'; import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext'; import { useFileContext } from '../../contexts/FileContext';
import { useFileSelection } from '../../contexts/FileSelectionContext';
import { FileOperation } from '../../types/fileContext'; import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService'; import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from '../pageEditor/PageEditor.module.css'; import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail'; import FileThumbnail from '../pageEditor/FileThumbnail';
import DragDropGrid from '../pageEditor/DragDropGrid'; import DragDropGrid from '../pageEditor/DragDropGrid';
@ -31,23 +33,27 @@ interface FileEditorProps {
onOpenPageEditor?: (file: File) => void; onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void; onMergeFiles?: (files: File[]) => void;
toolMode?: boolean; toolMode?: boolean;
multiSelect?: boolean;
showUpload?: boolean; showUpload?: boolean;
showBulkActions?: boolean; showBulkActions?: boolean;
onFileSelect?: (files: File[]) => void; supportedExtensions?: string[];
} }
const FileEditor = ({ const FileEditor = ({
onOpenPageEditor, onOpenPageEditor,
onMergeFiles, onMergeFiles,
toolMode = false, toolMode = false,
multiSelect = true,
showUpload = true, showUpload = true,
showBulkActions = true, showBulkActions = true,
onFileSelect supportedExtensions = ["pdf"]
}: FileEditorProps) => { }: FileEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
// Get file context // Get file context
const fileContext = useFileContext(); const fileContext = useFileContext();
const { const {
@ -63,6 +69,14 @@ const FileEditor = ({
markOperationApplied markOperationApplied
} = fileContext; } = fileContext;
// Get file selection context
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
} = useFileSelection();
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -99,14 +113,14 @@ const FileEditor = ({
const lastActiveFilesRef = useRef<string[]>([]); const lastActiveFilesRef = useRef<string[]>([]);
const lastProcessedFilesRef = useRef<number>(0); const lastProcessedFilesRef = useRef<number>(0);
// Map context selected file names to local file IDs // Get selected file IDs from context (defensive programming)
// Defensive programming: ensure selectedFileIds is always an array const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
const localSelectedFiles = files // Map context selections to local file IDs for UI display
const localSelectedIds = files
.filter(file => { .filter(file => {
const fileId = (file.file as any).id || file.name; const fileId = (file.file as any).id || file.name;
return safeSelectedFileIds.includes(fileId); return contextSelectedIds.includes(fileId);
}) })
.map(file => file.id); .map(file => file.id);
@ -219,49 +233,46 @@ const FileEditor = ({
// Handle PDF files normally // Handle PDF files normally
allExtractedFiles.push(file); allExtractedFiles.push(file);
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) { } else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
// Handle ZIP files // Handle ZIP files - only expand if they contain PDFs
try { try {
// Validate ZIP file first // Validate ZIP file first
const validation = await zipFileService.validateZipFile(file); const validation = await zipFileService.validateZipFile(file);
if (!validation.isValid) {
errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`);
continue;
}
// Extract PDF files from ZIP if (validation.isValid && validation.containsPDFs) {
setZipExtractionProgress({ // ZIP contains PDFs - extract them
isExtracting: true,
currentFile: file.name,
progress: 0,
extractedCount: 0,
totalFiles: validation.fileCount
});
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
setZipExtractionProgress({ setZipExtractionProgress({
isExtracting: true, isExtracting: true,
currentFile: progress.currentFile, currentFile: file.name,
progress: progress.progress, progress: 0,
extractedCount: progress.extractedCount, extractedCount: 0,
totalFiles: progress.totalFiles totalFiles: validation.fileCount
}); });
});
// Reset extraction progress const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
setZipExtractionProgress({ setZipExtractionProgress({
isExtracting: false, isExtracting: true,
currentFile: '', currentFile: progress.currentFile,
progress: 0, progress: progress.progress,
extractedCount: 0, extractedCount: progress.extractedCount,
totalFiles: 0 totalFiles: progress.totalFiles
}); });
});
if (extractionResult.success) { // Reset extraction progress
allExtractedFiles.push(...extractionResult.extractedFiles); setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
// Record ZIP extraction operation if (extractionResult.success) {
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; allExtractedFiles.push(...extractionResult.extractedFiles);
const operation: FileOperation = {
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId, id: operationId,
type: 'convert', type: 'convert',
timestamp: Date.now(), timestamp: Date.now(),
@ -285,8 +296,13 @@ const FileEditor = ({
if (extractionResult.errors.length > 0) { if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors); errors.push(...extractionResult.errors);
} }
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
}
} else { } else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); // ZIP doesn't contain PDFs or is invalid - treat as regular file
console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`);
allExtractedFiles.push(file);
} }
} catch (zipError) { } catch (zipError) {
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`); errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
@ -299,7 +315,8 @@ const FileEditor = ({
}); });
} }
} else { } else {
errors.push(`Unsupported file type: ${file.name} (${file.type})`); console.log(`Adding none PDF file: ${file.name} (${file.type})`);
allExtractedFiles.push(file);
} }
} }
@ -396,44 +413,41 @@ const FileEditor = ({
if (!targetFile) return; if (!targetFile) return;
const contextFileId = (targetFile.file as any).id || targetFile.name; const contextFileId = (targetFile.file as any).id || targetFile.name;
const isSelected = contextSelectedIds.includes(contextFileId);
if (!multiSelect) { let newSelection: string[];
// Single select mode for tools - toggle on/off
const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId); if (isSelected) {
if (isCurrentlySelected) { // Remove file from selection
// Deselect the file newSelection = contextSelectedIds.filter(id => id !== contextFileId);
setContextSelectedFiles([]);
if (onFileSelect) {
onFileSelect([]);
}
} else {
// Select the file
setContextSelectedFiles([contextFileId]);
if (onFileSelect) {
onFileSelect([targetFile.file]);
}
}
} else { } else {
// Multi select mode (default) // Add file to selection
setContextSelectedFiles(prev => { if (maxFiles === 1) {
const safePrev = Array.isArray(prev) ? prev : []; newSelection = [contextFileId];
return safePrev.includes(contextFileId) } else {
? safePrev.filter(id => id !== contextFileId) // Check if we've hit the selection limit
: [...safePrev, contextFileId]; if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) {
}); setStatus(`Maximum ${maxFiles} files can be selected`);
return;
// Notify parent with selected files }
if (onFileSelect) { newSelection = [...contextSelectedIds, contextFileId];
const selectedFiles = files
.filter(f => {
const fId = (f.file as any).id || f.name;
return safeSelectedFileIds.includes(fId) || fId === contextFileId;
})
.map(f => f.file);
onFileSelect(selectedFiles);
} }
} }
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]);
// Update context
setContextSelectedFiles(newSelection);
// Update tool selection context if in tool mode
if (isToolMode || toolMode) {
const selectedFiles = files
.filter(f => {
const fId = (f.file as any).id || f.name;
return newSelection.includes(fId);
})
.map(f => f.file);
setToolSelectedFiles(selectedFiles);
}
}, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]);
const toggleSelectionMode = useCallback(() => { const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => { setSelectionMode(prev => {
@ -450,15 +464,15 @@ const FileEditor = ({
const handleDragStart = useCallback((fileId: string) => { const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId); setDraggedFile(fileId);
if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) { if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) {
setMultiFileDrag({ setMultiFileDrag({
fileIds: localSelectedFiles, fileIds: localSelectedIds,
count: localSelectedFiles.length count: localSelectedIds.length
}); });
} else { } else {
setMultiFileDrag(null); setMultiFileDrag(null);
} }
}, [selectionMode, localSelectedFiles]); }, [selectionMode, localSelectedIds]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setDraggedFile(null); setDraggedFile(null);
@ -519,8 +533,8 @@ const FileEditor = ({
if (targetIndex === -1) return; if (targetIndex === -1) return;
} }
const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile) const filesToMove = selectionMode && localSelectedIds.includes(draggedFile)
? localSelectedFiles ? localSelectedIds
: [draggedFile]; : [draggedFile];
// Update the local files state and sync with activeFiles // Update the local files state and sync with activeFiles
@ -545,7 +559,7 @@ const FileEditor = ({
const moveCount = multiFileDrag ? multiFileDrag.count : 1; const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]); }, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
const handleEndZoneDragEnter = useCallback(() => { const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) { if (draggedFile) {
@ -651,46 +665,35 @@ const FileEditor = ({
return ( return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}> <Dropzone
<LoadingOverlay visible={false} /> onDrop={handleFileUpload}
accept={["*/*"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{
height: '100vh',
border: 'none',
borderRadius: 0,
backgroundColor: 'transparent'
}}
activateOnClick={false}
activateOnDrag={true}
>
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<Box p="md" pt="xl"> <Box p="md" pt="xl">
<Group mb="md"> <Group mb="md">
{showBulkActions && !toolMode && ( {showBulkActions && !toolMode && (
<> <>
<Button onClick={selectAll} variant="light">Select All</Button> <Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button> <Button onClick={deselectAll} variant="light">Deselect All</Button>
<Button onClick={closeAllFiles} variant="light" color="orange"> <Button onClick={closeAllFiles} variant="light" color="orange">
Close All Close All
</Button>
</>
)}
{/* Load from storage and upload buttons */}
{showUpload && (
<>
<Button
variant="outline"
color="blue"
onClick={() => setShowFilePickerModal(true)}
>
Load from Storage
</Button>
<Dropzone
onDrop={handleFileUpload}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }}
>
<Button variant="outline" color="green">
Upload Files
</Button> </Button>
</Dropzone> </>
</> )}
)} </Group>
</Group>
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? ( {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
@ -764,7 +767,7 @@ const FileEditor = ({
) : ( ) : (
<DragDropGrid <DragDropGrid
items={files} items={files}
selectedItems={localSelectedFiles} selectedItems={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
isAnimating={isAnimating} isAnimating={isAnimating}
onDragStart={handleDragStart} onDragStart={handleDragStart}
@ -783,7 +786,7 @@ const FileEditor = ({
file={file} file={file}
index={index} index={index}
totalFiles={files.length} totalFiles={files.length}
selectedFiles={localSelectedFiles} selectedFiles={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
draggedFile={draggedFile} draggedFile={draggedFile}
dropTarget={dropTarget} dropTarget={dropTarget}
@ -802,6 +805,7 @@ const FileEditor = ({
onSplitFile={handleSplitFile} onSplitFile={handleSplitFile}
onSetStatus={setStatus} onSetStatus={setStatus}
toolMode={toolMode} toolMode={toolMode}
isSupported={isFileSupported(file.name)}
/> />
)} )}
renderSplitMarker={(file, index) => ( renderSplitMarker={(file, index) => (
@ -851,7 +855,8 @@ const FileEditor = ({
{error} {error}
</Notification> </Notification>
)} )}
</Box> </Box>
</Dropzone>
); );
}; };

View File

@ -1,92 +0,0 @@
import React from "react";
import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage";
import DeleteIcon from "@mui/icons-material/Delete";
import WarningIcon from "@mui/icons-material/Warning";
import { StorageStats } from "../../services/fileStorage";
import { formatFileSize } from "../../utils/fileUtils";
import { getStorageUsagePercent } from "../../utils/storageUtils";
import { StorageConfig } from "../../types/file";
interface StorageStatsCardProps {
storageStats: StorageStats | null;
filesCount: number;
onClearAll: () => void;
onReloadFiles: () => void;
storageConfig: StorageConfig;
}
const StorageStatsCard = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
storageConfig,
}: StorageStatsCardProps) => {
const { t } = useTranslation();
if (!storageStats) return null;
const storageUsagePercent = getStorageUsagePercent(storageStats);
const totalUsed = storageStats.totalSize || storageStats.used;
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
return (
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
<Card withBorder p="sm">
<Group align="center" gap="md">
<StorageIcon />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
</Text>
<Progress
value={hardLimitPercent}
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
size="sm"
mt={4}
/>
<Group justify="space-between" mt={2}>
<Text size="xs" c="dimmed">
{storageStats.fileCount} files {t("storage.approximateSize", "Approximate size")}
</Text>
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
{Math.round(hardLimitPercent)}% used
</Text>
</Group>
{isNearLimit && (
<Text size="xs" c="red" mt={4}>
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
</Text>
)}
</div>
<Group gap="xs">
{filesCount > 0 && (
<Button
variant="light"
color="red"
size="xs"
onClick={onClearAll}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
>
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
{t("fileManager.reloadFiles", "Reload Files")}
</Button>
</Group>
</Group>
</Card>
</Stack>
);
};
export default StorageStatsCard;

View File

@ -0,0 +1,126 @@
import React from 'react';
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
interface CompactFileDetailsProps {
currentFile: FileWithUrl | null;
thumbnail: string | null;
selectedFiles: FileWithUrl[];
currentFileIndex: number;
numberOfFiles: number;
isAnimating: boolean;
onPrevious: () => void;
onNext: () => void;
onOpenFiles: () => void;
}
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
currentFile,
thumbnail,
selectedFiles,
currentFileIndex,
numberOfFiles,
isAnimating,
onPrevious,
onNext,
onOpenFiles
}) => {
const { t } = useTranslation();
const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = numberOfFiles > 1;
return (
<Stack gap="xs" style={{ height: '100%' }}>
{/* Compact mobile layout */}
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
{/* Small preview */}
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{currentFile && thumbnail ? (
<img
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '0.25rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
) : currentFile ? (
<Center style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}>
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}
{selectedFiles.length > 1 && `${selectedFiles.length} files`}
</Text>
{hasMultipleFiles && (
<Text size="xs" c="blue">
{currentFileIndex + 1} of {selectedFiles.length}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: 'flex', gap: '0.25rem' }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={onPrevious}
disabled={isAnimating}
>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={onNext}
disabled={isAnimating}
>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Box>
)}
</Box>
{/* Action Button */}
<Button
size="sm"
onClick={onOpenFiles}
disabled={!hasSelection}
fullWidth
style={{
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
}}
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
</Button>
</Stack>
);
};
export default CompactFileDetails;

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