';
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/docs/faq/index.md b/docs/faq/index.md
index d87301002..01829652b 100644
--- a/docs/faq/index.md
+++ b/docs/faq/index.md
@@ -356,38 +356,71 @@ and enter/adjust the @chromium-browser entries as followed (adjust the value _19
---
-## How does the connection to the FTP server work?
+## How does the connection to the FTP/SFTP server work?
-The connection to the FTP server needs 4 distinct properties.
+### Connection settings
-- `baseURL` which is the url where all requests will be made
-- `port` for ssl connection (the default value is 21)
-- `username` the username of the user authorized to interact to the FTP server
-- `password` the password of the user
+The following properties are required to connect to the remote server:
-With these four variables you can test the connection to the FTP server to check if everything is alright.
+- `type` — protocol: `ftp` (default) or `sftp`
+- `baseURL` — hostname or IP address of the server
+- `port` — port number (default: `21` for FTP, `22` for SFTP)
+- `username` — username authorized to access the server
+- `password` — password for that user (stored encrypted on disk)
-The next variables are for the place where you want the pictures to be stored:
+Use **Test Connection** in the admin panel to verify the credentials before saving.
-- `baseFolder` is the folder of your website (if you have multiple websites living on the server with this property you can choose on which of these the file should be stored)
-- `folder` the folder dedicated to the upload of the files
-- `title` if you are doing an event you can set the title of the event to create another folder (the system will slugify the string)
+### Storage location
-In the end the processed picture, and the thumbnails, will be uploaded in the folder according to these variables.
+- `baseFolder` — root folder on the server where all files are stored (e.g. `photobooth` or `www/gallery`). You can browse available folders using the folder picker in the admin panel.
+- `title` — optional event title; used as the page title of the online gallery
-If you have a website, you can use the following variables to generate the qr codes that will point to the photos uploaded to the ftp server
+Photos are stored inside `/images/` and thumbnails inside `/thumbs/` using privacy-preserving random filenames so the original file names are never exposed on the server.
-- `useForQr` to enable this functionality
-- `website` accessible from the internet, it will be the base of the qr code link
-- `urlTemplate` starting from the previous set of variables, you have to define the template which will be used to generate the qrcode link (each variable should be written whit '%' before e.g. %website/%folder/%date)
+### QR code integration
-Last but not least you can upload a php file on the `title` folder on the FTP server to create an online gallery which is updated with every new picture (and collage) taken.
-The variable to manage this feature are the following:
+- `useForQr` — when enabled, QR codes point to the remote gallery instead of the local Photobooth
+- `website` — publicly accessible base URL of the remote server (e.g. `https://example.com`)
-- `create_webpage` to enable this functionality
-- `template_location` which is the location of the index.php file, which is formatted with the title of the current event and uploaded to the FTP server
+The QR code URL is built automatically as `//?img=`. No manual URL template is required.
-In the end you can enable the `delete` functionality that will delete photos (and collages) from the ftp server when they are deleted from the photobooth gallery (no admin reset)
+### Online gallery
+
+- `create_webpage` — when enabled, Photobooth uploads a ready-to-use PHP gallery (`index.php` and `config.inc.php`) to the `baseFolder` on the server. The gallery is updated with every new photo.
+- `template_location` — path to the local `index.php` template that is uploaded (default: `resources/template/index.php`)
+
+The gallery shows photos sorted newest-first and supports single-image deep links via `?img=`. While a photo is still being uploaded the visitor sees a spinner; the page reloads automatically once the image is available. A native share button (Web Share API) and a lightbox with keyboard navigation are included.
+
+Directory listing for `images/` and `thumbs/` is blocked by `index.php` redirect guards that are uploaded automatically.
+
+### Async upload queue
+
+Uploads run **asynchronously** in a background worker so the Photobooth UI is never blocked while files are transferred. After a photo is taken, a job is added to a local SQLite queue. The worker processes jobs one by one and retries up to 5 times on failure.
+
+To run the worker as a persistent background service, copy the provided systemd unit and enable it:
+
+```bash
+sudo cp resources/config/photobooth-upload-worker.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now photobooth-upload-worker
+sudo systemctl status photobooth-upload-worker
+```
+
+The worker can also be run manually for testing:
+
+```bash
+# Process all pending jobs once and exit
+sudo -u www-data php bin/photobooth photobooth:upload:worker --once
+
+# Run continuously (polls every 5 seconds)
+sudo -u www-data php bin/photobooth photobooth:upload:worker
+```
+
+Upload queue status (pending jobs, failed jobs) is visible in the Photobooth debug panel.
+
+### Delete on local delete
+
+Enable `delete` to automatically remove the photo and thumbnail from the FTP/SFTP server whenever a photo is deleted from the local Photobooth gallery (does not apply to admin reset).
---
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'] ?>
= $styles ?>
@@ -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(= $urlPrefix . $firstImage ?>);
+ 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)
= $templateConfig['meta']['title'] ?>
-