From d6aecca55a6dd665a71f4abe6c49a62bd760968f Mon Sep 17 00:00:00 2001 From: flacoonb Date: Tue, 17 Mar 2026 23:00:29 +0000 Subject: [PATCH 1/3] feat(ftp): implement asynchronous FTP/SFTP upload queue with improved remote gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a fully asynchronous upload pipeline for FTP/SFTP remote storage, decoupling image upload from the photo capture workflow so the Photobooth UI is never blocked while files are transferred. Core architecture ----------------- - New SQLite-backed UploadQueueService (src/Service/UploadQueueService.php) manages a persistent job queue (pending → in_progress → completed/failed). Each job stores a random 32-hex-character remote filename so local and remote filenames are never the same, preventing enumeration. - New EncryptionService (src/Service/EncryptionService.php) encrypts the FTP password at rest using libsodium secretbox; key is stored in var/run/. - New Symfony Console command UploadWorkerCommand (src/Command/UploadWorkerCommand.php) runs as a long-lived background worker (or once with --once). Picks up jobs, calls RemoteStorageService, retries up to 5 times on failure, and reloads config on every run so admin panel changes are picked up without restarting. - New api/ftpListFolders.php lets the admin panel browse remote directories before saving configuration. - resources/config/photobooth-upload-worker.service: systemd unit for running the worker as www-data. API / backend changes --------------------- - api/applyEffects.php: instead of uploading directly, enqueues a job via UploadQueueService and triggers the worker process in the background. - api/deletePhoto.php: resolves the remote random filename via UploadQueueService::getRemoteFilename() before deleting on FTP, so the correct remote file is removed. - api/qrcode.php, api/print.php: QR URLs now use UploadQueueService to look up the remote filename when FTP + useForQr is enabled. - api/admin.php: exposes upload queue status (pending/failed counts) for the debug panel. - api/serverInfo.php: includes FTP queue stats in server-info response. - RemoteStorageService::getWebpageUri() correctly appends baseFolder to the website URL. - RemoteStorageService::createWebpage() removes legacy .htaccess files (caused 403 on hosts without AllowOverride Options) and instead uploads index.php redirect guards to images/ and thumbs/. - ConfigurationService: decrypts FTP password on load via EncryptionService. - FtpConfiguration: password field marked as sensitive. - src/Console/Application.php: registers UploadWorkerCommand. Admin UI -------- - lib/configsetup.inc.php: removed deprecated folder/urlTemplate fields; added folder browser (ftpListFolders API), queue status display, and "Test Connection" improvements. - assets/js/admin/buttons.js: folder-browser modal, queue status polling, FTP test-connection wiring. - resources/lang/en.json: removed ftp:folder / ftp:urlTemplate keys; updated manual:ftp:website description. Remote gallery (resources/template/index.php) --------------------------------------------- - Single-image view: image is rendered immediately with a JS onload/onerror spinner; onerror auto-reloads every 3 s while upload is in progress. Removed the PHP-side file_exists() upload-wait loop. - Gallery mode: fixed guard so gallery is only rendered when gallery_enabled=true (was falling through to else). - Images sorted newest-first via usort + filemtime. - ZIP download: fixed corrupt archive by using ZipArchive::CREATE | ZipArchive::OVERWRITE (tempnam creates an existing file). - Share button: uses Web Share API with native file sharing where supported; falls back to wa.me deep-link. Icon changed from fa-brands fa-whatsapp to fa-solid fa-share-nodes. - Lightbox navigation: prev/next arrow buttons with wrap-around and keyboard support (ArrowLeft / ArrowRight / Escape). Code style ---------- - PHP CS Fixer: expanded single-line closures in Configuration/Section/* to multi-line form for consistency with project style rules. --- admin/debug/index.php | 1 + api/admin.php | 18 ++ api/applyEffects.php | 14 +- api/deletePhoto.php | 10 +- api/ftpListFolders.php | 44 +++ api/print.php | 8 +- api/qrcode.php | 12 +- api/serverInfo.php | 2 + assets/js/admin/buttons.js | 223 +++++++++++++- assets/js/debugpanel.js | 8 +- lib/configsetup.inc.php | 22 +- .../config/photobooth-upload-worker.service | 18 ++ resources/lang/en.json | 16 +- resources/template/index.php | 286 +++++++++++++----- src/Command/UploadWorkerCommand.php | 126 ++++++++ .../Section/FtpConfiguration.php | 2 - src/Console/Application.php | 1 + src/Service/ConfigurationService.php | 15 + src/Service/EncryptionService.php | 87 ++++++ src/Service/RemoteStorageService.php | 153 ++++++++-- src/Service/UploadQueueService.php | 218 +++++++++++++ view.php | 4 +- 22 files changed, 1131 insertions(+), 157 deletions(-) create mode 100644 api/ftpListFolders.php create mode 100644 resources/config/photobooth-upload-worker.service create mode 100644 src/Command/UploadWorkerCommand.php create mode 100644 src/Service/EncryptionService.php create mode 100644 src/Service/UploadQueueService.php diff --git a/admin/debug/index.php b/admin/debug/index.php index 71005ce31..f500edf55 100644 --- a/admin/debug/index.php +++ b/admin/debug/index.php @@ -51,6 +51,7 @@ echo getNavItemDebug('remotebuzzerlog'); echo getNavItemDebug('synctodrivelog'); echo getNavItemDebug('remotestoragelog'); +echo getNavItemDebug('uploadworkerlog'); echo getNavItemDebug('rembglog'); echo getNavItemDebug('devlog'); if (Environment::isLinux()) { diff --git a/api/admin.php b/api/admin.php index 798eba21a..f02590bae 100644 --- a/api/admin.php +++ b/api/admin.php @@ -12,6 +12,7 @@ use Photobooth\Service\DatabaseManagerService; use Photobooth\Service\ImageMetadataCacheService; use Photobooth\Service\LoggerService; +use Photobooth\Service\EncryptionService; use Photobooth\Service\MailService; use Photobooth\Service\PrintManagerService; use Photobooth\Service\ProcessService; @@ -182,6 +183,14 @@ $newConfig['login']['pin'] = $keepExistingSecret('pin', $newConfig['login']['pin'] ?? null, $config); $newConfig['login']['rental_pin'] = $keepExistingSecret('rental_pin', $newConfig['login']['rental_pin'] ?? null, $config); + // Keep existing FTP/Mail passwords when the form sends an empty value + if (($newConfig['ftp']['password'] ?? '') === '' && !empty($config['ftp']['password'])) { + $newConfig['ftp']['password'] = $config['ftp']['password']; + } + if (($newConfig['mail']['password'] ?? '') === '' && !empty($config['mail']['password'])) { + $newConfig['mail']['password'] = $config['mail']['password']; + } + // Hash password early when a new value is provided if (!empty($newConfig['login']['password']) && $newConfig['login']['password'] !== ($config['login']['password'] ?? null)) { $newConfig['login']['password'] = password_hash($newConfig['login']['password'], PASSWORD_DEFAULT); @@ -334,6 +343,15 @@ } } + // Encrypt FTP and Mail passwords before saving to config file + $encryptionService = EncryptionService::getInstance(); + if (!empty($newConfig['ftp']['password']) && !$encryptionService->isEncrypted($newConfig['ftp']['password'])) { + $newConfig['ftp']['password'] = $encryptionService->encrypt($newConfig['ftp']['password']); + } + if (!empty($newConfig['mail']['password']) && !$encryptionService->isEncrypted($newConfig['mail']['password'])) { + $newConfig['mail']['password'] = $encryptionService->encrypt($newConfig['mail']['password']); + } + if ($newConfig['logo']['enabled']) { $logoPath = $newConfig['logo']['path']; diff --git a/api/applyEffects.php b/api/applyEffects.php index 25530c386..87bed9d3e 100644 --- a/api/applyEffects.php +++ b/api/applyEffects.php @@ -13,6 +13,7 @@ use Photobooth\Service\DatabaseManagerService; use Photobooth\Service\LoggerService; use Photobooth\Service\RemoteStorageService; +use Photobooth\Service\UploadQueueService; use Photobooth\Utility\ImageUtility; use Photobooth\Utility\PathUtility; @@ -336,13 +337,14 @@ } } - // Store images on remote storage + // Queue images for async remote storage upload if ($config['ftp']['enabled']) { - $remoteStorage->write($remoteStorage->getStorageFolder() . '/images/' . $vars['singleImageFile'], (string) file_get_contents($vars['resultFile'])); - $remoteStorage->write($remoteStorage->getStorageFolder() . '/thumbs/' . $vars['singleImageFile'], (string) file_get_contents($vars['thumbFile'])); - if ($config['ftp']['create_webpage']) { - $remoteStorage->createWebpage(); - } + $uploadQueue = UploadQueueService::getInstance(); + $uploadQueue->enqueue( + $vars['singleImageFile'], + $vars['singleImageFile'], + (bool) $config['ftp']['create_webpage'] + ); } // Change permissions diff --git a/api/deletePhoto.php b/api/deletePhoto.php index fbafb01f3..5f5a0ba93 100644 --- a/api/deletePhoto.php +++ b/api/deletePhoto.php @@ -10,14 +10,13 @@ use Photobooth\Service\ImageMetadataCacheService; use Photobooth\Service\LoggerService; use Photobooth\Service\RemoteStorageService; +use Photobooth\Service\UploadQueueService; header('Content-Type: application/json'); $logger = LoggerService::getInstance()->getLogger('main'); $logger->debug(basename($_SERVER['PHP_SELF'])); -$remoteStorage = RemoteStorageService::getInstance(); - try { if (empty($_POST['file'])) { throw new \Exception('No file provided'); @@ -81,8 +80,11 @@ } if ($config['ftp']['enabled'] && $config['ftp']['delete']) { - $remoteStorage->delete($remoteStorage->getStorageFolder() . '/images/' . $fileName); - $remoteStorage->delete($remoteStorage->getStorageFolder() . '/thumbs/' . $fileName); + $remoteStorage = RemoteStorageService::getInstance(); + $uploadQueue = UploadQueueService::getInstance(); + $remoteFilename = $uploadQueue->getRemoteFilename($fileName) ?? $fileName; + $remoteStorage->delete('images/' . $remoteFilename); + $remoteStorage->delete('thumbs/' . $remoteFilename); } } diff --git a/api/ftpListFolders.php b/api/ftpListFolders.php new file mode 100644 index 000000000..7e06de17f --- /dev/null +++ b/api/ftpListFolders.php @@ -0,0 +1,44 @@ + 'Missing connection parameters']); + exit(); +} + +// If password is empty, use the saved (decrypted) config password +if ($password === '') { + $savedConfig = ConfigurationService::getInstance()->getConfiguration(); + $password = (string) ($savedConfig['ftp']['password'] ?? ''); +} + +try { + $folders = RemoteStorageService::listFolders([ + 'type' => $type, + 'baseURL' => $host, + 'port' => $port, + 'username' => $username, + 'password' => $password, + ], $path); + + echo json_encode(['folders' => $folders]); +} catch (\Throwable $e) { + echo json_encode(['error' => $e->getMessage()]); +} +exit(); diff --git a/api/print.php b/api/print.php index ac87e778d..962dcb565 100644 --- a/api/print.php +++ b/api/print.php @@ -10,6 +10,7 @@ use Photobooth\Service\LoggerService; use Photobooth\Service\PrintManagerService; use Photobooth\Service\RemoteStorageService; +use Photobooth\Service\UploadQueueService; use Photobooth\Utility\PathUtility; header('Content-Type: application/json'); @@ -156,10 +157,11 @@ $remoteStorageService = RemoteStorageService::getInstance(); $url = $remoteStorageService->getWebpageUri(); if ($config['qr']['append_filename']) { - $url .= '/images/'; + $uploadQueue = UploadQueueService::getInstance(); + $remoteFilename = $uploadQueue->getRemoteFilename($vars['fileName']) ?? $vars['fileName']; + $url .= '/?img=' . rawurlencode($remoteFilename); } - } - if ($config['qr']['append_filename']) { + } elseif ($config['qr']['append_filename']) { $url .= $vars['fileName']; } $imageHandler->qrUrl = PathUtility::getPublicPath($url, true); diff --git a/api/qrcode.php b/api/qrcode.php index 225449ee3..9b8090a65 100644 --- a/api/qrcode.php +++ b/api/qrcode.php @@ -3,6 +3,7 @@ /** @var array $config */ use Photobooth\Service\RemoteStorageService; +use Photobooth\Service\UploadQueueService; use Photobooth\Utility\PathUtility; use Photobooth\Utility\QrCodeUtility; @@ -20,12 +21,15 @@ if ($config['ftp']['enabled'] && $config['ftp']['useForQr']) { $remoteStorageService = RemoteStorageService::getInstance(); $url = $remoteStorageService->getWebpageUri(); - if ($config['qr']['append_filename']) { - $url .= '/images/'; - } } if ($config['qr']['append_filename']) { - $url .= $filename; + if ($config['ftp']['enabled'] && $config['ftp']['useForQr']) { + $uploadQueue = UploadQueueService::getInstance(); + $remoteFilename = $uploadQueue->getRemoteFilename($filename) ?? $filename; + $url .= '/?img=' . rawurlencode($remoteFilename); + } else { + $url .= $filename; + } } $url = PathUtility::getPublicPath($url, true); try { diff --git a/api/serverInfo.php b/api/serverInfo.php index 6ad9af579..0123b3714 100644 --- a/api/serverInfo.php +++ b/api/serverInfo.php @@ -20,6 +20,8 @@ function handleDebugPanel(string $content, array $config): string|false return readFileContents(PathUtility::getAbsolutePath('var/log/synctodrive.log')); case 'nav-remotestoragelog': return readFileContents(PathUtility::getAbsolutePath('var/log/remotestorage.log')); + case 'nav-uploadworkerlog': + return readFileContents(PathUtility::getAbsolutePath('var/log/uploadworker.log')); case 'nav-rembglog': return readFileContents(PathUtility::getAbsolutePath('var/log/rembg.log')); case 'nav-myconfig': diff --git a/assets/js/admin/buttons.js b/assets/js/admin/buttons.js index b8b608aee..461cdafbd 100644 --- a/assets/js/admin/buttons.js +++ b/assets/js/admin/buttons.js @@ -97,6 +97,205 @@ $(function () { return false; }); + // FTP Folder Browser + function ftpGetFormData() { + const formData = $('form').serializeArray(); + if (typeof csrf !== 'undefined') { + formData.push({ name: csrf.key, value: csrf.token }); + } + return formData; + } + + function ftpGetParentPath(path) { + if (path === '/' || path === '') { + return '/'; + } + const trimmed = path.replace(/\/+$/, ''); + const lastSlash = trimmed.lastIndexOf('/'); + return lastSlash <= 0 ? '/' : trimmed.substring(0, lastSlash); + } + + function ftpLoadFolders(path) { + const $container = $('#ftp-folder-browser'); + const $list = $container.find('.ftp-folder-list'); + $list.html( + '
' + ); + $container.removeClass('hidden'); + + const formData = ftpGetFormData(); + formData.push({ name: 'path', value: path }); + + $.ajax({ + url: '../api/ftpListFolders.php', + dataType: 'json', + data: formData, + type: 'post', + success: (resp) => { + if (resp.error) { + $list.html('
' + resp.error + '
'); + return; + } + + let html = ''; + + // Toolbar: back button + current path + html += '
'; + if (path !== '/') { + html += + ''; + } + // Breadcrumb path display + const parts = path === '/' ? [] : path.replace(/^\//, '').split('/'); + html += '
'; + html += + ''; + let buildPath = ''; + for (let i = 0; i < parts.length; i++) { + buildPath += '/' + parts[i]; + html += '/'; + const isLast = i === parts.length - 1; + html += + ''; + } + html += '
'; + html += '
'; + + // Select current folder button (only when not at root) + if (path !== '/') { + const folderValue = path.replace(/^\//, ''); + html += + ''; + } + + // Folder list + if (resp.folders.length === 0 && path !== '/') { + html += + '
' + + photoboothTools.getTranslation('ftp:no_subfolders') + + '
'; + } else if (resp.folders.length === 0) { + html += + '
' + + photoboothTools.getTranslation('ftp:no_subfolders') + + '
'; + } else { + html += '
'; + resp.folders.forEach((folder) => { + const folderName = folder.split('/').pop(); + const fullPath = '/' + folder; + html += + '
'; + // Clickable folder row (navigate into) + html += + ''; + // Select button + html += + ''; + html += '
'; + }); + html += '
'; + } + + $list.html(html); + }, + error: () => { + $list.html('
Failed to load folders
'); + } + }); + } + + function ftpEnsureBrowseButton() { + const $baseFolderCard = $('[name="ftp\\[baseFolder\\]"]').closest('.adminSettingCard'); + if ($baseFolderCard.length === 0) { + return; + } + + // Add browse button next to baseFolder input if not already present + if ($('#ftp-browse-btn').length === 0) { + const $input = $baseFolderCard.find('input[name="ftp\\[baseFolder\\]"]'); + $input.wrap('
'); + $input.after( + '' + ); + } + + // Add folder browser container below baseFolder card if not present + if ($('#ftp-folder-browser').length === 0) { + let browserHtml = + ''; + $baseFolderCard.append(browserHtml); + + $('#ftp-folder-browser-close').on('click', function () { + $('#ftp-folder-browser').addClass('hidden'); + }); + } + + // Show the browse button + $('#ftp-browse-btn').removeClass('hidden'); + } + + // Delegated click handlers for folder browser + $(document).on('click', '.ftp-nav-folder', function (e) { + e.preventDefault(); + ftpLoadFolders($(this).data('path')); + }); + $(document).on('click', '.ftp-select-folder', function (e) { + e.preventDefault(); + const folder = $(this).data('folder'); + $('[name="ftp\\[baseFolder\\]"]').val(folder).trigger('change'); + $('#ftp-folder-browser').addClass('hidden'); + }); + $(document).on('click', '#ftp-browse-btn', function (e) { + e.preventDefault(); + ftpLoadFolders('/'); + }); + $('#test-connection').on('click', function (e) { e.preventDefault(); const elem = $(this); @@ -108,13 +307,7 @@ $(function () { $.ajax({ url: '../api/testFtpConnection.php', dataType: 'json', - data: (function () { - const formData = $('form').serializeArray(); - if (typeof csrf !== 'undefined') { - formData.push({ name: csrf.key, value: csrf.token }); - } - return formData; - })(), + data: ftpGetFormData(), type: 'post', success: (resp) => { photoboothTools.console.log('resp', resp); @@ -124,6 +317,11 @@ $(function () { $('#ftp\\:' + el).addClass('required'); }); alert(photoboothTools.getTranslation(resp.message)); + + // On successful connection, enable folder browser at baseFolder field + if (resp.response === 'success') { + ftpEnsureBrowseButton(); + } }, error: (jqXHR) => { @@ -132,9 +330,10 @@ $(function () { complete: (jqXHR, textStatus) => { const status = jqXHR.status; + const resp = jqXHR.responseJSON; let classes = 'isActive isSuccess'; let findClasses = '.success span'; - if (status != 200 || jqXHR.responseJSON.response != 'success' || textStatus != 'success') { + if (status != 200 || !resp || resp.response != 'success' || textStatus != 'success') { classes = 'isActive isError'; findClasses = '.error span'; } @@ -336,4 +535,12 @@ $(function () { return false; }); + + // Show FTP folder browse button on page load if FTP credentials are already configured + const ftpHost = $('[name="ftp\\[baseURL\\]"]').val(); + const ftpUser = $('[name="ftp\\[username\\]"]').val(); + const ftpPassPlaceholder = $('[name="ftp\\[password\\]"]').attr('placeholder'); + if (ftpHost && ftpUser && ftpPassPlaceholder === '********') { + ftpEnsureBrowseButton(); + } }); diff --git a/assets/js/debugpanel.js b/assets/js/debugpanel.js index 35d097c7f..114fb8b6b 100644 --- a/assets/js/debugpanel.js +++ b/assets/js/debugpanel.js @@ -77,7 +77,13 @@ class DebugPanel { .then((html) => { this.debugContent.innerHTML = '
' + html + '
'; if ( - ['nav-devlog', 'nav-remotebuzzerlog', 'nav-synctodrivelog'].indexOf(this.currentNavigationId) != -1 + [ + 'nav-devlog', + 'nav-remotebuzzerlog', + 'nav-synctodrivelog', + 'nav-remotestoragelog', + 'nav-uploadworkerlog' + ].indexOf(this.currentNavigationId) != -1 ) { this.adminContent.scrollTo(0, this.adminContent.scrollHeight); } else { diff --git a/lib/configsetup.inc.php b/lib/configsetup.inc.php index c15e4923a..f761bc538 100644 --- a/lib/configsetup.inc.php +++ b/lib/configsetup.inc.php @@ -2405,9 +2405,9 @@ 'mail_password' => [ 'view' => 'basic', 'type' => 'input', - 'placeholder' => $defaultConfig['mail']['password'], + 'placeholder' => !empty($config['mail']['password']) ? '********' : '', 'name' => 'mail[password]', - 'value' => htmlentities($config['mail']['password'] ?? ''), + 'value' => '', ], 'mail_fromAddress' => [ 'view' => 'basic', @@ -2689,9 +2689,9 @@ 'password' => [ 'view' => 'advanced', 'type' => 'input', - 'placeholder' => '', + 'placeholder' => !empty($config['ftp']['password']) ? '********' : '', 'name' => 'ftp[password]', - 'value' => $config['ftp']['password'], + 'value' => '', ], 'test_connection' => [ 'view' => 'basic', @@ -2707,13 +2707,6 @@ 'name' => 'ftp[baseFolder]', 'value' => $config['ftp']['baseFolder'], ], - 'folder' => [ - 'view' => 'advanced', - 'type' => 'input', - 'placeholder' => 'photobooth', - 'name' => 'ftp[folder]', - 'value' => $config['ftp']['folder'], - ], 'title' => [ 'view' => 'advanced', 'type' => 'input', @@ -2734,13 +2727,6 @@ 'name' => 'ftp[website]', 'value' => $config['ftp']['website'], ], - 'urlTemplate' => [ - 'view' => 'advanced', - 'type' => 'input', - 'placeholder' => '%website%/%folder%/%title%', - 'name' => 'ftp[urlTemplate]', - 'value' => $config['ftp']['urlTemplate'], - ], 'create_webpage' => [ 'view' => 'basic', 'type' => 'checkbox', diff --git a/resources/config/photobooth-upload-worker.service b/resources/config/photobooth-upload-worker.service new file mode 100644 index 000000000..62ed8a390 --- /dev/null +++ b/resources/config/photobooth-upload-worker.service @@ -0,0 +1,18 @@ +[Unit] +Description=Photobooth Async Upload Worker +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/html +ExecStart=/usr/bin/php bin/photobooth photobooth:upload:worker +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=photobooth-upload + +[Install] +WantedBy=multi-user.target diff --git a/resources/lang/en.json b/resources/lang/en.json index ed830ba4f..c063187f2 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -214,20 +214,22 @@ "ftp": "FTP Server", "ftp:baseFolder": "Base folder", "ftp:baseURL": "Server URL", + "ftp:browse_folders": "Browse Folders", "ftp:connected": "Successfully connected to the FTP server.", "ftp:create_webpage": "Create webpage on ftp folder", "ftp:delete": "Delete image on ftp server if deleted on photobooth", "ftp:enabled": "Enable server FTP", - "ftp:folder": "Folder", + "ftp:image_uploading": "Your photo is being uploaded...", "ftp:missing_parameters": "Missing parameters! Fill all required input.", "ftp:no_connection": "Unable to connect to the FTP server!", + "ftp:no_subfolders": "No subfolders", "ftp:password": "Password", "ftp:port": "FTP-Server port", + "ftp:select_folder": "Select", "ftp:template_location": "Template file location", "ftp:test_connection": "Test connection", "ftp:title": "Title", "ftp:type": "Connection", - "ftp:urlTemplate": "Url template for qr generation", "ftp:useForQr": "Use the output from the FTP server as input to generate the QR code", "ftp:username": "Username", "ftp:website": "Website", @@ -505,17 +507,15 @@ "manual:ftp:create_webpage": "Create a webpage starting from the file index.php that function as a gallery on the base folder.", "manual:ftp:delete": "If enabled, it will delete the image and the thumbnail on the ftp server if it is deleted on the Photobooth.", "manual:ftp:enabled": "If enabled, after taking and processing the picture, it will be sent to the ftp server to store it.", - "manual:ftp:folder": "The /path/to/directory on the disk of the ftp server. If the folder does not exist it will be created (always check your ftp server configuration).", "manual:ftp:password": "In combination with username to authenticate the user who send the file on the server.", "manual:ftp:port": "FTP Server port.", "manual:ftp:template_location": "Location of the index.php file. Choose another location to use a custom index.php file. REMEMBER: the file has to have a {title} placeholder.", "manual:ftp:test_connection": "Will execute a test connection to the FTP server using the data passed.", "manual:ftp:title": "Title of the sub-folder. It will be appended to folder. If website is enabled it will be used on the html title tag and in the page header.", "manual:ftp:type": "Choose to use FTP or SFTP connection.", - "manual:ftp:urlTemplate": "Set the url template to generate the qr code. The placeholder are '%website', '%baseFolder', '%folder', '%title' (as slug) and '%date'. The filename is appended at the end.", "manual:ftp:useForQr": "If enabled, the output received from the FTP server after storing the picture it will be used as input for the generation of the QR code (always check your ftp server configuration).", "manual:ftp:username": "In combination with password to authenticate the user who send the file on the server.", - "manual:ftp:website": "The website accessible from the internet of the ftp server. To this website will be appended the folder structure and the file name of the picture to reach the file on the internet.", + "manual:ftp:website": "The root URL of the FTP server website (e.g. https://photos.example.com). The base folder path is automatically appended to build the gallery URL.", "manual:gallery:gallery_allow_delete": "If enabled pictures can be deleted from the gallery at any time.", "manual:gallery:gallery_bottom_bar": "If enabled, the button bar is shown in the gallery below.", "manual:gallery:gallery_date_format": "Enter your date style.", @@ -1006,6 +1006,8 @@ "remotebuzzer:remotebuzzer_userotary": "Enable Rotary Encoder", "remotebuzzer:remotebuzzer_videobutton": "Video Button", "remotebuzzerGetTrigger": "Remotebuzzer GET request trigger", + "remotebuzzerlog": "Remote Buzzer Log", + "remotestoragelog": "Remote Storage Log", "remoteStorageTemplate": "Remote Storage Template test", "reset": "Reset", "reset_lock": "Unlock", @@ -1071,6 +1073,7 @@ "synctodrive:synctodrive_enabled": "Enable", "synctodrive:synctodrive_interval": "Automated syncing interval", "synctodrive:synctodrive_target": "USB device identifier", + "synctodrivelog": "Sync to Drive Log", "takeCollage": "Collage", "takePhoto": "Picture", "takeSelfie": "Selfie", @@ -1104,6 +1107,7 @@ "upload_unable_to_write_folder": "Unable to upload the file to the folder. Enable write access!", "upload_wrong_type": "The file is not in the correct type list", "uploading": "Uploading...", + "uploadworkerlog": "FTP Upload Worker Log", "userinterface": "User interface", "userinterface:background_admin": "Admin panel background image path", "userinterface:background_chroma": "Chroma keying panel background image path", @@ -1195,6 +1199,6 @@ "video:video_gif": "Video as GIF", "video:video_qr": "Show video QR code", "viewer_photo_title": "Your photo", - "viewer_video_fallback": "Your browser can’t play this video.", + "viewer_video_fallback": "Your browser can't play this video.", "wait_message": "Please wait..." } diff --git a/resources/template/index.php b/resources/template/index.php index b31a291bc..95b4ff99a 100644 --- a/resources/template/index.php +++ b/resources/template/index.php @@ -1,66 +1,49 @@ glob($templateConfig['paths']['images'] . '/*.{jpg,JPG}', GLOB_BRACE) ?: [], - 'thumbs' => glob($templateConfig['paths']['thumbs'] . '/*.{jpg,JPG}', GLOB_BRACE) ?: [], + 'images' => glob(__DIR__ . '/' . $templateConfig['paths']['images'] . '/*.{jpg,JPG,png,PNG}', GLOB_BRACE) ?: [], + 'thumbs' => glob(__DIR__ . '/' . $templateConfig['paths']['thumbs'] . '/*.{jpg,JPG,png,PNG}', GLOB_BRACE) ?: [], ]; -asort($images['images']); -asort($images['thumbs']); +usort($images['images'], fn ($a, $b) => filemtime($b) - filemtime($a)); +usort($images['thumbs'], fn ($a, $b) => filemtime($b) - filemtime($a)); -$firstImage = $images['images'][0] ?? null; $totalImages = count($images['images']); $requestUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; -$urlPrefix = $requestUrl; -if (substr($urlPrefix, -4) === '.php') { - $baseName = basename($urlPrefix); - $urlPrefix = rtrim($urlPrefix, $baseName); -} +$urlPrefix = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . dirname($_SERVER['SCRIPT_NAME']); if (substr($urlPrefix, -1) !== '/') { $urlPrefix .= '/'; } -$ogImage = $urlPrefix . $firstImage; - -header('Cache-Control: max-age=' . $templateConfig['meta']['max-age']); - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - zipFilesAndDownload($images['images'], $templateConfig); -} - -function zipFilesAndDownload($files, $templateConfig) -{ - // create new zip opbject +// ZIP download handler +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode === 'gallery') { $zip = new ZipArchive(); + $tmp_file = tempnam(sys_get_temp_dir(), 'zipped'); + $zip->open($tmp_file, ZipArchive::CREATE | ZipArchive::OVERWRITE); - // create a temp file & open it - $tmp_file = tempnam('.', 'zipped'); - $zip->open($tmp_file, ZipArchive::CREATE); - - // loop through each file - foreach ($files as $file) { - if (str_contains($file, 'tmb_')) { + foreach ($images['images'] as $file) { + $download_file = file_get_contents($file); + if ($download_file === false) { continue; } - - // download file - $download_file = file_get_contents($file); - //add it to the zip $zip->addFromString(basename($file), $download_file); } - // close zip $zip->close(); - // send the file to the browser as a download header('Content-disposition: attachment; filename="' . $templateConfig['files']['download_prefix'] . '.zip"'); header('Content-type: application/zip'); header('Content-length: ' . filesize($tmp_file)); @@ -69,6 +52,15 @@ function zipFilesAndDownload($files, $templateConfig) readfile($tmp_file); ignore_user_abort(true); unlink($tmp_file); + exit; +} + +header('Cache-Control: max-age=' . $templateConfig['meta']['max-age']); + +// Determine display content based on mode +if ($mode === 'single' && $requestedImage !== '') { + $singleImageUrl = 'images/' . rawurlencode($requestedImage); + $singleDownloadName = $templateConfig['files']['download_prefix'] . '_' . $requestedImage; } $styles = ''; @@ -87,6 +79,7 @@ function zipFilesAndDownload($files, $templateConfig) + - - + + + + - <?= $templateConfig['meta']['title'] ?> @@ -127,7 +121,7 @@ function zipFilesAndDownload($files, $templateConfig) font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-weight: 600; background-image: linear-gradient(135deg, var(--primary-color) 30%, var(--secondary-color)); - color: var(--primary-color); + color: var(--font-color); min-height: 100dvh; } @@ -137,7 +131,7 @@ function zipFilesAndDownload($files, $templateConfig) } .front-cover { - background-image: linear-gradient(rgba(0,0,0,.5), rgba(0,0,0,.85)), url(); + background-image: linear-gradient(rgba(0,0,0,.5), rgba(0,0,0,.85)); background-position: center; background-repeat: no-repeat; background-size: cover; @@ -268,7 +262,99 @@ function zipFilesAndDownload($files, $templateConfig) .lightbox-action-bar > a { margin: 0 1rem; } + + .lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + color: white; + font-size: clamp(1.5rem, 4vw, 2.5rem); + padding: 1rem 1.25rem; + background: rgba(0,0,0,.35); + border-radius: .5rem; + line-height: 1; + transition: background .2s; + z-index: 1; + } + + .lightbox-nav:hover { + background: rgba(0,0,0,.6); + } + + .lightbox-nav-prev { left: .75rem; } + .lightbox-nav-next { right: .75rem; } + + /* Single image viewer */ + .viewer-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + padding: 2rem; + min-height: 80vh; + justify-content: center; + } + + .viewer-container img { + max-width: 100%; + max-height: 70vh; + border-radius: .5rem; + box-shadow: 0 10px 30px 5px rgba(0, 0, 0, 0.3); + } + + .viewer-actions { + display: flex; + gap: 1rem; + align-items: center; + } + + .viewer-actions a { + display: inline-flex; + align-items: center; + gap: .5rem; + padding: .75rem 1.5rem; + border-radius: 2rem; + background: var(--primary-color); + color: var(--button-font-color); + font-size: 1rem; + box-shadow: 0 4px 12px rgba(0,0,0,.25); + transition: background .3s; + } + + .viewer-actions a:hover { + background: color-mix(in srgb, var(--primary-color), var(--button-font-color) 20%); + } +
@@ -276,48 +362,90 @@ function zipFilesAndDownload($files, $templateConfig)
-
-