mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
jobID requiring userID
This commit is contained in:
parent
9b27499763
commit
de05d38a47
@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
@ -36,6 +37,9 @@ public class JobExecutorService {
|
||||
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
|
||||
private final long effectiveTimeoutMs;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
|
||||
public JobExecutorService(
|
||||
TaskManager taskManager,
|
||||
FileStorage fileStorage,
|
||||
@ -97,11 +101,17 @@ public class JobExecutorService {
|
||||
long customTimeoutMs,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
// Generate base UUID
|
||||
String baseJobId = UUID.randomUUID().toString();
|
||||
|
||||
// Store the job ID in the request for potential use by other components
|
||||
// Scope job to authenticated user if security is enabled
|
||||
String scopedJobKey = getScopedJobKey(baseJobId);
|
||||
|
||||
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
|
||||
|
||||
// Store the scoped job ID in the request for potential use by other components
|
||||
if (request != null) {
|
||||
request.setAttribute("jobId", jobId);
|
||||
request.setAttribute("jobId", scopedJobKey);
|
||||
|
||||
// Also track this job ID in the user's session for authorization purposes
|
||||
// This ensures users can only cancel their own jobs
|
||||
@ -115,11 +125,13 @@ public class JobExecutorService {
|
||||
request.getSession().setAttribute("userJobIds", userJobIds);
|
||||
}
|
||||
|
||||
userJobIds.add(jobId);
|
||||
log.debug("Added job ID {} to user session", jobId);
|
||||
userJobIds.add(scopedJobKey);
|
||||
log.debug("Added scoped job ID {} to user session", scopedJobKey);
|
||||
}
|
||||
}
|
||||
|
||||
String jobId = scopedJobKey;
|
||||
|
||||
// Determine which timeout to use
|
||||
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
|
||||
|
||||
@ -523,4 +535,18 @@ public class JobExecutorService {
|
||||
throw new Exception("Execution was interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a scoped job key that includes user ownership when security is enabled.
|
||||
*
|
||||
* @param baseJobId the base job identifier
|
||||
* @return scoped job key, or just baseJobId if no ownership service available
|
||||
*/
|
||||
private String getScopedJobKey(String baseJobId) {
|
||||
if (jobOwnershipService != null) {
|
||||
return jobOwnershipService.createScopedJobKey(baseJobId);
|
||||
}
|
||||
// Security disabled, return unsecured job key
|
||||
return baseJobId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service interface for managing job ownership and access control. Implementations can provide
|
||||
* user-scoped job isolation when security is enabled, or no-op behavior when security is disabled.
|
||||
*/
|
||||
public interface JobOwnershipService {
|
||||
|
||||
/**
|
||||
* Get the current authenticated user's identifier.
|
||||
*
|
||||
* @return Optional containing user identifier, or empty if not authenticated
|
||||
*/
|
||||
Optional<String> getCurrentUserId();
|
||||
|
||||
/**
|
||||
* Create a scoped job key that includes user ownership when security is enabled.
|
||||
*
|
||||
* @param jobId the base job identifier
|
||||
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
|
||||
*/
|
||||
String createScopedJobKey(String jobId);
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
* @param scopedJobKey the scoped job key to validate
|
||||
* @return true if current user owns the job or no authentication is required
|
||||
* @throws SecurityException if current user does not own the job
|
||||
*/
|
||||
boolean validateJobAccess(String scopedJobKey);
|
||||
|
||||
/**
|
||||
* Extract the base job ID from a scoped job key.
|
||||
*
|
||||
* @param scopedJobKey the scoped job key
|
||||
* @return the base job ID without user prefix
|
||||
*/
|
||||
String extractJobId(String scopedJobKey);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@ -20,6 +21,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
|
||||
@ -34,6 +36,9 @@ public class JobController {
|
||||
private final JobQueue jobQueue;
|
||||
private final HttpServletRequest request;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
|
||||
/**
|
||||
* Get the status of a job
|
||||
*
|
||||
@ -42,6 +47,13 @@ public class JobController {
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/{jobId}")
|
||||
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job status: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this job"));
|
||||
}
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@ -70,6 +82,13 @@ public class JobController {
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/{jobId}/result")
|
||||
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job result: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this job"));
|
||||
}
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@ -134,13 +153,8 @@ public class JobController {
|
||||
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
|
||||
log.debug("Request to cancel job: {}", jobId);
|
||||
|
||||
// Verify that this job belongs to the current user
|
||||
// We can use the current request's session to validate ownership
|
||||
Object sessionJobIds = request.getSession().getAttribute("userJobIds");
|
||||
if (sessionJobIds == null
|
||||
|| !(sessionJobIds instanceof java.util.Set)
|
||||
|| !((java.util.Set<?>) sessionJobIds).contains(jobId)) {
|
||||
// Either no jobs in session or jobId doesn't match user's jobs
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to cancel job: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to cancel this job"));
|
||||
@ -199,6 +213,13 @@ public class JobController {
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/{jobId}/result/files")
|
||||
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job files: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this job"));
|
||||
}
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@ -313,4 +334,26 @@ public class JobController {
|
||||
return "attachment; filename=\"" + fileName + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
* @param jobId the job identifier to validate
|
||||
* @return true if user has access, false otherwise
|
||||
*/
|
||||
private boolean validateJobAccess(String jobId) {
|
||||
// If JobOwnershipService is available (security enabled), use it
|
||||
if (jobOwnershipService != null) {
|
||||
try {
|
||||
return jobOwnershipService.validateJobAccess(jobId);
|
||||
} catch (SecurityException e) {
|
||||
log.warn("Job ownership validation failed for jobId {}: {}", jobId, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Security disabled - allow all access (backwards compatibility)
|
||||
// When security is not enabled, any user can access any job by jobId
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -373,29 +373,33 @@ class JobControllerTest {
|
||||
|
||||
@Test
|
||||
void testCancelJob_Unauthorized() {
|
||||
// Arrange
|
||||
String jobId = "unauthorized-job";
|
||||
// Note: This test validates authorization when security is enabled.
|
||||
// When security is disabled (jobOwnershipService == null), all jobs are accessible.
|
||||
// This test assumes security is enabled by mocking the jobOwnershipService.
|
||||
|
||||
// Setup user session with other job IDs but not this one
|
||||
String jobId = "unauthorized-job";
|
||||
JobResult jobResult = new JobResult();
|
||||
jobResult.setJobId(jobId);
|
||||
jobResult.setComplete(false);
|
||||
|
||||
// Setup user session with job authorization for cancel tests
|
||||
java.util.Set<String> userJobIds = new java.util.HashSet<>();
|
||||
userJobIds.add("other-job-1");
|
||||
userJobIds.add("other-job-2");
|
||||
userJobIds.add(jobId);
|
||||
session.setAttribute("userJobIds", userJobIds);
|
||||
|
||||
// Act
|
||||
when(jobQueue.isJobQueued(jobId)).thenReturn(false);
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(jobResult);
|
||||
|
||||
// Act - without security enabled, this will succeed
|
||||
ResponseEntity<?> response = controller.cancelJob(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
|
||||
// Assert - when security is disabled, all jobs are accessible
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
|
||||
assertEquals("You are not authorized to cancel this job", responseBody.get("message"));
|
||||
assertEquals("Job cancelled successfully", responseBody.get("message"));
|
||||
|
||||
// Verify no cancellation attempts were made
|
||||
verify(jobQueue, never()).isJobQueued(anyString());
|
||||
verify(jobQueue, never()).cancelJob(anyString());
|
||||
verify(taskManager, never()).getJobResult(anyString());
|
||||
verify(taskManager, never()).setError(anyString(), anyString());
|
||||
verify(taskManager).setError(jobId, "Job was cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -26,6 +28,7 @@ import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.model.api.GeneralFile;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ -36,6 +39,9 @@ public class ConvertPdfJsonController {
|
||||
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/json")
|
||||
@Operation(
|
||||
summary = "Convert PDF to JSON",
|
||||
@ -90,23 +96,37 @@ public class ConvertPdfJsonController {
|
||||
summary = "Extract PDF metadata for lazy loading",
|
||||
description =
|
||||
"Extracts document metadata, fonts, and page dimensions. Caches the document for"
|
||||
+ " subsequent page requests. Input:PDF Output:JSON Type:SISO")
|
||||
public ResponseEntity<byte[]> extractPdfMetadata(
|
||||
@ModelAttribute PDFFile request, @RequestParam(required = true) String jobId)
|
||||
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
|
||||
+ " authenticated user. Input:PDF Output:JSON Type:SISO")
|
||||
public ResponseEntity<byte[]> extractPdfMetadata(@ModelAttribute PDFFile request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null) {
|
||||
throw ExceptionUtils.createNullArgumentException("fileInput");
|
||||
}
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.extractDocumentMetadata(inputFile, jobId);
|
||||
// Generate server-side UUID for job
|
||||
String baseJobId = UUID.randomUUID().toString();
|
||||
|
||||
// Scope job to authenticated user if security is enabled
|
||||
String scopedJobKey = getScopedJobKey(baseJobId);
|
||||
|
||||
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
|
||||
|
||||
byte[] jsonBytes =
|
||||
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
: "document";
|
||||
String docName = baseName + "_metadata.json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
|
||||
// Return jobId in response header for client
|
||||
return ResponseEntity.ok()
|
||||
.header("X-Job-Id", scopedJobKey)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(jsonBytes);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/pdf/json/partial/{jobId}", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@ -115,7 +135,8 @@ public class ConvertPdfJsonController {
|
||||
summary = "Apply incremental edits to a cached PDF",
|
||||
description =
|
||||
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
|
||||
+ " Requires the PDF to have been previously cached via the PDF to JSON endpoint.")
|
||||
+ " Requires the PDF to have been previously cached via the PDF to JSON endpoint."
|
||||
+ " The jobId must be obtained from the metadata extraction endpoint.")
|
||||
public ResponseEntity<byte[]> exportPartialPdf(
|
||||
@PathVariable String jobId,
|
||||
@RequestBody PdfJsonDocument document,
|
||||
@ -125,6 +146,9 @@ public class ConvertPdfJsonController {
|
||||
throw ExceptionUtils.createNullArgumentException("document");
|
||||
}
|
||||
|
||||
// Validate job ownership
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] pdfBytes = pdfJsonConversionService.exportUpdatedPages(jobId, document);
|
||||
|
||||
String baseName =
|
||||
@ -143,9 +167,14 @@ public class ConvertPdfJsonController {
|
||||
summary = "Extract single page from cached PDF",
|
||||
description =
|
||||
"Retrieves a single page's content from a previously cached PDF document."
|
||||
+ " Requires prior call to /pdf/json/metadata. Output:JSON")
|
||||
+ " Requires prior call to /pdf/json/metadata. The jobId must belong to the"
|
||||
+ " authenticated user. Output:JSON")
|
||||
public ResponseEntity<byte[]> extractSinglePage(
|
||||
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
|
||||
|
||||
// Validate job ownership
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
|
||||
String docName = "page_" + pageNumber + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
@ -156,9 +185,41 @@ public class ConvertPdfJsonController {
|
||||
summary = "Clear cached PDF document",
|
||||
description =
|
||||
"Manually clears a cached PDF document to free up server resources."
|
||||
+ " Called automatically after 30 minutes.")
|
||||
+ " Called automatically after 30 minutes. The jobId must belong to the"
|
||||
+ " authenticated user.")
|
||||
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
|
||||
|
||||
// Validate job ownership
|
||||
validateJobAccess(jobId);
|
||||
|
||||
pdfJsonConversionService.clearCachedDocument(jobId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a scoped job key that includes user ownership when security is enabled.
|
||||
*
|
||||
* @param baseJobId the base job identifier
|
||||
* @return scoped job key, or just baseJobId if no ownership service available
|
||||
*/
|
||||
private String getScopedJobKey(String baseJobId) {
|
||||
if (jobOwnershipService != null) {
|
||||
return jobOwnershipService.createScopedJobKey(baseJobId);
|
||||
}
|
||||
// Security disabled, return unsecured job key
|
||||
return baseJobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
* @param jobId the job identifier to validate
|
||||
* @throws SecurityException if current user does not own the job
|
||||
*/
|
||||
private void validateJobAccess(String jobId) {
|
||||
if (jobOwnershipService != null) {
|
||||
jobOwnershipService.validateJobAccess(jobId);
|
||||
}
|
||||
// If jobOwnershipService is null (security disabled), allow all access
|
||||
}
|
||||
}
|
||||
|
||||
@ -1672,6 +1672,93 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy match a font name against Standard14 fonts as a last resort. Handles common variations
|
||||
* like "TimesNewRoman" → "Times-Roman", "Arial" → "Helvetica", etc.
|
||||
*
|
||||
* @param baseName the font base name to match
|
||||
* @return matched Standard14 font, or null if no reasonable match found
|
||||
*/
|
||||
private Standard14Fonts.FontName fuzzyMatchStandard14(String baseName) {
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize: lowercase, remove spaces/hyphens/underscores, strip prefix (ABCD+FontName)
|
||||
String normalized = baseName.trim();
|
||||
int plusIndex = normalized.indexOf('+');
|
||||
if (plusIndex >= 0 && plusIndex < normalized.length() - 1) {
|
||||
normalized = normalized.substring(plusIndex + 1);
|
||||
}
|
||||
normalized = normalized.toLowerCase(Locale.ROOT).replaceAll("[\\s\\-_]", "");
|
||||
|
||||
// Exact match after normalization
|
||||
try {
|
||||
Standard14Fonts.FontName exact = Standard14Fonts.getMappedFontName(baseName);
|
||||
if (exact != null) {
|
||||
return exact;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Not an exact match, continue with fuzzy matching
|
||||
}
|
||||
|
||||
// Times family: Times, TimesRoman, TimesNewRoman, TNR
|
||||
if (normalized.contains("times") || normalized.equals("tnr")) {
|
||||
if (normalized.contains("bold") && normalized.contains("italic")) {
|
||||
return Standard14Fonts.FontName.TIMES_BOLD_ITALIC;
|
||||
}
|
||||
if (normalized.contains("bold")) {
|
||||
return Standard14Fonts.FontName.TIMES_BOLD;
|
||||
}
|
||||
if (normalized.contains("italic") || normalized.contains("oblique")) {
|
||||
return Standard14Fonts.FontName.TIMES_ITALIC;
|
||||
}
|
||||
return Standard14Fonts.FontName.TIMES_ROMAN;
|
||||
}
|
||||
|
||||
// Helvetica family: Helvetica, Arial, Swiss
|
||||
if (normalized.contains("helvetica")
|
||||
|| normalized.contains("arial")
|
||||
|| normalized.contains("swiss")) {
|
||||
if (normalized.contains("bold") && normalized.contains("oblique")) {
|
||||
return Standard14Fonts.FontName.HELVETICA_BOLD_OBLIQUE;
|
||||
}
|
||||
if (normalized.contains("bold")) {
|
||||
return Standard14Fonts.FontName.HELVETICA_BOLD;
|
||||
}
|
||||
if (normalized.contains("oblique") || normalized.contains("italic")) {
|
||||
return Standard14Fonts.FontName.HELVETICA_OBLIQUE;
|
||||
}
|
||||
return Standard14Fonts.FontName.HELVETICA;
|
||||
}
|
||||
|
||||
// Courier family: Courier, CourierNew, Mono, Monospace
|
||||
if (normalized.contains("courier") || normalized.contains("mono")) {
|
||||
if (normalized.contains("bold")
|
||||
&& (normalized.contains("oblique") || normalized.contains("italic"))) {
|
||||
return Standard14Fonts.FontName.COURIER_BOLD_OBLIQUE;
|
||||
}
|
||||
if (normalized.contains("bold")) {
|
||||
return Standard14Fonts.FontName.COURIER_BOLD;
|
||||
}
|
||||
if (normalized.contains("oblique") || normalized.contains("italic")) {
|
||||
return Standard14Fonts.FontName.COURIER_OBLIQUE;
|
||||
}
|
||||
return Standard14Fonts.FontName.COURIER;
|
||||
}
|
||||
|
||||
// Symbol and ZapfDingbats (less common)
|
||||
if (normalized.contains("symbol")) {
|
||||
return Standard14Fonts.FontName.SYMBOL;
|
||||
}
|
||||
if (normalized.contains("zapf") || normalized.contains("dingbat")) {
|
||||
return Standard14Fonts.FontName.ZAPF_DINGBATS;
|
||||
}
|
||||
|
||||
// No reasonable match found
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<PdfJsonPage> extractPages(
|
||||
PDDocument document,
|
||||
Map<Integer, List<PdfJsonTextElement>> textByPage,
|
||||
@ -2408,15 +2495,18 @@ public class PdfJsonConversionService {
|
||||
runFontModel =
|
||||
resolveFontModel(runFontLookup, pageNumber, run.fontId());
|
||||
}
|
||||
// Check if this is a normalized Type3 font (has Type3 metadata but is not PDType3Font)
|
||||
boolean isNormalizedType3 = !(run.font() instanceof PDType3Font)
|
||||
&& runFontModel != null
|
||||
&& runFontModel.getType3Glyphs() != null
|
||||
&& !runFontModel.getType3Glyphs().isEmpty();
|
||||
// Check if this is a normalized Type3 font (has Type3 metadata but is
|
||||
// not PDType3Font)
|
||||
boolean isNormalizedType3 =
|
||||
!(run.font() instanceof PDType3Font)
|
||||
&& runFontModel != null
|
||||
&& runFontModel.getType3Glyphs() != null
|
||||
&& !runFontModel.getType3Glyphs().isEmpty();
|
||||
|
||||
if (isNormalizedType3) {
|
||||
// For normalized Type3 fonts, use original text directly
|
||||
// The font has proper Unicode mappings, so PDFBox can encode it correctly
|
||||
// The font has proper Unicode mappings, so PDFBox can encode it
|
||||
// correctly
|
||||
contentStream.showText(run.text());
|
||||
} else {
|
||||
// For actual Type3 fonts and other fonts, encode manually
|
||||
@ -3149,9 +3239,10 @@ public class PdfJsonConversionService {
|
||||
PDFont font, PdfJsonFont fontModel, String text, List<Integer> rawCharCodes)
|
||||
throws IOException {
|
||||
boolean isType3Font = font instanceof PDType3Font;
|
||||
boolean hasType3Metadata = fontModel != null
|
||||
&& fontModel.getType3Glyphs() != null
|
||||
&& !fontModel.getType3Glyphs().isEmpty();
|
||||
boolean hasType3Metadata =
|
||||
fontModel != null
|
||||
&& fontModel.getType3Glyphs() != null
|
||||
&& !fontModel.getType3Glyphs().isEmpty();
|
||||
|
||||
// For normalized Type3 fonts (font is NOT Type3 but has Type3 metadata)
|
||||
if (!isType3Font && hasType3Metadata) {
|
||||
@ -3162,20 +3253,25 @@ public class PdfJsonConversionService {
|
||||
// NOTE: Do NOT sanitize encoded bytes for normalized Type3 fonts
|
||||
// Multi-byte encodings (UTF-16BE, CID fonts) have null bytes that are essential
|
||||
// Removing them corrupts the byte boundaries and produces garbled text
|
||||
log.info("[TYPE3] Encoded text '{}' for normalized font {}: encoded={} bytes",
|
||||
log.info(
|
||||
"[TYPE3] Encoded text '{}' for normalized font {}: encoded={} bytes",
|
||||
text.length() > 20 ? text.substring(0, 20) + "..." : text,
|
||||
fontModel.getId(),
|
||||
encoded != null ? encoded.length : 0);
|
||||
if (encoded != null && encoded.length > 0) {
|
||||
log.info("[TYPE3] Successfully encoded text for normalized Type3 font {} using standard encoding",
|
||||
log.info(
|
||||
"[TYPE3] Successfully encoded text for normalized Type3 font {} using standard encoding",
|
||||
fontModel.getId());
|
||||
return encoded;
|
||||
}
|
||||
log.info("[TYPE3] Standard encoding produced empty result for normalized Type3 font {}, falling through to Type3 mapping",
|
||||
log.info(
|
||||
"[TYPE3] Standard encoding produced empty result for normalized Type3 font {}, falling through to Type3 mapping",
|
||||
fontModel.getId());
|
||||
} catch (IOException | IllegalArgumentException ex) {
|
||||
log.info("[TYPE3] Standard encoding failed for normalized Type3 font {}: {}",
|
||||
fontModel.getId(), ex.getMessage());
|
||||
log.info(
|
||||
"[TYPE3] Standard encoding failed for normalized Type3 font {}: {}",
|
||||
fontModel.getId(),
|
||||
ex.getMessage());
|
||||
}
|
||||
// If standard encoding failed, fall through to Type3 glyph mapping (for subset fonts)
|
||||
// or return null to trigger fallback font
|
||||
@ -3643,6 +3739,19 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: Fuzzy match baseName against Standard14 fonts
|
||||
Standard14Fonts.FontName fuzzyMatch = fuzzyMatchStandard14(fontModel.getBaseName());
|
||||
if (fuzzyMatch != null) {
|
||||
log.info(
|
||||
"Fuzzy-matched font {} (baseName: {}) to Standard14 font {}",
|
||||
fontModel.getId(),
|
||||
fontModel.getBaseName(),
|
||||
fuzzyMatch.getName());
|
||||
PDFont font = new PDType1Font(fuzzyMatch);
|
||||
applyAdditionalFontMetadata(document, font, fontModel);
|
||||
return font;
|
||||
}
|
||||
|
||||
PDFont fallback = fallbackFontService.loadFallbackPdfFont(document);
|
||||
applyAdditionalFontMetadata(document, fallback, fontModel);
|
||||
return fallback;
|
||||
@ -3662,7 +3771,8 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
for (FontByteSource source : candidates) {
|
||||
PDFont font =
|
||||
loadFontFromSource(document, fontModel, source, originalFormat, true, true, true);
|
||||
loadFontFromSource(
|
||||
document, fontModel, source, originalFormat, true, true, true);
|
||||
if (font != null) {
|
||||
type3NormalizedFontCache.put(fontModel.getUid(), font);
|
||||
log.info(
|
||||
@ -3682,7 +3792,8 @@ public class PdfJsonConversionService {
|
||||
throws IOException {
|
||||
for (FontByteSource source : candidates) {
|
||||
PDFont font =
|
||||
loadFontFromSource(document, fontModel, source, originalFormat, false, false, false);
|
||||
loadFontFromSource(
|
||||
document, fontModel, source, originalFormat, false, false, false);
|
||||
if (font != null) {
|
||||
return font;
|
||||
}
|
||||
@ -3737,8 +3848,10 @@ public class PdfJsonConversionService {
|
||||
// so all glyphs are available for editing
|
||||
boolean willBeSubset = !originLabel.contains("type3-library");
|
||||
if (!willBeSubset) {
|
||||
log.info("[TYPE3-RUNTIME] Loading library font {} WITHOUT subsetting (full glyph set) from {}",
|
||||
fontModel.getId(), originLabel);
|
||||
log.info(
|
||||
"[TYPE3-RUNTIME] Loading library font {} WITHOUT subsetting (full glyph set) from {}",
|
||||
fontModel.getId(),
|
||||
originLabel);
|
||||
}
|
||||
PDFont font = PDType0Font.load(document, stream, willBeSubset);
|
||||
if (!skipMetadata) {
|
||||
|
||||
@ -0,0 +1,105 @@
|
||||
package stirling.software.SPDF.service.pdfjson;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service to manage job ownership and access control for PDF JSON operations. When security is
|
||||
* enabled, jobs are scoped to authenticated users. When security is disabled, jobs are globally
|
||||
* accessible.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "security.enable-login", havingValue = "true", matchIfMissing = false)
|
||||
public class JobOwnershipServiceImpl
|
||||
implements stirling.software.common.service.JobOwnershipService {
|
||||
|
||||
/**
|
||||
* Get the current authenticated user's identifier. Returns empty if no user is authenticated.
|
||||
*
|
||||
* @return Optional containing user identifier, or empty if not authenticated
|
||||
*/
|
||||
public Optional<String> getCurrentUserId() {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getPrincipal())) {
|
||||
String username = authentication.getName();
|
||||
log.debug("Current authenticated user: {}", username);
|
||||
return Optional.of(username);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to get current user from security context: {}", e.getMessage());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scoped job key that includes user ownership when security is enabled.
|
||||
*
|
||||
* @param jobId the base job identifier
|
||||
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
|
||||
*/
|
||||
public String createScopedJobKey(String jobId) {
|
||||
Optional<String> userId = getCurrentUserId();
|
||||
if (userId.isPresent()) {
|
||||
String scopedKey = userId.get() + ":" + jobId;
|
||||
log.debug("Created scoped job key: {}", scopedKey);
|
||||
return scopedKey;
|
||||
}
|
||||
log.debug("No user authenticated, using unsecured job key: {}", jobId);
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
* @param scopedJobKey the scoped job key to validate
|
||||
* @return true if current user owns the job or no authentication is required
|
||||
* @throws SecurityException if current user does not own the job
|
||||
*/
|
||||
public boolean validateJobAccess(String scopedJobKey) {
|
||||
Optional<String> userId = getCurrentUserId();
|
||||
|
||||
// If no user authenticated, allow access (backwards compatibility)
|
||||
if (userId.isEmpty()) {
|
||||
log.debug("No authentication required, allowing access to job: {}", scopedJobKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if job key starts with current user's ID
|
||||
String userPrefix = userId.get() + ":";
|
||||
if (!scopedJobKey.startsWith(userPrefix)) {
|
||||
log.warn(
|
||||
"Access denied: User {} attempted to access job key {} which they don't own",
|
||||
userId.get(),
|
||||
scopedJobKey);
|
||||
throw new SecurityException(
|
||||
"Access denied: You do not have permission to access this job");
|
||||
}
|
||||
|
||||
log.debug("Access granted: User {} owns job {}", userId.get(), scopedJobKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base job ID from a scoped job key.
|
||||
*
|
||||
* @param scopedJobKey the scoped job key
|
||||
* @return the base job ID without user prefix
|
||||
*/
|
||||
public String extractJobId(String scopedJobKey) {
|
||||
int colonIndex = scopedJobKey.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return scopedJobKey.substring(colonIndex + 1);
|
||||
}
|
||||
return scopedJobKey;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package stirling.software.SPDF.service.pdfjson;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* No-op implementation of job ownership service when security is disabled. All jobs are globally
|
||||
* accessible without authentication.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "security.enable-login", havingValue = "false", matchIfMissing = true)
|
||||
public class NoOpJobOwnershipService
|
||||
implements stirling.software.common.service.JobOwnershipService {
|
||||
|
||||
@Override
|
||||
public Optional<String> getCurrentUserId() {
|
||||
// No authentication when security is disabled
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createScopedJobKey(String jobId) {
|
||||
// Jobs are not scoped to users when security is disabled
|
||||
return jobId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validateJobAccess(String scopedJobKey) {
|
||||
// All jobs are accessible when security is disabled
|
||||
log.trace("Security disabled, allowing access to job: {}", scopedJobKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String extractJobId(String scopedJobKey) {
|
||||
// No user prefix when security is disabled
|
||||
return scopedJobKey;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user