From d2677e64ddb9abb64820bfbb189e176b72a9329b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:18:37 +0000 Subject: [PATCH] OCR fix and Mobile QR changes (#5433) # Description of Changes ## OCR / Tesseract path handling Makes tessDataPath resolution deterministic with priority: config > TESSDATA_PREFIX env > default. Updates language discovery to use runtimePathConfig.getTessDataPath() instead of raw config value. Ensure default OCR dir is debian based not alpine ## Mobile scanner: feature gating + new conversion settings Adds system.mobileScannerSettings (convert-to-PDF + resolution + page format + stretch) exposed via backend config and configurable in the proprietary admin UI. Enforces enableMobileScanner on the MobileScannerController endpoints (403 when disabled). Frontend mobile upload flow can now optionally convert received images to PDF (pdf-lib + canvas). ## Desktop/Tauri connectivity work Expands tauri-plugin-http permissions and enables dangerous-settings. Adds a very comprehensive multi-stage server connection diagnostic routine (with lots of logging). image --- ## 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. --- .../configuration/RuntimePathConfig.java | 19 +- .../common/model/ApplicationProperties.java | 9 + .../SPDF/controller/api/UIDataController.java | 2 +- .../controller/api/misc/ConfigController.java | 17 +- .../api/misc/MobileScannerController.java | 60 +- .../src/main/resources/settings.yml.template | 5 + build.gradle | 2 +- docker/unified/entrypoint.sh | 24 +- .../public/locales/en-GB/translation.toml | 42 +- frontend/src-tauri/Cargo.toml | 2 +- frontend/src-tauri/capabilities/default.json | 22 +- .../fileManager/FileSourceButtons.tsx | 9 +- .../components/shared/MobileUploadModal.tsx | 44 +- .../src/core/contexts/AppConfigContext.tsx | 4 + frontend/src/core/utils/imageToPdfUtils.ts | 297 +++++++ .../SetupWizard/ServerSelection.tsx | 6 +- .../desktop/services/connectionModeService.ts | 825 ++++++++++++++++-- .../AdminConnectionsSection.tsx | 167 +++- .../proprietary/routes/authShared/auth.css | 17 + scripts/init.sh | 38 +- 20 files changed, 1478 insertions(+), 133 deletions(-) create mode 100644 frontend/src/core/utils/imageToPdfUtils.ts diff --git a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java index 7e6da0f0f..fef6af3ed 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java @@ -94,16 +94,19 @@ public class RuntimePathConfig { defaultSOfficePath, operations != null ? operations.getSoffice() : null); // Initialize Tesseract data path - String defaultTessDataPath = - isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata"; - + // Priority: config setting > TESSDATA_PREFIX env var > default path String tessPath = system.getTessdataDir(); - String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX"); + String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX"); + String defaultPath = "/usr/share/tesseract-ocr/5/tessdata"; + + if (tessPath != null && !tessPath.isEmpty()) { + this.tessDataPath = tessPath; + } else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) { + this.tessDataPath = tessdataPrefix; + } else { + this.tessDataPath = defaultPath; + } - this.tessDataPath = - resolvePath( - defaultTessDataPath, - (tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir); log.info("Using Tesseract data path: {}", this.tessDataPath); } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index a0c3bae91..58c120329 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -418,6 +418,15 @@ public class ApplicationProperties { // 'https://app.example.com'). If not set, falls back to backendUrl. private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature + private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings(); + + @Data + public static class MobileScannerSettings { + private boolean convertToPdf = true; // Whether to automatically convert images to PDF + private String imageResolution = "full"; // Options: "full", "reduced" + private String pageFormat = "A4"; // Options: "keep", "A4", "letter" + private boolean stretchToFit = false; // Whether to stretch image to fill page + } public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); 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 6285636ef..c60965f72 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 @@ -191,7 +191,7 @@ public class UIDataController { } private List getAvailableTesseractLanguages() { - String tessdataDir = applicationProperties.getSystem().getTessdataDir(); + String tessdataDir = runtimePathConfig.getTessDataPath(); java.io.File[] files = new java.io.File(tessdataDir).listFiles(); if (files == null) { return Collections.emptyList(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 626703ace..fc73d778e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -75,10 +75,25 @@ public class ConfigController { String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); configData.put("frontendUrl", frontendUrl != null ? frontendUrl : ""); - // Add mobile scanner setting + // Add mobile scanner settings configData.put( "enableMobileScanner", applicationProperties.getSystem().isEnableMobileScanner()); + configData.put( + "mobileScannerConvertToPdf", + applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf()); + configData.put( + "mobileScannerImageResolution", + applicationProperties + .getSystem() + .getMobileScannerSettings() + .getImageResolution()); + configData.put( + "mobileScannerPageFormat", + applicationProperties.getSystem().getMobileScannerSettings().getPageFormat()); + configData.put( + "mobileScannerStretchToFit", + applicationProperties.getSystem().getMobileScannerSettings().isStretchToFit()); // Extract values from ApplicationProperties configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar()); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java index a5e3ba7f4..c04911ee5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java @@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.MobileScannerService; import stirling.software.common.service.MobileScannerService.FileMetadata; @@ -51,9 +52,31 @@ import stirling.software.common.service.MobileScannerService.FileMetadata; public class MobileScannerController { private final MobileScannerService mobileScannerService; + private final ApplicationProperties applicationProperties; - public MobileScannerController(MobileScannerService mobileScannerService) { + public MobileScannerController( + MobileScannerService mobileScannerService, + ApplicationProperties applicationProperties) { this.mobileScannerService = mobileScannerService; + this.applicationProperties = applicationProperties; + } + + /** + * Check if mobile scanner feature is enabled + * + * @return Error response if disabled, null if enabled + */ + private ResponseEntity> checkFeatureEnabled() { + if (!applicationProperties.getSystem().isEnableMobileScanner()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body( + Map.of( + "error", + "Mobile scanner feature is not enabled", + "enabled", + false)); + } + return null; } /** @@ -71,10 +94,16 @@ public class MobileScannerController { description = "Session created successfully", content = @Content(schema = @Schema(implementation = SessionInfoResponse.class))) @ApiResponse(responseCode = "400", description = "Invalid session ID") + @ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled") public ResponseEntity> createSession( @Parameter(description = "Session ID for QR code", required = true) @PathVariable String sessionId) { + ResponseEntity> featureCheck = checkFeatureEnabled(); + if (featureCheck != null) { + return featureCheck; + } + try { MobileScannerService.SessionInfo sessionInfo = mobileScannerService.createSession(sessionId); @@ -109,10 +138,16 @@ public class MobileScannerController { description = "Session is valid", content = @Content(schema = @Schema(implementation = SessionInfoResponse.class))) @ApiResponse(responseCode = "404", description = "Session not found or expired") + @ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled") public ResponseEntity> validateSession( @Parameter(description = "Session ID to validate", required = true) @PathVariable String sessionId) { + ResponseEntity> featureCheck = checkFeatureEnabled(); + if (featureCheck != null) { + return featureCheck; + } + MobileScannerService.SessionInfo sessionInfo = mobileScannerService.validateSession(sessionId); @@ -147,6 +182,7 @@ public class MobileScannerController { description = "Files uploaded successfully", content = @Content(schema = @Schema(implementation = UploadResponse.class))) @ApiResponse(responseCode = "400", description = "Invalid session ID or files") + @ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled") @ApiResponse(responseCode = "500", description = "Upload failed") public ResponseEntity> uploadFiles( @Parameter(description = "Session ID from QR code", required = true) @PathVariable @@ -154,6 +190,11 @@ public class MobileScannerController { @Parameter(description = "Files to upload", required = true) @RequestParam("files") List files) { + ResponseEntity> featureCheck = checkFeatureEnabled(); + if (featureCheck != null) { + return featureCheck; + } + try { if (files == null || files.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "No files provided")); @@ -194,10 +235,16 @@ public class MobileScannerController { responseCode = "200", description = "File list retrieved", content = @Content(schema = @Schema(implementation = FileListResponse.class))) + @ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled") public ResponseEntity> getSessionFiles( @Parameter(description = "Session ID", required = true) @PathVariable String sessionId) { + ResponseEntity> featureCheck = checkFeatureEnabled(); + if (featureCheck != null) { + return featureCheck; + } + List files = mobileScannerService.getSessionFiles(sessionId); Map response = new HashMap<>(); @@ -221,12 +268,17 @@ public class MobileScannerController { description = "Download a file that was uploaded to a session. File is automatically deleted after download.") @ApiResponse(responseCode = "200", description = "File downloaded successfully") + @ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled") @ApiResponse(responseCode = "404", description = "File or session not found") public ResponseEntity downloadFile( @Parameter(description = "Session ID", required = true) @PathVariable String sessionId, @Parameter(description = "Filename to download", required = true) @PathVariable String filename) { + if (!applicationProperties.getSystem().isEnableMobileScanner()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + try { Path filePath = mobileScannerService.getFile(sessionId, filename); @@ -268,10 +320,16 @@ public class MobileScannerController { summary = "Delete a session", description = "Manually delete a session and all its uploaded files") @ApiResponse(responseCode = "200", description = "Session deleted successfully") + @ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled") public ResponseEntity> deleteSession( @Parameter(description = "Session ID to delete", required = true) @PathVariable String sessionId) { + ResponseEntity> featureCheck = checkFeatureEnabled(); + if (featureCheck != null) { + return featureCheck; + } + mobileScannerService.deleteSession(sessionId); return ResponseEntity.ok( diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 25caac273..5f68e65e5 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -146,6 +146,11 @@ system: backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured. + mobileScannerSettings: + convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is. + imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true. + pageFormat: A4 # Page format for converted PDFs: 'keep' (original image dimensions), 'A4' (A4 page size), or 'letter' (US Letter page size). Only applies when convertToPdf is true. + stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option organizationName: Stirling-PDF # Organization name for generated certificates diff --git a/build.gradle b/build.gradle index cb5580ef3..c54e20eb0 100644 --- a/build.gradle +++ b/build.gradle @@ -60,7 +60,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.2.0' + version = '2.2.1' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/docker/unified/entrypoint.sh b/docker/unified/entrypoint.sh index 92075ff3a..e930fae33 100644 --- a/docker/unified/entrypoint.sh +++ b/docker/unified/entrypoint.sh @@ -14,17 +14,20 @@ echo "===================================" setup_ocr() { echo "Setting up OCR languages..." - # Copy tessdata - mkdir -p /usr/share/tessdata - cp -rn /usr/share/tessdata-original/* /usr/share/tessdata 2>/dev/null || true + # In Alpine, tesseract uses /usr/share/tessdata + TESSDATA_DIR="/usr/share/tessdata" - if [ -d /usr/share/tesseract-ocr/4.00/tessdata ]; then - cp -r /usr/share/tesseract-ocr/4.00/tessdata/* /usr/share/tessdata 2>/dev/null || true + # Create tessdata directory + mkdir -p "$TESSDATA_DIR" + + # Restore system languages from backup (Dockerfile moved them to tessdata-original) + if [ -d /usr/share/tessdata-original ]; then + echo "Restoring system tessdata from backup..." + cp -rn /usr/share/tessdata-original/* "$TESSDATA_DIR"/ 2>/dev/null || true fi - if [ -d /usr/share/tesseract-ocr/5/tessdata ]; then - cp -r /usr/share/tesseract-ocr/5/tessdata/* /usr/share/tessdata 2>/dev/null || true - fi + # Note: If user mounted custom languages to /usr/share/tessdata, they'll be overlaid here. + # The cp -rn above won't overwrite user files, just adds missing system files. # Install additional languages if specified if [[ -n "$TESSERACT_LANGS" ]]; then @@ -32,10 +35,15 @@ setup_ocr() { pattern='^[a-zA-Z]{2,4}(_[a-zA-Z]{2,4})?$' for LANG in $SPACE_SEPARATED_LANGS; do if [[ $LANG =~ $pattern ]]; then + echo "Installing tesseract language: $LANG" apk add --no-cache "tesseract-ocr-data-$LANG" 2>/dev/null || true fi done fi + + # Point to the consolidated location + export TESSDATA_PREFIX="$TESSDATA_DIR" + echo "Using TESSDATA_PREFIX=$TESSDATA_PREFIX" } # Function to setup user permissions (from init-without-ocr.sh) diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index ec4d82120..621345bcc 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -441,6 +441,13 @@ currentVersion = "Current Version" latestVersion = "Latest Version" checkForUpdates = "Check for Updates" viewDetails = "View Details" +serverNeedsUpdate = "Server needs to be updated by administrator" + +[settings.general.versionInfo] +title = "Version Information" +description = "Desktop and server version details" +desktop = "Desktop Version" +server = "Server Version" [settings.security] title = "Security" @@ -4277,6 +4284,8 @@ fetchError = "Failed to load settings" saveError = "Failed to save settings" saved = "Settings saved successfully" saveSuccess = "Settings saved successfully" +success = "Settings saved successfully" +error = "Failed to save settings" save = "Save Changes" discard = "Discard" restartRequired = "Restart Required" @@ -4543,6 +4552,19 @@ connect = "Connect" disconnect = "Disconnect" disconnected = "Provider disconnected successfully" disconnectError = "Failed to disconnect provider" +mobileScannerConvertToPdf = "Convert Images to PDF" +mobileScannerConvertToPdfDesc = "Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is." +mobileScannerImageResolution = "Image Resolution" +mobileScannerImageResolutionDesc = "Resolution of uploaded images. \"Reduced\" scales images to max 1200px to reduce file size." +imageResolutionFull = "Full (Original Size)" +imageResolutionReduced = "Reduced (Max 1200px)" +mobileScannerPageFormat = "Page Format" +mobileScannerPageFormatDesc = "PDF page size for converted images. \"Keep\" uses original image dimensions." +pageFormatKeep = "Keep (Original Dimensions)" +pageFormatA4 = "A4 (210×297mm)" +pageFormatLetter = "Letter (8.5×11in)" +mobileScannerStretchToFit = "Stretch to Fit" +mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio." [admin.settings.connections.ssoAutoLogin] label = "SSO Auto Login" @@ -4617,6 +4639,19 @@ enable = "Enable QR Code Upload" description = "Allow users to upload files from mobile devices by scanning a QR code" note = "Note: Requires Frontend URL to be configured. " link = "Configure in System Settings" +mobileScannerConvertToPdf = "Convert Images to PDF" +mobileScannerConvertToPdfDesc = "Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is." +mobileScannerImageResolution = "Image Resolution" +mobileScannerImageResolutionDesc = "Resolution of uploaded images. \"Reduced\" scales images to max 1200px to reduce file size." +imageResolutionFull = "Full (Original Size)" +imageResolutionReduced = "Reduced (Max 1200px)" +mobileScannerPageFormat = "Page Format" +mobileScannerPageFormatDesc = "PDF page size for converted images. \"Keep\" uses original image dimensions." +pageFormatKeep = "Keep (Original Dimensions)" +pageFormatA4 = "A4 (210×297mm)" +pageFormatLetter = "Letter (8.5×11in)" +mobileScannerStretchToFit = "Stretch to Fit" +mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio." [admin.settings.database] title = "Database" @@ -4990,6 +5025,7 @@ noRecentFiles = "No recent files found" googleDriveNotAvailable = "Google Drive integration not available" mobileUpload = "Mobile Upload" mobileShort = "Mobile" +mobileUploadNotAvailable = "Mobile upload not enabled" downloadSelected = "Download Selected" saveSelected = "Save Selected" openFiles = "Open Files" @@ -6464,7 +6500,8 @@ failed = "An error occurred while adding text to the PDF." [mobileUpload] title = "Upload from Mobile" -description = "Scan this QR code with your mobile device to upload photos directly to this page." +description = "Scan to upload photos. Images auto-convert to PDF." +descriptionNoConvert = "Scan to upload photos from your mobile device." error = "Connection Error" pollingError = "Error checking for files" sessionId = "Session ID" @@ -6473,7 +6510,8 @@ expiryWarning = "Session Expiring Soon" expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically." filesReceived = "{{count}} file(s) received" connected = "Mobile device connected" -instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices." +instructions = "Scan with your phone camera. Images convert to PDF automatically." +instructionsNoConvert = "Scan with your phone camera to upload files." [mobileScanner] title = "Mobile Scanner" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 455436cdf..42b3faaca 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -28,7 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] } tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" -tauri-plugin-http = "2.4.4" +tauri-plugin-http = { version = "2.4.4", features = ["dangerous-settings"] } tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] } tauri-plugin-store = "2.1.0" tauri-plugin-opener = "2.0.0" diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 445e1d30a..ac4e364ae 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -13,9 +13,29 @@ "allow": [ { "url": "http://*" }, { "url": "http://*:*" }, - { "url": "https://*" } + { "url": "https://*" }, + { "url": "https://*:*" }, + { "url": "http://192.168.*.*" }, + { "url": "http://192.168.*.*:*" }, + { "url": "https://192.168.*.*" }, + { "url": "https://192.168.*.*:*" }, + { "url": "http://10.*.*.*" }, + { "url": "http://10.*.*.*:*" }, + { "url": "https://10.*.*.*" }, + { "url": "https://10.*.*.*:*" }, + { "url": "http://172.16.*.*" }, + { "url": "http://172.16.*.*:*" }, + { "url": "https://172.16.*.*" }, + { "url": "https://172.16.*.*:*" }, + { "url": "http://localhost:*" }, + { "url": "https://localhost:*" }, + { "url": "http://127.0.0.1:*" }, + { "url": "https://127.0.0.1:*" } ] }, + "http:allow-fetch-cancel", + "http:allow-fetch-read-body", + "http:allow-fetch-send", { "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] diff --git a/frontend/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/src/core/components/fileManager/FileSourceButtons.tsx index 0979637c6..ba2ebcb92 100644 --- a/frontend/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/src/core/components/fileManager/FileSourceButtons.tsx @@ -8,6 +8,8 @@ import { useFileManagerContext } from '@app/contexts/FileManagerContext'; import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker'; import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useIsMobile } from '@app/hooks/useIsMobile'; import MobileUploadModal from '@app/components/shared/MobileUploadModal'; interface FileSourceButtonsProps { @@ -24,6 +26,9 @@ const FileSourceButtons: React.FC = ({ const icons = useFileActionIcons(); const UploadIcon = icons.upload; const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false); + const { config } = useAppConfig(); + const isMobile = useIsMobile(); + const isMobileUploadEnabled = config?.enableMobileScanner && !isMobile; const handleGoogleDriveClick = async () => { try { @@ -127,15 +132,17 @@ const FileSourceButtons: React.FC = ({ onClick={handleMobileUploadClick} fullWidth={!horizontal} size={horizontal ? "xs" : "sm"} + disabled={!isMobileUploadEnabled} styles={{ root: { backgroundColor: 'transparent', border: 'none', '&:hover': { - backgroundColor: 'var(--mantine-color-gray-0)' + backgroundColor: isMobileUploadEnabled ? 'var(--mantine-color-gray-0)' : 'transparent' } } }} + title={!isMobileUploadEnabled ? t('fileManager.mobileUploadNotAvailable', 'Mobile upload not available') : undefined} > {horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')} diff --git a/frontend/src/core/components/shared/MobileUploadModal.tsx b/frontend/src/core/components/shared/MobileUploadModal.tsx index d16bcf814..28d362ba2 100644 --- a/frontend/src/core/components/shared/MobileUploadModal.tsx +++ b/frontend/src/core/components/shared/MobileUploadModal.tsx @@ -9,6 +9,7 @@ import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; import { withBasePath } from '@app/constants/app'; +import { convertImageToPdf, isImageFile } from '@app/utils/imageToPdfUtils'; interface MobileUploadModalProps { opened: boolean; @@ -132,10 +133,25 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: if (downloadResponse.ok) { const blob = await downloadResponse.blob(); - const file = new File([blob], fileMetadata.filename, { + let file = new File([blob], fileMetadata.filename, { type: fileMetadata.contentType || 'image/jpeg' }); + // Convert images to PDF if enabled + if (isImageFile(file) && config?.mobileScannerConvertToPdf !== false) { + try { + file = await convertImageToPdf(file, { + imageResolution: config?.mobileScannerImageResolution as 'full' | 'reduced' | undefined, + pageFormat: config?.mobileScannerPageFormat as 'keep' | 'A4' | 'letter' | undefined, + stretchToFit: config?.mobileScannerStretchToFit, + }); + console.log('Converted image to PDF:', file.name); + } catch (convertError) { + console.warn('Failed to convert image to PDF, using original file:', convertError); + // Continue with original image file if conversion fails + } + } + processedFiles.current.add(fileMetadata.filename); setFilesReceived((prev) => prev + 1); onFilesReceived([file]); @@ -256,10 +272,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: variant="light" > - {t( - 'mobileUpload.description', - 'Scan this QR code with your mobile device to upload photos directly to this page.' - )} + {config?.mobileScannerConvertToPdf !== false + ? t( + 'mobileUpload.description', + 'Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.' + ) + : t( + 'mobileUpload.descriptionNoConvert', + 'Scan this QR code with your mobile device to upload photos.' + )} @@ -308,10 +329,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: )} - {t( - 'mobileUpload.instructions', - 'Open the camera app on your phone and scan this code. Files will be uploaded through the server.' - )} + {config?.mobileScannerConvertToPdf !== false + ? t( + 'mobileUpload.instructions', + 'Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.' + ) + : t( + 'mobileUpload.instructionsNoConvert', + 'Open the camera app on your phone and scan this code. Files will be uploaded through the server.' + )} { + const { + imageResolution = 'full', + pageFormat = 'A4', + stretchToFit = false, + } = options; + try { + // Create a new PDF document + const pdfDoc = await PDFDocument.create(); + + // Read the image file as an array buffer + let imageBytes = await imageFile.arrayBuffer(); + + // Apply image resolution reduction if requested + if (imageResolution === 'reduced') { + const reducedImage = await reduceImageResolution(imageFile, 1200); // Max 1200px on longest side + imageBytes = await reducedImage.arrayBuffer(); + } + + // Embed the image based on its type + let image; + const imageType = imageFile.type.toLowerCase(); + + if (imageType === 'image/jpeg' || imageType === 'image/jpg') { + image = await pdfDoc.embedJpg(imageBytes); + } else if (imageType === 'image/png') { + image = await pdfDoc.embedPng(imageBytes); + } else { + // For other image types, try to convert to PNG first using canvas + const convertedImage = await convertImageToPng(imageFile); + const convertedBytes = await convertedImage.arrayBuffer(); + image = await pdfDoc.embedPng(convertedBytes); + } + + // Get image dimensions + const { width: imageWidth, height: imageHeight } = image; + + // Determine page dimensions based on pageFormat option + let pageWidth: number; + let pageHeight: number; + + if (pageFormat === 'keep') { + // Use original image dimensions + pageWidth = imageWidth; + pageHeight = imageHeight; + } else if (pageFormat === 'letter') { + // US Letter: 8.5" x 11" = 612 x 792 points + pageWidth = PageSizes.Letter[0]; + pageHeight = PageSizes.Letter[1]; + } else { + // A4: 210mm x 297mm = 595 x 842 points (default) + pageWidth = PageSizes.A4[0]; + pageHeight = PageSizes.A4[1]; + } + + // Adjust page orientation based on image orientation if using standard page size + if (pageFormat !== 'keep') { + const imageIsLandscape = imageWidth > imageHeight; + const pageIsLandscape = pageWidth > pageHeight; + + // Rotate page to match image orientation + if (imageIsLandscape !== pageIsLandscape) { + [pageWidth, pageHeight] = [pageHeight, pageWidth]; + } + } + + // Create a page + const page = pdfDoc.addPage([pageWidth, pageHeight]); + + // Calculate image placement based on stretchToFit option + let drawX: number; + let drawY: number; + let drawWidth: number; + let drawHeight: number; + + if (stretchToFit || pageFormat === 'keep') { + // Stretch/fill to page + drawX = 0; + drawY = 0; + drawWidth = pageWidth; + drawHeight = pageHeight; + } else { + // Fit within page bounds while preserving aspect ratio + const imageAspectRatio = imageWidth / imageHeight; + const pageAspectRatio = pageWidth / pageHeight; + + if (imageAspectRatio > pageAspectRatio) { + // Image is wider than page - fit to width + drawWidth = pageWidth; + drawHeight = pageWidth / imageAspectRatio; + drawX = 0; + drawY = (pageHeight - drawHeight) / 2; // Center vertically + } else { + // Image is taller than page - fit to height + drawHeight = pageHeight; + drawWidth = pageHeight * imageAspectRatio; + drawY = 0; + drawX = (pageWidth - drawWidth) / 2; // Center horizontally + } + } + + // Draw the image on the page + page.drawImage(image, { + x: drawX, + y: drawY, + width: drawWidth, + height: drawHeight, + }); + + // Save the PDF to bytes + const pdfBytes = await pdfDoc.save(); + + // Create a filename by replacing the image extension with .pdf + const pdfFilename = imageFile.name.replace(/\.[^.]+$/, '.pdf'); + + // Create a File object from the PDF bytes + const pdfFile = new File([new Uint8Array(pdfBytes)], pdfFilename, { + type: 'application/pdf', + }); + + return pdfFile; + } catch (error) { + console.error('Error converting image to PDF:', error); + throw new Error( + `Failed to convert image to PDF: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Convert an image file to PNG using canvas + * This is used for image types that pdf-lib doesn't directly support + */ +async function convertImageToPng(imageFile: File): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(imageFile); + + img.onload = () => { + try { + // Create a canvas with the image dimensions + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + + // Draw the image on the canvas + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + ctx.drawImage(img, 0, 0); + + // Convert canvas to blob + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Failed to convert canvas to blob')); + return; + } + + // Create a File object from the blob + const pngFilename = imageFile.name.replace(/\.[^.]+$/, '.png'); + const pngFile = new File([blob], pngFilename, { + type: 'image/png', + }); + + URL.revokeObjectURL(url); + resolve(pngFile); + }, + 'image/png', + 1.0 + ); + } catch (error) { + URL.revokeObjectURL(url); + reject(error); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image')); + }; + + img.src = url; + }); +} + +/** + * Reduce image resolution to a maximum dimension + * @param imageFile - The image file to reduce + * @param maxDimension - Maximum width or height in pixels + * @returns A Promise that resolves to a reduced resolution image file + */ +async function reduceImageResolution( + imageFile: File, + maxDimension: number +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(imageFile); + + img.onload = () => { + try { + const { width, height } = img; + + // Check if reduction is needed + if (width <= maxDimension && height <= maxDimension) { + URL.revokeObjectURL(url); + resolve(imageFile); // No reduction needed + return; + } + + // Calculate new dimensions while preserving aspect ratio + let newWidth: number; + let newHeight: number; + + if (width > height) { + newWidth = maxDimension; + newHeight = (height / width) * maxDimension; + } else { + newHeight = maxDimension; + newWidth = (width / height) * maxDimension; + } + + // Create a canvas with the new dimensions + const canvas = document.createElement('canvas'); + canvas.width = newWidth; + canvas.height = newHeight; + + // Draw the resized image on the canvas + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + ctx.drawImage(img, 0, 0, newWidth, newHeight); + + // Convert canvas to blob (preserve original format if possible) + const outputType = imageFile.type.startsWith('image/') + ? imageFile.type + : 'image/jpeg'; + + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Failed to convert canvas to blob')); + return; + } + + // Create a File object from the blob + const reducedFile = new File([blob], imageFile.name, { + type: outputType, + }); + + URL.revokeObjectURL(url); + resolve(reducedFile); + }, + outputType, + 0.9 // Quality (only applies to JPEG) + ); + } catch (error) { + URL.revokeObjectURL(url); + reject(error); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image')); + }; + + img.src = url; + }); +} + +/** + * Check if a file is an image + */ +export function isImageFile(file: File): boolean { + return file.type.startsWith('image/'); +} diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx index 1bbe1496f..091c01d1e 100644 --- a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx +++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -75,12 +75,12 @@ export const ServerSelection: React.FC = ({ onSelect, load console.log('[ServerSelection] Fetching login configuration...'); const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`); - // Check if security is disabled (status 403 or error response) + // Check if security is disabled (status 403, 401, or 404 - endpoint doesn't exist) if (!response.ok) { console.warn(`[ServerSelection] Login config request failed with status ${response.status}`); - if (response.status === 403 || response.status === 401) { - console.log('[ServerSelection] Security is disabled on this server'); + if (response.status === 403 || response.status === 401 || response.status === 404) { + console.log('[ServerSelection] Security/SSO not configured on this server (or endpoint does not exist)'); setSecurityDisabled(true); setTesting(false); return; diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index b47720a91..c11b27c2a 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -19,6 +19,20 @@ export interface ConnectionConfig { server_config: ServerConfig | null; } +export interface DiagnosticResult { + stage: string; + success: boolean; + message: string; + duration?: number; +} + +export interface ConnectionTestResult { + success: boolean; + error?: string; + errorCode?: string; + diagnostics?: DiagnosticResult[]; +} + export class ConnectionModeService { private static instance: ConnectionModeService; private currentConfig: ConnectionConfig | null = null; @@ -106,97 +120,766 @@ export class ConnectionModeService { } /** - * Test connection to a server URL and return detailed error information - * @returns Object with success status and optional error message + * Test connection to a server URL with comprehensive multi-stage diagnostics + * @returns Detailed test results with diagnostics and recommendations */ - async testConnection(url: string): Promise<{ success: boolean; error?: string; errorCode?: string }> { - console.log(`[ConnectionModeService] Testing connection to: ${url}`); + async testConnection(url: string): Promise { + console.log(`[ConnectionModeService] 🔍 Starting comprehensive connection diagnostics for: ${url}`); + console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION START ====================`); + console.log(`[ConnectionModeService] System Information:`); + console.log(`[ConnectionModeService] - User Agent: ${navigator.userAgent}`); + console.log(`[ConnectionModeService] - Platform: ${navigator.platform}`); + console.log(`[ConnectionModeService] - Online: ${navigator.onLine}`); + console.log(`[ConnectionModeService] - Connection Type: ${(navigator as any).connection?.effectiveType || 'unknown'}`); + console.log(`[ConnectionModeService] - Language: ${navigator.language}`); + console.log(`[ConnectionModeService] - Cookies Enabled: ${navigator.cookieEnabled}`); + console.log(`[ConnectionModeService] - Hardware Concurrency: ${navigator.hardwareConcurrency || 'unknown'} cores`); + console.log(`[ConnectionModeService] - Max Touch Points: ${navigator.maxTouchPoints}`); - try { - // Test connection by hitting the health/status endpoint - const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`; - console.log(`[ConnectionModeService] Health check URL: ${healthUrl}`); + // Check for proxy environment variables + console.log(`[ConnectionModeService] Environment Check:`); + const envProxy = (window as any).process?.env?.HTTP_PROXY || (window as any).process?.env?.HTTPS_PROXY; + if (envProxy) { + console.log(`[ConnectionModeService] - Proxy detected: ${envProxy}`); + } else { + console.log(`[ConnectionModeService] - No proxy environment variables detected`); + } - const response = await fetch(healthUrl, { - method: 'GET', - connectTimeout: 10000, - }); + // Check if running in Tauri (v2 uses different detection) + console.log(`[ConnectionModeService] - Checking Tauri context...`); + console.log(`[ConnectionModeService] - window.__TAURI__ type: ${typeof (window as any).__TAURI__}`); + console.log(`[ConnectionModeService] - window.__TAURI_INTERNALS__ type: ${typeof (window as any).__TAURI_INTERNALS__}`); + console.log(`[ConnectionModeService] - window.location.href:`, window.location.href); + console.log(`[ConnectionModeService] - window.location.protocol:`, window.location.protocol); + + // Tauri v2 detection: check for __TAURI_INTERNALS__ or tauri:// protocol + const isTauriV2 = typeof (window as any).__TAURI_INTERNALS__ !== 'undefined' || + window.location.protocol === 'tauri:' || + window.location.hostname === 'tauri.localhost'; + const isTauriV1 = typeof (window as any).__TAURI__ !== 'undefined'; + const isTauri = isTauriV1 || isTauriV2; + + console.log(`[ConnectionModeService] - Running in Tauri v1: ${isTauriV1}`); + console.log(`[ConnectionModeService] - Running in Tauri v2: ${isTauriV2}`); + console.log(`[ConnectionModeService] - Running in Tauri: ${isTauri}`); + + if (isTauri) { + if (isTauriV1) { + const tauriApi = (window as any).__TAURI__; + console.log(`[ConnectionModeService] - Tauri v1 API:`, tauriApi); + } + if (isTauriV2) { + console.log(`[ConnectionModeService] - Tauri v2 detected via internals/protocol`); + const internals = (window as any).__TAURI_INTERNALS__; + console.log(`[ConnectionModeService] - Tauri internals:`, internals); + } + } + + const diagnostics: DiagnosticResult[] = []; + const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`; + const isLocal = this.isLocalAddress(url); + const isHttpUrl = url.startsWith('http://'); + const isHttpsUrl = url.startsWith('https://'); + + console.log(`[ConnectionModeService] Connection Parameters:`); + console.log(`[ConnectionModeService] - Target URL: ${url}`); + console.log(`[ConnectionModeService] - Health endpoint: ${healthUrl}`); + console.log(`[ConnectionModeService] - Is local address: ${isLocal}`); + console.log(`[ConnectionModeService] - Protocol: ${isHttpUrl ? 'HTTP' : isHttpsUrl ? 'HTTPS' : 'Unknown'}`); + console.log(`[ConnectionModeService] ================================================================`); + + // STAGE 1: Test the protocol they specified + if (isHttpUrl) { + console.log(`[ConnectionModeService] Stage 1: Testing HTTP (as specified in URL)`); + const stage1Result = await this.testHTTP(healthUrl, 'Stage 1: HTTP (as specified)'); + diagnostics.push(stage1Result); + + if (stage1Result.success) { + console.log(`[ConnectionModeService] ✅ Connection successful with HTTP`); + + // Log success summary + console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`); + console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL`); + console.log(`[ConnectionModeService] Protocol: HTTP (as requested by user)`); + console.log(`[ConnectionModeService] Duration: ${stage1Result.duration}ms`); + console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`); - if (response.ok) { - console.log(`[ConnectionModeService] ✅ Server connection test successful`); - return { success: true }; - } else { - const errorMsg = `Server returned status ${response.status}`; - console.error(`[ConnectionModeService] ❌ ${errorMsg}`); return { - success: false, - error: errorMsg, - errorCode: `HTTP_${response.status}`, + success: true, + diagnostics, }; } - } catch (error) { - console.error('[ConnectionModeService] ❌ Server connection test failed:', error); - // Extract detailed error information - if (error instanceof Error) { - const errMsg = error.message.toLowerCase(); + // HTTP failed, try HTTPS as fallback + console.log(`[ConnectionModeService] Stage 2: HTTP failed, trying HTTPS`); + const httpsUrl = healthUrl.replace('http://', 'https://'); + const stage2Result = await this.testHTTPS(httpsUrl, 'Stage 2: Trying HTTPS', false); + diagnostics.push(stage2Result); - // Connection refused - if (errMsg.includes('connection refused') || errMsg.includes('econnrefused')) { - return { - success: false, - error: 'Connection refused. Server may not be running or the port is incorrect.', - errorCode: 'CONNECTION_REFUSED', - }; - } - // Timeout - else if (errMsg.includes('timeout') || errMsg.includes('timed out')) { - return { - success: false, - error: 'Connection timed out. Server is not responding within 10 seconds.', - errorCode: 'TIMEOUT', - }; - } - // DNS failure - else if (errMsg.includes('getaddrinfo') || errMsg.includes('dns') || errMsg.includes('not found') || errMsg.includes('enotfound')) { - return { - success: false, - error: 'Cannot resolve server address. Please check the URL is correct.', - errorCode: 'DNS_FAILURE', - }; - } - // SSL/TLS errors - else if (errMsg.includes('ssl') || errMsg.includes('tls') || errMsg.includes('certificate') || errMsg.includes('cert')) { - return { - success: false, - error: 'SSL/TLS certificate error. Server may have an invalid or self-signed certificate.', - errorCode: 'SSL_ERROR', - }; - } - // Protocol errors - else if (errMsg.includes('protocol')) { - return { - success: false, - error: 'Protocol error. Try using https:// instead of http:// or vice versa.', - errorCode: 'PROTOCOL_ERROR', - }; - } - // Generic error + if (stage2Result.success) { return { success: false, - error: error.message, - errorCode: 'NETWORK_ERROR', + error: 'Server is only accessible via HTTPS, not HTTP.', + errorCode: 'HTTP_NOT_AVAILABLE', + diagnostics, + }; + } + + // Both failed, continue with more diagnostics below + } else { + // HTTPS URL or no protocol - test HTTPS + console.log(`[ConnectionModeService] Stage 1: Testing HTTPS with full certificate validation`); + const stage1Result = await this.testHTTPS(healthUrl, 'Stage 1: Standard HTTPS', false); + diagnostics.push(stage1Result); + + if (stage1Result.success) { + console.log(`[ConnectionModeService] ✅ Connection successful with standard HTTPS`); + + // Log success summary + console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`); + console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL`); + console.log(`[ConnectionModeService] Protocol: HTTPS with valid certificate`); + console.log(`[ConnectionModeService] Duration: ${stage1Result.duration}ms`); + console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`); + + return { success: true, diagnostics }; + } + + // STAGE 2: Test with certificate validation disabled (diagnose cert issues) + console.log(`[ConnectionModeService] Stage 2: Testing HTTPS with certificate validation disabled`); + const stage2Result = await this.testHTTPS(healthUrl, 'Stage 2: HTTPS (no cert validation)', true); + diagnostics.push(stage2Result); + + if (stage2Result.success) { + console.log(`[ConnectionModeService] ⚠️ Certificate issue detected - works without validation`); + return { + success: false, + error: 'SSL certificate validation failed. The server has an invalid, self-signed, or untrusted certificate.', + errorCode: 'SSL_CERTIFICATE_INVALID', + diagnostics, + }; + } + + // STAGE 3: Try HTTP instead (for local/internal servers) + console.log(`[ConnectionModeService] Stage 3: Testing HTTP instead of HTTPS`); + const httpUrl = healthUrl.replace('https://', 'http://'); + const stage3Result = await this.testHTTP(httpUrl, 'Stage 3: HTTP (unencrypted)'); + diagnostics.push(stage3Result); + + if (stage3Result.success) { + console.log(`[ConnectionModeService] ⚠️ HTTP works but HTTPS doesn't`); + return { + success: false, + error: 'Server is only accessible via HTTP (not HTTPS).', + errorCode: 'HTTPS_NOT_AVAILABLE', + diagnostics, + }; + } + } + + // STAGE 4: Test with longer timeout (diagnose slow connections) + console.log(`[ConnectionModeService] Stage 4: Testing with extended timeout (30s)`); + const stage4Result = await this.testWithLongTimeout(healthUrl); + diagnostics.push(stage4Result); + + if (stage4Result.success) { + console.log(`[ConnectionModeService] ⚠️ Connection slow but eventually successful`); + return { + success: true, + diagnostics, + }; + } + + // STAGE 5A: Test external connectivity with standard endpoint + console.log(`[ConnectionModeService] Stage 5A: Testing external connectivity (google.com)`); + const stage5aResult = await this.testStage5_ExternalConnectivity(); + diagnostics.push(stage5aResult); + + // STAGE 5B: Test with alternative endpoint (in case google is blocked) + console.log(`[ConnectionModeService] Stage 5B: Testing alternative endpoint (cloudflare.com)`); + const stage5bResult = await this.testAlternativeEndpoint(); + diagnostics.push(stage5bResult); + + // STAGE 5C: Test with HTTP vs HTTPS for external endpoint + console.log(`[ConnectionModeService] Stage 5C: Testing HTTP external endpoint`); + const stage5cResult = await this.testHTTPExternal(); + diagnostics.push(stage5cResult); + + if (!stage5aResult.success && !stage5bResult.success && !stage5cResult.success) { + console.log(`[ConnectionModeService] ❌ No external connectivity - network/firewall issue`); + return { + success: false, + error: 'No internet connectivity detected. All network requests are failing.', + errorCode: 'NETWORK_BLOCKED', + diagnostics, + }; + } + + // If some external endpoints work but not the target, it's more specific + if (stage5aResult.success || stage5bResult.success || stage5cResult.success) { + console.log(`[ConnectionModeService] ✅ External connectivity confirmed - issue is specific to target server`); + } + + // STAGE 6: Test DNS resolution for the target server + console.log(`[ConnectionModeService] Stage 6: Testing DNS resolution for target server`); + const urlObj = new URL(url); + const stage6Result = await this.testStage6_DNSResolution(urlObj.hostname); + diagnostics.push(stage6Result); + + if (!stage6Result.success && stage6Result.message.includes('DNS lookup failed')) { + console.log(`[ConnectionModeService] ❌ DNS resolution failed for target server`); + return { + success: false, + error: `Cannot resolve hostname: ${urlObj.hostname}`, + errorCode: 'DNS_RESOLUTION_FAILED', + diagnostics, + }; + } + + // STAGE 7: Try different HTTP method (HEAD instead of GET) + console.log(`[ConnectionModeService] Stage 7: Testing with HEAD method`); + const stage7Result = await this.testWithHEADMethod(healthUrl); + diagnostics.push(stage7Result); + + if (stage7Result.success) { + console.log(`[ConnectionModeService] ⚠️ HEAD method works but GET doesn't - unusual server behavior`); + return { + success: false, + error: 'Server responds to HEAD requests but not GET requests.', + errorCode: 'METHOD_MISMATCH', + diagnostics, + }; + } + + // STAGE 8: Try with modified User-Agent + console.log(`[ConnectionModeService] Stage 8: Testing with browser User-Agent`); + const stage8Result = await this.testWithBrowserUserAgent(healthUrl); + diagnostics.push(stage8Result); + + if (stage8Result.success) { + console.log(`[ConnectionModeService] ⚠️ Works with browser User-Agent - server may be blocking desktop apps`); + return { + success: false, + error: 'Server blocks Tauri/desktop app User-Agent but allows browser User-Agent.', + errorCode: 'USER_AGENT_BLOCKED', + diagnostics, + }; + } + + // STAGE 9: Final analysis - server-specific issue + console.log(`[ConnectionModeService] ❌ Server unreachable - all diagnostic tests failed`); + + // Analyze timing patterns + const avgDuration = diagnostics + .filter(d => !d.success && d.duration) + .reduce((sum, d) => sum + (d.duration || 0), 0) / + diagnostics.filter(d => !d.success && d.duration).length; + + // Log comprehensive diagnostic summary + console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`); + console.log(`[ConnectionModeService] Total tests run: ${diagnostics.length}`); + console.log(`[ConnectionModeService] Passed: ${diagnostics.filter(d => d.success).length}`); + console.log(`[ConnectionModeService] Failed: ${diagnostics.filter(d => !d.success).length}`); + console.log(`[ConnectionModeService] Average failure time: ${avgDuration.toFixed(0)}ms`); + console.log(`[ConnectionModeService] ---------------------------------------------------------------`); + diagnostics.forEach((diag) => { + const icon = diag.success ? '✅' : '❌'; + console.log(`[ConnectionModeService] ${icon} ${diag.stage}: ${diag.message} (${diag.duration}ms)`); + }); + console.log(`[ConnectionModeService] ================================================================`); + console.log(`[ConnectionModeService] Error Code: SERVER_UNREACHABLE`); + + // Log timing-based analysis + if (avgDuration < 100) { + console.log(`[ConnectionModeService] Analysis: Immediate rejections (<${avgDuration.toFixed(0)}ms) suggest firewall/antivirus blocking`); + } else if (avgDuration > 5000) { + console.log(`[ConnectionModeService] Analysis: Timeouts (avg ${(avgDuration/1000).toFixed(1)}s) suggest server not responding or network route blocked`); + } else { + console.log(`[ConnectionModeService] Analysis: Server may be down, blocking connections, or behind a firewall`); + } + + console.log(`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`); + + return { + success: false, + error: 'Cannot connect to server. Internet works but this specific server is unreachable.', + errorCode: 'SERVER_UNREACHABLE', + diagnostics, + }; + } + + private isLocalAddress(url: string): boolean { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname.toLowerCase(); + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + hostname.startsWith('172.16.') || + hostname.endsWith('.local') + ); + } catch { + return false; + } + } + + private async testHTTPS(url: string, stageName: string, disableCertValidation: boolean): Promise { + const startTime = Date.now(); + try { + console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`); + console.log(`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? 'DISABLED' : 'ENABLED'}`); + + const fetchOptions: any = { + method: 'GET', + connectTimeout: 10000, + }; + + if (disableCertValidation) { + fetchOptions.danger = { + acceptInvalidCerts: true, + acceptInvalidHostnames: true, + }; + } + + console.log(`[ConnectionModeService] - Fetch options:`, JSON.stringify(fetchOptions)); + const response = await fetch(url, fetchOptions); + const duration = Date.now() - startTime; + + console.log(`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`); + + if (response.ok) { + return { + stage: stageName, + success: true, + message: disableCertValidation + ? 'Connected successfully when certificate validation disabled' + : 'Successfully connected with full certificate validation', + duration, }; } return { + stage: stageName, success: false, - error: 'Unknown error occurred while testing connection', - errorCode: 'UNKNOWN', + message: `Server returned HTTP ${response.status}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + + // Enhanced error logging + console.error(`[ConnectionModeService] ❌ ${stageName}: Request failed (${duration}ms)`); + console.error(`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`); + console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`); + + // Log full error object structure for debugging + if (error && typeof error === 'object') { + console.error(`[ConnectionModeService] - Error keys:`, Object.keys(error)); + console.error(`[ConnectionModeService] - Error object:`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2)); + } + + // Categorize error type + const errorMsg = error instanceof Error ? error.message : String(error); + const errorLower = errorMsg.toLowerCase(); + + let detailedMessage = `Failed: ${errorMsg}`; + + if (errorLower.includes('timeout') || errorLower.includes('timed out')) { + detailedMessage = `Timeout after ${duration}ms - server not responding`; + } else if (errorLower.includes('certificate') || errorLower.includes('cert') || errorLower.includes('ssl') || errorLower.includes('tls')) { + detailedMessage = `SSL/TLS error - ${errorMsg}`; + } else if (errorLower.includes('connection refused') || errorLower.includes('econnrefused')) { + detailedMessage = `Connection refused - server may not be running`; + } else if (errorLower.includes('network') || errorLower.includes('dns') || errorLower.includes('enotfound')) { + detailedMessage = `Network error - ${errorMsg}`; + } else if (errorLower.includes('blocked') || errorLower.includes('filtered')) { + detailedMessage = `Request blocked - possible firewall/antivirus`; + } else if (duration < 100) { + detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`; + } + + console.error(`[ConnectionModeService] - Categorized as: ${detailedMessage}`); + + return { + stage: stageName, + success: false, + message: detailedMessage, + duration, }; } } + private async testHTTP(url: string, stageName: string): Promise { + const startTime = Date.now(); + try { + console.log(`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`); + + const response = await fetch(url, { + method: 'GET', + connectTimeout: 10000, + }); + const duration = Date.now() - startTime; + + console.log(`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`); + + if (response.ok) { + return { + stage: stageName, + success: true, + message: 'Successfully connected using HTTP', + duration, + }; + } + + return { + stage: stageName, + success: false, + message: `Server returned HTTP ${response.status}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + + // Enhanced error logging + console.error(`[ConnectionModeService] ❌ ${stageName}: Request failed (${duration}ms)`); + console.error(`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`); + console.error(`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`); + + if (error && typeof error === 'object') { + console.error(`[ConnectionModeService] - Error object:`, JSON.stringify(error, Object.getOwnPropertyNames(error), 2)); + } + + const errorMsg = error instanceof Error ? error.message : String(error); + const errorLower = errorMsg.toLowerCase(); + + let detailedMessage = `Failed: ${errorMsg}`; + + if (errorLower.includes('timeout') || errorLower.includes('timed out')) { + detailedMessage = `Timeout after ${duration}ms - server not responding`; + } else if (duration < 100) { + detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`; + } + + console.error(`[ConnectionModeService] - Categorized as: ${detailedMessage}`); + + return { + stage: stageName, + success: false, + message: detailedMessage, + duration, + }; + } + } + + private async testWithLongTimeout(url: string): Promise { + const startTime = Date.now(); + try { + const response = await fetch(url, { + method: 'GET', + connectTimeout: 30000, // 30 seconds + }); + const duration = Date.now() - startTime; + + if (response.ok) { + return { + stage: 'Stage 4: Extended timeout (30s)', + success: true, + message: `Connected after ${duration}ms (slow connection)`, + duration, + }; + } + + return { + stage: 'Stage 4: Extended timeout (30s)', + success: false, + message: `Server returned HTTP ${response.status}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + return { + stage: 'Stage 4: Extended timeout (30s)', + success: false, + message: `Failed: ${errorMsg}`, + duration, + }; + } + } + + private async testStage5_ExternalConnectivity(): Promise { + const startTime = Date.now(); + try { + console.log(`[ConnectionModeService] 🌐 Stage 5A: Testing external connectivity (google.com)`); + + // Test connectivity to a reliable external service + const response = await fetch('https://www.google.com', { + method: 'HEAD', + connectTimeout: 5000, + }); + const duration = Date.now() - startTime; + + console.log(`[ConnectionModeService] ✅ Stage 5A: External connectivity confirmed - HTTP ${response.status} (${duration}ms)`); + + if (response.ok || response.status === 301 || response.status === 302) { + return { + stage: 'Stage 5A: External (google.com)', + success: true, + message: 'Internet connectivity confirmed via google.com', + duration, + }; + } + + return { + stage: 'Stage 5A: External (google.com)', + success: false, + message: `Unexpected response from google.com: ${response.status}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + console.error(`[ConnectionModeService] ❌ Stage 5A: External connectivity test failed (${duration}ms)`); + console.error(`[ConnectionModeService] - Error:`, error); + + const errorMsg = error instanceof Error ? error.message : String(error); + return { + stage: 'Stage 5A: External (google.com)', + success: false, + message: `Failed: ${errorMsg}`, + duration, + }; + } + } + + private async testAlternativeEndpoint(): Promise { + const startTime = Date.now(); + try { + console.log(`[ConnectionModeService] 🌐 Stage 5B: Testing alternative endpoint (cloudflare.com)`); + + const response = await fetch('https://1.1.1.1', { + method: 'HEAD', + connectTimeout: 5000, + }); + const duration = Date.now() - startTime; + + console.log(`[ConnectionModeService] ✅ Stage 5B: Alternative endpoint success - HTTP ${response.status} (${duration}ms)`); + + if (response.ok || response.status === 301 || response.status === 302 || response.status === 403) { + return { + stage: 'Stage 5B: External (cloudflare)', + success: true, + message: 'Alternative endpoint (1.1.1.1) reachable', + duration, + }; + } + + return { + stage: 'Stage 5B: External (cloudflare)', + success: false, + message: `Unexpected response: ${response.status}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + console.error(`[ConnectionModeService] ❌ Stage 5B: Alternative endpoint failed (${duration}ms)`); + console.error(`[ConnectionModeService] - Error:`, error); + + const errorMsg = error instanceof Error ? error.message : String(error); + return { + stage: 'Stage 5B: External (cloudflare)', + success: false, + message: `Failed: ${errorMsg}`, + duration, + }; + } + } + + private async testHTTPExternal(): Promise { + const startTime = Date.now(); + try { + console.log(`[ConnectionModeService] 🌐 Stage 5C: Testing HTTP external endpoint (httpbin.org)`); + + // Try HTTP (not HTTPS) to see if TLS/SSL is the issue + const response = await fetch('http://httpbin.org/status/200', { + method: 'GET', + connectTimeout: 5000, + }); + const duration = Date.now() - startTime; + + console.log(`[ConnectionModeService] ✅ Stage 5C: HTTP endpoint success - HTTP ${response.status} (${duration}ms)`); + + if (response.ok) { + return { + stage: 'Stage 5C: External HTTP (no TLS)', + success: true, + message: 'HTTP (unencrypted) connectivity works', + duration, + }; + } + + return { + stage: 'Stage 5C: External HTTP (no TLS)', + success: false, + message: `Unexpected response: ${response.status}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + console.error(`[ConnectionModeService] ❌ Stage 5C: HTTP external failed (${duration}ms)`); + console.error(`[ConnectionModeService] - Error:`, error); + + const errorMsg = error instanceof Error ? error.message : String(error); + return { + stage: 'Stage 5C: External HTTP (no TLS)', + success: false, + message: `Failed: ${errorMsg}`, + duration, + }; + } + } + + private async testStage6_DNSResolution(hostname: string): Promise { + const startTime = Date.now(); + try { + console.log(`[ConnectionModeService] 🔍 Stage 6: Testing DNS resolution for ${hostname}`); + + // Try to resolve DNS by making a HEAD request to the base domain + // If DNS fails, we'll get an immediate error + const testUrl = `https://${hostname}`; + await fetch(testUrl, { + method: 'HEAD', + connectTimeout: 3000, + }); + const duration = Date.now() - startTime; + + console.log(`[ConnectionModeService] ✅ Stage 6: DNS resolved successfully (${duration}ms)`); + + return { + stage: 'Stage 6: DNS resolution', + success: true, + message: `DNS resolution successful for ${hostname}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + const errorMsg = error instanceof Error ? error.message : String(error); + const errorLower = errorMsg.toLowerCase(); + + console.error(`[ConnectionModeService] ❌ Stage 6: DNS test failed (${duration}ms)`); + console.error(`[ConnectionModeService] - Error:`, errorMsg); + + // Check if it's a DNS-specific error + if (errorLower.includes('dns') || errorLower.includes('enotfound') || errorLower.includes('getaddrinfo')) { + return { + stage: 'Stage 6: DNS resolution', + success: false, + message: `DNS lookup failed - cannot resolve ${hostname}`, + duration, + }; + } + + // If we got here, DNS might be working but connection failed for other reasons + return { + stage: 'Stage 6: DNS resolution', + success: false, + message: `DNS test inconclusive: ${errorMsg}`, + duration, + }; + } + } + + private async testWithHEADMethod(url: string): Promise { + const startTime = Date.now(); + try { + console.log(`[ConnectionModeService] 🔗 Stage 7: Testing with HEAD method`); + + const response = await fetch(url, { + method: 'HEAD', + connectTimeout: 10000, + }); + const duration = Date.now() - startTime; + + console.log(`[ConnectionModeService] ✅ Stage 7: HEAD method success - HTTP ${response.status} (${duration}ms)`); + + if (response.ok) { + return { + stage: 'Stage 7: HEAD method', + success: true, + message: 'HEAD method works (GET does not)', + duration, + }; + } + + return { + stage: 'Stage 7: HEAD method', + success: false, + message: `HEAD method returned ${response.status}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + console.error(`[ConnectionModeService] ❌ Stage 7: HEAD method failed (${duration}ms)`); + + const errorMsg = error instanceof Error ? error.message : String(error); + return { + stage: 'Stage 7: HEAD method', + success: false, + message: `Failed: ${errorMsg}`, + duration, + }; + } + } + + private async testWithBrowserUserAgent(url: string): Promise { + const startTime = Date.now(); + try { + console.log(`[ConnectionModeService] 🔗 Stage 8: Testing with browser User-Agent`); + + // Try with a standard browser User-Agent instead of Tauri's default + const response = await fetch(url, { + method: 'GET', + connectTimeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + }, + }); + const duration = Date.now() - startTime; + + console.log(`[ConnectionModeService] ✅ Stage 8: Browser User-Agent success - HTTP ${response.status} (${duration}ms)`); + + if (response.ok) { + return { + stage: 'Stage 8: Browser User-Agent', + success: true, + message: 'Works with browser User-Agent (blocked with desktop UA)', + duration, + }; + } + + return { + stage: 'Stage 8: Browser User-Agent', + success: false, + message: `Browser UA returned ${response.status}`, + duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + console.error(`[ConnectionModeService] ❌ Stage 8: Browser User-Agent failed (${duration}ms)`); + + const errorMsg = error instanceof Error ? error.message : String(error); + return { + stage: 'Stage 8: Browser User-Agent', + success: false, + message: `Failed: ${errorMsg}`, + duration, + }; + } + } + + async isFirstLaunch(): Promise { try { const result = await invoke('is_first_launch'); diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index bb1199f4f..85a29c8f1 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -1,13 +1,14 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor } from '@mantine/core'; +import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor, Select, Collapse } from '@mantine/core'; import { alert } from '@app/components/toast'; import LocalIcon from '@app/components/shared/LocalIcon'; import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex'; import ProviderCard from '@app/components/shared/config/configSections/ProviderCard'; import { ALL_PROVIDERS, @@ -46,6 +47,10 @@ interface ConnectionsSettingsData { }; ssoAutoLogin?: boolean; enableMobileScanner?: boolean; + mobileScannerConvertToPdf?: boolean; + mobileScannerImageResolution?: string; + mobileScannerPageFormat?: string; + mobileScannerStretchToFit?: boolean; } export default function AdminConnectionsSection() { @@ -78,7 +83,11 @@ export default function AdminConnectionsSection() { saml2: securityData.saml2 || {}, mail: mailData || {}, ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false, - enableMobileScanner: systemData.enableMobileScanner || false + enableMobileScanner: systemData.enableMobileScanner || false, + mobileScannerConvertToPdf: systemData.mobileScannerSettings?.convertToPdf !== false, + mobileScannerImageResolution: systemData.mobileScannerSettings?.imageResolution || 'full', + mobileScannerPageFormat: systemData.mobileScannerSettings?.pageFormat || 'A4', + mobileScannerStretchToFit: systemData.mobileScannerSettings?.stretchToFit || false }; // Merge pending blocks from all four endpoints @@ -98,6 +107,18 @@ export default function AdminConnectionsSection() { if (systemData._pending?.enableMobileScanner !== undefined) { pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner; } + if (systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined) { + pendingBlock.mobileScannerConvertToPdf = systemData._pending.mobileScannerSettings.convertToPdf; + } + if (systemData._pending?.mobileScannerSettings?.imageResolution !== undefined) { + pendingBlock.mobileScannerImageResolution = systemData._pending.mobileScannerSettings.imageResolution; + } + if (systemData._pending?.mobileScannerSettings?.pageFormat !== undefined) { + pendingBlock.mobileScannerPageFormat = systemData._pending.mobileScannerSettings.pageFormat; + } + if (systemData._pending?.mobileScannerSettings?.stretchToFit !== undefined) { + pendingBlock.mobileScannerStretchToFit = systemData._pending.mobileScannerSettings.stretchToFit; + } if (Object.keys(pendingBlock).length > 0) { result._pending = pendingBlock; @@ -356,6 +377,35 @@ export default function AdminConnectionsSection() { const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); + if (response.status === 200) { + alert({ + alertType: 'success', + title: t('admin.settings.success', 'Settings saved successfully') + }); + fetchSettings(); + } + } catch (error) { + console.error('Failed to save mobile scanner setting:', error); + alert({ + alertType: 'error', + title: t('admin.settings.error', 'Failed to save settings') + }); + } + }; + + const handleMobileScannerSettingsSave = async (settingKey: string, newValue: string | boolean) => { + // Block save if login is disabled or mobile scanner is not enabled + if (!validateLoginEnabled() || !settings?.enableMobileScanner) { + return; + } + + try { + const deltaSettings = { + [`system.mobileScannerSettings.${settingKey}`]: newValue + }; + + const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); + if (response.status === 200) { alert({ alertType: 'success', @@ -471,6 +521,119 @@ export default function AdminConnectionsSection() { + + {/* Mobile Scanner Settings - Only show when enabled */} + + + {/* Convert to PDF */} +
+ + {t('admin.settings.connections.mobileScannerConvertToPdf', 'Convert Images to PDF')} + + + {t('admin.settings.connections.mobileScannerConvertToPdfDesc', 'Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is.')} + + + { + if (!loginEnabled) return; + const newValue = e.target.checked; + setSettings({ ...settings, mobileScannerConvertToPdf: newValue }); + handleMobileScannerSettingsSave('convertToPdf', newValue); + }} + disabled={!loginEnabled} + /> + + +
+ + {/* PDF Conversion Settings - Only show when convertToPdf is enabled */} + {settings?.mobileScannerConvertToPdf !== false && ( + <> + {/* Image Resolution */} +
+ + {t('admin.settings.connections.mobileScannerImageResolution', 'Image Resolution')} + + + {t('admin.settings.connections.mobileScannerImageResolutionDesc', 'Resolution of uploaded images. "Reduced" scales images to max 1200px to reduce file size.')} + + + { + if (!loginEnabled) return; + setSettings({ ...settings, mobileScannerPageFormat: value || 'A4' }); + handleMobileScannerSettingsSave('pageFormat', value || 'A4'); + }} + data={[ + { value: 'keep', label: t('admin.settings.connections.pageFormatKeep', 'Keep (Original Dimensions)') }, + { value: 'A4', label: t('admin.settings.connections.pageFormatA4', 'A4 (210×297mm)') }, + { value: 'letter', label: t('admin.settings.connections.pageFormatLetter', 'Letter (8.5×11in)') } + ]} + disabled={!loginEnabled} + style={{ width: '250px' }} + comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }} + /> + + +
+ + {/* Stretch to Fit */} +
+ + {t('admin.settings.connections.mobileScannerStretchToFit', 'Stretch to Fit')} + + + {t('admin.settings.connections.mobileScannerStretchToFitDesc', 'Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio.')} + + + { + if (!loginEnabled) return; + const newValue = e.target.checked; + setSettings({ ...settings, mobileScannerStretchToFit: newValue }); + handleMobileScannerSettingsSave('stretchToFit', newValue); + }} + disabled={!loginEnabled} + /> + + +
+ + )} +
+
diff --git a/frontend/src/proprietary/routes/authShared/auth.css b/frontend/src/proprietary/routes/authShared/auth.css index 05cb815f6..37864e5b7 100644 --- a/frontend/src/proprietary/routes/authShared/auth.css +++ b/frontend/src/proprietary/routes/authShared/auth.css @@ -213,6 +213,23 @@ outline-offset: 2px; } +/* Fix Mantine Button internal spans to not crop content */ +.oauth-button-vertical .mantine-Button-inner { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; +} + +.oauth-button-vertical .mantine-Button-label { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.75rem; + overflow: visible; +} + .oauth-icon-small { width: 1.75rem; /* 28px */ height: 1.75rem; /* 28px */ diff --git a/scripts/init.sh b/scripts/init.sh index 80ed42015..bc5a4208a 100644 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -68,36 +68,28 @@ fi # # === tessdata === # # Prepare Tesseract OCR data directory. -REAL_TESSDATA="/usr/share/tesseract-ocr/5/tessdata" -SEC_TESSDATA="/usr/share/tessdata" +# In Debian, tesseract looks in /usr/share/tesseract-ocr/5/tessdata +# For backwards compatibility, copy any user-mounted files from /usr/share/tessdata +TESSDATA_SYSTEM="/usr/share/tesseract-ocr/5/tessdata" +TESSDATA_MOUNT="/usr/share/tessdata" log_warn() { echo "[init][warn] $*" >&2 } -if [ -d "$REAL_TESSDATA" ] && [ -w "$REAL_TESSDATA" ]; then - log_warn "Skipping tessdata adjustments; directory writable: $REAL_TESSDATA" -else - log_warn "Skipping tessdata adjustments; directory missing or not writable: $REAL_TESSDATA" +# Ensure system tessdata directory exists +mkdir -p "$TESSDATA_SYSTEM" 2>/dev/null || true + +# For backwards compatibility: if user mounted custom languages to /usr/share/tessdata, +# copy them to the system location where Tesseract actually looks +if [ -d "$TESSDATA_MOUNT" ] && [ "$(ls -A "$TESSDATA_MOUNT" 2>/dev/null)" ]; then + log_warn "Found user-mounted tessdata in $TESSDATA_MOUNT, copying to system location $TESSDATA_SYSTEM" + cp -rn "$TESSDATA_MOUNT"/* "$TESSDATA_SYSTEM"/ 2>/dev/null || true fi -if [ -d /usr/share/tesseract-ocr/5/tessdata ]; then - REAL_TESSDATA="/usr/share/tesseract-ocr/5/tessdata" - log_warn "Using /usr/share/tesseract-ocr/5/tessdata as TESSDATA_PREFIX" -elif [ -d /usr/share/tessdata ]; then - REAL_TESSDATA="/usr/share/tessdata" - log_warn "Using /usr/share/tessdata as TESSDATA_PREFIX" -elif [ -d /tessdata ]; then - REAL_TESSDATA="/tessdata" - log_warn "Using /tessdata as TESSDATA_PREFIX" -else - REAL_TESSDATA="" - log_warn "No tessdata directory found" -fi - -if [ -n "$REAL_TESSDATA" ]; then - export TESSDATA_PREFIX="$REAL_TESSDATA" -fi +# Set TESSDATA_PREFIX to system location +export TESSDATA_PREFIX="$TESSDATA_SYSTEM" +log_warn "Using TESSDATA_PREFIX=$TESSDATA_PREFIX" # === Temp dir === # Ensure the temporary directory exists and has proper permissions.