From 85d9b5b83d1d20f32d017c62bdd4ed2606169a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:53:26 +0100 Subject: [PATCH 01/11] feat(viewer): Add interactive link layer with (basic) internal/external navigation support (#5077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Added a new `LinkLayer` component to the PDF viewer that renders clickable overlays for PDF link annotations, enabling both internal page navigation and external URL links. - Created `LinkLayer.tsx` component that extracts link annotations using the EmbedPDF annotation API with fallback to direct PDF document access - Implemented scale-aware positioning to maintain accurate link hotspots at different zoom levels - Added support for internal navigation (GoTo actions) using smooth scrolling and external links (URI actions) opening in new tabs - Integrated accessibility features with proper ARIA labels and keyboard navigation - Modified `LocalEmbedPDF.tsx` to always register the annotation plugin (even when editing is disabled) to enable reading existing link annotations - Updated `ReactRoutingController.java` and test formatting for code style consistency **Key features:** - Multi-source annotation detection (annotation API → document API → page API fallback) - Navigation lock to prevent race conditions - React performance optimizations (useMemo, useCallback) - TypeScript type safety for PDF actions and destinations This does not address support for Attachment links. Sadly, that does not seem to be possible with EmbedPDF image image image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. Signed-off-by: Balázs Szücs --- .../common/util/RequestUriUtilsTest.java | 3 +- .../web/ReactRoutingController.java | 6 +- .../src/core/components/viewer/LinkLayer.tsx | 244 ++++++++++++++++++ .../core/components/viewer/LocalEmbedPDF.tsx | 109 ++++---- 4 files changed, 305 insertions(+), 57 deletions(-) create mode 100644 frontend/src/core/components/viewer/LinkLayer.tsx diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 0edb546cc3..b7c121ab30 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -51,7 +51,8 @@ public class RequestUriUtilsTest { @Test void testIsFrontendRoute() { - assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route"); + assertTrue( + RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route"); assertTrue( RequestUriUtils.isFrontendRoute("", "/app/dashboard"), "React routes without extensions should be frontend routes"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 95acd62970..daa6233ce8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -6,12 +6,14 @@ import org.springframework.web.bind.annotation.GetMapping; @Controller public class ReactRoutingController { - @GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}") + @GetMapping( + "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}") public String forwardRootPaths() { return "forward:/index.html"; } - @GetMapping("/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}") + @GetMapping( + "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}") public String forwardNestedPaths() { return "forward:/index.html"; } diff --git a/frontend/src/core/components/viewer/LinkLayer.tsx b/frontend/src/core/components/viewer/LinkLayer.tsx new file mode 100644 index 0000000000..4062d96d72 --- /dev/null +++ b/frontend/src/core/components/viewer/LinkLayer.tsx @@ -0,0 +1,244 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; + +enum PDFActionType { + GoTo = 0, + GoToR = 1, + GoToE = 2, + URI = 3, + // Add other types as needed +} + +interface PDFRect { + origin: { x: number; y: number }; + size: { width: number; height: number }; +} + +interface PDFDestination { + pageIndex: number; + view: [number, number]; +} + +interface PDFAction { + type: string | number; + destination?: PDFDestination; + uri?: string; +} + +interface LinkAnnotation { + id: string; + type: number; + rect: PDFRect; + target?: { + type: string; + action?: PDFAction; + destination?: PDFDestination; + uri?: string; + }; +} + +function isGoToAction(action: PDFAction): boolean { + return action.type === 'GoTo' || action.type === PDFActionType.GoTo; +} + +function isURIAction(action: PDFAction): boolean { + return action.type === 'URI' || action.type === PDFActionType.URI; +} + +function isInternalLink(link: LinkAnnotation): boolean { + return Boolean(link.target?.type === 'destination' || + (link.target?.type === 'action' && link.target.action && isGoToAction(link.target.action))); +} + +function isExternalLink(link: LinkAnnotation): boolean { + return Boolean(link.target?.type === 'uri' || + (link.target?.type === 'action' && link.target.action && isURIAction(link.target.action))); +} + +interface LinkLayerProps { + pageIndex: number; + scale: number; + document?: any; + pdfFile?: File | Blob; + onLinkClick?: (target: any) => void; +} + +const getLinkTitle = (link: LinkAnnotation): string => { + if (link.target?.type === 'destination') { + return `Go to page ${(link.target.destination?.pageIndex ?? 0) + 1}`; + } + if (link.target?.type === 'action' && link.target.action?.type === 'GoTo') { + return `Go to page ${(link.target.action.destination?.pageIndex ?? 0) + 1}`; + } + if (link.target?.type === 'action' && (link.target.action?.type === 'URI' || link.target.action?.type === 3)) { + return `Open link: ${link.target.action.uri}`; + } + if (link.target?.uri) { + return `Open link: ${link.target.uri}`; + } + return 'Link'; +}; + +const getLinkAriaLabel = (link: LinkAnnotation): string => { + if (link.target?.type === 'destination') { + return `Navigate to page ${(link.target.destination?.pageIndex ?? 0) + 1}`; + } + if (link.target?.type === 'action' && link.target.action?.type === 'GoTo') { + return `Navigate to page ${(link.target.action.destination?.pageIndex ?? 0) + 1}`; + } + if (link.target?.type === 'action' && (link.target.action?.type === 'URI' || link.target.action?.type === 3)) { + return 'Open external link'; + } + return 'Open external link'; +}; + +export const LinkLayer: React.FC = ({ + pageIndex, + scale, + document: pdfDocument, + onLinkClick +}) => { + const { provides: annotation } = useAnnotationCapability(); + const { provides: scroll } = useScroll(); + const [links, setLinks] = useState([]); + const [isNavigating, setIsNavigating] = useState(false); + + const processedLinks = useMemo(() => { + return links.map(link => ({ + ...link, + scaledRect: { + left: link.rect.origin.x * scale, + top: link.rect.origin.y * scale, + width: link.rect.size.width * scale, + height: link.rect.size.height * scale, + } + })); + }, [links, scale]); + + useEffect(() => { + const fetchLinks = async () => { + if (!annotation) return; + + try { + // Use the annotation API's built-in filtering if available + const pageAnnotations = await annotation + .getPageAnnotations({ + pageIndex, + // Try to filter for link annotations (type 2) if the API supports it + ...(annotation.getPageAnnotations.length > 1 ? { types: [2] } : {}) + }) + .toPromise(); + + // Filter for link annotations (type 2 is LINK in PDF spec) as fallback + const linkAnnotations = pageAnnotations.filter( + (ann: any) => ann.type === 2 + ) as LinkAnnotation[]; + + if (linkAnnotations.length > 0) { + setLinks(linkAnnotations); + return; + } + } catch (error) { + console.error('[LinkLayer] Failed to fetch links from annotation API:', error); + } + + if (pdfDocument) { + try { + // Try different methods to get link annotations + let pdfLinks: any[] = []; + + if (pdfDocument.getPageAnnotations && typeof pdfDocument.getPageAnnotations === 'function') { + pdfLinks = await pdfDocument.getPageAnnotations(pageIndex); + } else if (pdfDocument.getAnnotations && typeof pdfDocument.getAnnotations === 'function') { + const allAnnotations = await pdfDocument.getAnnotations(); + pdfLinks = allAnnotations.filter((ann: any) => ann.pageIndex === pageIndex && ann.type === 2); + } else if (pdfDocument.pages && pdfDocument.pages[pageIndex]) { + const page = pdfDocument.pages[pageIndex]; + if (page.getAnnotations && typeof page.getAnnotations === 'function') { + pdfLinks = await page.getAnnotations(); + } + } + + const convertedLinks = pdfLinks.map((ann: any) => ({ + id: ann.id || `pdf-link-${pageIndex}-${Math.random()}`, + type: ann.type || 2, + rect: ann.rect || ann, + target: ann.target || ann.action + })) as LinkAnnotation[]; + + setLinks(convertedLinks); + } catch (error) { + console.warn('[LinkLayer] Failed to get annotations from PDF document:', error); + } + } else { + console.warn('[LinkLayer] No annotation API or PDF document available'); + } + }; + + fetchLinks(); + }, [annotation, pageIndex, pdfDocument]); + + const handleLinkClick = useCallback(async (link: LinkAnnotation) => { + if (isNavigating) return; // Prevent multiple simultaneous navigations + + try { + setIsNavigating(true); + + if (onLinkClick) { + onLinkClick(link.target); + return; + } + + if (isInternalLink(link)) { + const targetPage = link.target?.destination?.pageIndex ?? + link.target?.action?.destination?.pageIndex; + if (targetPage !== undefined && scroll) { + await scroll.scrollToPage({ + pageNumber: targetPage + 1, // PDF pages are 1-indexed + behavior: 'smooth', + }); + } + } else if (isExternalLink(link)) { + const uri = link.target?.uri ?? link.target?.action?.uri; + if (uri) { + window.open(uri, '_blank', 'noopener,noreferrer'); + } + } else { + throw new Error(`Unsupported link type: ${link.target?.type}`); + } + } catch (error) { + console.error('[LinkLayer] Navigation failed:', error); + } finally { + setIsNavigating(false); + } + }, [isNavigating, onLinkClick, scroll]); + + return ( +
+ {processedLinks.map((link) => { + const { id } = link; + const { left, top, width, height } = link.scaledRect; + + return ( +
+ ); +}; diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 2de2a2e588..adef8c4fbf 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -43,6 +43,7 @@ import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge'; import { isPdfFile } from '@app/utils/fileUtils'; import { useTranslation } from 'react-i18next'; +import { LinkLayer } from '@app/components/viewer/LinkLayer'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -103,15 +104,17 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur createPluginRegistration(SelectionPluginPackage), // Register history plugin for undo/redo (recommended for annotations) - ...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []), + // Always register for reading existing annotations + createPluginRegistration(HistoryPluginPackage), // Register annotation plugin (depends on InteractionManager, Selection, History) - ...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, { + // Always register for reading existing annotations like links + createPluginRegistration(AnnotationPluginPackage, { annotationAuthor: 'Digital Signature', autoCommit: true, deactivateToolAfterCreate: false, selectAfterCreate: true, - })] : []), + }), // Register pan plugin (depends on Viewport, InteractionManager) createPluginRegistration(PanPluginPackage, { @@ -229,68 +232,62 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur { + onInitialized={async (registry) => { const annotationPlugin = registry.getPlugin('annotation'); if (!annotationPlugin || !annotationPlugin.provides) return; const annotationApi = annotationPlugin.provides(); if (!annotationApi) return; - // Add custom signature stamp tool for image signatures - annotationApi.addTool({ - id: 'signatureStamp', - name: 'Digital Signature', - interaction: { exclusive: false, cursor: 'copy' }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.STAMP, - // Image will be set dynamically when signature is created - }, - }); + if (enableAnnotations) { + annotationApi.addTool({ + id: 'signatureStamp', + name: 'Digital Signature', + interaction: { exclusive: false, cursor: 'copy' }, + matchScore: () => 0, + defaults: { + type: PdfAnnotationSubtype.STAMP, + }, + }); - // Add custom ink signature tool for drawn signatures - annotationApi.addTool({ - id: 'signatureInk', - name: 'Signature Draw', - interaction: { exclusive: true, cursor: 'crosshair' }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.INK, - color: '#000000', - opacity: 1.0, - borderWidth: 2, - }, - }); + annotationApi.addTool({ + id: 'signatureInk', + name: 'Signature Draw', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: () => 0, + defaults: { + type: PdfAnnotationSubtype.INK, + color: '#000000', + opacity: 1.0, + borderWidth: 2, + }, + }); - // Listen for annotation events to track annotations and notify parent - annotationApi.onAnnotationEvent((event: any) => { - if (event.type === 'create' && event.committed) { - // Add to annotations list - setAnnotations(prev => [...prev, { - id: event.annotation.id, - pageIndex: event.pageIndex, - rect: event.annotation.rect - }]); + annotationApi.onAnnotationEvent((event: any) => { + if (event.type === 'create' && event.committed) { + setAnnotations(prev => [...prev, { + id: event.annotation.id, + pageIndex: event.pageIndex, + rect: event.annotation.rect + }]); - // Notify parent if callback provided - if (onSignatureAdded) { - onSignatureAdded(event.annotation); + if (onSignatureAdded) { + onSignatureAdded(event.annotation); + } + } else if (event.type === 'delete' && event.committed) { + setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id)); + } else if (event.type === 'loaded') { + const loadedAnnotations = event.annotations || []; + setAnnotations(loadedAnnotations.map((ann: any) => ({ + id: ann.id, + pageIndex: ann.pageIndex || 0, + rect: ann.rect + }))); } - } else if (event.type === 'delete' && event.committed) { - // Remove from annotations list - setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id)); - } else if (event.type === 'loaded') { - // Handle initial load of annotations - const loadedAnnotations = event.annotations || []; - setAnnotations(loadedAnnotations.map((ann: any) => ({ - id: ann.id, - pageIndex: ann.pageIndex || 0, - rect: ann.rect - }))); - } - }); - } : undefined} + }); + } + }} > @@ -352,6 +349,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur {/* Selection layer for text interaction */} + + {/* Link layer for clickable PDF links */} + + {/* Annotation layer for signatures (only when enabled) */} {enableAnnotations && ( Date: Sat, 29 Nov 2025 16:03:44 +0000 Subject: [PATCH 02/11] Fix email invite/ allow non auth and table refresh issues (#5076) # Description of Changes - Show warning when email invite fails but user is created - Auto-refresh user/team tables after modifications - Fix invite email URLs to use frontend URL instead of backend - Support anonymous SMTP for local development --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../security/configuration/MailConfig.java | 33 +++++++++++++-- .../controller/api/UserController.java | 42 +++++++++++++++++-- .../security/service/EmailService.java | 14 ++++++- .../public/locales/en-GB/translation.toml | 2 +- .../components/shared/InviteMembersModal.tsx | 17 +++++++- .../config/configSections/PeopleSection.tsx | 1 + .../config/configSections/TeamsSection.tsx | 18 ++++---- 7 files changed, 106 insertions(+), 21 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java index c9b6e9d77d..6a565cade3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java @@ -35,15 +35,40 @@ public class MailConfig { JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); mailSender.setHost(mailProperties.getHost()); mailSender.setPort(mailProperties.getPort()); - mailSender.setUsername(mailProperties.getUsername()); - mailSender.setPassword(mailProperties.getPassword()); mailSender.setDefaultEncoding("UTF-8"); + // Only set username and password if they are provided + String username = mailProperties.getUsername(); + String password = mailProperties.getPassword(); + boolean hasCredentials = + (username != null && !username.trim().isEmpty()) + || (password != null && !password.trim().isEmpty()); + + if (username != null && !username.trim().isEmpty()) { + mailSender.setUsername(username); + log.info("SMTP username configured"); + } else { + log.info("SMTP username not configured - using anonymous connection"); + } + + if (password != null && !password.trim().isEmpty()) { + mailSender.setPassword(password); + log.info("SMTP password configured"); + } else { + log.info("SMTP password not configured"); + } + // Retrieves the JavaMail properties to configure additional SMTP parameters Properties props = mailSender.getJavaMailProperties(); - // Enables SMTP authentication - props.put("mail.smtp.auth", "true"); + // Only enable SMTP authentication if credentials are provided + if (hasCredentials) { + props.put("mail.smtp.auth", "true"); + log.info("SMTP authentication enabled"); + } else { + props.put("mail.smtp.auth", "false"); + log.info("SMTP authentication disabled - no credentials provided"); + } // Enables STARTTLS to encrypt the connection if supported by the SMTP server props.put("mail.smtp.starttls.enable", "true"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 9e31ee69ce..f2b3fd5101 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -407,7 +407,8 @@ public class UserController { public ResponseEntity inviteUsers( @RequestParam(name = "emails", required = true) String emails, @RequestParam(name = "role", defaultValue = "ROLE_USER") String role, - @RequestParam(name = "teamId", required = false) Long teamId) + @RequestParam(name = "teamId", required = false) Long teamId, + HttpServletRequest request) throws SQLException, UnsupportedProviderException { // Check if email invites are enabled @@ -477,6 +478,9 @@ public class UserController { } } + // Build login URL + String loginUrl = buildLoginUrl(request); + int successCount = 0; int failureCount = 0; StringBuilder errors = new StringBuilder(); @@ -488,7 +492,7 @@ public class UserController { continue; } - InviteResult result = processEmailInvite(email, effectiveTeamId, role); + InviteResult result = processEmailInvite(email, effectiveTeamId, role, loginUrl); if (result.isSuccess()) { successCount++; } else { @@ -687,15 +691,45 @@ public class UserController { return ResponseEntity.ok(apiKey); } + /** + * Helper method to build the login URL from the application configuration or request. + * + * @param request The HTTP request + * @return The login URL + */ + private String buildLoginUrl(HttpServletRequest request) { + String baseUrl; + String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { + // Use configured frontend URL (remove trailing slash if present) + baseUrl = + configuredFrontendUrl.endsWith("/") + ? configuredFrontendUrl.substring(0, configuredFrontendUrl.length() - 1) + : configuredFrontendUrl; + } else { + // Fall back to backend URL from request + baseUrl = + request.getScheme() + + "://" + + request.getServerName() + + (request.getServerPort() != 80 && request.getServerPort() != 443 + ? ":" + request.getServerPort() + : ""); + } + return baseUrl + "/login"; + } + /** * Helper method to process a single email invitation. * * @param email The email address to invite * @param teamId The team ID to assign the user to * @param role The role to assign to the user + * @param loginUrl The URL to the login page * @return InviteResult containing success status and optional error message */ - private InviteResult processEmailInvite(String email, Long teamId, String role) { + private InviteResult processEmailInvite( + String email, Long teamId, String role, String loginUrl) { try { // Validate email format (basic check) if (!email.contains("@") || !email.contains(".")) { @@ -715,7 +749,7 @@ public class UserController { // Send invite email try { - emailService.get().sendInviteEmail(email, email, temporaryPassword); + emailService.get().sendInviteEmail(email, email, temporaryPassword, loginUrl); log.info("Sent invite email to: {}", email); return InviteResult.success(); } catch (Exception emailEx) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 870c96f230..8df76fca3c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -115,10 +115,12 @@ public class EmailService { * @param to The recipient email address * @param username The username for the new account * @param temporaryPassword The temporary password + * @param loginUrl The URL to the login page * @throws MessagingException If there is an issue with creating or sending the email. */ @Async - public void sendInviteEmail(String to, String username, String temporaryPassword) + public void sendInviteEmail( + String to, String username, String temporaryPassword, String loginUrl) throws MessagingException { String subject = "Welcome to Stirling PDF"; @@ -144,6 +146,14 @@ public class EmailService {

⚠️ Important: You will be required to change your password upon first login for security reasons.

+ + +

Or copy and paste this link in your browser:

+
+ %s +

Please keep these credentials secure and do not share them with anyone.

— The Stirling PDF Team

@@ -155,7 +165,7 @@ public class EmailService { """ - .formatted(username, temporaryPassword); + .formatted(username, temporaryPassword, loginUrl, loginUrl); sendPlainEmail(to, subject, body, true); } diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index d1ef09e6fe..3ff331b189 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5261,7 +5261,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com" emailsRequired = "At least one email address is required" submit = "Send Invites" success = "user(s) invited successfully" -partialSuccess = "Some invites failed" +partialFailure = "Some invites failed" allFailed = "Failed to invite users" error = "Failed to send invites" diff --git a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx index 9a13d18a2c..f583f3cb78 100644 --- a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx +++ b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx @@ -27,9 +27,10 @@ import { useNavigate } from 'react-router-dom'; interface InviteMembersModalProps { opened: boolean; onClose: () => void; + onSuccess?: () => void; } -export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) { +export default function InviteMembersModal({ opened, onClose, onSuccess }: InviteMembersModalProps) { const { t } = useTranslation(); const { config } = useAppConfig(); const navigate = useNavigate(); @@ -136,6 +137,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod }); alert({ alertType: 'success', title: t('workspace.people.addMember.success') }); onClose(); + onSuccess?.(); // Reset form setInviteForm({ username: '', @@ -168,11 +170,23 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod }); if (response.successCount > 0) { + // Show success message alert({ alertType: 'success', title: t('workspace.people.emailInvite.success', { count: response.successCount, defaultValue: `Successfully invited ${response.successCount} user(s)` }) }); + + // Show warning if there were partial failures + if (response.failureCount > 0 && response.errors) { + alert({ + alertType: 'warning', + title: t('workspace.people.emailInvite.partialFailure', 'Some invites failed'), + body: response.errors + }); + } + onClose(); + onSuccess?.(); setEmailInviteForm({ emails: '', role: 'ROLE_USER', @@ -208,6 +222,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod sendEmail: inviteLinkForm.sendEmail, }); setGeneratedInviteLink(response.inviteUrl); + onSuccess?.(); if (inviteLinkForm.sendEmail && inviteLinkForm.email) { alert({ alertType: 'success', title: t('workspace.people.inviteLink.emailSent', 'Invite link generated and sent via email') }); } diff --git a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 611912dbc9..9c4f56ba07 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -588,6 +588,7 @@ export default function PeopleSection() { setInviteModalOpened(false)} + onSuccess={fetchData} /> {/* Edit User Modal */} diff --git a/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx index cfd6346ae0..eb256bc209 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx @@ -80,9 +80,9 @@ export default function TeamsSection() { setProcessing(true); await teamService.createTeam(newTeamName); alert({ alertType: 'success', title: t('workspace.teams.createTeam.success') }); - setCreateModalOpened(false); setNewTeamName(''); - fetchTeams(); + setCreateModalOpened(false); + await fetchTeams(); } catch (error: any) { console.error('Failed to create team:', error); const errorMessage = error.response?.data?.message || @@ -105,10 +105,10 @@ export default function TeamsSection() { setProcessing(true); await teamService.renameTeam(selectedTeam.id, renameTeamName); alert({ alertType: 'success', title: t('workspace.teams.renameTeam.success') }); - setRenameModalOpened(false); - setSelectedTeam(null); setRenameTeamName(''); - fetchTeams(); + setSelectedTeam(null); + setRenameModalOpened(false); + await fetchTeams(); } catch (error: any) { console.error('Failed to rename team:', error); const errorMessage = error.response?.data?.message || @@ -134,7 +134,7 @@ export default function TeamsSection() { try { await teamService.deleteTeam(team.id); alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') }); - fetchTeams(); + await fetchTeams(); } catch (error: any) { console.error('Failed to delete team:', error); const errorMessage = error.response?.data?.message || @@ -182,10 +182,10 @@ export default function TeamsSection() { setProcessing(true); await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId)); alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') }); - setAddMemberModalOpened(false); - setSelectedTeam(null); setSelectedUserId(''); - fetchTeams(); + setSelectedTeam(null); + setAddMemberModalOpened(false); + await fetchTeams(); } catch (error) { console.error('Failed to add member to team:', error); alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') }); From d908bc67854838c465387ca105bf5f0c13c3d673 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:03:57 +0000 Subject: [PATCH 03/11] Swagger fixes (#5071) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../common/annotations/api/AnalysisApi.java | 26 +++++------------ .../common/annotations/api/ConvertApi.java | 26 +++++------------ .../common/annotations/api/FilterApi.java | 26 +++++------------ .../common/annotations/api/GeneralApi.java | 22 +++++---------- .../common/annotations/api/MiscApi.java | 28 ++++++------------- .../common/annotations/api/PipelineApi.java | 25 ++++------------- .../common/annotations/api/SecurityApi.java | 26 +++++------------ .../software/SPDF/config/SpringDocConfig.java | 16 +++++++---- .../api/form/FormFillController.java | 14 +++++++++- frontend/vite.config.ts | 12 ++++++++ 10 files changed, 86 insertions(+), 135 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/AnalysisApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/AnalysisApi.java index 31de4c8d28..d091c8e335 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/api/AnalysisApi.java +++ b/app/common/src/main/java/stirling/software/common/annotations/api/AnalysisApi.java @@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; name = "Analysis", description = """ - Document analysis and information extraction services for content intelligence and insights. + Read-only inspection of PDFs: page count, page sizes, fonts, form fields, annotations, document properties, and security details. + Use these endpoints to understand what's inside a document without changing it. - This endpoint group provides analytical capabilities to understand document structure, - extract information, and generate insights from PDF content for automated processing. - - Common use cases: - • Document inventory management and content audit for compliance verification - • Quality assurance workflows and business intelligence analytics - • Migration planning, accessibility evaluation, and document forensics - - Business applications: - • Legal discovery, financial document review, and healthcare records analysis - • Academic research, government processing, and publishing optimization - - Operational scenarios: - • Large-scale profiling, migration assessment, and performance optimization - • Automated quality control and content strategy development - - Target users: Data analysts, QA teams, administrators, and business intelligence - professionals requiring detailed document insights. + Typical uses: + • Get page counts and dimensions for layout or print rules + • List fonts and annotations to spot compatibility issues + • Inspect form fields before deciding how to fill or modify them + • Pull metadata and security settings for audits or reports """) public @interface AnalysisApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/ConvertApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/ConvertApi.java index 9d9d85bd73..b3202ad3c7 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/api/ConvertApi.java +++ b/app/common/src/main/java/stirling/software/common/annotations/api/ConvertApi.java @@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; name = "Convert", description = """ - Document format transformation services for cross-platform compatibility and workflow integration. + Convert PDFs to and from other formats (Word, images, HTML, Markdown, PDF/A, CBZ/CBR, EML, etc.). + This group also powers the text-editor / jobId-based editing flow for incremental PDF edits. - This endpoint group enables transformation between various formats, supporting - diverse business workflows and system integrations for mixed document ecosystems. - - Common use cases: - • Legacy system integration, document migration, and cross-platform sharing - • Archive standardization, publishing preparation, and content adaptation - • Accessibility compliance and mobile-friendly document preparation - - Business applications: - • Enterprise content management, digital publishing, and educational platforms - • Legal document processing, healthcare interoperability, and government standardization - - Integration scenarios: - • API-driven pipelines, automated workflow preparation, and batch conversions - • Real-time format adaptation for user requests - - Target users: System integrators, content managers, digital archivists, and - organizations requiring flexible document format interoperability. + Typical uses: + • Turn PDFs into Word or text for editing + • Convert office files, images, HTML, or email (EML) into PDFs + • Create PDF/A for long-term archiving + • Export PDFs as images, HTML, CSV, or Markdown for search, analysis, or reuse """) public @interface ConvertApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/FilterApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/FilterApi.java index 2c925609ed..27b3bfa80e 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/api/FilterApi.java +++ b/app/common/src/main/java/stirling/software/common/annotations/api/FilterApi.java @@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; name = "Filter", description = """ - Document content filtering and search operations for information discovery and organization. + Check basic properties of PDFs before you process them: page count, file size, page size/rotation, and whether they contain text or images. + Use these endpoints as a "pre-check" step to decide what to do with a file next. - This endpoint group enables intelligent content discovery and organization within - document collections for content-based processing and information extraction. - - Common use cases: - • Legal discovery, research organization, and compliance auditing - • Content moderation, academic research, and business intelligence - • Quality assurance and content validation workflows - - Business applications: - • Contract analysis, financial review, and healthcare records organization - • Government processing, educational curation, and IP protection - - Workflow scenarios: - • Large-scale processing, automated classification, and information extraction - • Document preparation for further processing or analysis - - Target users: Legal professionals, researchers, compliance officers, and - organizations requiring intelligent document content discovery and organization. + Typical uses: + • Reject files that are too big or too small + • Detect image-only PDFs that should go through OCR + • Ensure a document has enough pages before it enters a workflow + • Check orientation of pages before printing or merging """) public @interface FilterApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/GeneralApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/GeneralApi.java index 535f47773d..4a3601732f 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/api/GeneralApi.java +++ b/app/common/src/main/java/stirling/software/common/annotations/api/GeneralApi.java @@ -22,21 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; name = "General", description = """ - Core PDF processing operations for fundamental document manipulation workflows. + Page-level PDF editing: split, merge, rotate, crop, rearrange, and scale pages. + These endpoints handle most daily "I opened a PDF editor just to…" type tasks. - This endpoint group provides essential PDF functionality that forms the foundation - of most document processing workflows across various industries. - - Common use cases: - • Document preparation for archival systems and content organization - • File preparation for distribution, accessibility compliance, and batch processing - • Document consolidation for reporting and legal compliance workflows - - Typical applications: - • Content management, publishing workflows, and educational content distribution - • Business process automation and archive management - - Target users: Content managers, document processors, and organizations requiring - reliable foundational PDF manipulation capabilities. + Typical uses: + • Split a large PDF into smaller files (by pages, chapters, or size) + • Merge several PDFs into one report or pack + • Rotate or reorder pages before sending or archiving + • Turn a multi-page document into one long scrolling page """) public @interface GeneralApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/MiscApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/MiscApi.java index ee9cf62bf9..d58c71b1e6 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/api/MiscApi.java +++ b/app/common/src/main/java/stirling/software/common/annotations/api/MiscApi.java @@ -22,25 +22,15 @@ import io.swagger.v3.oas.annotations.tags.Tag; name = "Misc", description = """ - Specialized utilities and supplementary tools for enhanced document processing workflows. + Tools that don't fit neatly elsewhere: OCR, compress, repair, flatten, extract images, update metadata, add stamps/page numbers/images, and more. + These endpoints help fix problem PDFs and prepare them for sharing, storage, or further processing. - This endpoint group provides utility operations that support core document processing - tasks and address specific workflow needs in real-world scenarios. - - Common use cases: - • Document optimization for bandwidth-limited environments and storage cost management - • Document repair, content extraction, and validation for quality assurance - • Accessibility improvement and custom processing for specialized needs - - Business applications: - • Web publishing optimization, email attachment management, and archive efficiency - • Mobile compatibility, print production, and legacy document recovery - - Operational scenarios: - • Batch processing, quality control, and performance optimization - • Troubleshooting and recovery of problematic documents - - Target users: System administrators, document specialists, and organizations requiring - specialized document processing and optimization tools. + Typical uses: + • Repair a damaged PDF or remove blank pages + • Run OCR on scanned PDFs so they become searchable + • Compress large PDFs for email or web download + • Extract embedded images or scans + • Add page numbers, stamps, or overlay an image (e.g. logo, seal) + • Update PDF metadata (title, author, etc.) """) public @interface MiscApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/PipelineApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/PipelineApi.java index 38407b6a09..f5ad92c99b 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/api/PipelineApi.java +++ b/app/common/src/main/java/stirling/software/common/annotations/api/PipelineApi.java @@ -22,25 +22,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; name = "Pipeline", description = """ - Automated document processing workflows for complex multi-stage business operations. + Run several PDF operations in one configured pipeline instead of calling multiple endpoints yourself. + Useful when you always do the same steps in sequence (for example: convert → OCR → compress → watermark). - This endpoint group enables organizations to create sophisticated document processing - workflows that combine multiple operations into streamlined, repeatable processes. - - Common use cases: - • Invoice processing, legal document review, and healthcare records standardization - • Government processing, educational content preparation, and publishing automation - • Contract lifecycle management and approval processes - - Business applications: - • Automated compliance reporting, large-scale migration, and quality assurance - • Archive preparation, content delivery, and document approval workflows - - Operational scenarios: - • Scheduled batch processing and event-driven document processing - • Multi-department coordination and business system integration - - Target users: Business process managers, IT automation specialists, and organizations - requiring consistent, repeatable document processing workflows. + Typical uses: + • Process incoming invoices in one go (clean, OCR, compress, stamp, etc.) + • Normalise documents before they enter an archive + • Wrap a complex document flow behind a single API call for your own apps """) public @interface PipelineApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/SecurityApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/SecurityApi.java index fe9756d3f1..fa34ede40b 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/api/SecurityApi.java +++ b/app/common/src/main/java/stirling/software/common/annotations/api/SecurityApi.java @@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; name = "Security", description = """ - Document security and protection services for confidential and sensitive content. + Protect and clean PDFs: passwords, digital signatures, redaction, and sanitizing. + These endpoints help you control who can open a file, what they can do with it, and remove sensitive content when needed. - This endpoint group provides essential security operations for organizations handling - sensitive documents and materials requiring controlled access. - - Common use cases: - • Legal confidentiality, healthcare privacy (HIPAA), and financial regulatory compliance - • Government classified handling, corporate IP protection, and educational privacy (FERPA) - • Contract security for business transactions - - Business applications: - • Document authentication, confidential sharing, and secure archiving - • Content watermarking, access control, and privacy protection through redaction - - Industry scenarios: - • Legal discovery, medical records exchange, financial audit documentation - • Enterprise policy enforcement and data governance - - Target users: Legal professionals, healthcare administrators, compliance officers, - government agencies, and enterprises handling sensitive content. + Typical uses: + • Add or remove a password on a PDF + • Redact personal or confidential information (manually or automatically) + • Validate or remove digital signatures + • Sanitize a PDF to strip scripts and embedded content """) public @interface SecurityApi {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/SpringDocConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/SpringDocConfig.java index cdde38d48c..6733eb22dc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/SpringDocConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/SpringDocConfig.java @@ -21,6 +21,9 @@ public class SpringDocConfig { "/api/v1/user/**", "/api/v1/settings/**", "/api/v1/team/**", + "/api/v1/auth/**", + "/api/v1/invite/**", + "/api/v1/audit/**", "/api/v1/ui-data/**", "/api/v1/proprietary/ui-data/**", "/api/v1/info/**", @@ -33,7 +36,7 @@ public class SpringDocConfig { openApi.getInfo() .title("Stirling PDF - Processing API") .description( - "API documentation for PDF processing operations including conversion, manipulation, security, and utilities.")); + "APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs.")); }) .build(); } @@ -47,14 +50,17 @@ public class SpringDocConfig { "/api/v1/admin/**", "/api/v1/user/**", "/api/v1/settings/**", - "/api/v1/team/**") + "/api/v1/team/**", + "/api/v1/auth/**", + "/api/v1/invite/**", + "/api/v1/audit/**") .addOpenApiCustomizer( openApi -> { openApi.info( openApi.getInfo() - .title("Stirling PDF - Admin API") + .title("Stirling PDF - Management API") .description( - "API documentation for administrative functions, user management, and system configuration.")); + "Endpoints for authentication, user management, invitations, audit logging, and system configuration.")); }) .build(); } @@ -76,7 +82,7 @@ public class SpringDocConfig { openApi.getInfo() .title("Stirling PDF - System API") .description( - "API documentation for system information, UI data, and utility endpoints.")); + "System information, UI metadata, job status, and file management endpoints.")); }) .build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java index ddc7048bdf..c28733f81d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java @@ -34,7 +34,19 @@ import stirling.software.proprietary.util.FormUtils; @RestController @RequestMapping("/api/v1/form") -@Tag(name = "Forms", description = "PDF form APIs") +@Tag( + name = "Forms", + description = + """ + Work with PDF form fields: read them, fill them, edit them, or remove them. + Treats a PDF as a structured form instead of just flat pages. + + Typical uses: + • Inspect which form fields exist in a PDF + • Autofill forms from your own systems (e.g. CRM, ERP) + • Change or delete form fields before sending out a final, non-editable copy + • Unlock read-only form fields when you need to update them + """) @RequiredArgsConstructor public class FormFillController { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 49aacd3aef..0eb35e94cc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -51,6 +51,18 @@ export default defineConfig(({ mode }) => { secure: false, xfwd: true, }, + '/swagger-ui': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/v1/api-docs': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, }, }, base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './', From b49e8a2355d4b551e70702e2464c680b77b6ae58 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:04:19 +0000 Subject: [PATCH 04/11] tauri remote connection fix (#5070) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/src-tauri/capabilities/default.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index b6f98d92ee..b992b32211 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -11,8 +11,8 @@ { "identifier": "http:allow-fetch", "allow": [ - { "url": "http://localhost:*" }, - { "url": "http://127.0.0.1:*" }, + { "url": "http://*" }, + { "url": "http://*:*" }, { "url": "https://*" } ] }, From 12f1fd485e1734267ea400fb4488db9d287a6196 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:04:29 +0000 Subject: [PATCH 05/11] Audit viewer making api call when shouldnt (#5069) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/src/core/hooks/useAuditFilters.ts | 8 +++++--- .../config/configSections/audit/AuditEventsTable.tsx | 2 +- .../config/configSections/audit/AuditExportSection.tsx | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/core/hooks/useAuditFilters.ts b/frontend/src/core/hooks/useAuditFilters.ts index ef8f0c7dff..b650c3491f 100644 --- a/frontend/src/core/hooks/useAuditFilters.ts +++ b/frontend/src/core/hooks/useAuditFilters.ts @@ -4,7 +4,7 @@ import auditService, { AuditFilters } from '@app/services/auditService'; /** * Shared hook for managing audit filters across components */ -export function useAuditFilters(initialFilters: Partial = {}) { +export function useAuditFilters(initialFilters: Partial = {}, loginEnabled: boolean = true) { const [eventTypes, setEventTypes] = useState([]); const [users, setUsers] = useState([]); const [filters, setFilters] = useState({ @@ -30,8 +30,10 @@ export function useAuditFilters(initialFilters: Partial = {}) { } }; - fetchMetadata(); - }, []); + if (loginEnabled) { + fetchMetadata(); + } + }, [loginEnabled]); const handleFilterChange = (key: keyof AuditFilters, value: any) => { setFilters((prev) => ({ ...prev, [key]: value })); diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx index adad943884..d8bf714b0a 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx @@ -35,7 +35,7 @@ const AuditEventsTable: React.FC = ({ loginEnabled = true const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({ page: 0, pageSize: 20, - }); + }, loginEnabled); useEffect(() => { const fetchEvents = async () => { diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx index 2b4e533f31..04cc260e91 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx @@ -23,7 +23,7 @@ const AuditExportSection: React.FC = ({ loginEnabled = const [exporting, setExporting] = useState(false); // Use shared filters hook - const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters(); + const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({}, loginEnabled); const handleExport = async () => { if (!loginEnabled) return; From 85e91217450f0c1c155e0c83030219ee9acd29d5 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:04:43 +0000 Subject: [PATCH 06/11] desktop fix debian (#5068) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/src-tauri/stirling-pdf.desktop | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src-tauri/stirling-pdf.desktop b/frontend/src-tauri/stirling-pdf.desktop index 9d60293771..45db59c737 100644 --- a/frontend/src-tauri/stirling-pdf.desktop +++ b/frontend/src-tauri/stirling-pdf.desktop @@ -3,6 +3,7 @@ Version=1.0 Type=Application Name=Stirling-PDF Comment=Locally hosted web application that allows you to perform various operations on PDF files +Exec=/usr/bin/stirling-pdf Icon={{icon}} Terminal=false MimeType=application/pdf; From fde449e738259c530b58257065842d3d488d1cfa Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:05:15 +0000 Subject: [PATCH 07/11] Use frontend translations for OCR language picker (#5051) ## Summary - revert OCR ui-data endpoint to only expose language codes without backend labels - translate OCR language options on the frontend using existing lang.* keys with locale-aware fallbacks ## Testing - Not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6928ae7c85448328a0d2660a0c021b22) --- .../components/tools/ocr/LanguagePicker.tsx | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/frontend/src/core/components/tools/ocr/LanguagePicker.tsx b/frontend/src/core/components/tools/ocr/LanguagePicker.tsx index 428853b532..784d22da5c 100644 --- a/frontend/src/core/components/tools/ocr/LanguagePicker.tsx +++ b/frontend/src/core/components/tools/ocr/LanguagePicker.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; import { Text, Loader } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { tempOcrLanguages, getAutoOcrLanguage } from '@app/utils/languageMapping'; +import { getAutoOcrLanguage, getBrowserLanguagesForOcr, getOcrDisplayName } from '@app/utils/languageMapping'; +import apiClient from '@app/services/apiClient'; import DropdownListWithFooter, { DropdownItem } from '@app/components/shared/DropdownListWithFooter'; export interface LanguageOption { @@ -37,32 +38,41 @@ const LanguagePicker: React.FC = ({ // Fetch available languages from backend const fetchLanguages = async () => { try { - const response = await fetch(languagesEndpoint); + const { data } = await apiClient.get<{ languages: string[] }>(languagesEndpoint); + const displayNames = typeof Intl.DisplayNames !== 'undefined' + ? new Intl.DisplayNames([i18n.language], { type: 'language' }) + : null; - if (response.ok) { - const data: { languages: string[] } = await response.json(); - const languages = data.languages; + const languageOptions = [...new Set(data.languages)] + .map((lang) => { + const displayName = getOcrDisplayName(lang); + const browserLanguageCodes = getBrowserLanguagesForOcr(lang); + const langKey = `lang.${lang}`; + const translatedFromKey = t(langKey); + const hasKeyTranslation = translatedFromKey !== langKey; - const languageOptions = languages.map(lang => { - // TODO: Use actual language translations when they become available - // For now, use temporary English translations - const translatedName = tempOcrLanguages.lang[lang as keyof typeof tempOcrLanguages.lang] || lang; - const displayName = translatedName; + const intlTranslatedName = displayNames + ? browserLanguageCodes + .map((code) => displayNames.of(code)) + .find((name): name is string => Boolean(name)) + : null; + + const translatedName = + (hasKeyTranslation ? translatedFromKey : null) + || intlTranslatedName + || t(`ocr.languages.${lang}`, displayName); return { value: lang, - name: displayName + name: translatedName, + label: translatedName }; - }); + }) + .sort((a, b) => a.name.localeCompare(b.name, i18n.language)); - setAvailableLanguages(languageOptions); - } else { - console.error('[LanguagePicker] Response not OK:', response.status, response.statusText); - const errorText = await response.text(); - console.error('[LanguagePicker] Error response body:', errorText); - } + setAvailableLanguages(languageOptions); } catch (error) { console.error('[LanguagePicker] Fetch failed with error:', error); console.error('[LanguagePicker] Error details:', { @@ -76,7 +86,7 @@ const LanguagePicker: React.FC = ({ }; fetchLanguages(); - }, [languagesEndpoint]); + }, [languagesEndpoint, i18n.language, t]); // Auto-fill OCR language based on browser language when languages are loaded useEffect(() => { From 651f17f1c6aff196148b21d260f24bc3a1258bdc Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:29:06 +0000 Subject: [PATCH 08/11] Save signatures to server (#5080) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../PersonalSignatureServiceInterface.java | 21 + .../SPDF/controller/api/UIDataController.java | 6 +- .../controller/web/GeneralWebController.java | 6 +- .../controller/web/SignatureController.java | 48 --- .../web/SignatureImageController.java | 84 ++++ .../api/signature/SavedSignatureRequest.java | 18 + .../api/signature/SavedSignatureResponse.java | 22 ++ .../SPDF/service/SharedSignatureService.java | 308 +++++++++++++++ .../SPDF/service/SignatureService.java | 107 ----- .../SPDF/service/SignatureServiceTest.java | 55 ++- .../controller/api/SignatureController.java | 102 +++++ .../api/signature/SavedSignatureRequest.java | 18 + .../api/signature/SavedSignatureResponse.java | 22 ++ .../proprietary/service/SignatureService.java | 299 ++++++++++++++ .../public/locales/en-GB/translation.toml | 8 + .../tools/sign/SavedSignaturesSection.tsx | 371 +++++++++++++----- .../components/tools/sign/SignSettings.tsx | 88 ++++- .../hooks/tools/sign/useSavedSignatures.ts | 234 ++++++----- .../core/services/signatureStorageService.ts | 282 +++++++++++++ 19 files changed, 1674 insertions(+), 425 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/PersonalSignatureServiceInterface.java delete mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureImageController.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequest.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponse.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java delete mode 100644 app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java create mode 100644 frontend/src/core/services/signatureStorageService.ts diff --git a/app/common/src/main/java/stirling/software/common/service/PersonalSignatureServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/PersonalSignatureServiceInterface.java new file mode 100644 index 0000000000..0f031c2e34 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/PersonalSignatureServiceInterface.java @@ -0,0 +1,21 @@ +package stirling.software.common.service; + +import java.io.IOException; + +/** + * Interface for personal signature access (proprietary feature). Implemented only in proprietary + * module to provide authenticated users access to their personal signatures. + */ +public interface PersonalSignatureServiceInterface { + + /** + * Get a personal signature from the user's folder. Only checks personal folder, not shared + * folder. + * + * @param username Username of the signature owner + * @param fileName Signature filename + * @return Personal signature image bytes + * @throws IOException If file not found or read error + */ + byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index 18445d7da4..82dc3b204f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -26,7 +26,7 @@ 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.SPDF.service.SharedSignatureService; import stirling.software.common.annotations.api.UiDataApi; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.RuntimePathConfig; @@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils; public class UIDataController { private final ApplicationProperties applicationProperties; - private final SignatureService signatureService; + private final SharedSignatureService signatureService; private final UserServiceInterface userService; private final ResourceLoader resourceLoader; private final RuntimePathConfig runtimePathConfig; public UIDataController( ApplicationProperties applicationProperties, - SignatureService signatureService, + SharedSignatureService signatureService, @Autowired(required = false) UserServiceInterface userService, ResourceLoader resourceLoader, RuntimePathConfig runtimePathConfig) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java index d2a2e5a17c..e1796b8277 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java @@ -25,7 +25,7 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.SignatureFile; -import stirling.software.SPDF.service.SignatureService; +import stirling.software.SPDF.service.SharedSignatureService; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.service.UserServiceInterface; @@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils; @Slf4j public class GeneralWebController { - private final SignatureService signatureService; + private final SharedSignatureService signatureService; private final UserServiceInterface userService; private final ResourceLoader resourceLoader; private final RuntimePathConfig runtimePathConfig; public GeneralWebController( - SignatureService signatureService, + SharedSignatureService signatureService, @Autowired(required = false) UserServiceInterface userService, ResourceLoader resourceLoader, RuntimePathConfig runtimePathConfig) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java deleted file mode 100644 index ad046f3262..0000000000 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java +++ /dev/null @@ -1,48 +0,0 @@ -package stirling.software.SPDF.controller.web; - -import java.io.IOException; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; - -import stirling.software.SPDF.service.SignatureService; -import stirling.software.common.service.UserServiceInterface; - -// @Controller // Disabled - Backend-only mode, no Thymeleaf UI -@RequestMapping("/api/v1/general") -public class SignatureController { - - private final SignatureService signatureService; - - private final UserServiceInterface userService; - - public SignatureController( - SignatureService signatureService, - @Autowired(required = false) UserServiceInterface userService) { - this.signatureService = signatureService; - this.userService = userService; - } - - @GetMapping("/sign/{fileName}") - public ResponseEntity getSignature(@PathVariable(name = "fileName") String fileName) - throws IOException { - String username = "NON_SECURITY_USER"; - if (userService != null) { - username = userService.getCurrentUsername(); - } - // Verify access permission - if (!signatureService.hasAccessToFile(username, fileName)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - byte[] imageBytes = signatureService.getSignatureBytes(username, fileName); - return ResponseEntity.ok() - .contentType( // Adjust based on file type - MediaType.IMAGE_JPEG) - .body(imageBytes); - } -} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureImageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureImageController.java new file mode 100644 index 0000000000..90313af29b --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureImageController.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.controller.web; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.service.SharedSignatureService; +import stirling.software.common.service.PersonalSignatureServiceInterface; +import stirling.software.common.service.UserServiceInterface; + +/** + * Unified signature image controller that works for both authenticated and unauthenticated users. + * Uses composition pattern: - Core SharedSignatureService (always available): reads shared signatures - + * PersonalSignatureService (proprietary, optional): reads personal signatures For authenticated + * signature management (save/delete), see proprietary SignatureController. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/general") +public class SignatureImageController { + + private final SharedSignatureService sharedSignatureService; + private final PersonalSignatureServiceInterface personalSignatureService; + private final UserServiceInterface userService; + + public SignatureImageController( + SharedSignatureService sharedSignatureService, + @Autowired(required = false) PersonalSignatureServiceInterface personalSignatureService, + @Autowired(required = false) UserServiceInterface userService) { + this.sharedSignatureService = sharedSignatureService; + this.personalSignatureService = personalSignatureService; + this.userService = userService; + } + + /** + * Get a signature image (works for both authenticated and unauthenticated users). - + * Authenticated with proprietary: tries personal first, then shared - Unauthenticated or + * community: tries shared only + */ + @GetMapping("/signatures/{fileName}") + public ResponseEntity getSignature(@PathVariable(name = "fileName") String fileName) { + try { + byte[] imageBytes = null; + + // If proprietary service available and user authenticated, try personal folder first + if (personalSignatureService != null && userService != null) { + try { + String username = userService.getCurrentUsername(); + imageBytes = + personalSignatureService.getPersonalSignatureBytes(username, fileName); + } catch (Exception e) { + // Not found in personal folder or not authenticated, will try shared + log.debug("Personal signature not found, trying shared: {}", e.getMessage()); + } + } + + // If not found in personal (or no personal service), try shared + if (imageBytes == null) { + imageBytes = sharedSignatureService.getSharedSignatureBytes(fileName); + } + + // Determine content type from file extension + MediaType contentType = MediaType.IMAGE_PNG; // Default + String lowerFileName = fileName.toLowerCase(); + if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) { + contentType = MediaType.IMAGE_JPEG; + } + + return ResponseEntity.ok().contentType(contentType).body(imageBytes); + } catch (IOException e) { + log.debug("Signature not found: {}", fileName); + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequest.java new file mode 100644 index 0000000000..b1c23ad717 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequest.java @@ -0,0 +1,18 @@ +package stirling.software.SPDF.model.api.signature; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SavedSignatureRequest { + private String id; + private String label; + private String type; // "canvas", "image", "text" + private String scope; // "personal", "shared" + private String dataUrl; // For canvas and image types + private String signerName; // For text type + private String fontFamily; // For text type + private Integer fontSize; // For text type + private String textColor; // For text type +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponse.java b/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponse.java new file mode 100644 index 0000000000..e7e21338ed --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponse.java @@ -0,0 +1,22 @@ +package stirling.software.SPDF.model.api.signature; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SavedSignatureResponse { + private String id; + private String label; + private String type; // "canvas", "image", "text" + private String scope; // "personal", "shared" + private String dataUrl; // For canvas and image types (or URL to fetch image) + private String signerName; // For text type + private String fontFamily; // For text type + private Integer fontSize; // For text type + private String textColor; // For text type + private Long createdAt; + private Long updatedAt; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java b/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java new file mode 100644 index 0000000000..6c349581db --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java @@ -0,0 +1,308 @@ +package stirling.software.SPDF.service; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.SignatureFile; +import stirling.software.SPDF.model.api.signature.SavedSignatureRequest; +import stirling.software.SPDF.model.api.signature.SavedSignatureResponse; +import stirling.software.common.configuration.InstallationPathConfig; + +@Service +@Slf4j +public class SharedSignatureService { + + private final String SIGNATURE_BASE_PATH; + private final String ALL_USERS_FOLDER = "ALL_USERS"; + private final ObjectMapper objectMapper; + + public SharedSignatureService() { + SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath(); + this.objectMapper = new ObjectMapper(); + } + + public boolean hasAccessToFile(String username, String fileName) throws IOException { + validateFileName(fileName); + // Check if file exists in user's personal folder or ALL_USERS folder + Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName); + Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName); + + return Files.exists(userPath) || Files.exists(allUsersPath); + } + + public List getAvailableSignatures(String username) { + List signatures = new ArrayList<>(); + + // Get signatures from user's personal folder + if (StringUtils.hasText(username)) { + Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username); + if (Files.exists(userFolder)) { + try { + signatures.addAll(getSignaturesFromFolder(userFolder, "Personal")); + } catch (IOException e) { + log.error("Error reading user signatures folder", e); + } + } + } + + // Get signatures from ALL_USERS folder + Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER); + if (Files.exists(allUsersFolder)) { + try { + signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared")); + } catch (IOException e) { + log.error("Error reading shared signatures folder", e); + } + } + + return signatures; + } + + private List getSignaturesFromFolder(Path folder, String category) + throws IOException { + try (Stream stream = Files.list(folder)) { + return stream.filter(this::isImageFile) + .map(path -> new SignatureFile(path.getFileName().toString(), category)) + .toList(); + } + } + + /** + * Get a signature from the shared (ALL_USERS) folder. This is always available for both + * authenticated and unauthenticated users. + */ + public byte[] getSharedSignatureBytes(String fileName) throws IOException { + validateFileName(fileName); + Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName); + if (!Files.exists(allUsersPath)) { + throw new FileNotFoundException("Shared signature file not found"); + } + return Files.readAllBytes(allUsersPath); + } + + private boolean isImageFile(Path path) { + String fileName = path.getFileName().toString().toLowerCase(); + return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png"); + } + + private void validateFileName(String fileName) { + if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) { + throw new IllegalArgumentException("Invalid filename"); + } + // Only allow alphanumeric, hyphen, underscore, and dot (for extensions) + if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) { + throw new IllegalArgumentException("Filename contains invalid characters"); + } + } + + private String validateAndNormalizeExtension(String extension) { + String normalized = extension.toLowerCase().trim(); + // Whitelist only safe image extensions + if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) { + return normalized; + } + throw new IllegalArgumentException("Unsupported image extension: " + extension); + } + + private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory) + throws IOException { + Path canonicalTarget = targetDirectory.toAbsolutePath().normalize(); + Path canonicalResolved = resolvedPath.toAbsolutePath().normalize(); + if (!canonicalResolved.startsWith(canonicalTarget)) { + throw new IOException("Resolved path is outside the target directory"); + } + } + + /** Save a signature as image file */ + public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request) + throws IOException { + validateFileName(request.getId()); + + // Determine folder based on scope + String scope = request.getScope(); + if (scope == null || scope.isEmpty()) { + scope = "personal"; // Default to personal + } + + String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username; + Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName); + Files.createDirectories(targetFolder); + + long timestamp = System.currentTimeMillis(); + + SavedSignatureResponse response = new SavedSignatureResponse(); + response.setId(request.getId()); + response.setLabel(request.getLabel()); + response.setType(request.getType()); + response.setScope(scope); + response.setCreatedAt(timestamp); + response.setUpdatedAt(timestamp); + + // Extract and save image data + String dataUrl = request.getDataUrl(); + if (dataUrl != null && dataUrl.startsWith("data:image/")) { + // Extract base64 data + String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1); + byte[] imageBytes = Base64.getDecoder().decode(base64Data); + + // Determine and validate file extension from data URL + String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";")); + String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1); + String extension = validateAndNormalizeExtension(rawExtension); + + // Save image file only + String imageFileName = request.getId() + "." + extension; + Path imagePath = targetFolder.resolve(imageFileName); + + // Verify path is within target directory + verifyPathWithinDirectory(imagePath, targetFolder); + + Files.write( + imagePath, + imageBytes, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + + // Store reference to image file + response.setDataUrl("/api/v1/general/sign/" + imageFileName); + } + + log.info("Saved signature {} for user {}", request.getId(), username); + return response; + } + + /** Get all saved signatures for a user */ + public List getSavedSignatures(String username) throws IOException { + List signatures = new ArrayList<>(); + + // Load personal signatures + Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username); + if (Files.exists(personalFolder)) { + try (Stream stream = Files.list(personalFolder)) { + stream.filter(this::isImageFile) + .forEach( + path -> { + try { + String fileName = path.getFileName().toString(); + String id = + fileName.substring(0, fileName.lastIndexOf('.')); + + SavedSignatureResponse sig = new SavedSignatureResponse(); + sig.setId(id); + sig.setLabel(id); // Use ID as label + sig.setType("image"); // Default type + sig.setScope("personal"); + sig.setDataUrl("/api/v1/general/sign/" + fileName); + sig.setCreatedAt( + Files.getLastModifiedTime(path).toMillis()); + sig.setUpdatedAt( + Files.getLastModifiedTime(path).toMillis()); + + signatures.add(sig); + } catch (IOException e) { + log.error("Error reading signature file: " + path, e); + } + }); + } + } + + // Load shared signatures + Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER); + if (Files.exists(sharedFolder)) { + try (Stream stream = Files.list(sharedFolder)) { + stream.filter(this::isImageFile) + .forEach( + path -> { + try { + String fileName = path.getFileName().toString(); + String id = + fileName.substring(0, fileName.lastIndexOf('.')); + + SavedSignatureResponse sig = new SavedSignatureResponse(); + sig.setId(id); + sig.setLabel(id); // Use ID as label + sig.setType("image"); // Default type + sig.setScope("shared"); + sig.setDataUrl("/api/v1/general/sign/" + fileName); + sig.setCreatedAt( + Files.getLastModifiedTime(path).toMillis()); + sig.setUpdatedAt( + Files.getLastModifiedTime(path).toMillis()); + + signatures.add(sig); + } catch (IOException e) { + log.error("Error reading signature file: " + path, e); + } + }); + } + } + + return signatures; + } + + /** Delete a saved signature */ + public void deleteSignature(String username, String signatureId) throws IOException { + validateFileName(signatureId); + + // Try to find and delete image file in personal folder + Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username); + boolean deleted = false; + + if (Files.exists(personalFolder)) { + try (Stream stream = Files.list(personalFolder)) { + List matchingFiles = + stream.filter( + path -> + path.getFileName() + .toString() + .startsWith(signatureId + ".")) + .toList(); + for (Path file : matchingFiles) { + Files.delete(file); + deleted = true; + } + } + } + + // Try shared folder if not found in personal + if (!deleted) { + Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER); + if (Files.exists(sharedFolder)) { + try (Stream stream = Files.list(sharedFolder)) { + List matchingFiles = + stream.filter( + path -> + path.getFileName() + .toString() + .startsWith(signatureId + ".")) + .toList(); + for (Path file : matchingFiles) { + Files.delete(file); + deleted = true; + } + } + } + } + + if (!deleted) { + throw new FileNotFoundException("Signature not found"); + } + + log.info("Deleted signature {} for user {}", signatureId, username); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java b/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java deleted file mode 100644 index 579ea5507b..0000000000 --- a/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java +++ /dev/null @@ -1,107 +0,0 @@ -package stirling.software.SPDF.service; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; - -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.SPDF.model.SignatureFile; -import stirling.software.common.configuration.InstallationPathConfig; - -@Service -@Slf4j -public class SignatureService { - - private final String SIGNATURE_BASE_PATH; - private final String ALL_USERS_FOLDER = "ALL_USERS"; - - public SignatureService() { - SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath(); - } - - public boolean hasAccessToFile(String username, String fileName) throws IOException { - validateFileName(fileName); - // Check if file exists in user's personal folder or ALL_USERS folder - Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName); - Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName); - - return Files.exists(userPath) || Files.exists(allUsersPath); - } - - public List getAvailableSignatures(String username) { - List signatures = new ArrayList<>(); - - // Get signatures from user's personal folder - if (StringUtils.hasText(username)) { - Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username); - if (Files.exists(userFolder)) { - try { - signatures.addAll(getSignaturesFromFolder(userFolder, "Personal")); - } catch (IOException e) { - log.error("Error reading user signatures folder", e); - } - } - } - - // Get signatures from ALL_USERS folder - Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER); - if (Files.exists(allUsersFolder)) { - try { - signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared")); - } catch (IOException e) { - log.error("Error reading shared signatures folder", e); - } - } - - return signatures; - } - - private List getSignaturesFromFolder(Path folder, String category) - throws IOException { - try (Stream stream = Files.list(folder)) { - return stream.filter(this::isImageFile) - .map(path -> new SignatureFile(path.getFileName().toString(), category)) - .toList(); - } - } - - public byte[] getSignatureBytes(String username, String fileName) throws IOException { - validateFileName(fileName); - // First try user's personal folder - Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName); - if (Files.exists(userPath)) { - return Files.readAllBytes(userPath); - } - - // Then try ALL_USERS folder - Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName); - if (Files.exists(allUsersPath)) { - return Files.readAllBytes(allUsersPath); - } - - throw new FileNotFoundException("Signature file not found"); - } - - private boolean isImageFile(Path path) { - String fileName = path.getFileName().toString().toLowerCase(); - return fileName.endsWith(".jpg") - || fileName.endsWith(".jpeg") - || fileName.endsWith(".png") - || fileName.endsWith(".gif"); - } - - private void validateFileName(String fileName) { - if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) { - throw new IllegalArgumentException("Invalid filename"); - } - } -} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java index 5161e82eaa..8c8ce97cf8 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java @@ -23,7 +23,7 @@ import stirling.software.common.configuration.InstallationPathConfig; class SignatureServiceTest { @TempDir Path tempDir; - private SignatureService signatureService; + private SharedSignatureService signatureService; private Path personalSignatureFolder; private Path sharedSignatureFolder; private final String ALL_USERS_FOLDER = "ALL_USERS"; @@ -53,7 +53,7 @@ class SignatureServiceTest { .thenReturn(tempDir.toString()); // Initialize the service with our temp directory - signatureService = new SignatureService(); + signatureService = new SharedSignatureService(); } } @@ -165,7 +165,7 @@ class SignatureServiceTest { } @Test - void testGetSignatureBytes_PersonalFile() throws IOException { + void testGetSharedSignatureBytes_SharedFile() throws IOException { // Mock static method for each test try (MockedStatic mockedConfig = mockStatic(InstallationPathConfig.class)) { @@ -173,28 +173,8 @@ class SignatureServiceTest { .when(InstallationPathConfig::getSignaturesPath) .thenReturn(tempDir.toString()); - // Test - byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "personal.png"); - - // Verify - assertEquals( - "personal signature content", - new String(bytes), - "Should return the correct content for personal file"); - } - } - - @Test - void testGetSignatureBytes_SharedFile() throws IOException { - // Mock static method for each test - try (MockedStatic mockedConfig = - mockStatic(InstallationPathConfig.class)) { - mockedConfig - .when(InstallationPathConfig::getSignaturesPath) - .thenReturn(tempDir.toString()); - - // Test - byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "shared.jpg"); + // Test - core service only reads shared signatures + byte[] bytes = signatureService.getSharedSignatureBytes("shared.jpg"); // Verify assertEquals( @@ -205,7 +185,7 @@ class SignatureServiceTest { } @Test - void testGetSignatureBytes_FileNotFound() { + void testGetSharedSignatureBytes_FileNotFound() { // Mock static method for each test try (MockedStatic mockedConfig = mockStatic(InstallationPathConfig.class)) { @@ -216,13 +196,13 @@ class SignatureServiceTest { // Test and verify assertThrows( FileNotFoundException.class, - () -> signatureService.getSignatureBytes(TEST_USER, "nonexistent.png"), + () -> signatureService.getSharedSignatureBytes("nonexistent.png"), "Should throw exception for non-existent files"); } } @Test - void testGetSignatureBytes_InvalidFileName() { + void testGetSharedSignatureBytes_InvalidFileName() { // Mock static method for each test try (MockedStatic mockedConfig = mockStatic(InstallationPathConfig.class)) { @@ -233,11 +213,28 @@ class SignatureServiceTest { // Test and verify assertThrows( IllegalArgumentException.class, - () -> signatureService.getSignatureBytes(TEST_USER, "../invalid.png"), + () -> signatureService.getSharedSignatureBytes("../invalid.png"), "Should throw exception for file names with directory traversal"); } } + @Test + void testGetSharedSignatureBytes_CannotAccessPersonalFiles() { + // Mock static method for each test + try (MockedStatic mockedConfig = + mockStatic(InstallationPathConfig.class)) { + mockedConfig + .when(InstallationPathConfig::getSignaturesPath) + .thenReturn(tempDir.toString()); + + // Test and verify - core service should NOT be able to read personal files + assertThrows( + FileNotFoundException.class, + () -> signatureService.getSharedSignatureBytes("personal.png"), + "Core service should not have access to personal signatures"); + } + } + @Test void testGetAvailableSignatures_EmptyUsername() throws IOException { // Mock static method for each test diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java new file mode 100644 index 0000000000..f42b23a972 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java @@ -0,0 +1,102 @@ +package stirling.software.proprietary.controller.api; + +import java.io.IOException; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.api.UserApi; +import stirling.software.proprietary.model.api.signature.SavedSignatureRequest; +import stirling.software.proprietary.model.api.signature.SavedSignatureResponse; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.service.SignatureService; + +/** + * Controller for managing user signatures in proprietary/authenticated mode only. Requires user + * authentication and enforces per-user storage limits. All endpoints require authentication + * via @PreAuthorize("isAuthenticated()"). + */ +@UserApi +@Slf4j +@RestController +@RequestMapping("/api/v1/proprietary/signatures") +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +public class SignatureController { + + private final SignatureService signatureService; + private final UserService userService; + + /** + * Save a new signature for the authenticated user. Enforces storage limits and authentication + * requirements. + */ + @PostMapping + public ResponseEntity saveSignature( + @RequestBody SavedSignatureRequest request) { + try { + String username = userService.getCurrentUsername(); + + // Validate request + if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) { + log.warn("User {} attempted to save signature without dataUrl", username); + return ResponseEntity.badRequest().build(); + } + + SavedSignatureResponse response = signatureService.saveSignature(username, request); + log.info("User {} saved signature {}", username, request.getId()); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + log.warn("Invalid signature save request: {}", e.getMessage()); + return ResponseEntity.badRequest().build(); + } catch (IOException e) { + log.error("Failed to save signature", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** + * List all signatures accessible to the authenticated user. Includes both personal and shared + * signatures. + */ + @GetMapping + public ResponseEntity> listSignatures() { + try { + String username = userService.getCurrentUsername(); + List signatures = signatureService.getSavedSignatures(username); + return ResponseEntity.ok(signatures); + } catch (IOException e) { + log.error("Failed to list signatures for user", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** + * Delete a signature owned by the authenticated user. Users can only delete their own personal + * signatures, not shared ones. + */ + @DeleteMapping("/{signatureId}") + public ResponseEntity deleteSignature(@PathVariable String signatureId) { + try { + String username = userService.getCurrentUsername(); + signatureService.deleteSignature(username, signatureId); + log.info("User {} deleted signature {}", username, signatureId); + return ResponseEntity.noContent().build(); + } catch (IOException e) { + log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureRequest.java new file mode 100644 index 0000000000..21c43a3602 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureRequest.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.model.api.signature; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SavedSignatureRequest { + private String id; + private String label; + private String type; // "canvas", "image", "text" + private String scope; // "personal", "shared" + private String dataUrl; // For canvas and image types + private String signerName; // For text type + private String fontFamily; // For text type + private Integer fontSize; // For text type + private String textColor; // For text type +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureResponse.java new file mode 100644 index 0000000000..7ab6876d20 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureResponse.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.model.api.signature; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SavedSignatureResponse { + private String id; + private String label; + private String type; // "canvas", "image", "text" + private String scope; // "personal", "shared" + private String dataUrl; // For canvas and image types (or URL to fetch image) + private String signerName; // For text type + private String fontFamily; // For text type + private Integer fontSize; // For text type + private String textColor; // For text type + private Long createdAt; + private Long updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java new file mode 100644 index 0000000000..6f849ebfe6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java @@ -0,0 +1,299 @@ +package stirling.software.proprietary.service; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.service.PersonalSignatureServiceInterface; +import stirling.software.proprietary.model.api.signature.SavedSignatureRequest; +import stirling.software.proprietary.model.api.signature.SavedSignatureResponse; + +/** + * Service for managing user signatures with authentication and storage limits. This proprietary + * version enforces per-user quotas and requires authentication. Provides access to personal + * signatures only (shared signatures handled by core service). + */ +@Service +@Slf4j +public class SignatureService implements PersonalSignatureServiceInterface { + + private final String SIGNATURE_BASE_PATH; + private final String ALL_USERS_FOLDER = "ALL_USERS"; + + // Storage limits per user + private static final int MAX_SIGNATURES_PER_USER = 20; + private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000; // 2MB per signature + private static final long MAX_TOTAL_USER_STORAGE_BYTES = 20_000_000; // 20MB total per user + + public SignatureService() { + SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath(); + } + + /** + * Get a personal signature from the user's folder only. Does NOT check shared folder (that's + * handled by core service). + */ + @Override + public byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException { + validateFileName(fileName); + Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName); + + if (!Files.exists(userPath)) { + throw new FileNotFoundException("Personal signature not found"); + } + + return Files.readAllBytes(userPath); + } + + /** Save a signature with storage limits enforced. */ + public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request) + throws IOException { + validateFileName(request.getId()); + + // Determine folder based on scope + String scope = request.getScope(); + if (scope == null || scope.isEmpty()) { + scope = "personal"; // Default to personal + } + + String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username; + Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName); + + // Only enforce limits for personal signatures (not shared) + if ("personal".equals(scope)) { + enforceStorageLimits(username, request.getDataUrl()); + } + + Files.createDirectories(targetFolder); + + long timestamp = System.currentTimeMillis(); + + SavedSignatureResponse response = new SavedSignatureResponse(); + response.setId(request.getId()); + response.setLabel(request.getLabel()); + response.setType(request.getType()); + response.setScope(scope); + response.setCreatedAt(timestamp); + response.setUpdatedAt(timestamp); + + // Extract and save image data + String dataUrl = request.getDataUrl(); + if (dataUrl != null && dataUrl.startsWith("data:image/")) { + // Validate dataUrl size before decoding + if (dataUrl.length() > MAX_SIGNATURE_SIZE_BYTES * 2) { + throw new IllegalArgumentException( + "Signature data too large (max " + + (MAX_SIGNATURE_SIZE_BYTES / 1024) + + "KB)"); + } + + // Extract base64 data + String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1); + byte[] imageBytes = Base64.getDecoder().decode(base64Data); + + // Validate decoded size + if (imageBytes.length > MAX_SIGNATURE_SIZE_BYTES) { + throw new IllegalArgumentException( + "Signature image too large (max " + + (MAX_SIGNATURE_SIZE_BYTES / 1024) + + "KB)"); + } + + // Determine and validate file extension from data URL + String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";")); + String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1); + String extension = validateAndNormalizeExtension(rawExtension); + + // Save image file + String imageFileName = request.getId() + "." + extension; + Path imagePath = targetFolder.resolve(imageFileName); + + // Verify path is within target directory + verifyPathWithinDirectory(imagePath, targetFolder); + + Files.write( + imagePath, + imageBytes, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + + // Store reference to image file (unified endpoint for all signatures) + response.setDataUrl("/api/v1/general/signatures/" + imageFileName); + } + + log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope); + return response; + } + + /** Get all saved signatures for a user (personal + shared). */ + public List getSavedSignatures(String username) throws IOException { + List signatures = new ArrayList<>(); + + // Load personal signatures + Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username); + if (Files.exists(personalFolder)) { + signatures.addAll(loadSignaturesFromFolder(personalFolder, "personal", true)); + } + + // Load shared signatures + Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER); + if (Files.exists(sharedFolder)) { + signatures.addAll(loadSignaturesFromFolder(sharedFolder, "shared", false)); + } + + return signatures; + } + + /** Delete a signature from user's personal folder. Cannot delete shared signatures. */ + public void deleteSignature(String username, String signatureId) throws IOException { + validateFileName(signatureId); + + // Only allow deletion from personal folder + Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username); + boolean deleted = false; + + if (Files.exists(personalFolder)) { + try (Stream stream = Files.list(personalFolder)) { + List matchingFiles = + stream.filter( + path -> + path.getFileName() + .toString() + .startsWith(signatureId + ".")) + .toList(); + for (Path file : matchingFiles) { + Files.delete(file); + deleted = true; + log.info("Deleted signature file: {}", file); + } + } + } + + if (!deleted) { + throw new FileNotFoundException("Signature not found or cannot be deleted"); + } + } + + // Private helper methods + + private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException { + Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username); + + if (!Files.exists(userFolder)) { + return; // First signature, no limits to check + } + + // Count existing signatures + long signatureCount; + try (Stream stream = Files.list(userFolder)) { + signatureCount = stream.filter(this::isImageFile).count(); + } + + if (signatureCount >= MAX_SIGNATURES_PER_USER) { + throw new IllegalArgumentException( + "Maximum signatures limit reached (" + MAX_SIGNATURES_PER_USER + ")"); + } + + // Calculate total storage used + long totalSize = 0; + try (Stream stream = Files.list(userFolder)) { + totalSize = + stream.filter(this::isImageFile) + .mapToLong( + path -> { + try { + return Files.size(path); + } catch (IOException e) { + return 0; + } + }) + .sum(); + } + + // Estimate new signature size (base64 decodes to ~75% of original) + long estimatedNewSize = (long) (dataUrlToAdd.length() * 0.75); + + if (totalSize + estimatedNewSize > MAX_TOTAL_USER_STORAGE_BYTES) { + throw new IllegalArgumentException( + "Storage quota exceeded (max " + + (MAX_TOTAL_USER_STORAGE_BYTES / 1_000_000) + + "MB)"); + } + } + + private List loadSignaturesFromFolder( + Path folder, String scope, boolean isPersonal) throws IOException { + List signatures = new ArrayList<>(); + + try (Stream stream = Files.list(folder)) { + stream.filter(this::isImageFile) + .forEach( + path -> { + try { + String fileName = path.getFileName().toString(); + String id = fileName.substring(0, fileName.lastIndexOf('.')); + + SavedSignatureResponse sig = new SavedSignatureResponse(); + sig.setId(id); + sig.setLabel(id); + sig.setType("image"); + sig.setScope(scope); + sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis()); + sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis()); + + // Set unified URL path (works for both personal and shared) + sig.setDataUrl("/api/v1/general/signatures/" + fileName); + + signatures.add(sig); + } catch (IOException e) { + log.error("Error reading signature file: " + path, e); + } + }); + } + + return signatures; + } + + private boolean isImageFile(Path path) { + String fileName = path.getFileName().toString().toLowerCase(); + return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png"); + } + + private void validateFileName(String fileName) { + if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) { + throw new IllegalArgumentException("Invalid filename"); + } + if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) { + throw new IllegalArgumentException("Filename contains invalid characters"); + } + } + + private String validateAndNormalizeExtension(String extension) { + String normalized = extension.toLowerCase().trim(); + if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) { + return normalized; + } + throw new IllegalArgumentException("Unsupported image extension: " + extension); + } + + private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory) + throws IOException { + Path canonicalTarget = targetDirectory.toAbsolutePath().normalize(); + Path canonicalResolved = resolvedPath.toAbsolutePath().normalize(); + if (!canonicalResolved.startsWith(canonicalTarget)) { + throw new IOException("Resolved path is outside the target directory"); + } + } +} diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 3ff331b189..da021cdde7 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -2267,8 +2267,16 @@ defaultCanvasLabel = "Drawing signature" defaultImageLabel = "Uploaded signature" defaultTextLabel = "Typed signature" saveButton = "Save signature" +savePersonal = "Save Personal" +saveShared = "Save Shared" saveUnavailable = "Create a signature first to save it." noChanges = "Current signature is already saved." +tempStorageTitle = "Temporary browser storage" +tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers." +personalHeading = "Personal Signatures" +sharedHeading = "Shared Signatures" +personalDescription = "Only you can see these signatures." +sharedDescription = "All users can see and use these signatures." [sign.saved.type] canvas = "Drawing" diff --git a/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx b/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx index bdb6c07e5d..c2e8ca8f1d 100644 --- a/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx +++ b/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx @@ -1,13 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core'; import { LocalIcon } from '@app/components/shared/LocalIcon'; import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures'; +import type { StorageType } from '@app/services/signatureStorageService'; interface SavedSignaturesSectionProps { signatures: SavedSignature[]; disabled?: boolean; isAtCapacity: boolean; + storageType?: StorageType | null; onUseSignature: (signature: SavedSignature) => void; onDeleteSignature: (signature: SavedSignature) => void; onRenameSignature: (id: string, label: string) => void; @@ -24,6 +26,7 @@ export const SavedSignaturesSection = ({ signatures, disabled = false, isAtCapacity, + storageType: _storageType, onUseSignature, onDeleteSignature, onRenameSignature, @@ -36,20 +39,30 @@ export const SavedSignaturesSection = ({ [t, translationScope] ); const [labelDrafts, setLabelDrafts] = useState>({}); - const [activeIndex, setActiveIndex] = useState(0); - const activeSignature = signatures[activeIndex]; - const activeSignatureRef = useRef(activeSignature ?? null); - const appliedSignatureIdRef = useRef(null); + + // Group signatures by scope + const groupedSignatures = useMemo(() => { + const personal = signatures.filter(sig => sig.scope === 'personal'); + const shared = signatures.filter(sig => sig.scope === 'shared'); + const localStorage = signatures.filter(sig => sig.scope === 'localStorage'); + return { personal, shared, localStorage }; + }, [signatures]); + + // Separate carousel state for each category + const [activePersonalIndex, setActivePersonalIndex] = useState(0); + const [activeSharedIndex, setActiveSharedIndex] = useState(0); + const [activeLocalStorageIndex, setActiveLocalStorageIndex] = useState(0); + + const activePersonalSignature = groupedSignatures.personal[activePersonalIndex]; + const activeSharedSignature = groupedSignatures.shared[activeSharedIndex]; + const activeLocalStorageSignature = groupedSignatures.localStorage[activeLocalStorageIndex]; + const onUseSignatureRef = useRef(onUseSignature); useEffect(() => { onUseSignatureRef.current = onUseSignature; }, [onUseSignature]); - useEffect(() => { - activeSignatureRef.current = activeSignature ?? null; - }, [activeSignature]); - useEffect(() => { setLabelDrafts(prev => { const nextDrafts: Record = {}; @@ -60,25 +73,18 @@ export const SavedSignaturesSection = ({ }); }, [signatures]); + // Reset carousel indices when categories change useEffect(() => { - if (signatures.length === 0) { - setActiveIndex(0); - return; - } - setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0))); - }, [signatures.length]); + setActivePersonalIndex(prev => Math.min(prev, Math.max(groupedSignatures.personal.length - 1, 0))); + }, [groupedSignatures.personal.length]); - const handleNavigate = useCallback( - (direction: 'prev' | 'next') => { - setActiveIndex(prev => { - if (direction === 'prev') { - return Math.max(0, prev - 1); - } - return Math.min(signatures.length - 1, prev + 1); - }); - }, - [signatures.length] - ); + useEffect(() => { + setActiveSharedIndex(prev => Math.min(prev, Math.max(groupedSignatures.shared.length - 1, 0))); + }, [groupedSignatures.shared.length]); + + useEffect(() => { + setActiveLocalStorageIndex(prev => Math.min(prev, Math.max(groupedSignatures.localStorage.length - 1, 0))); + }, [groupedSignatures.localStorage.length]); const renderPreview = (signature: SavedSignature) => { if (signature.type === 'text') { @@ -193,21 +199,6 @@ export const SavedSignaturesSection = ({ } }; - useEffect(() => { - const signature = activeSignatureRef.current; - if (!signature || disabled) { - appliedSignatureIdRef.current = null; - return; - } - - if (appliedSignatureIdRef.current === signature.id) { - return; - } - - appliedSignatureIdRef.current = signature.id; - onUseSignatureRef.current(signature); - }, [activeSignature?.id, disabled]); - return ( @@ -234,67 +225,253 @@ export const SavedSignaturesSection = ({ {signatures.length === 0 ? ( emptyState ) : ( - - - - {translate('saved.carouselPosition', '{{current}} of {{total}}', { - current: activeIndex + 1, - total: signatures.length, - })} - - - handleNavigate('prev')} - disabled={disabled || activeIndex === 0} - > - - - handleNavigate('next')} - disabled={disabled || activeIndex >= signatures.length - 1} - > - - - - + + {/* Personal Signatures */} + {groupedSignatures.personal.length > 0 && activePersonalSignature && ( + + + + + {translate('saved.personalHeading', 'Personal Signatures')} + + + + {translate('saved.personalDescription', 'Only you can see these signatures.')} + - {activeSignature && ( - - - - - {typeLabel(activeSignature.type)} - - - onDeleteSignature(activeSignature)} - disabled={disabled} - > - - - + + + {translate('saved.carouselPosition', '{{current}} of {{total}}', { + current: activePersonalIndex + 1, + total: groupedSignatures.personal.length, + })} + + + setActivePersonalIndex(prev => Math.max(0, prev - 1))} + disabled={disabled || activePersonalIndex === 0} + > + + + setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))} + disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1} + > + + + - {renderPreview(activeSignature)} + + + + + {typeLabel(activePersonalSignature.type)} + + + onUseSignature(activePersonalSignature)} + disabled={disabled} + > + + + + onDeleteSignature(activePersonalSignature)} + disabled={disabled} + > + + + + + + {renderPreview(activePersonalSignature)} + handleLabelChange(event, activePersonalSignature)} + onBlur={() => handleLabelBlur(activePersonalSignature)} + onKeyDown={event => handleLabelKeyDown(event, activePersonalSignature)} + disabled={disabled} + /> + + + + )} - handleLabelChange(event, activeSignature)} - onBlur={() => handleLabelBlur(activeSignature)} - onKeyDown={event => handleLabelKeyDown(event, activeSignature)} - disabled={disabled} - /> + {/* Shared Signatures */} + {groupedSignatures.shared.length > 0 && activeSharedSignature && ( + + + + + {translate('saved.sharedHeading', 'Shared Signatures')} + + + + {translate('saved.sharedDescription', 'All users can see and use these signatures.')} + - - + + + {translate('saved.carouselPosition', '{{current}} of {{total}}', { + current: activeSharedIndex + 1, + total: groupedSignatures.shared.length, + })} + + + setActiveSharedIndex(prev => Math.max(0, prev - 1))} + disabled={disabled || activeSharedIndex === 0} + > + + + setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))} + disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1} + > + + + + + + + + + + {typeLabel(activeSharedSignature.type)} + + + onUseSignature(activeSharedSignature)} + disabled={disabled} + > + + + + onDeleteSignature(activeSharedSignature)} + disabled={disabled} + > + + + + + + {renderPreview(activeSharedSignature)} + handleLabelChange(event, activeSharedSignature)} + onBlur={() => handleLabelBlur(activeSharedSignature)} + onKeyDown={event => handleLabelKeyDown(event, activeSharedSignature)} + disabled={disabled} + /> + + + + )} + + {/* Browser Storage (localStorage) - Temporary */} + {groupedSignatures.localStorage.length > 0 && activeLocalStorageSignature && ( + + + + {translate( + 'saved.tempStorageDescription', + 'Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers.' + )} + + + + + + {translate('saved.carouselPosition', '{{current}} of {{total}}', { + current: activeLocalStorageIndex + 1, + total: groupedSignatures.localStorage.length, + })} + + + setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))} + disabled={disabled || activeLocalStorageIndex === 0} + > + + + setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))} + disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1} + > + + + + + + + + + + {typeLabel(activeLocalStorageSignature.type)} + + + onUseSignature(activeLocalStorageSignature)} + disabled={disabled} + > + + + + onDeleteSignature(activeLocalStorageSignature)} + disabled={disabled} + > + + + + + + {renderPreview(activeLocalStorageSignature)} + handleLabelChange(event, activeLocalStorageSignature)} + onBlur={() => handleLabelBlur(activeLocalStorageSignature)} + onKeyDown={event => handleLabelKeyDown(event, activeLocalStorageSignature)} + disabled={disabled} + /> + + + )} )} diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index a506eeffb0..6fb3238f8c 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -16,6 +16,7 @@ import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; import { LocalIcon } from "@app/components/shared/LocalIcon"; import { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, MAX_SAVED_SIGNATURES, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures'; import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection'; +import { buildSignaturePreview } from '@app/utils/signaturePreview'; type SignatureDrafts = { canvas?: string; @@ -99,6 +100,7 @@ const SignSettings = ({ removeSignature, updateSignatureLabel, byTypeCounts, + storageType, } = useSavedSignatures(); const [signatureSource, setSignatureSource] = useState(() => { const paramSource = parameters.signatureType as SignatureSource; @@ -157,11 +159,11 @@ const SignSettings = ({ }, [canvasSignatureData, imageSignatureData, buildTextSignatureKey, parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]); const saveSignatureToLibrary = useCallback( - (payload: SavedSignaturePayload, type: SavedSignatureType): AddSignatureResult => { + async (payload: SavedSignaturePayload, type: SavedSignatureType, scope: 'personal' | 'shared'): Promise => { if (isSavedSignatureLimitReached) { return { success: false, reason: 'limit' }; } - return addSignature(payload, getDefaultSavedLabel(type)); + return await addSignature(payload, getDefaultSavedLabel(type), scope); }, [addSignature, getDefaultSavedLabel, isSavedSignatureLimitReached] ); @@ -176,40 +178,57 @@ const SignSettings = ({ [signatureKeysByType] ); - const handleSaveCanvasSignature = useCallback(() => { + const handleSaveCanvasSignature = useCallback(async (scope: 'personal' | 'shared') => { if (!canvasSignatureData) { return; } - const result = saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas'); + const result = await saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas', scope); if (result.success) { setLastSavedKeyForType('canvas'); } }, [canvasSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]); - const handleSaveImageSignature = useCallback(() => { + const handleSaveImageSignature = useCallback(async (scope: 'personal' | 'shared') => { if (!imageSignatureData) { return; } - const result = saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image'); + const result = await saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image', scope); if (result.success) { setLastSavedKeyForType('image'); } }, [imageSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]); - const handleSaveTextSignature = useCallback(() => { + const handleSaveTextSignature = useCallback(async (scope: 'personal' | 'shared') => { const signerName = (parameters.signerName ?? '').trim(); if (!signerName) { return; } - const result = saveSignatureToLibrary( + + // Generate image from text signature + const preview = await buildSignaturePreview({ + signatureType: 'text', + signerName, + fontFamily: parameters.fontFamily ?? 'Helvetica', + fontSize: parameters.fontSize ?? 16, + textColor: parameters.textColor ?? '#000000', + }); + + if (!preview?.dataUrl) { + console.error('Failed to generate text signature preview'); + return; + } + + const result = await saveSignatureToLibrary( { type: 'text', + dataUrl: preview.dataUrl, signerName, fontFamily: parameters.fontFamily ?? 'Helvetica', fontSize: parameters.fontSize ?? 16, textColor: parameters.textColor ?? '#000000', }, - 'text' + 'text', + scope ); if (result.success) { setLastSavedKeyForType('text'); @@ -286,16 +305,21 @@ const SignSettings = ({ [updateSignatureLabel] ); - const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => { + const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void, scope: 'personal' | 'shared', icon: string, label: string) => { if (!canUseSavedLibrary) { return null; } - const label = translate('saved.saveButton', 'Save signature'); const currentKey = signatureKeysByType[type]; const lastSavedKey = lastSavedSignatureKeys[type]; const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey); const isSaved = isReady && !hasChanges; + // Only show backend storage buttons when backend is available + const showButton = storageType === 'backend' || storageType === null; + if (!showButton) { + return null; + } + let tooltipMessage: string | undefined; if (!isReady) { tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.'); @@ -312,11 +336,11 @@ const SignSettings = ({ size="xs" variant="outline" color={isSaved ? 'green' : undefined} - onClick={onClick} + onClick={() => onClick(scope)} disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges} - leftSection={} + leftSection={} > - {isSaved ? translate('saved.status.saved', 'Saved') : label} + {label} ); @@ -331,15 +355,38 @@ const SignSettings = ({ return button; }; - const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => { - const button = renderSaveButton(type, isReady, onClick); - if (!button) { + const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void) => { + if (!canUseSavedLibrary) { return null; } + + const personalButton = renderSaveButton( + type, + isReady, + onClick, + 'personal', + 'material-symbols:person-rounded', + translate('saved.savePersonal', 'Save Personal') + ); + + const sharedButton = renderSaveButton( + type, + isReady, + onClick, + 'shared', + 'material-symbols:groups-rounded', + translate('saved.saveShared', 'Save Shared') + ); + + if (!personalButton && !sharedButton) { + return null; + } + return ( - - {button} - + + {personalButton} + {sharedButton} + ); }; @@ -744,6 +791,7 @@ const SignSettings = ({ signatures={savedSignatures} disabled={disabled} isAtCapacity={isSavedSignatureLimitReached} + storageType={storageType} onUseSignature={handleUseSavedSignature} onDeleteSignature={handleDeleteSavedSignature} onRenameSignature={handleRenameSavedSignature} diff --git a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts index 665f408145..655038a3f2 100644 --- a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts +++ b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { signatureStorageService, type StorageType } from '@app/services/signatureStorageService'; -const STORAGE_KEY = 'stirling:saved-signatures:v1'; export const MAX_SAVED_SIGNATURES = 10; export type SavedSignatureType = 'canvas' | 'image' | 'text'; +export type SignatureScope = 'personal' | 'shared' | 'localStorage'; export type SavedSignaturePayload = | { @@ -16,6 +17,7 @@ export type SavedSignaturePayload = } | { type: 'text'; + dataUrl: string; signerName: string; fontFamily: string; fontSize: number; @@ -25,6 +27,7 @@ export type SavedSignaturePayload = export type SavedSignature = SavedSignaturePayload & { id: string; label: string; + scope: SignatureScope; createdAt: number; updatedAt: number; }; @@ -35,68 +38,6 @@ export type AddSignatureResult = const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; -const safeParse = (raw: string | null): SavedSignature[] => { - if (!raw) { - return []; - } - - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.filter((entry: any): entry is SavedSignature => { - if (!entry || typeof entry !== 'object') { - return false; - } - if (typeof entry.id !== 'string' || typeof entry.label !== 'string') { - return false; - } - if (typeof entry.type !== 'string') { - return false; - } - - if (entry.type === 'text') { - return ( - typeof entry.signerName === 'string' && - typeof entry.fontFamily === 'string' && - typeof entry.fontSize === 'number' && - typeof entry.textColor === 'string' - ); - } - - return typeof entry.dataUrl === 'string'; - }); - } catch { - return []; - } -}; - -const readFromStorage = (): SavedSignature[] => { - if (!isSupportedEnvironment()) { - return []; - } - - try { - return safeParse(window.localStorage.getItem(STORAGE_KEY)); - } catch { - return []; - } -}; - -const writeToStorage = (entries: SavedSignature[]) => { - if (!isSupportedEnvironment()) { - return; - } - - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); - } catch { - // Swallow storage errors silently; we still keep state in memory. - } -}; - const generateId = () => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -105,29 +46,61 @@ const generateId = () => { }; export const useSavedSignatures = () => { - const [savedSignatures, setSavedSignatures] = useState(() => readFromStorage()); + const [savedSignatures, setSavedSignatures] = useState([]); + const [storageType, setStorageType] = useState(null); + const [isLoading, setIsLoading] = useState(true); + // Load signatures and detect storage type on mount useEffect(() => { - if (!isSupportedEnvironment()) { + const loadSignatures = async () => { + try { + const [signatures, type] = await Promise.all([ + signatureStorageService.loadSignatures(), + signatureStorageService.getStorageType(), + ]); + setSavedSignatures(signatures); + setStorageType(type); + } catch (error) { + console.error('[useSavedSignatures] Failed to load signatures:', error); + } finally { + setIsLoading(false); + } + }; + + loadSignatures(); + }, []); + + // Attempt migration from localStorage to backend when backend becomes available + useEffect(() => { + if (storageType === 'backend' && !isLoading) { + signatureStorageService.migrateToBackend().then(result => { + if (result.migrated > 0) { + console.log(`[useSavedSignatures] Migrated ${result.migrated} signatures to backend`); + // Reload after migration + signatureStorageService.loadSignatures().then(setSavedSignatures); + } + }); + } + }, [storageType, isLoading]); + + // Listen for storage events (for localStorage only) + useEffect(() => { + if (!isSupportedEnvironment() || storageType !== 'localStorage') { return; } const syncFromStorage = () => { - setSavedSignatures(readFromStorage()); + signatureStorageService.loadSignatures().then(setSavedSignatures); }; window.addEventListener('storage', syncFromStorage); return () => window.removeEventListener('storage', syncFromStorage); - }, []); - - useEffect(() => { - writeToStorage(savedSignatures); - }, [savedSignatures]); + }, [storageType]); const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES; const addSignature = useCallback( - (payload: SavedSignaturePayload, label?: string): AddSignatureResult => { + async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise => { if ( (payload.type === 'text' && !payload.signerName.trim()) || ((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl) @@ -135,62 +108,85 @@ export const useSavedSignatures = () => { return { success: false, reason: 'invalid' }; } - let createdSignature: SavedSignature | null = null; - setSavedSignatures(prev => { - if (prev.length >= MAX_SAVED_SIGNATURES) { - return prev; - } + if (savedSignatures.length >= MAX_SAVED_SIGNATURES) { + return { success: false, reason: 'limit' }; + } - const timestamp = Date.now(); - const nextEntry: SavedSignature = { - ...payload, - id: generateId(), - label: (label || 'Signature').trim() || 'Signature', - createdAt: timestamp, - updatedAt: timestamp, - }; - createdSignature = nextEntry; - return [nextEntry, ...prev]; - }); + const timestamp = Date.now(); + const newSignature: SavedSignature = { + ...payload, + id: generateId(), + label: (label || 'Signature').trim() || 'Signature', + scope: scope || (storageType === 'backend' ? 'personal' : 'localStorage'), + createdAt: timestamp, + updatedAt: timestamp, + }; - return createdSignature - ? { success: true, signature: createdSignature } - : { success: false, reason: 'limit' }; + try { + await signatureStorageService.saveSignature(newSignature); + setSavedSignatures(prev => [newSignature, ...prev]); + return { success: true, signature: newSignature }; + } catch (error) { + console.error('[useSavedSignatures] Failed to save signature:', error); + return { success: false, reason: 'invalid' }; + } }, - [] + [savedSignatures.length, storageType] ); - const removeSignature = useCallback((id: string) => { - setSavedSignatures(prev => prev.filter(entry => entry.id !== id)); + const removeSignature = useCallback(async (id: string) => { + try { + await signatureStorageService.deleteSignature(id); + setSavedSignatures(prev => prev.filter(entry => entry.id !== id)); + } catch (error) { + console.error('[useSavedSignatures] Failed to delete signature:', error); + } }, []); - const updateSignatureLabel = useCallback((id: string, nextLabel: string) => { - setSavedSignatures(prev => - prev.map(entry => - entry.id === id - ? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() } - : entry - ) - ); + const updateSignatureLabel = useCallback(async (id: string, nextLabel: string) => { + try { + await signatureStorageService.updateSignatureLabel(id, nextLabel); + setSavedSignatures(prev => + prev.map(entry => + entry.id === id + ? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() } + : entry + ) + ); + } catch (error) { + console.error('[useSavedSignatures] Failed to update signature label:', error); + } }, []); - const replaceSignature = useCallback((id: string, payload: SavedSignaturePayload) => { - setSavedSignatures(prev => - prev.map(entry => - entry.id === id - ? { - ...entry, - ...payload, - updatedAt: Date.now(), - } - : entry - ) - ); - }, []); + const replaceSignature = useCallback( + async (id: string, payload: SavedSignaturePayload) => { + const existing = savedSignatures.find(s => s.id === id); + if (!existing) return; - const clearSignatures = useCallback(() => { - setSavedSignatures([]); - }, []); + const updated: SavedSignature = { + ...existing, + ...payload, + updatedAt: Date.now(), + }; + + try { + await signatureStorageService.saveSignature(updated); + setSavedSignatures(prev => prev.map(entry => (entry.id === id ? updated : entry))); + } catch (error) { + console.error('[useSavedSignatures] Failed to replace signature:', error); + } + }, + [savedSignatures] + ); + + const clearSignatures = useCallback(async () => { + try { + await Promise.all(savedSignatures.map(sig => signatureStorageService.deleteSignature(sig.id))); + setSavedSignatures([]); + } catch (error) { + console.error('[useSavedSignatures] Failed to clear signatures:', error); + } + }, [savedSignatures]); const byTypeCounts = useMemo(() => { return savedSignatures.reduce>( @@ -211,6 +207,8 @@ export const useSavedSignatures = () => { replaceSignature, clearSignatures, byTypeCounts, + storageType, + isLoading, }; }; diff --git a/frontend/src/core/services/signatureStorageService.ts b/frontend/src/core/services/signatureStorageService.ts new file mode 100644 index 0000000000..365a0cbc60 --- /dev/null +++ b/frontend/src/core/services/signatureStorageService.ts @@ -0,0 +1,282 @@ +import apiClient from '@app/services/apiClient'; +import type { SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures'; + +export type StorageType = 'backend' | 'localStorage'; + +interface SignatureStorageCapabilities { + supportsBackend: boolean; + storageType: StorageType; +} + +/** + * Service to handle signature storage with adaptive backend/localStorage fallback + */ +class SignatureStorageService { + private capabilities: SignatureStorageCapabilities | null = null; + private detectionPromise: Promise | null = null; + private blobUrls: Set = new Set(); + + /** + * Detect if backend supports signature storage API + */ + async detectCapabilities(): Promise { + // Return cached result if already detected + if (this.capabilities) { + return this.capabilities; + } + + // Return in-flight detection if already running + if (this.detectionPromise) { + return this.detectionPromise; + } + + // Start new detection + this.detectionPromise = this._performDetection(); + this.capabilities = await this.detectionPromise; + this.detectionPromise = null; + + return this.capabilities; + } + + private async _performDetection(): Promise { + try { + // Probe the proprietary signatures endpoint (requires authentication) + await apiClient.get('/api/v1/proprietary/signatures', { + timeout: 3000, + }); + + // 200 = Backend available and accessible (authenticated) + console.log('[SignatureStorage] Backend signature API detected and accessible (authenticated)'); + return { + supportsBackend: true, + storageType: 'backend', + }; + } catch (error: any) { + // Check if it's an HTTP error with status code + if (error?.response?.status === 401 || error?.response?.status === 403) { + // Backend exists but needs auth - gracefully fall back to localStorage + console.log('[SignatureStorage] Backend signature API requires authentication, using localStorage'); + } else if (error?.response?.status === 404) { + // Endpoint doesn't exist (not running proprietary mode) + console.log('[SignatureStorage] Backend signature API not available (not in proprietary mode), using localStorage'); + } else { + // Network error, timeout, or other error + console.log('[SignatureStorage] Backend signature API not available, using localStorage'); + } + + return { + supportsBackend: false, + storageType: 'localStorage', + }; + } + } + + /** + * Get current storage type + */ + async getStorageType(): Promise { + const capabilities = await this.detectCapabilities(); + return capabilities.storageType; + } + + /** + * Load all signatures + */ + async loadSignatures(): Promise { + // Clean up old blob URLs before loading new ones + this.cleanup(); + + const capabilities = await this.detectCapabilities(); + + if (capabilities.supportsBackend) { + return this._loadFromBackend(); + } else { + return this._loadFromLocalStorage(); + } + } + + /** + * Save a signature + */ + async saveSignature(signature: SavedSignature): Promise { + const capabilities = await this.detectCapabilities(); + + if (capabilities.supportsBackend && signature.scope !== 'localStorage') { + await this._saveToBackend(signature); + } else { + // Force scope to localStorage for browser storage + signature.scope = 'localStorage'; + this._saveToLocalStorage(signature); + } + } + + /** + * Delete a signature + */ + async deleteSignature(id: string): Promise { + const capabilities = await this.detectCapabilities(); + + if (capabilities.supportsBackend) { + await this._deleteFromBackend(id); + } else { + this._deleteFromLocalStorage(id); + } + } + + /** + * Update signature label + */ + async updateSignatureLabel(id: string, label: string): Promise { + const capabilities = await this.detectCapabilities(); + + if (capabilities.supportsBackend) { + // Backend only stores images - labels not supported for backend signatures + console.log('[SignatureStorage] Label updates not supported for backend signatures'); + return; + } else { + this._updateLabelInLocalStorage(id, label); + } + } + + // Backend methods + private async _loadFromBackend(): Promise { + try { + const response = await apiClient.get('/api/v1/proprietary/signatures'); + const signatures = response.data; + + // Fetch image data for each signature and convert to blob URLs + const signaturePromises = signatures.map(async (sig) => { + if (sig.dataUrl && sig.dataUrl.startsWith('/api/v1/general/signatures/')) { + try { + // Fetch image via apiClient (unified endpoint works for both authenticated and unauthenticated) + const imageResponse = await apiClient.get(sig.dataUrl, { + responseType: 'arraybuffer', + }); + + // Convert to blob URL + const blob = new Blob([imageResponse.data], { + type: imageResponse.headers['content-type'] || 'image/png', + }); + const blobUrl = URL.createObjectURL(blob); + this.blobUrls.add(blobUrl); + + return { ...sig, dataUrl: blobUrl }; + } catch (error) { + console.error(`[SignatureStorage] Failed to load image for ${sig.id}:`, error); + return sig; // Return original if image fetch fails + } + } + return sig; + }); + + return await Promise.all(signaturePromises); + } catch (error) { + console.error('[SignatureStorage] Failed to load from backend:', error); + return []; + } + } + + private async _saveToBackend(signature: SavedSignature): Promise { + await apiClient.post('/api/v1/proprietary/signatures', signature); + } + + private async _deleteFromBackend(id: string): Promise { + await apiClient.delete(`/api/v1/proprietary/signatures/${id}`); + } + + // LocalStorage methods + private readonly STORAGE_KEY = 'stirling:saved-signatures:v1'; + + private _loadFromLocalStorage(): SavedSignature[] { + try { + const raw = localStorage.getItem(this.STORAGE_KEY); + if (!raw) return []; + const signatures = JSON.parse(raw); + // Ensure all localStorage signatures have the correct scope + return signatures.map((sig: SavedSignature) => ({ + ...sig, + scope: 'localStorage' as const, + })); + } catch { + return []; + } + } + + private _saveToLocalStorage(signature: SavedSignature): void { + const signatures = this._loadFromLocalStorage(); + const index = signatures.findIndex(s => s.id === signature.id); + + if (index >= 0) { + signatures[index] = signature; + } else { + signatures.unshift(signature); + } + + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures)); + } + + private _deleteFromLocalStorage(id: string): void { + const signatures = this._loadFromLocalStorage(); + const filtered = signatures.filter(s => s.id !== id); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered)); + } + + private _updateLabelInLocalStorage(id: string, label: string): void { + const signatures = this._loadFromLocalStorage(); + const signature = signatures.find(s => s.id === id); + if (signature) { + signature.label = label; + signature.updatedAt = Date.now(); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures)); + } + } + + /** + * Migrate signatures from localStorage to backend + */ + async migrateToBackend(): Promise<{ migrated: number; failed: number }> { + const capabilities = await this.detectCapabilities(); + + if (!capabilities.supportsBackend) { + return { migrated: 0, failed: 0 }; + } + + const localSignatures = this._loadFromLocalStorage(); + if (localSignatures.length === 0) { + return { migrated: 0, failed: 0 }; + } + + let migrated = 0; + let failed = 0; + + for (const signature of localSignatures) { + try { + await this._saveToBackend(signature); + migrated++; + } catch (error) { + console.error(`[SignatureStorage] Failed to migrate signature ${signature.id}:`, error); + failed++; + } + } + + // Clear localStorage after successful migration + if (migrated > 0 && failed === 0) { + localStorage.removeItem(this.STORAGE_KEY); + console.log(`[SignatureStorage] Successfully migrated ${migrated} signatures to backend`); + } + + return { migrated, failed }; + } + + /** + * Clean up blob URLs to prevent memory leaks + */ + cleanup(): void { + this.blobUrls.forEach(url => { + URL.revokeObjectURL(url); + }); + this.blobUrls.clear(); + } +} + +export const signatureStorageService = new SignatureStorageService(); From 8f6fcee42820103572aaacbb7d7f341327e05196 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:29:30 +0000 Subject: [PATCH 09/11] Fix: Only block export when signatures are placed but not applied (#5084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, activating signature placement mode would immediately set signaturesApplied=false, blocking export even when no signatures were actually placed on the PDF. This caused the "unapplied signatures" warning to appear incorrectly after clicking "Apply Signatures". Changes: - Remove signaturesApplied=false from activateDrawMode and activateSignaturePlacementMode in SignatureContext - Add signaturesApplied=false to onAnnotationEvent handler in SignatureAPIBridge when event.type === 'create' - Now signatures are only marked as unapplied when actually placed This ensures: - Users can activate placement mode without triggering export warning - Export is only blocked when signatures are actually placed but not applied - After applying signatures, users can immediately export without warning 🤖 Generated with [Claude Code](https://claude.com/claude-code) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. Co-authored-by: Claude --- .../components/viewer/SignatureAPIBridge.tsx | 16 +++++++++++++--- frontend/src/core/contexts/SignatureContext.tsx | 4 ---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/core/components/viewer/SignatureAPIBridge.tsx b/frontend/src/core/components/viewer/SignatureAPIBridge.tsx index c51a49b322..92d436e5d9 100644 --- a/frontend/src/core/components/viewer/SignatureAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SignatureAPIBridge.tsx @@ -122,7 +122,7 @@ const createTextStampImage = ( export const SignatureAPIBridge = forwardRef(function SignatureAPIBridge(_, ref) { const { provides: annotationApi } = useAnnotationCapability(); - const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature(); + const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize, setSignaturesApplied } = useSignature(); const { getZoomState, registerImmediateZoomUpdate } = useViewer(); const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1); const lastStampImageRef = useRef(null); @@ -389,6 +389,11 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI return; } + // Mark signatures as not applied when a new signature is placed + if (event.type === 'create') { + setSignaturesApplied(false); + } + const directData = extractDataUrl(annotation.imageSrc) || extractDataUrl(annotation.imageData) || @@ -408,7 +413,7 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI return () => { unsubscribe?.(); }; - }, [annotationApi, storeImageData]); + }, [annotationApi, storeImageData, setSignaturesApplied]); useEffect(() => { if (!isPlacementMode) { @@ -443,6 +448,11 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI return; } + // Mark signatures as not applied when a new signature is placed + if (event.type === 'create') { + setSignaturesApplied(false); + } + const directData = extractDataUrl(annotation.imageSrc) || extractDataUrl(annotation.imageData) || @@ -462,7 +472,7 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI return () => { unsubscribe?.(); }; - }, [annotationApi, storeImageData]); + }, [annotationApi, storeImageData, setSignaturesApplied]); useEffect(() => { if (!isPlacementMode) { diff --git a/frontend/src/core/contexts/SignatureContext.tsx b/frontend/src/core/contexts/SignatureContext.tsx index 2912bbaeed..24fdc22cb1 100644 --- a/frontend/src/core/contexts/SignatureContext.tsx +++ b/frontend/src/core/contexts/SignatureContext.tsx @@ -74,8 +74,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children if (signatureApiRef.current) { signatureApiRef.current.activateDrawMode(); setPlacementMode(true); - // Mark signatures as not applied when entering draw mode - setState(prev => ({ ...prev, signaturesApplied: false })); } }, [setPlacementMode]); @@ -90,8 +88,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children if (signatureApiRef.current) { signatureApiRef.current.activateSignaturePlacementMode(); setPlacementMode(true); - // Mark signatures as not applied when placing new signatures - setState(prev => ({ ...prev, signaturesApplied: false })); } }, [setPlacementMode]); From 959d14f075eb9532f9b750b01fcd4689c644dc3f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:29:58 +0000 Subject: [PATCH 10/11] Bump version from 2.0.1 to 2.0.2 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 859b7e17df..b85c2d4138 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.0.1' + version = '2.0.2' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' From 1e72416d55145ebd89b13bb59b5fee566f09e02b Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:35:50 +0000 Subject: [PATCH 11/11] Added file endpoint for license files and easy upload in admin UI (#5055) image --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../api/AdminLicenseController.java | 160 ++++++++++++++++++ frontend/package-lock.json | 57 +++++-- .../public/locales/en-GB/translation.toml | 21 +++ .../configSections/AdminPlanSection.tsx | 159 ++++++++++++++--- .../proprietary/services/licenseService.ts | 27 +++ 5 files changed, 389 insertions(+), 35 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java index 7bd5836c8b..c75b4d23f8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -1,22 +1,32 @@ package stirling.software.proprietary.security.controller.api; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.GeneralUtils; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier; @@ -242,4 +252,154 @@ public class AdminLicenseController { .body(Map.of("error", "Failed to retrieve license information")); } } + + /** + * Upload a license certificate file for offline activation. Accepts .lic or .cert files, + * validates the certificate format, saves to configs directory, and activates the license. + * + * @param file The license certificate file to upload + * @return Response with success status, license type, and file information + */ + @PostMapping(value = "/license-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Upload license certificate file", + description = + "Upload a license certificate file (.lic, .cert) for offline activation." + + " Validates the file format and activates the license.") + public ResponseEntity> uploadLicenseFile( + @RequestParam("file") MultipartFile file) { + + // Validate file exists + if (file == null || file.isEmpty()) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "error", "File is empty")); + } + + String filename = file.getOriginalFilename(); + if (filename == null || filename.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "error", "Invalid filename")); + } + // Prevent path traversal and enforce single filename component + if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "error", "Filename must not contain path separators or '..'")); + } + + // Validate file extension + if (!isValidLicenseFile(filename)) { + return ResponseEntity.badRequest() + .body( + Map.of( + "success", + false, + "error", + "Invalid file type. Expected .lic or .cert")); + } + + // Check file size (max 1MB for license files) + if (file.getSize() > 1_048_576) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "error", "File too large. Maximum 1MB allowed")); + } + + try { + // Validate certificate format by reading content + byte[] fileBytes = file.getBytes(); + String content = new String(fileBytes, StandardCharsets.UTF_8); + if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) { + return ResponseEntity.badRequest() + .body( + Map.of( + "success", + false, + "error", + "Invalid license certificate format")); + } + + // Get config directory and target path + Path configPath = Paths.get(InstallationPathConfig.getConfigPath()); + Path targetPath = configPath.resolve(filename).normalize(); + // Prevent directory traversal: ensure targetPath is inside configPath + if (!targetPath.startsWith(configPath.normalize().toAbsolutePath())) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "error", "Invalid file path")); + } + + // Backup existing file if present + if (Files.exists(targetPath)) { + Path backupDir = configPath.resolve("backup"); + Files.createDirectories(backupDir); + + String backupFilename = filename + ".bak." + System.currentTimeMillis(); + Path backupPath = backupDir.resolve(backupFilename); + + Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING); + log.info("Backed up existing license file to: {}", backupPath); + } + + // Write new license file + Files.write(targetPath, fileBytes); + log.info("License file saved to: {}", targetPath); + + // assume premium enabled when setting license key + applicationProperties.getPremium().setEnabled(true); + + // Update settings with file reference (relative path) + String fileReference = "file:configs/" + filename; + licenseKeyChecker.updateLicenseKey(fileReference); + + // Get license status after activation + License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("licenseType", license.name()); + response.put("filename", filename); + response.put("filePath", "configs/" + filename); + response.put("enabled", applicationProperties.getPremium().isEnabled()); + response.put("maxUsers", applicationProperties.getPremium().getMaxUsers()); + response.put("message", "License file uploaded and activated"); + + log.info( + "License file uploaded and activated: filename={}, type={}", + filename, + license.name()); + + return ResponseEntity.ok(response); + + } catch (IOException e) { + log.error("Failed to save license file", e); + return ResponseEntity.internalServerError() + .body( + Map.of( + "success", + false, + "error", + "Failed to save license file: " + e.getMessage())); + } catch (Exception e) { + log.error("Failed to activate license from file", e); + return ResponseEntity.badRequest() + .body( + Map.of( + "success", + false, + "error", + "Failed to activate license: " + e.getMessage())); + } + } + + /** + * Validates if the filename has a valid license file extension (.lic or .cert) + * + * @param filename The filename to validate + * @return true if the filename ends with .lic or .cert (case-insensitive) + */ + private boolean isValidLicenseFile(String filename) { + if (filename == null) { + return false; + } + String lower = filename.toLowerCase(); + return lower.endsWith(".lic") || lower.endsWith(".cert"); + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b78669f4e..bbbe0dc9ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -455,6 +455,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -498,6 +499,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -578,6 +580,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", "integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/engines": "1.4.1", "@embedpdf/models": "1.4.1" @@ -677,6 +680,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz", "integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -693,6 +697,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz", "integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -710,6 +715,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz", "integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -746,6 +752,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz", "integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -780,6 +787,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz", "integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -816,6 +824,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz", "integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -891,6 +900,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz", "integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -1046,6 +1056,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1089,6 +1100,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2119,6 +2131,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz", "integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2169,6 +2182,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz", "integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2236,6 +2250,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.5", @@ -3168,6 +3183,7 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.16" } @@ -3286,7 +3302,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^8.9.0" } @@ -4063,6 +4078,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4391,6 +4407,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4401,6 +4418,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4470,6 +4488,7 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -5183,7 +5202,6 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "3.5.24" } @@ -5193,7 +5211,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.24", "@vue/shared": "3.5.24" @@ -5204,7 +5221,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.24", "@vue/runtime-core": "3.5.24", @@ -5217,7 +5233,6 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.24", "@vue/shared": "3.5.24" @@ -5244,6 +5259,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5651,7 +5667,6 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 0.4" } @@ -5928,6 +5943,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6975,7 +6991,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7370,6 +7387,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7540,6 +7558,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7706,8 +7725,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/espree": { "version": "10.4.0", @@ -7772,7 +7790,6 @@ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz", "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } @@ -8863,6 +8880,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9339,7 +9357,6 @@ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.6" } @@ -9660,6 +9677,7 @@ "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.19", "@asamuzakjp/dom-selector": "^6.7.3", @@ -10246,8 +10264,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", @@ -11393,6 +11410,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11672,6 +11690,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -12054,6 +12073,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12063,6 +12083,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13574,7 +13595,6 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 0.4" } @@ -13783,6 +13803,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14084,6 +14105,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14165,6 +14187,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14369,6 +14392,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14520,6 +14544,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14533,6 +14558,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -15144,8 +15170,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/zod": { "version": "3.25.76", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index da021cdde7..487c8d4cf6 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -4516,6 +4516,7 @@ description = "URL or filename to impressum (required in some jurisdictions)" title = "Premium & Enterprise" description = "Configure your premium or enterprise license key." license = "License Configuration" +noInput = "Please provide a license key or file" [admin.settings.premium.licenseKey] toggle = "Got a license key or certificate file?" @@ -4533,6 +4534,26 @@ line1 = "Overwriting your current license key cannot be undone." line2 = "Your previous license will be permanently lost unless you have backed it up elsewhere." line3 = "Important: Keep license keys private and secure. Never share them publicly." +[admin.settings.premium.inputMethod] +text = "License Key" +file = "Certificate File" + +[admin.settings.premium.file] +label = "License Certificate File" +description = "Upload your .lic or .cert license file from offline purchases" +choose = "Choose License File" +selected = "Selected: {{filename}} ({{size}})" +successMessage = "License file uploaded and activated successfully. No restart required." + +[admin.settings.premium.currentLicense] +title = "Active License" +file = "Source: License file ({{path}})" +key = "Source: License key" +type = "Type: {{type}}" + +noInput = "Please provide a license key or upload a certificate file" +success = "Success" + [admin.settings.premium.enabled] label = "Enable Premium Features" description = "Enable license key checks for pro/enterprise features" diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 4cfc909361..2c86c9ced7 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react'; -import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core'; +import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper, SegmentedControl, FileButton } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePlans } from '@app/hooks/usePlans'; import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService'; @@ -29,6 +29,8 @@ const AdminPlanSection: React.FC = () => { const [showLicenseKey, setShowLicenseKey] = useState(false); const [licenseKeyInput, setLicenseKeyInput] = useState(''); const [savingLicense, setSavingLicense] = useState(false); + const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text'); + const [licenseFile, setLicenseFile] = useState(null); const { plans, loading, error, refetch } = usePlans(currency); const licenseAlert = useLicenseAlert(); @@ -49,34 +51,55 @@ const AdminPlanSection: React.FC = () => { try { setSavingLicense(true); - // Allow empty string to clear/remove license - const response = await licenseService.saveLicenseKey(licenseKeyInput.trim()); + + let response; + + if (inputMethod === 'file' && licenseFile) { + // Upload file + response = await licenseService.saveLicenseFile(licenseFile); + } else if (inputMethod === 'text' && licenseKeyInput.trim()) { + // Save key string (allow empty string to clear/remove license) + response = await licenseService.saveLicenseKey(licenseKeyInput.trim()); + } else { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.premium.noInput', 'Please provide a license key or file'), + }); + return; + } if (response.success) { // Refresh license context to update all components await refetchLicense(); + const successMessage = inputMethod === 'file' + ? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully') + : t('admin.settings.premium.key.successMessage', 'License key activated successfully'); + alert({ alertType: 'success', - title: t('admin.settings.premium.key.success', 'License Key Saved'), - body: t('admin.settings.premium.key.successMessage', 'Your license key has been activated successfully. No restart required.'), + title: t('success', 'Success'), + body: successMessage, }); - // Clear input + // Clear inputs setLicenseKeyInput(''); + setLicenseFile(null); + setInputMethod('text'); // Reset to default } else { alert({ alertType: 'error', title: t('admin.error', 'Error'), - body: response.error || t('admin.settings.saveError', 'Failed to save license key'), + body: response.error || t('admin.settings.saveError', 'Failed to save license'), }); } } catch (error) { - console.error('Failed to save license key:', error); + console.error('Failed to save license:', error); alert({ alertType: 'error', title: t('admin.error', 'Error'), - body: t('admin.settings.saveError', 'Failed to save license key'), + body: t('admin.settings.saveError', 'Failed to save license'), }); } finally { setSavingLicense(false); @@ -300,20 +323,118 @@ const AdminPlanSection: React.FC = () => { )} + {/* Show current license source */} + {licenseInfo?.licenseKey && ( + } + > + + + {t('admin.settings.premium.currentLicense.title', 'Active License')} + + + {licenseInfo.licenseKey.startsWith('file:') + ? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', { + path: licenseInfo.licenseKey.substring(5) + }) + : t('admin.settings.premium.currentLicense.key', 'Source: License key')} + + + {t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', { + type: licenseInfo.licenseType + })} + + + + )} + + {/* Input method selector */} + { + setInputMethod(value as 'text' | 'file'); + // Clear opposite input when switching + if (value === 'text') setLicenseFile(null); + if (value === 'file') setLicenseKeyInput(''); + }} + data={[ + { + label: t('admin.settings.premium.inputMethod.text', 'License Key'), + value: 'text' + }, + { + label: t('admin.settings.premium.inputMethod.file', 'Certificate File'), + value: 'file' + } + ]} + disabled={!loginEnabled || savingLicense} + /> + + {/* Input area */} - setLicenseKeyInput(e.target.value)} - placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} - type="password" - disabled={!loginEnabled || savingLicense} - /> + {inputMethod === 'text' ? ( + /* Existing text input */ + setLicenseKeyInput(e.target.value)} + placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} + type="password" + disabled={!loginEnabled || savingLicense} + /> + ) : ( + /* File upload */ +
+ + {t('admin.settings.premium.file.label', 'License Certificate File')} + + + {t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')} + + + {(props) => ( + + )} + + {licenseFile && ( + + {t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', { + filename: licenseFile.name, + size: (licenseFile.size / 1024).toFixed(2) + ' KB' + })} + + )} +
+ )} - diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts index 8675679770..d53129c176 100644 --- a/frontend/src/proprietary/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -80,6 +80,10 @@ export interface LicenseInfo { export interface LicenseSaveResponse { success: boolean; licenseType?: string; + filename?: string; + filePath?: string; + enabled?: boolean; + maxUsers?: number; message?: string; error?: string; } @@ -419,6 +423,29 @@ const licenseService = { } }, + /** + * Upload license certificate file for offline activation + * @param file - The .lic or .cert file to upload + * @returns Promise with upload result + */ + async saveLicenseFile(file: File): Promise { + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post('/api/v1/admin/license-file', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return response.data; + } catch (error) { + console.error('Error uploading license file:', error); + throw error; + } + }, + /** * Get current license information from backend */