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.