Compare commits

..

84 Commits

Author SHA1 Message Date
Terranom674
157c6724ea Remove GitHub Actions workflow 2026-09-08 10:12:37 +02:00
Terranom674
bfc1a679db Bump version for public watermark default 2026-09-06 23:04:57 +02:00
Terranom674
a6a2f65f72 Let default public profile inherit base watermark file 2026-09-06 23:04:38 +02:00
Terranom674
940a5e3124 Make public albums watermarked by default 2026-09-06 23:04:15 +02:00
Terranom674
1a119aeb70 Bump Bratonien Tools to 0.9.7.1.50 2026-09-06 22:48:15 +02:00
Terranom674
a99aee3bcd Recover from false connector lock state before cache warmup 2026-09-06 22:48:00 +02:00
Terranom674
de193ca491 Only defer cache for an actually active connector service 2026-09-06 22:47:14 +02:00
Terranom674
51e864060b Bump Bratonien Tools to 0.9.7.1.49 2026-09-06 22:32:24 +02:00
Terranom674
c10c188587 Discard stale legacy connector wait states 2026-09-06 22:32:02 +02:00
Terranom674
ac0be6dcf7 Make every connector deferral resumable 2026-09-06 22:31:24 +02:00
Terranom674
c4813161cd Bump Bratonien Tools to 0.9.7.1.48 2026-09-06 22:30:36 +02:00
Terranom674
d97c828066 Resume cache warmup after connector sync instead of stalling 2026-09-06 22:30:19 +02:00
Terranom674
1cff325e3c Resume deferred cache worker after connector sync 2026-09-06 22:29:55 +02:00
Terranom674
776c5842ca Bump Bratonien Tools to 0.9.7.1.47 2026-09-06 21:57:24 +02:00
Terranom674
a5de1209d9 Treat missing WebDAV dimensions as non-fatal metadata 2026-09-06 21:57:09 +02:00
Terranom674
6cb6cd29e2 Bump Bratonien Tools to 0.9.7.1.46 2026-09-06 21:55:16 +02:00
Terranom674
d50cdaaccd Preserve WebDAV scan error details in connector status 2026-09-06 21:54:56 +02:00
Terranom674
65cfc0ae97 Revert unrelated sudo bridge change 2026-09-06 21:54:12 +02:00
Terranom674
6a985219e9 Restore complete plugin bootstrap for 0.9.7.1.45 2026-09-06 21:49:27 +02:00
Terranom674
8149793149 Bump Bratonien Tools to 0.9.7.1.45 2026-09-06 21:49:09 +02:00
Terranom674
36bb76f97e Provision narrow sudo bridge for manual NC sync 2026-09-06 21:48:55 +02:00
Terranom674
675815e7c0 Bump Bratonien Tools to 0.9.7.1.44 2026-09-06 21:45:47 +02:00
Terranom674
e5fb8ad90a Run manual NC sync through privileged system service 2026-09-06 21:45:18 +02:00
Terranom674
38bff89484 Keep empty WebDAV mappings valid for worker pruning 2026-09-06 21:12:38 +02:00
Terranom674
3102a3a3f5 Bump plugin version to 0.9.7.1.43 2026-09-06 21:11:20 +02:00
Terranom674
6e1cf27ef1 Allow empty WebDAV metadata after root removal 2026-09-06 21:10:45 +02:00
Terranom674
3af01bc812 Clean removed WebDAV images after Piwigo sync 2026-09-06 21:10:26 +02:00
Terranom674
e58ebc5538 Make WebDAV orphan cleanup continuous 2026-09-06 21:09:58 +02:00
Terranom674
eb002b007f Handle removed WebDAV roots without blocking sync 2026-09-06 21:09:39 +02:00
Terranom674
6983a98875 Keep WebDAV cancel signals until worker guards are secured 2026-09-05 23:43:17 +02:00
Terranom674
9f8a2569b2 Bump version for serialized cache clearing 2026-09-05 23:42:39 +02:00
Terranom674
5a9980de24 Serialize cache clear against all cache workers 2026-09-05 23:42:07 +02:00
Terranom674
50f4343029 Include derivative pipeline version in worker stage signature 2026-09-05 23:32:50 +02:00
Terranom674
bd51c27f5b Refresh presentation cache when watermark engine changes 2026-09-05 23:32:17 +02:00
Terranom674
7dc8348fd0 Version WebDAV derivative generation state for geometry repair 2026-09-05 23:31:32 +02:00
Terranom674
0fa552285e Refresh previews after active watermark profile edits 2026-09-05 23:30:47 +02:00
Terranom674
5d995dcca1 Refresh previews after global watermark rule changes 2026-09-05 23:30:23 +02:00
Terranom674
c06b9fc476 Refresh affected previews after album watermark rule changes 2026-09-05 23:30:06 +02:00
Terranom674
c96046b0ca Wire presentation refresh and bump plugin version 2026-09-05 23:29:46 +02:00
Terranom674
d039416dc5 Correct source orientation before Piwigo derivative generation 2026-09-05 23:29:21 +02:00
Terranom674
f9aa1f1a68 Resolve watermark endpoint from active plugin directory 2026-09-05 23:28:49 +02:00
Terranom674
15059f5b68 Add asynchronous Piwigo preview presentation refresh worker 2026-09-05 23:28:34 +02:00
Terranom674
136972b0eb Add local CLI caller for Bratonien watermark cache 2026-09-05 23:28:05 +02:00
Terranom674
30967247c0 Add queued Piwigo presentation refresh orchestration 2026-09-05 23:27:53 +02:00
Terranom674
6826963712 Bump version for frontend QR inventory 2026-09-05 23:05:48 +02:00
Terranom674
dc01294bdb Show QR inventory on public upload page 2026-09-05 23:05:24 +02:00
Terranom674
30b5207c2b Bump Bratonien Tools version for QR batch management 2026-09-05 23:03:10 +02:00
Terranom674
48ed46b1f0 Add QR completeness grid and batch ZIP actions 2026-09-05 23:02:45 +02:00
Terranom674
4dfc1c871c Add QR availability matrix and batch download data 2026-09-05 23:02:08 +02:00
Terranom674
6ccc61eb40 Add administrator QR code batch ZIP downloads 2026-09-05 23:01:38 +02:00
Terranom674
2f976aa9ed Bump plugin version for friendship code upload 2026-09-05 17:30:10 +02:00
Terranom674
a0fa4dfd1a Extend public upload with personal friendship codes 2026-09-05 17:29:44 +02:00
Terranom674
839683a260 Add friendship code management to QR upload admin 2026-09-05 17:28:55 +02:00
Terranom674
92724aa9a2 Expose friendship codes in QR upload administration 2026-09-05 17:28:30 +02:00
Terranom674
e7b0ecd85d Register friendship code administration actions 2026-09-05 17:27:59 +02:00
Terranom674
11d2b85948 Add protected friendship code preview and download endpoint 2026-09-05 17:27:38 +02:00
Terranom674
05a5013b8a Add personal friendship code upload storage and administration 2026-09-05 17:27:27 +02:00
Terranom674
acbf316f58 Bump Bratonien Tools version for QR downloads 2026-09-05 16:22:16 +02:00
Terranom674
930399c847 Add QR upload download action in admin 2026-09-05 16:21:54 +02:00
Terranom674
a441ad2991 Add admin download mode for QR uploads 2026-09-05 16:21:34 +02:00
Terranom674
d0f13a6d1f Bump version for QR upload management 2026-09-05 16:18:26 +02:00
Terranom674
efff4360f7 Rename QR admin tab 2026-09-05 16:18:01 +02:00
Terranom674
6cfcfd624e Add QR upload management to admin 2026-09-05 16:17:37 +02:00
Terranom674
7fb3969647 Register QR upload delete action 2026-09-05 16:17:12 +02:00
Terranom674
d006217660 Add QR upload administration and deletion 2026-09-05 16:16:52 +02:00
Terranom674
3662bdefc2 Add admin-only QR upload preview endpoint 2026-09-05 16:16:15 +02:00
Terranom674
3d66bd1137 Move customer QR upload out of Discover into root frontend navigation 2026-09-05 16:02:09 +02:00
Terranom674
21cbe59b55 Expose customer QR upload as top-level frontend menu item 2026-09-05 16:01:33 +02:00
Terranom674
420ec47a51 Expose customer QR upload in public Piwigo menu 2026-09-05 15:56:32 +02:00
Terranom674
0c8065a01c Bump Bratonien Tools to 0.9.7.1.33 2026-09-05 15:51:20 +02:00
Terranom674
7015d822cd Show fixed customer QR year capacities in admin 2026-09-05 15:50:53 +02:00
Terranom674
1c1d29af07 Restrict customer QR upload to configured yearly ranges 2026-09-05 15:50:41 +02:00
Terranom674
074a74af1f Limit customer QR years and yearly code ranges 2026-09-05 15:50:11 +02:00
Terranom674
fa86adc1d2 Keep QR batch result numbers isolated per file 2026-09-05 15:45:51 +02:00
Terranom674
289eea396e Harden customer QR batch form validation 2026-09-05 15:45:12 +02:00
Terranom674
a386c71c3c Bump Bratonien Tools to 0.9.7.1.32 2026-09-05 15:43:48 +02:00
Terranom674
9a4d39af1a Add customer QR admin tab 2026-09-05 15:43:26 +02:00
Terranom674
958c9242ff Expose customer QR upload administration 2026-09-05 15:43:04 +02:00
Terranom674
8d5e99389c Register customer QR upload settings 2026-09-05 15:42:32 +02:00
Terranom674
5a35244029 Add customer QR upload database table 2026-09-05 15:42:14 +02:00
Terranom674
d87c395be4 Add customer QR upload administration 2026-09-05 15:41:56 +02:00
Terranom674
e8cf6cc6c8 Add public customer QR upload form 2026-09-05 15:41:45 +02:00
Terranom674
3d654d30cf Add customer QR upload module 2026-09-05 15:41:11 +02:00
Terranom674
b9140103ec Add legal links to Piwigo footer 2026-09-04 22:04:38 +02:00
32 changed files with 3367 additions and 296 deletions

View File

@@ -1,82 +0,0 @@
name: Syntax Check
on:
push:
pull_request:
jobs:
php-syntax:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4', '8.5']
steps:
- name: Repository auschecken
uses: actions/checkout@v4
- name: PHP installieren
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- name: PHP Syntax pruefen
shell: bash
run: |
set -euo pipefail
while IFS= read -r -d '' file; do
php -l "$file"
done < <(find . -type f -name '*.php' -print0)
- name: Doppelte Bratonien-Funktionen pruefen
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
import pathlib
import re
import sys
found = {}
for path in pathlib.Path('.').rglob('*.php'):
text = path.read_text(encoding='utf-8', errors='ignore')
for name in re.findall(r'\bfunction\s+(bratonien_tools_[A-Za-z0-9_]+)\s*\(', text):
found.setdefault(name, []).append(str(path))
duplicates = {name: paths for name, paths in found.items() if len(paths) > 1}
if duplicates:
for name, paths in sorted(duplicates.items()):
print(f'{name}: {", ".join(paths)}', file=sys.stderr)
sys.exit(1)
PY
script-syntax:
name: JavaScript, Python, Shell
runs-on: ubuntu-latest
steps:
- name: Repository auschecken
uses: actions/checkout@v4
- name: JavaScript Syntax pruefen
shell: bash
run: |
set -euo pipefail
while IFS= read -r -d '' file; do
node --check "$file"
done < <(find . -type f -name '*.js' -print0)
- name: Python Syntax pruefen
shell: bash
run: |
set -euo pipefail
python3 -m compileall -q runtime
- name: Shell Syntax pruefen
shell: bash
run: |
set -euo pipefail
while IFS= read -r -d '' file; do
bash -n "$file"
done < <(find . -type f -name '*.sh' -print0)

View File

@@ -110,6 +110,47 @@ $engine = bratonien_tools_get_watermark_engine_config();
$cache_workers = bratonien_tools_get_cache_worker_settings();
$public_selection = bratonien_tools_get_public_selection_settings();
$public_selection_groups = bratonien_tools_get_piwigo_groups();
$customer_qr = bratonien_tools_customer_qr_admin_data();
$customer_qr['friendship_codes'] = bratonien_tools_friendship_code_admin_data();
$qr_present = array();
foreach ($customer_qr['uploads'] as $qr_upload)
{
$qr_year = (int)$qr_upload['year'];
$qr_number = (int)$qr_upload['number'];
if ($qr_year < 1 || $qr_number < 1)
{
continue;
}
if (!isset($qr_present[$qr_year]))
{
$qr_present[$qr_year] = array();
}
$qr_present[$qr_year][$qr_number] = $qr_upload;
}
$qr_batch_base = get_absolute_root_url(true).'plugins/'.BRATONIEN_TOOLS_ID.'/customer-qr-admin-batch.php';
$customer_qr['batch_download_available'] = class_exists('ZipArchive');
$customer_qr['batch_download_url'] = $qr_batch_base;
foreach ($customer_qr['years'] as &$qr_year)
{
$year = (int)$qr_year['year'];
$capacity = (int)$qr_year['capacity'];
$qr_year['batch_download_url'] = $qr_batch_base.'?year='.$year;
$qr_year['slots'] = array();
for ($number = 1; $number <= $capacity; $number++)
{
$present = isset($qr_present[$year][$number]);
$qr_year['slots'][] = array(
'number' => $number,
'present' => $present,
'preview_url' => $present ? (string)$qr_present[$year][$number]['preview_url'] : '',
);
}
}
unset($qr_year);
$assets = bratonien_tools_get_assets();
$asset_environment = bratonien_tools_get_asset_environment();
$album_shares = bratonien_tools_get_album_shares();
@@ -244,6 +285,7 @@ $template->assign(array(
'CACHE_WORKERS' => $cache_workers,
'PUBLIC_SELECTION' => $public_selection,
'PUBLIC_SELECTION_GROUPS' => $public_selection_groups,
'CUSTOMER_QR' => $customer_qr,
'BRATONIEN_ASSETS' => $assets,
'ASSET_ENV' => $asset_environment,
'BRATONIEN_ALBUM_SHARES' => $album_shares,
@@ -261,6 +303,7 @@ $template->set_filename('admin_tabs_content', BRATONIEN_TOOLS_PATH . 'template/a
$template->set_filename('plugin_admin_content', BRATONIEN_TOOLS_PATH . 'template/admin.tpl');
$template->set_filename('webdav_warmup_admin_content', BRATONIEN_TOOLS_PATH . 'template/webdav_warmup_admin.tpl');
$template->set_filename('public_selection_admin_content', BRATONIEN_TOOLS_PATH . 'template/public_selection_admin.tpl');
$template->set_filename('customer_qr_admin_content', BRATONIEN_TOOLS_PATH . 'template/customer_qr_admin.tpl');
$template->set_filename('asset_manager_admin_content', BRATONIEN_TOOLS_PATH . 'template/asset_manager_admin.tpl');
$template->set_filename('album_shares_admin_content', BRATONIEN_TOOLS_PATH . 'template/album_shares_admin.tpl');
$template->set_filename('nc_connector_admin_content', BRATONIEN_TOOLS_PATH . 'template/nc_connector_admin.tpl');
@@ -270,6 +313,7 @@ $admin_content = $template->parse('admin_tabs_content', true);
$admin_content .= $template->parse('plugin_admin_content', true);
$admin_content .= $template->parse('webdav_warmup_admin_content', true);
$admin_content .= $template->parse('public_selection_admin_content', true);
$admin_content .= $template->parse('customer_qr_admin_content', true);
$admin_content .= $template->parse('asset_manager_admin_content', true);
$admin_content .= $template->parse('album_shares_admin_content', true);
$admin_content .= $template->parse('nc_connector_admin_content', true);

122
customer-qr-admin-batch.php Normal file
View File

@@ -0,0 +1,122 @@
<?php
$piwigo_root = realpath(dirname(__DIR__, 2));
if ($piwigo_root === false)
{
http_response_code(500);
exit;
}
define('PHPWG_ROOT_PATH', rtrim($piwigo_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR);
include_once(PHPWG_ROOT_PATH.'include/common.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
http_response_code(404);
exit;
}
check_status(ACCESS_ADMINISTRATOR);
require_once(BRATONIEN_TOOLS_PATH.'include/customer_qr_upload.inc.php');
if (!class_exists('ZipArchive'))
{
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
exit('ZIP-Unterstützung ist auf diesem Server nicht verfügbar.');
}
bratonien_tools_customer_qr_ensure_storage();
$limits = bratonien_tools_customer_qr_year_limits();
$table = bratonien_tools_customer_qr_table();
$requested_year = isset($_GET['year']) && $_GET['year'] !== '' ? bratonien_tools_customer_qr_year($_GET['year']) : 0;
$query = 'SELECT upload_year, code_number, stored_name FROM `'.$table.'`';
if ($requested_year > 0)
{
$query .= ' WHERE upload_year='.(int)$requested_year;
}
$query .= ' ORDER BY upload_year ASC, CAST(code_number AS UNSIGNED) ASC, id ASC';
$result = pwg_query($query);
$files = array();
$root = bratonien_tools_customer_qr_storage_root();
while ($row = pwg_db_fetch_assoc($result))
{
$year = (int)$row['upload_year'];
if (!isset($limits[$year]))
{
continue;
}
$number = bratonien_tools_customer_qr_number_for_year($year, $row['code_number']);
$stored_name = (string)$row['stored_name'];
if ($stored_name === '' || basename($stored_name) !== $stored_name)
{
continue;
}
$path = $root.DIRECTORY_SEPARATOR.$year.DIRECTORY_SEPARATOR.$stored_name;
if (!is_file($path) || !is_readable($path))
{
continue;
}
$extension = strtolower((string)pathinfo($stored_name, PATHINFO_EXTENSION));
if (!in_array($extension, array('png', 'jpg', 'jpeg', 'webp', 'gif'), true))
{
continue;
}
$width = max(2, strlen((string)$limits[$year]));
$archive_name = 'qr-'.str_pad($number, $width, '0', STR_PAD_LEFT).'.'.$extension;
if ($requested_year === 0)
{
$archive_name = $year.'/'.$archive_name;
}
$files[] = array(
'path' => $path,
'archive_name' => $archive_name,
);
}
if (empty($files))
{
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
exit('Keine QR-Codes für den Batch-Download vorhanden.');
}
$temp = tempnam(sys_get_temp_dir(), 'bratonien-qr-');
if ($temp === false)
{
http_response_code(500);
exit;
}
$zip = new ZipArchive();
if ($zip->open($temp, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true)
{
@unlink($temp);
http_response_code(500);
exit;
}
foreach ($files as $file)
{
$zip->addFile($file['path'], $file['archive_name']);
}
$zip->close();
$download_name = $requested_year > 0
? 'qr-codes-'.$requested_year.'.zip'
: 'qr-codes-alle-jahre.zip';
header('Content-Type: application/zip');
header('Content-Length: '.filesize($temp));
header('Content-Disposition: attachment; filename="'.$download_name.'"');
header('Cache-Control: private, no-store, max-age=0');
header('X-Content-Type-Options: nosniff');
readfile($temp);
@unlink($temp);
exit;

View File

@@ -0,0 +1,70 @@
<?php
$piwigo_root = realpath(dirname(__DIR__, 2));
if ($piwigo_root === false)
{
http_response_code(500);
exit;
}
define('PHPWG_ROOT_PATH', rtrim($piwigo_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR);
include_once(PHPWG_ROOT_PATH.'include/common.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
http_response_code(404);
exit;
}
check_status(ACCESS_ADMINISTRATOR);
require_once(BRATONIEN_TOOLS_PATH.'include/customer_qr_upload.inc.php');
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id < 1)
{
http_response_code(404);
exit;
}
$table = bratonien_tools_customer_qr_table();
$result = pwg_query('SELECT id, upload_year, code_number, stored_name, mime_type FROM `'.$table.'` WHERE id='.$id.' LIMIT 1');
$row = pwg_db_fetch_assoc($result);
if (!$row)
{
http_response_code(404);
exit;
}
$year = (int)$row['upload_year'];
$number = (string)$row['code_number'];
$stored_name = (string)$row['stored_name'];
$mime = strtolower((string)$row['mime_type']);
$allowed_mimes = array('image/png', 'image/jpeg', 'image/webp', 'image/gif');
if (
$stored_name === ''
|| basename($stored_name) !== $stored_name
|| !in_array($mime, $allowed_mimes, true)
)
{
http_response_code(404);
exit;
}
$path = bratonien_tools_customer_qr_storage_root().DIRECTORY_SEPARATOR.$year.DIRECTORY_SEPARATOR.$stored_name;
if (!is_file($path) || !is_readable($path))
{
http_response_code(404);
exit;
}
$extension = strtolower((string)pathinfo($stored_name, PATHINFO_EXTENSION));
$download = isset($_GET['download']) && (string)$_GET['download'] === '1';
$filename = 'qr-'.$year.'-'.$number.'.'.$extension;
header('Content-Type: '.$mime);
header('Content-Length: '.filesize($path));
header('Cache-Control: private, no-store, max-age=0');
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: '.($download ? 'attachment' : 'inline').'; filename="'.$filename.'"');
readfile($path);
exit;

584
customer-qr-upload.php Normal file
View File

@@ -0,0 +1,584 @@
<?php
$piwigo_root = realpath(dirname(__DIR__, 2));
if ($piwigo_root === false)
{
http_response_code(500);
exit;
}
define('PHPWG_ROOT_PATH', rtrim($piwigo_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR);
include_once(PHPWG_ROOT_PATH.'include/common.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
http_response_code(404);
exit('Bratonien Tools ist nicht aktiv.');
}
require_once(BRATONIEN_TOOLS_PATH.'include/customer_qr_upload.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/friendship_code_upload.inc.php');
$settings = bratonien_tools_customer_qr_settings();
if (empty($settings['enabled']))
{
http_response_code(404);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>QR-Code Upload</title></head><body><main style="max-width:720px;margin:48px auto;padding:24px;font-family:system-ui,sans-serif"><h1>QR-Code Upload</h1><p>Dieses Formular ist derzeit nicht aktiviert.</p></main></body></html>';
exit;
}
bratonien_tools_customer_qr_ensure_storage();
bratonien_tools_friendship_code_ensure_storage();
$year_limits = bratonien_tools_customer_qr_year_limits();
$action = (string)($_GET['action'] ?? '');
function bratonien_tools_customer_qr_public_inventory($year)
{
$year = bratonien_tools_customer_qr_year($year);
$limits = bratonien_tools_customer_qr_year_limits();
$limit = (int)$limits[$year];
$present = array();
$table = bratonien_tools_customer_qr_table();
$result = pwg_query(
'SELECT code_number FROM `'.$table.'` '
.'WHERE upload_year='.(int)$year.' '
.'ORDER BY CAST(code_number AS UNSIGNED) ASC'
);
while ($row = pwg_db_fetch_assoc($result))
{
$number = (int)$row['code_number'];
if ($number >= 1 && $number <= $limit)
{
$present[$number] = true;
}
}
$slots = array();
for ($number = 1; $number <= $limit; $number++)
{
$slots[] = array(
'number' => $number,
'present' => isset($present[$number]),
);
}
$present_count = count($present);
return array(
'year' => $year,
'limit' => $limit,
'present_count' => $present_count,
'missing_count' => max(0, $limit - $present_count),
'slots' => $slots,
);
}
if ($action === 'inventory')
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
try
{
$inventory = bratonien_tools_customer_qr_public_inventory($_GET['year'] ?? '');
echo json_encode(array(
'ok' => true,
'inventory' => $inventory,
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
catch (Throwable $e)
{
http_response_code(400);
echo json_encode(array('ok' => false, 'message' => $e->getMessage()), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
exit;
}
if ($action === 'check')
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
try
{
$year = bratonien_tools_customer_qr_year($_GET['year'] ?? '');
$number = bratonien_tools_customer_qr_number_for_year($year, $_GET['number'] ?? '');
$exists = bratonien_tools_customer_qr_exists($year, $number);
echo json_encode(array(
'ok' => true,
'available' => !$exists,
'year' => $year,
'number' => $number,
'limit' => (int)$year_limits[$year],
'message' => $exists
? 'Diese QR-Code-Nummer ist für '.$year.' bereits vorhanden.'
: 'Nummer ist für '.$year.' frei.',
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
catch (Throwable $e)
{
http_response_code(400);
echo json_encode(array('ok' => false, 'message' => $e->getMessage()), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
exit;
}
if ($action === 'check_friendship')
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
try
{
$name = bratonien_tools_friendship_code_name($_GET['name'] ?? '');
$exists = bratonien_tools_friendship_code_exists_for_name($name);
echo json_encode(array(
'ok' => true,
'available' => !$exists,
'name' => $name,
'message' => $exists
? 'Für diesen Namen ist bereits ein Freundschaftscode hinterlegt.'
: 'Für diesen Namen kann ein Freundschaftscode hinterlegt werden.',
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
catch (Throwable $e)
{
http_response_code(400);
echo json_encode(array('ok' => false, 'message' => $e->getMessage()), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST')
{
$results = array();
$year = bratonien_tools_customer_qr_default_year();
$upload_type = (string)($_POST['upload_type'] ?? 'qr');
try
{
if (
empty($_POST)
&& empty($_FILES)
&& !empty($_SERVER['CONTENT_LENGTH'])
)
{
throw new RuntimeException('Der Upload überschreitet das PHP-Limit post_max_size ('.ini_get('post_max_size').').');
}
if (function_exists('check_pwg_token'))
{
check_pwg_token();
}
if ($upload_type === 'friendship')
{
$name = bratonien_tools_friendship_code_name($_POST['friend_name'] ?? '');
if (empty($_FILES['friend_file']))
{
throw new RuntimeException('Es wurde keine Freundschaftscode-Datei ausgewählt.');
}
$results[] = bratonien_tools_friendship_code_process_upload($name, $_FILES['friend_file']);
}
else
{
$upload_type = 'qr';
$year = bratonien_tools_customer_qr_year($_POST['upload_year'] ?? '');
if (empty($_FILES['qr_files']))
{
throw new RuntimeException('Es wurden keine QR-Code-Dateien ausgewählt.');
}
$numbers = isset($_POST['qr_numbers']) && is_array($_POST['qr_numbers'])
? array_values($_POST['qr_numbers'])
: array();
$results = bratonien_tools_customer_qr_process_uploads($year, $_FILES['qr_files'], $numbers);
}
}
catch (Throwable $e)
{
$results[] = array(
'status' => 'error',
'kind' => $upload_type === 'friendship' ? 'friendship' : 'qr',
'file' => '',
'name' => $upload_type === 'friendship' ? trim((string)($_POST['friend_name'] ?? '')) : '',
'year' => $year,
'number' => '',
'message' => $e->getMessage(),
);
}
$_SESSION['bratonien_customer_qr_flash'] = array(
'type' => $upload_type,
'year' => $year,
'results' => $results,
);
$target = strtok($_SERVER['REQUEST_URI'], '?');
header('Location: '.$target, true, 303);
exit;
}
$flash = array();
if (!empty($_SESSION['bratonien_customer_qr_flash']) && is_array($_SESSION['bratonien_customer_qr_flash']))
{
$flash = $_SESSION['bratonien_customer_qr_flash'];
unset($_SESSION['bratonien_customer_qr_flash']);
}
$selected_year = isset($flash['year']) ? (int)$flash['year'] : bratonien_tools_customer_qr_default_year();
if (!isset($year_limits[$selected_year]))
{
$selected_year = bratonien_tools_customer_qr_default_year();
}
$selected_limit = (int)$year_limits[$selected_year];
$inventory = bratonien_tools_customer_qr_public_inventory($selected_year);
$results = isset($flash['results']) && is_array($flash['results']) ? $flash['results'] : array();
$token = function_exists('get_pwg_token') ? get_pwg_token() : '';
$max_files = max(1, (int)ini_get('max_file_uploads'));
$endpoint = strtok($_SERVER['REQUEST_URI'], '?');
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QR-Code Upload</title>
<style>
:root{color-scheme:light dark;--bg:#111214;--card:#1a1c20;--text:#f2f2f2;--muted:#a9adb5;--border:#383c44;--accent:#d99a43;--ok:#67b779;--bad:#d46b6b;--wait:#c7a65a}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{max-width:860px;margin:0 auto;padding:34px 18px 60px}
h1{font-size:clamp(1.8rem,5vw,2.6rem);margin:0 0 8px}
h2{font-size:1.12rem;margin:0 0 12px}
p{margin:0 0 16px}.muted{color:var(--muted)}
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:20px;margin-top:18px}
.grid{display:grid;grid-template-columns:minmax(0,180px) minmax(0,1fr);gap:14px 18px;align-items:center}
label{font-weight:650}.control{min-width:0}
select,input[type="text"],input[type="file"]{width:100%;font:inherit;color:inherit;background:#121418;border:1px solid var(--border);border-radius:9px;padding:11px 12px}
input[type="file"]{padding:9px}
input:focus,select:focus,button:focus{outline:2px solid var(--accent);outline-offset:2px}
.file-list{display:grid;gap:10px;margin-top:16px}
.file-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,180px) minmax(170px,220px);gap:10px;align-items:center;padding:12px;border:1px solid var(--border);border-radius:10px;background:#15171b}
.file-name{overflow-wrap:anywhere}.status{font-size:.92rem;color:var(--muted)}.status.ok{color:var(--ok)}.status.bad{color:var(--bad)}.status.wait{color:var(--wait)}
.actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:18px}
button{border:0;border-radius:9px;padding:12px 18px;background:var(--accent);color:#1a140d;font:inherit;font-weight:750;cursor:pointer}
button:disabled{opacity:.45;cursor:not-allowed}
.result{padding:12px 14px;border-radius:9px;margin-top:8px;border:1px solid var(--border)}
.result.ok{border-color:color-mix(in srgb,var(--ok),transparent 45%)}.result.duplicate,.result.error{border-color:color-mix(in srgb,var(--bad),transparent 45%)}
.result strong{display:block}.meta{color:var(--muted);font-size:.92rem}
.inventory{margin-top:20px;padding-top:18px;border-top:1px solid var(--border)}
.inventory-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:10px}
.inventory-head h2{margin:0}
.inventory-legend{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-bottom:12px;font-size:.92rem;color:var(--muted)}
.inventory-legend span{display:inline-flex;align-items:center;gap:6px}
.inventory-dot{width:14px;height:14px;border-radius:3px;border:1px solid}
.inventory-dot.present{background:rgba(62,170,94,.30);border-color:#58c878}
.inventory-dot.missing{background:rgba(205,72,72,.28);border-color:#db6d6d}
.inventory-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(42px,1fr));gap:6px}
.inventory-slot{min-height:38px;display:flex;align-items:center;justify-content:center;border:1px solid;border-radius:6px;font-weight:750}
.inventory-slot.present{background:rgba(62,170,94,.30);border-color:#58c878;color:#dff7e6}
.inventory-slot.missing{background:rgba(205,72,72,.28);border-color:#db6d6d;color:#ffdede}
@media(max-width:680px){.grid{grid-template-columns:1fr}.file-row{grid-template-columns:1fr}.card{padding:16px}.inventory-grid{grid-template-columns:repeat(auto-fill,minmax(38px,1fr))}}
</style>
</head>
<body>
<main>
<header>
<h1>QR-Code Upload</h1>
<p class="muted">QR-Codes und persönliche Freundschaftscodes hochladen.</p>
</header>
<?php if (!empty($results)): ?>
<section class="card" aria-labelledby="upload-result-title">
<h2 id="upload-result-title">Ergebnis</h2>
<?php foreach ($results as $result): ?>
<?php
$status = in_array(($result['status'] ?? ''), array('ok','duplicate','error'), true) ? $result['status'] : 'error';
$kind = (string)($result['kind'] ?? 'qr');
if ($status === 'ok') $headline = 'Gespeichert';
elseif ($status === 'duplicate') $headline = $kind === 'friendship' ? 'Nicht gespeichert: bereits vorhanden' : 'Nicht gespeichert: Nummer bereits vergeben';
else $headline = 'Nicht gespeichert';
?>
<div class="result <?php echo htmlspecialchars($status, ENT_QUOTES, 'UTF-8'); ?>">
<strong><?php echo htmlspecialchars($headline, ENT_QUOTES, 'UTF-8'); ?></strong>
<?php if (!empty($result['file'])): ?><div><?php echo htmlspecialchars($result['file'], ENT_QUOTES, 'UTF-8'); ?></div><?php endif; ?>
<?php if ($kind === 'friendship' && !empty($result['name'])): ?><div class="meta">Name: <?php echo htmlspecialchars($result['name'], ENT_QUOTES, 'UTF-8'); ?></div><?php endif; ?>
<?php if ($kind !== 'friendship' && !empty($result['number'])): ?><div class="meta">Jahr <?php echo (int)$result['year']; ?> · QR-Code #<?php echo htmlspecialchars($result['number'], ENT_QUOTES, 'UTF-8'); ?></div><?php endif; ?>
<div class="meta"><?php echo htmlspecialchars((string)($result['message'] ?? ''), ENT_QUOTES, 'UTF-8'); ?></div>
</div>
<?php endforeach; ?>
</section>
<?php endif; ?>
<form class="card" method="post" enctype="multipart/form-data" id="qr-upload-form" novalidate>
<input type="hidden" name="pwg_token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
<input type="hidden" name="upload_type" value="qr">
<h2>QR-Code</h2>
<div class="grid">
<label for="upload-year">Jahr</label>
<div class="control">
<select id="upload-year" name="upload_year" required>
<?php foreach ($year_limits as $year => $limit): ?>
<option value="<?php echo (int)$year; ?>"<?php echo (int)$year === $selected_year ? ' selected' : ''; ?>><?php echo (int)$year; ?></option>
<?php endforeach; ?>
</select>
<div id="year-limit-note" class="muted" style="margin-top:6px">Für <?php echo $selected_year; ?> sind QR-Code-Nummern 1 bis <?php echo $selected_limit; ?> vorgesehen.</div>
</div>
<label for="qr-files">QR-Code-Datei(en)</label>
<div class="control">
<input id="qr-files" name="qr_files[]" type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple required>
<div class="muted" style="margin-top:6px">PNG, JPG, WEBP oder GIF. Batch-Upload bis zum Serverlimit von <?php echo $max_files; ?> Dateien pro Anfrage.</div>
</div>
</div>
<section class="inventory" aria-labelledby="inventory-title">
<div class="inventory-head">
<h2 id="inventory-title">QR-Code-Bestand <span id="inventory-year"><?php echo (int)$inventory['year']; ?></span></h2>
<span id="inventory-count" class="muted"><?php echo (int)$inventory['present_count']; ?> vorhanden · <?php echo (int)$inventory['missing_count']; ?> fehlen</span>
</div>
<div class="inventory-legend" aria-hidden="true">
<span><i class="inventory-dot present"></i>Vorhanden</span>
<span><i class="inventory-dot missing"></i>Fehlt</span>
</div>
<div id="inventory-grid" class="inventory-grid" aria-live="polite">
<?php foreach ($inventory['slots'] as $slot): ?>
<span class="inventory-slot <?php echo !empty($slot['present']) ? 'present' : 'missing'; ?>" title="QR-Code #<?php echo (int)$slot['number']; ?> <?php echo !empty($slot['present']) ? 'vorhanden' : 'fehlt'; ?>"><?php echo (int)$slot['number']; ?></span>
<?php endforeach; ?>
</div>
</section>
<div id="file-list" class="file-list" aria-live="polite"></div>
<div class="actions">
<button id="submit-button" type="submit" disabled>QR-Code(s) hochladen</button>
<span class="muted">Eine Nummer kann innerhalb eines Jahres nur einmal verwendet werden.</span>
</div>
</form>
<form class="card" method="post" enctype="multipart/form-data" id="friendship-upload-form" novalidate>
<input type="hidden" name="pwg_token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
<input type="hidden" name="upload_type" value="friendship">
<h2>Persönlicher Freundschaftscode</h2>
<div class="grid">
<label for="friend-name">Name</label>
<div class="control">
<input id="friend-name" name="friend_name" type="text" maxlength="120" autocomplete="name" required>
<div id="friend-name-status" class="status" style="margin-top:6px">Bitte Namen eingeben.</div>
</div>
<label for="friend-file">Freundschaftscode</label>
<div class="control">
<input id="friend-file" name="friend_file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" required>
<div class="muted" style="margin-top:6px">Ein Bild pro Person. PNG, JPG, WEBP oder GIF.</div>
</div>
</div>
<div class="actions">
<button id="friend-submit-button" type="submit" disabled>Freundschaftscode hochladen</button>
<span class="muted">Pro Name wird genau ein persönlicher Freundschaftscode gespeichert.</span>
</div>
</form>
</main>
<script>
(function(){
'use strict';
var form=document.getElementById('qr-upload-form');
var fileInput=document.getElementById('qr-files');
var yearInput=document.getElementById('upload-year');
var yearNote=document.getElementById('year-limit-note');
var list=document.getElementById('file-list');
var submit=document.getElementById('submit-button');
var inventoryYear=document.getElementById('inventory-year');
var inventoryCount=document.getElementById('inventory-count');
var inventoryGrid=document.getElementById('inventory-grid');
var endpoint=<?php echo json_encode($endpoint, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); ?>;
var limits=<?php echo json_encode($year_limits, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); ?>;
var timers=new WeakMap();
function yearLimit(){return Number(limits[String(yearInput.value)]||limits[yearInput.value]||0);}
function canonical(value){
value=String(value||'').trim();
if(!/^\d+$/.test(value))return '';
value=value.replace(/^0+(?=\d)/,'');
if(value==='0')return '';
return value;
}
function rows(){return Array.prototype.slice.call(list.querySelectorAll('.file-row'));}
function setStatus(row,text,kind){
var node=row.querySelector('.status');
node.textContent=text;
node.className='status'+(kind?' '+kind:'');
row.dataset.valid=kind==='ok'?'1':'0';
updateSubmit();
}
function updateSubmit(){
var all=rows();
submit.disabled=!all.length||all.some(function(row){return row.dataset.valid!=='1';});
}
function renderInventory(inventory){
inventoryYear.textContent=String(inventory.year);
inventoryCount.textContent=String(inventory.present_count)+' vorhanden · '+String(inventory.missing_count)+' fehlen';
inventoryGrid.textContent='';
(inventory.slots||[]).forEach(function(slot){
var node=document.createElement('span');
node.className='inventory-slot '+(slot.present?'present':'missing');
node.textContent=String(slot.number);
node.title='QR-Code #'+slot.number+' '+(slot.present?'vorhanden':'fehlt');
inventoryGrid.appendChild(node);
});
}
function loadInventory(){
inventoryCount.textContent='Bestand wird geladen …';
var requestedYear=yearInput.value;
var url=endpoint+'?action=inventory&year='+encodeURIComponent(requestedYear)+'&_='+Date.now();
fetch(url,{credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}})
.then(function(response){return response.json().then(function(data){return {response:response,data:data};});})
.then(function(result){
if(!result.response.ok||!result.data.ok||!result.data.inventory)throw new Error(result.data.message||'Bestand konnte nicht geladen werden.');
if(String(yearInput.value)!==String(requestedYear))return;
renderInventory(result.data.inventory);
})
.catch(function(error){inventoryCount.textContent=error.message||'Bestand konnte nicht geladen werden.';});
}
function markBatchDuplicates(){
var grouped={};
rows().forEach(function(row){
var input=row.querySelector('input[name="qr_numbers[]"]');
var value=canonical(input.value);
if(!value)return;
if(!grouped[value])grouped[value]=[];
grouped[value].push(row);
});
Object.keys(grouped).forEach(function(key){
if(grouped[key].length>1){
grouped[key].forEach(function(row){setStatus(row,'Nummer ist in diesem Batch doppelt.','bad');});
}
});
return grouped;
}
function checkRow(row){
var input=row.querySelector('input[name="qr_numbers[]"]');
var value=canonical(input.value);
var limit=yearLimit();
if(!value){setStatus(row,'Bitte eine QR-Code-Nummer ab 1 eingeben.','bad');return;}
if(!limit||Number(value)>limit){setStatus(row,'Für '+yearInput.value+' sind nur die Nummern 1 bis '+limit+' vorgesehen.','bad');return;}
var grouped=markBatchDuplicates();
if(grouped[value]&&grouped[value].length>1)return;
setStatus(row,'Nummer wird geprüft …','wait');
var url=endpoint+'?action=check&year='+encodeURIComponent(yearInput.value)+'&number='+encodeURIComponent(value)+'&_='+Date.now();
fetch(url,{credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}})
.then(function(response){return response.json().then(function(data){return {response:response,data:data};});})
.then(function(result){
if(!result.response.ok||!result.data.ok)throw new Error(result.data.message||'Prüfung fehlgeschlagen.');
if(result.data.available)setStatus(row,result.data.message,'ok');
else setStatus(row,result.data.message,'bad');
})
.catch(function(error){setStatus(row,error.message||'Prüfung fehlgeschlagen.','bad');});
}
function scheduleCheck(row){
var old=timers.get(row);if(old)window.clearTimeout(old);
timers.set(row,window.setTimeout(function(){checkRow(row);},250));
}
function scheduleAll(){rows().forEach(function(row){scheduleCheck(row);});}
function updateYearNote(){
yearNote.textContent='Für '+yearInput.value+' sind QR-Code-Nummern 1 bis '+yearLimit()+' vorgesehen.';
}
function rebuild(){
list.textContent='';
Array.prototype.slice.call(fileInput.files||[]).forEach(function(file,index){
var row=document.createElement('div');row.className='file-row';row.dataset.valid='0';
var name=document.createElement('div');name.className='file-name';name.textContent=file.name;
var input=document.createElement('input');input.type='text';input.name='qr_numbers[]';input.inputMode='numeric';input.autocomplete='off';input.placeholder='QR-Nummer';input.setAttribute('aria-label','QR-Code-Nummer für '+file.name);input.maxLength=3;input.required=true;
var status=document.createElement('div');status.className='status';status.textContent='Nummer fehlt.';
row.appendChild(name);row.appendChild(input);row.appendChild(status);list.appendChild(row);
input.addEventListener('input',scheduleAll);
if(index===0)window.setTimeout(function(){input.focus();},0);
});
updateSubmit();
}
fileInput.addEventListener('change',rebuild);
yearInput.addEventListener('change',function(){updateYearNote();scheduleAll();loadInventory();});
form.addEventListener('submit',function(event){
var invalid=rows().some(function(row){return row.dataset.valid!=='1';});
if(invalid){event.preventDefault();scheduleAll();}
else{submit.disabled=true;submit.textContent='Upload läuft …';}
});
})();
(function(){
'use strict';
var form=document.getElementById('friendship-upload-form');
var nameInput=document.getElementById('friend-name');
var fileInput=document.getElementById('friend-file');
var status=document.getElementById('friend-name-status');
var submit=document.getElementById('friend-submit-button');
var endpoint=<?php echo json_encode($endpoint, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); ?>;
var timer=null;
var nameValid=false;
function updateSubmit(){
submit.disabled=!nameValid||!fileInput.files||fileInput.files.length!==1;
}
function setStatus(text,kind){
status.textContent=text;
status.className='status'+(kind?' '+kind:'');
nameValid=kind==='ok';
updateSubmit();
}
function checkName(){
var name=String(nameInput.value||'').trim().replace(/\s+/g,' ');
if(!name){setStatus('Bitte Namen eingeben.','bad');return;}
setStatus('Name wird geprüft …','wait');
var url=endpoint+'?action=check_friendship&name='+encodeURIComponent(name)+'&_='+Date.now();
fetch(url,{credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}})
.then(function(response){return response.json().then(function(data){return {response:response,data:data};});})
.then(function(result){
if(!result.response.ok||!result.data.ok)throw new Error(result.data.message||'Prüfung fehlgeschlagen.');
if(result.data.available)setStatus(result.data.message,'ok');
else setStatus(result.data.message,'bad');
})
.catch(function(error){setStatus(error.message||'Prüfung fehlgeschlagen.','bad');});
}
nameInput.addEventListener('input',function(){
nameValid=false;updateSubmit();
if(timer)window.clearTimeout(timer);
timer=window.setTimeout(checkName,250);
});
fileInput.addEventListener('change',updateSubmit);
form.addEventListener('submit',function(event){
if(!nameValid||!fileInput.files||fileInput.files.length!==1){event.preventDefault();checkName();return;}
submit.disabled=true;submit.textContent='Upload läuft …';
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,74 @@
<?php
$piwigo_root = realpath(dirname(__DIR__, 2));
if ($piwigo_root === false)
{
http_response_code(500);
exit;
}
define('PHPWG_ROOT_PATH', rtrim($piwigo_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR);
include_once(PHPWG_ROOT_PATH.'include/common.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
http_response_code(404);
exit;
}
check_status(ACCESS_ADMINISTRATOR);
require_once(BRATONIEN_TOOLS_PATH.'include/friendship_code_upload.inc.php');
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id < 1)
{
http_response_code(404);
exit;
}
$table = bratonien_tools_friendship_code_table();
$result = pwg_query('SELECT id, person_name, stored_name, mime_type FROM `'.$table.'` WHERE id='.$id.' LIMIT 1');
$row = pwg_db_fetch_assoc($result);
if (!$row)
{
http_response_code(404);
exit;
}
$stored_name = (string)$row['stored_name'];
$mime = strtolower((string)$row['mime_type']);
$allowed_mimes = array('image/png', 'image/jpeg', 'image/webp', 'image/gif');
if ($stored_name === '' || basename($stored_name) !== $stored_name || !in_array($mime, $allowed_mimes, true))
{
http_response_code(404);
exit;
}
$path = bratonien_tools_friendship_code_storage_root().DIRECTORY_SEPARATOR.$stored_name;
if (!is_file($path) || !is_readable($path))
{
http_response_code(404);
exit;
}
$extension = strtolower((string)pathinfo($stored_name, PATHINFO_EXTENSION));
if ($extension === '')
{
$extension = 'png';
}
header('Content-Type: '.$mime);
header('Content-Length: '.filesize($path));
header('Cache-Control: private, no-store, max-age=0');
header('X-Content-Type-Options: nosniff');
if (!empty($_GET['download']))
{
header('Content-Disposition: attachment; filename="freundschaftscode-'.$id.'.'.$extension.'"');
}
else
{
header('Content-Disposition: inline; filename="freundschaftscode-'.$id.'.'.$extension.'"');
}
readfile($path);
exit;

View File

@@ -60,6 +60,114 @@ function bratonien_tools_atomic_cache_remove_tree($root, array &$failed)
if (!@rmdir($root) && is_dir($root)) $failed[] = $root;
}
function bratonien_tools_atomic_cache_acquire_lock($path, $label, $timeout_seconds=10.0)
{
$directory = dirname($path);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory))
{
throw new RuntimeException($label.'-Lockverzeichnis konnte nicht angelegt werden.');
}
$handle = @fopen($path, 'c');
if (!$handle)
{
throw new RuntimeException($label.'-Lock konnte nicht geöffnet werden: '.$path);
}
$deadline = microtime(true) + max(0.1, (float)$timeout_seconds);
do
{
if (@flock($handle, LOCK_EX | LOCK_NB))
{
return $handle;
}
usleep(100000);
}
while (microtime(true) < $deadline);
fclose($handle);
throw new RuntimeException($label.' ist noch aktiv. Der Cache wurde nicht angefasst; bitte den laufenden Vorgang kurz abschließen lassen und erneut leeren.');
}
function bratonien_tools_atomic_cache_release_locks(array &$locks)
{
foreach (array_reverse($locks) as $handle)
{
if (!is_resource($handle)) continue;
@flock($handle, LOCK_UN);
fclose($handle);
}
$locks = array();
}
function bratonien_tools_atomic_cache_prepare_webdav_guards(array &$locks, array &$cancel_files)
{
if (!function_exists('bratonien_tools_webdav_warmup_connections')) return 0;
$connections = bratonien_tools_webdav_warmup_connections();
if (!$connections) return 0;
ksort($connections, SORT_NUMERIC);
// Jeder aktive WebDAV-Worker bekommt zuerst sein reguläres Abbruchsignal.
// Ein bereits gestarteter 10er-Batch darf sauber fertig werden; ein neuer
// Batch darf danach nicht mehr beginnen.
if (function_exists('bratonien_tools_request_webdav_cache_cancel'))
{
bratonien_tools_request_webdav_cache_cancel();
}
// Das Signal wird für alle Verbindungen gesetzt, auch wenn deren Statusdatei
// veraltet sein sollte. Sobald wir anschließend den Prozess-Lock besitzen,
// kann garantiert kein WebDAV-Worker während des Cache-Leerens schreiben.
foreach ($connections as $connection_id=>$runtime)
{
$cancel = function_exists('bratonien_tools_webdav_warmup_cancel_file_for_connection')
? bratonien_tools_webdav_warmup_cancel_file_for_connection($connection_id)
: PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.cancel-'.(int)$connection_id;
if (@file_put_contents($cancel, (string)time()."\n", LOCK_EX) === false)
{
throw new RuntimeException('WebDAV-Abbruchsignal für Verbindung #'.(int)$connection_id.' konnte nicht geschrieben werden.');
}
@chmod($cancel, 0664);
$cancel_files[] = $cancel;
}
foreach ($connections as $connection_id=>$runtime)
{
$state_dir = rtrim((string)($runtime['state_dir'] ?? ''), '/');
if ($state_dir === '')
{
throw new RuntimeException('WebDAV-State-Verzeichnis für Verbindung #'.(int)$connection_id.' fehlt.');
}
$locks[] = bratonien_tools_atomic_cache_acquire_lock(
$state_dir.'/webdav-cache-warmup.lock',
'WebDAV-Cache-Worker #'.(int)$connection_id,
10.0
);
}
return count($connections);
}
function bratonien_tools_atomic_cache_prepare_presentation_guard(array &$locks)
{
if (!function_exists('bratonien_tools_presentation_refresh_queue_dir')) return false;
$queue_dir = bratonien_tools_presentation_refresh_queue_dir();
if (!is_dir($queue_dir)) return false;
// Der Presentation-Worker erzeugt Bratonien-Wasserzeichen-Vorschauen im
// selben Derivatbaum. Deshalb muss auch er während des atomaren Leerens
// vollständig draußen bleiben.
$locks[] = bratonien_tools_atomic_cache_acquire_lock(
rtrim($queue_dir, '/').'/.worker.lock',
'Vorschau-Aktualisierung',
10.0
);
return true;
}
function bratonien_tools_clear_image_cache_atomic()
{
global $conf;
@@ -69,125 +177,166 @@ function bratonien_tools_clear_image_cache_atomic()
throw new RuntimeException('PWG_DERIVATIVE_DIR ist nicht definiert.');
}
if (bratonien_tools_main_cache_process_active() || bratonien_tools_main_cache_is_running())
$held_locks = array();
$webdav_cancel_files = array();
$webdav_guards_ready = false;
try
{
bratonien_tools_request_main_cache_cancel();
if (!bratonien_tools_wait_main_cache_stopped(10.0))
// 1. Lokalen Cache-Builder sauber anhalten und seinen Prozess-Lock selbst
// halten. Damit kann zwischen Prüfung und atomarem Rename kein neuer
// lokaler Builder starten.
if (bratonien_tools_main_cache_process_active() || bratonien_tools_main_cache_is_running())
{
throw new RuntimeException('Der laufende Cache-Aufbau konnte noch nicht beendet werden. Bitte den Abbruch kurz abschließen lassen und erneut leeren.');
bratonien_tools_request_main_cache_cancel();
if (!bratonien_tools_wait_main_cache_stopped(10.0))
{
throw new RuntimeException('Der laufende Cache-Aufbau konnte noch nicht beendet werden. Bitte den Abbruch kurz abschließen lassen und erneut leeren.');
}
}
}
@unlink(bratonien_tools_main_cache_cancel_file());
$piwigo_root = realpath(PHPWG_ROOT_PATH);
if ($piwigo_root === false)
{
throw new RuntimeException('Piwigo-Root konnte für die Cache-Sicherheitsprüfung nicht aufgelöst werden.');
}
$cache_root = rtrim(PHPWG_ROOT_PATH, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.trim(PWG_DERIVATIVE_DIR, '/\\');
$real_cache_root = realpath($cache_root);
if ($real_cache_root === false || !is_dir($real_cache_root))
{
throw new RuntimeException('Bildcache-Verzeichnis wurde nicht gefunden: '.$cache_root);
}
$root_prefix = rtrim($piwigo_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (strpos(rtrim($real_cache_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR, $root_prefix) !== 0)
{
throw new RuntimeException('Bildcache liegt außerhalb der Piwigo-Installation. Abbruch.');
}
if (rtrim($real_cache_root, DIRECTORY_SEPARATOR) === rtrim($piwigo_root, DIRECTORY_SEPARATOR))
{
throw new RuntimeException('Bildcache-Pfad entspricht dem Piwigo-Root. Sicherheitsabbruch.');
}
$before = bratonien_tools_scan_image_cache($real_cache_root);
$parent = dirname($real_cache_root);
if (!is_dir($parent) || !is_writable($parent))
{
throw new RuntimeException('Übergeordnetes Bildcache-Verzeichnis ist nicht beschreibbar: '.$parent);
}
$detached = $parent.'/.'.basename($real_cache_root).'.bratonien-clear-'.date('YmdHis').'-'.bin2hex(random_bytes(4));
if (file_exists($detached) || is_link($detached))
{
throw new RuntimeException('Temporäres Cache-Auslagerungsverzeichnis existiert bereits. Abbruch.');
}
if (!@rename($real_cache_root, $detached))
{
throw new RuntimeException('Bildcache konnte nicht atomar aus dem aktiven Pfad ausgelagert werden.');
}
$mode = isset($conf['chmod_value']) ? (int)$conf['chmod_value'] : 0755;
$umask = umask(0);
$created = @mkdir($real_cache_root, $mode, true);
umask($umask);
if (!$created && !is_dir($real_cache_root))
{
if (!file_exists($real_cache_root) && @rename($detached, $real_cache_root))
{
throw new RuntimeException('Neuer Bildcache konnte nicht angelegt werden; der bisherige Cache wurde vollständig wiederhergestellt.');
}
throw new RuntimeException('Neuer Bildcache konnte nicht angelegt und der bisherige Cache nicht automatisch wiederhergestellt werden. Manueller Eingriff erforderlich.');
}
@chmod($real_cache_root, $mode);
@file_put_contents($real_cache_root.'/index.htm', 'Not allowed!');
// Der Quellenindex selbst bleibt bestehen, weil sich die Quellen durch ein
// Cache-Leeren nicht ändern. Seine Fertigmarkierungen sind danach aber
// zwingend ungültig. Der Worker darf sonst einen leeren Piwigo-Cache als
// bereits verarbeitet ansehen, ohne den Cache selbst anzuschauen.
$webdav_invalidated = 0;
if (function_exists('bratonien_tools_invalidate_webdav_cache_completion'))
{
$webdav_invalidated = bratonien_tools_invalidate_webdav_cache_completion('Piwigo-Bildcache wurde atomar geleert.');
}
$failed = array();
bratonien_tools_atomic_cache_remove_tree($detached, $failed);
$active = bratonien_tools_scan_image_cache($real_cache_root);
bratonien_tools_write_main_cache_status(array(
'state'=>'idle',
'message'=>'Bildcache wurde atomar geleert. Kein manueller Cache-Aufbau aktiv.',
));
if ($failed)
{
throw new RuntimeException(sprintf(
'Der aktive Bildcache wurde erfolgreich geleert und neu angelegt, aber %d Datei(en)/Verzeichnis(se) des ausgelagerten Altbestands konnten nicht entfernt werden. Erste problematische Stelle: %s',
count($failed),
$failed[0]
));
}
$message = sprintf(
'Bildcache atomar geleert: %d alte Datei(en) (%s) entfernt, davon %d Custom-Derivate.',
$before['files'],
bratonien_tools_format_bytes($before['bytes']),
$before['custom']
);
if ($webdav_invalidated > 0)
{
$message .= sprintf(' Die Cache-Fertigmarkierungen von %d WebDAV-Worker-Index(en) wurden verworfen; die Quellenindizes selbst bleiben erhalten.', $webdav_invalidated);
}
if ($active['files'] > 0)
{
$message .= sprintf(
' Während bzw. unmittelbar nach dem Umschalten wurden bereits %d neue Derivatdatei(en) durch laufende Anfragen erzeugt; diese gehören zum neuen Cache und sind kein Rest des gelöschten Bestands.',
$active['files']
@unlink(bratonien_tools_main_cache_cancel_file());
$held_locks[] = bratonien_tools_atomic_cache_acquire_lock(
bratonien_tools_main_cache_lock_file(),
'Lokaler Piwigo-Cache-Worker',
10.0
);
}
else
{
$message .= ' Der neu aktive Cache ist zum Abschluss der Prüfung leer.';
}
return array('message'=>$message);
// 2. WebDAV-Warmup regulär abbrechen und danach alle Verbindungs-Locks
// exklusiv halten. Der laufende Batch wird nicht hart beendet.
bratonien_tools_atomic_cache_prepare_webdav_guards($held_locks, $webdav_cancel_files);
$webdav_guards_ready = true;
// 3. Seit der Presentation-Refresh-Einführung kann auch dieser Worker
// Derivate erzeugen. Ohne seinen Lock konnte ein gerade geleerter Cache
// sofort wieder gefüllt werden und wie ein fehlgeschlagenes Löschen wirken.
bratonien_tools_atomic_cache_prepare_presentation_guard($held_locks);
$piwigo_root = realpath(PHPWG_ROOT_PATH);
if ($piwigo_root === false)
{
throw new RuntimeException('Piwigo-Root konnte für die Cache-Sicherheitsprüfung nicht aufgelöst werden.');
}
$cache_root = rtrim(PHPWG_ROOT_PATH, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.trim(PWG_DERIVATIVE_DIR, '/\\');
$real_cache_root = realpath($cache_root);
if ($real_cache_root === false || !is_dir($real_cache_root))
{
throw new RuntimeException('Bildcache-Verzeichnis wurde nicht gefunden: '.$cache_root);
}
$root_prefix = rtrim($piwigo_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (strpos(rtrim($real_cache_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR, $root_prefix) !== 0)
{
throw new RuntimeException('Bildcache liegt außerhalb der Piwigo-Installation. Abbruch.');
}
if (rtrim($real_cache_root, DIRECTORY_SEPARATOR) === rtrim($piwigo_root, DIRECTORY_SEPARATOR))
{
throw new RuntimeException('Bildcache-Pfad entspricht dem Piwigo-Root. Sicherheitsabbruch.');
}
$before = bratonien_tools_scan_image_cache($real_cache_root);
$parent = dirname($real_cache_root);
if (!is_dir($parent) || !is_writable($parent))
{
throw new RuntimeException('Übergeordnetes Bildcache-Verzeichnis ist nicht beschreibbar: '.$parent);
}
$detached = $parent.'/.'.basename($real_cache_root).'.bratonien-clear-'.date('YmdHis').'-'.bin2hex(random_bytes(4));
if (file_exists($detached) || is_link($detached))
{
throw new RuntimeException('Temporäres Cache-Auslagerungsverzeichnis existiert bereits. Abbruch.');
}
if (!@rename($real_cache_root, $detached))
{
throw new RuntimeException('Bildcache konnte nicht atomar aus dem aktiven Pfad ausgelagert werden.');
}
$mode = isset($conf['chmod_value']) ? (int)$conf['chmod_value'] : 0755;
$umask = umask(0);
$created = @mkdir($real_cache_root, $mode, true);
umask($umask);
if (!$created && !is_dir($real_cache_root))
{
if (!file_exists($real_cache_root) && @rename($detached, $real_cache_root))
{
throw new RuntimeException('Neuer Bildcache konnte nicht angelegt werden; der bisherige Cache wurde vollständig wiederhergestellt.');
}
throw new RuntimeException('Neuer Bildcache konnte nicht angelegt und der bisherige Cache nicht automatisch wiederhergestellt werden. Manueller Eingriff erforderlich.');
}
@chmod($real_cache_root, $mode);
@file_put_contents($real_cache_root.'/index.htm', 'Not allowed!');
// Der Quellenindex selbst bleibt bestehen, weil sich die Quellen durch ein
// Cache-Leeren nicht ändern. Seine Fertigmarkierungen sind danach aber
// zwingend ungültig. Der Worker darf sonst einen leeren Piwigo-Cache als
// bereits verarbeitet ansehen, ohne den Cache selbst anzuschauen.
$webdav_invalidated = 0;
if (function_exists('bratonien_tools_invalidate_webdav_cache_completion'))
{
$webdav_invalidated = bratonien_tools_invalidate_webdav_cache_completion('Piwigo-Bildcache wurde atomar geleert.');
}
$failed = array();
bratonien_tools_atomic_cache_remove_tree($detached, $failed);
$active = bratonien_tools_scan_image_cache($real_cache_root);
bratonien_tools_write_main_cache_status(array(
'state'=>'idle',
'message'=>'Bildcache wurde atomar geleert. Kein Cache-Worker schreibt während des Löschvorgangs in den Derivatbaum.',
));
if ($failed)
{
throw new RuntimeException(sprintf(
'Der aktive Bildcache wurde erfolgreich geleert und neu angelegt, aber %d Datei(en)/Verzeichnis(se) des ausgelagerten Altbestands konnten nicht entfernt werden. Erste problematische Stelle: %s',
count($failed),
$failed[0]
));
}
$message = sprintf(
'Bildcache atomar geleert: %d alte Datei(en) (%s) entfernt, davon %d Custom-Derivate.',
$before['files'],
bratonien_tools_format_bytes($before['bytes']),
$before['custom']
);
if ($webdav_invalidated > 0)
{
$message .= sprintf(' Die Cache-Fertigmarkierungen von %d WebDAV-Worker-Index(en) wurden verworfen; die Quellenindizes selbst bleiben erhalten.', $webdav_invalidated);
}
if ($active['files'] > 0)
{
$message .= sprintf(
' Unmittelbar nach dem atomaren Umschalten wurden bereits %d neue Derivatdatei(en) durch normale Piwigo-Anfragen erzeugt; die eigenen Cache-Worker waren während des Löschens gesperrt und der alte Cachebaum wurde vollständig entfernt.',
$active['files']
);
}
else
{
$message .= ' Der neu aktive Cache ist zum Abschluss der Prüfung leer.';
}
return array('message'=>$message);
}
finally
{
// Nur wenn wirklich alle WebDAV-Prozess-Locks gesichert wurden, sind die
// Cancel-Dateien nicht mehr nötig. Bei einem Timeout bleiben sie bestehen,
// damit der noch laufende Worker nach seinem aktuellen Batch tatsächlich
// stoppt und das fehlgeschlagene Cache-Leeren nicht durch einen Folgebatch
// verschärft wird.
if ($webdav_guards_ready)
{
foreach ($webdav_cancel_files as $cancel)
{
@unlink($cancel);
}
}
bratonien_tools_atomic_cache_release_locks($held_locks);
}
}

View File

@@ -0,0 +1,105 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_customer_qr_register_frontend_menu($menu_ref_array)
{
if (defined('IN_ADMIN'))
{
return;
}
$settings = bratonien_tools_customer_qr_settings();
if (empty($settings['enabled']))
{
return;
}
if (!is_array($menu_ref_array) || empty($menu_ref_array[0]) || !is_object($menu_ref_array[0]))
{
return;
}
$menu = $menu_ref_array[0];
if (!method_exists($menu, 'get_id') || $menu->get_id() !== 'menubar' || !method_exists($menu, 'register_block'))
{
return;
}
if (!class_exists('RegisteredBlock'))
{
return;
}
$menu->register_block(new RegisteredBlock(
'mbBratonienCustomerQr',
'QR-Code hochladen',
BRATONIEN_TOOLS_ID
));
}
function bratonien_tools_customer_qr_prepare_frontend_menu($menu_ref_array)
{
if (defined('IN_ADMIN'))
{
return;
}
$settings = bratonien_tools_customer_qr_settings();
if (empty($settings['enabled']))
{
return;
}
if (!is_array($menu_ref_array) || empty($menu_ref_array[0]) || !is_object($menu_ref_array[0]))
{
return;
}
$menu = $menu_ref_array[0];
if (!method_exists($menu, 'get_id') || $menu->get_id() !== 'menubar')
{
return;
}
$block = $menu->get_block('mbBratonienCustomerQr');
if ($block === null)
{
return;
}
$url = htmlspecialchars(
get_root_url().'plugins/'.BRATONIEN_TOOLS_ID.'/customer-qr-upload.php',
ENT_QUOTES,
'UTF-8'
);
// Bootstrap Darkroom renders raw-content blocks directly in the root navbar.
// Keeping this as a separate Piwigo menu block avoids hiding the customer
// action inside mbMenu/"Entdecken" and still follows the normal menubar
// lifecycle instead of modifying the theme.
$block->raw_content = '<li class="nav-item bratonien-customer-qr-nav">'
.'<a class="nav-link" href="'.$url.'" title="QR-Code hochladen">'
.'<i class="fas fa-qrcode fa-fw" aria-hidden="true"></i> QR-Code hochladen'
.'</a></li>';
if (method_exists($menu, 'set_block_position'))
{
// Core defaults: Specials=200, Menu=250. Darkroom combines those two as
// "Entdecken"; 225 therefore places QR upload directly after it.
$menu->set_block_position('mbBratonienCustomerQr', 225);
}
}
add_event_handler(
'blockmanager_register_blocks',
'bratonien_tools_customer_qr_register_frontend_menu',
EVENT_HANDLER_PRIORITY_NEUTRAL + 10
);
add_event_handler(
'blockmanager_prepare_display',
'bratonien_tools_customer_qr_prepare_frontend_menu',
EVENT_HANDLER_PRIORITY_NEUTRAL + 10
);

View File

@@ -0,0 +1,503 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_customer_qr_table()
{
return $GLOBALS['prefixeTable'].'bratonien_tools_customer_qr_uploads';
}
function bratonien_tools_customer_qr_year_limits()
{
return array(
2023 => 100,
2024 => 50,
2025 => 30,
2026 => 30,
);
}
function bratonien_tools_customer_qr_settings()
{
$default = array(
'enabled' => false,
);
if (!function_exists('conf_get_param'))
{
return $default;
}
$raw = conf_get_param('bratonien_customer_qr_upload', null);
if ($raw === null || $raw === '')
{
return $default;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? array_merge($default, $decoded) : $default;
}
function bratonien_tools_customer_qr_save_settings()
{
if (!function_exists('conf_update_param'))
{
throw new RuntimeException('Piwigo-Konfiguration ist nicht verfügbar.');
}
$settings = array(
'enabled' => !empty($_POST['customer_qr_enabled']),
);
conf_update_param('bratonien_customer_qr_upload', json_encode($settings));
return array(
'message' => $settings['enabled']
? 'QR-Upload aktiviert.'
: 'QR-Upload deaktiviert.',
);
}
function bratonien_tools_customer_qr_storage_root()
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-customer-qr';
}
function bratonien_tools_customer_qr_ensure_storage()
{
$table = bratonien_tools_customer_qr_table();
pwg_query("CREATE TABLE IF NOT EXISTS `$table` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
upload_year smallint(5) unsigned NOT NULL,
code_number varchar(32) NOT NULL,
original_name varchar(255) NOT NULL,
stored_name varchar(255) NOT NULL,
mime_type varchar(100) NOT NULL,
file_size bigint(20) unsigned NOT NULL DEFAULT 0,
created datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY year_code (upload_year, code_number),
KEY upload_year (upload_year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
$root = bratonien_tools_customer_qr_storage_root();
if (!is_dir($root) && !@mkdir($root, 0770, true) && !is_dir($root))
{
throw new RuntimeException('QR-Upload-Verzeichnis konnte nicht angelegt werden.');
}
return $root;
}
function bratonien_tools_customer_qr_year($value)
{
$year = (int)$value;
$limits = bratonien_tools_customer_qr_year_limits();
if (!isset($limits[$year]))
{
throw new InvalidArgumentException('Erlaubt sind nur die Jahre 2023, 2024, 2025 und 2026.');
}
return $year;
}
function bratonien_tools_customer_qr_default_year()
{
$limits = bratonien_tools_customer_qr_year_limits();
$current = (int)date('Y');
if (isset($limits[$current]))
{
return $current;
}
$years = array_keys($limits);
sort($years, SORT_NUMERIC);
return (int)end($years);
}
function bratonien_tools_customer_qr_number($value)
{
$value = trim((string)$value);
if ($value === '' || !preg_match('/^[0-9]{1,32}$/', $value))
{
throw new InvalidArgumentException('Die QR-Code-Nummer darf nur aus Ziffern bestehen.');
}
$value = ltrim($value, '0');
if ($value === '' || $value === '0')
{
throw new InvalidArgumentException('Die QR-Code-Nummer muss mindestens 1 sein.');
}
return $value;
}
function bratonien_tools_customer_qr_number_for_year($year, $value)
{
$year = bratonien_tools_customer_qr_year($year);
$number = bratonien_tools_customer_qr_number($value);
$limits = bratonien_tools_customer_qr_year_limits();
$limit = (int)$limits[$year];
if (strlen($number) > strlen((string)$limit) || (int)$number > $limit)
{
throw new InvalidArgumentException('Für '.$year.' sind nur QR-Code-Nummern 1 bis '.$limit.' vorgesehen.');
}
return $number;
}
function bratonien_tools_customer_qr_exists($year, $number)
{
bratonien_tools_customer_qr_ensure_storage();
$year = bratonien_tools_customer_qr_year($year);
$number = bratonien_tools_customer_qr_number_for_year($year, $number);
$table = bratonien_tools_customer_qr_table();
$query = 'SELECT id FROM `'.$table.'` WHERE upload_year='.(int)$year
." AND code_number='".pwg_db_real_escape_string($number)."' LIMIT 1";
return pwg_db_num_rows(pwg_query($query)) > 0;
}
function bratonien_tools_customer_qr_files_array(array $files)
{
if (!isset($files['name']))
{
return array();
}
if (!is_array($files['name']))
{
return array($files);
}
$normalized = array();
$count = count($files['name']);
for ($i = 0; $i < $count; $i++)
{
$normalized[] = array(
'name' => isset($files['name'][$i]) ? $files['name'][$i] : '',
'type' => isset($files['type'][$i]) ? $files['type'][$i] : '',
'tmp_name' => isset($files['tmp_name'][$i]) ? $files['tmp_name'][$i] : '',
'error' => isset($files['error'][$i]) ? $files['error'][$i] : UPLOAD_ERR_NO_FILE,
'size' => isset($files['size'][$i]) ? $files['size'][$i] : 0,
);
}
return $normalized;
}
function bratonien_tools_customer_qr_validate_image(array $file)
{
if ((int)$file['error'] !== UPLOAD_ERR_OK)
{
throw new RuntimeException('Datei-Upload fehlgeschlagen (Code '.(int)$file['error'].').');
}
if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name']))
{
throw new RuntimeException('Die hochgeladene Datei konnte nicht verifiziert werden.');
}
$image = @getimagesize($file['tmp_name']);
$mime = is_array($image) && !empty($image['mime']) ? strtolower((string)$image['mime']) : '';
$extensions = array(
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'image/gif' => 'gif',
);
if (!isset($extensions[$mime]))
{
throw new RuntimeException('Erlaubt sind PNG, JPG, WEBP und GIF.');
}
return array(
'mime' => $mime,
'extension' => $extensions[$mime],
);
}
function bratonien_tools_customer_qr_unique_name($number, $extension)
{
try
{
$suffix = bin2hex(random_bytes(6));
}
catch (Throwable $e)
{
$suffix = str_replace('.', '', uniqid('', true));
}
return 'qr-'.$number.'-'.$suffix.'.'.$extension;
}
function bratonien_tools_customer_qr_process_uploads($year, array $files, array $numbers)
{
$year = bratonien_tools_customer_qr_year($year);
$root = bratonien_tools_customer_qr_ensure_storage();
$year_dir = $root.DIRECTORY_SEPARATOR.$year;
if (!is_dir($year_dir) && !@mkdir($year_dir, 0770, true) && !is_dir($year_dir))
{
throw new RuntimeException('Jahresverzeichnis für den QR-Upload konnte nicht angelegt werden.');
}
$lock_dir = $root.DIRECTORY_SEPARATOR.'.locks';
if (!is_dir($lock_dir) && !@mkdir($lock_dir, 0770, true) && !is_dir($lock_dir))
{
throw new RuntimeException('QR-Lockverzeichnis konnte nicht angelegt werden.');
}
$items = bratonien_tools_customer_qr_files_array($files);
$results = array();
$seen = array();
foreach ($items as $index => $file)
{
$display_name = trim((string)($file['name'] ?? ''));
$number = '';
if ($display_name === '')
{
$display_name = 'Datei '.($index + 1);
}
try
{
$number = bratonien_tools_customer_qr_number_for_year($year, isset($numbers[$index]) ? $numbers[$index] : '');
if (isset($seen[$number]))
{
$results[] = array(
'status' => 'duplicate',
'file' => $display_name,
'year' => $year,
'number' => $number,
'message' => 'Nummer ist innerhalb dieses Batches doppelt.',
);
continue;
}
$seen[$number] = true;
$lock_path = $lock_dir.DIRECTORY_SEPARATOR.$year.'-'.$number.'.lock';
$lock = @fopen($lock_path, 'c+');
if ($lock === false || !@flock($lock, LOCK_EX))
{
if (is_resource($lock)) fclose($lock);
throw new RuntimeException('Die Nummer konnte nicht sicher reserviert werden.');
}
try
{
if (bratonien_tools_customer_qr_exists($year, $number))
{
$results[] = array(
'status' => 'duplicate',
'file' => $display_name,
'year' => $year,
'number' => $number,
'message' => 'Diese QR-Code-Nummer ist für das gewählte Jahr bereits vorhanden.',
);
continue;
}
$image = bratonien_tools_customer_qr_validate_image($file);
$stored_name = bratonien_tools_customer_qr_unique_name($number, $image['extension']);
$target = $year_dir.DIRECTORY_SEPARATOR.$stored_name;
if (!@move_uploaded_file($file['tmp_name'], $target))
{
throw new RuntimeException('Die Datei konnte nicht dauerhaft gespeichert werden.');
}
@chmod($target, 0660);
$table = bratonien_tools_customer_qr_table();
$query = "INSERT INTO `$table` (upload_year, code_number, original_name, stored_name, mime_type, file_size, created) VALUES ("
.(int)$year.", '".pwg_db_real_escape_string($number)."', '"
.pwg_db_real_escape_string($display_name)."', '"
.pwg_db_real_escape_string($stored_name)."', '"
.pwg_db_real_escape_string($image['mime'])."', "
.max(0, (int)($file['size'] ?? 0)).", NOW())";
if (pwg_query($query) === false)
{
@unlink($target);
throw new RuntimeException('Der Upload konnte nicht in der QR-Code-Liste gespeichert werden.');
}
$results[] = array(
'status' => 'ok',
'file' => $display_name,
'year' => $year,
'number' => $number,
'message' => 'QR-Code erfolgreich gespeichert.',
);
}
finally
{
@flock($lock, LOCK_UN);
fclose($lock);
}
}
catch (Throwable $e)
{
$results[] = array(
'status' => 'error',
'file' => $display_name,
'year' => $year,
'number' => $number,
'message' => $e->getMessage(),
);
}
}
return $results;
}
function bratonien_tools_customer_qr_delete_upload()
{
$id = isset($_POST['customer_qr_id']) ? (int)$_POST['customer_qr_id'] : 0;
if ($id < 1)
{
throw new InvalidArgumentException('Ungültiger QR-Upload.');
}
$root = bratonien_tools_customer_qr_ensure_storage();
$table = bratonien_tools_customer_qr_table();
$result = pwg_query('SELECT id, upload_year, code_number, stored_name FROM `'.$table.'` WHERE id='.$id.' LIMIT 1');
$row = pwg_db_fetch_assoc($result);
if (!$row)
{
throw new RuntimeException('Der QR-Upload wurde nicht gefunden.');
}
$year = bratonien_tools_customer_qr_year($row['upload_year']);
$number = bratonien_tools_customer_qr_number_for_year($year, $row['code_number']);
$stored_name = (string)$row['stored_name'];
if ($stored_name === '' || basename($stored_name) !== $stored_name)
{
throw new RuntimeException('Der gespeicherte Dateiname ist ungültig.');
}
$lock_dir = $root.DIRECTORY_SEPARATOR.'.locks';
if (!is_dir($lock_dir) && !@mkdir($lock_dir, 0770, true) && !is_dir($lock_dir))
{
throw new RuntimeException('QR-Lockverzeichnis konnte nicht angelegt werden.');
}
$lock_path = $lock_dir.DIRECTORY_SEPARATOR.$year.'-'.$number.'.lock';
$lock = @fopen($lock_path, 'c+');
if ($lock === false || !@flock($lock, LOCK_EX))
{
if (is_resource($lock)) fclose($lock);
throw new RuntimeException('Der QR-Upload konnte nicht sicher gesperrt werden.');
}
try
{
$file = $root.DIRECTORY_SEPARATOR.$year.DIRECTORY_SEPARATOR.$stored_name;
if (is_file($file) && !@unlink($file))
{
throw new RuntimeException('Die QR-Datei konnte nicht gelöscht werden. Der Datenbankeintrag bleibt erhalten.');
}
if (pwg_query('DELETE FROM `'.$table.'` WHERE id='.$id.' LIMIT 1') === false)
{
throw new RuntimeException('Der QR-Datenbankeintrag konnte nicht gelöscht werden.');
}
}
finally
{
@flock($lock, LOCK_UN);
fclose($lock);
}
return array(
'message' => 'QR-Code '.$number.' aus '.$year.' gelöscht. Die Nummer ist wieder frei.',
);
}
function bratonien_tools_customer_qr_size_label($bytes)
{
$bytes = max(0, (int)$bytes);
if ($bytes >= 1048576)
{
return number_format($bytes / 1048576, 1, ',', '.').' MB';
}
if ($bytes >= 1024)
{
return number_format($bytes / 1024, 0, ',', '.').' KB';
}
return $bytes.' B';
}
function bratonien_tools_customer_qr_admin_data()
{
bratonien_tools_customer_qr_ensure_storage();
$settings = bratonien_tools_customer_qr_settings();
$table = bratonien_tools_customer_qr_table();
$limits = bratonien_tools_customer_qr_year_limits();
$counts = array_fill_keys(array_keys($limits), 0);
$total = 0;
$current_year = bratonien_tools_customer_qr_default_year();
$result = pwg_query('SELECT upload_year, COUNT(*) AS cnt FROM `'.$table.'` GROUP BY upload_year');
while ($row = pwg_db_fetch_assoc($result))
{
$year = (int)$row['upload_year'];
$count = (int)$row['cnt'];
$total += $count;
if (isset($counts[$year]))
{
$counts[$year] = $count;
}
}
$years = array();
foreach ($limits as $year => $capacity)
{
$used = (int)$counts[$year];
$years[] = array(
'year' => (int)$year,
'capacity' => (int)$capacity,
'used' => $used,
'remaining' => max(0, (int)$capacity - $used),
);
}
$uploads = array();
$upload_result = pwg_query(
'SELECT id, upload_year, code_number, original_name, file_size, created '
.'FROM `'.$table.'` '
.'ORDER BY upload_year DESC, CAST(code_number AS UNSIGNED) ASC, id ASC'
);
while ($row = pwg_db_fetch_assoc($upload_result))
{
$id = (int)$row['id'];
$uploads[] = array(
'id' => $id,
'year' => (int)$row['upload_year'],
'number' => (string)$row['code_number'],
'original_name' => (string)$row['original_name'],
'file_size' => (int)$row['file_size'],
'file_size_label' => bratonien_tools_customer_qr_size_label($row['file_size']),
'created' => (string)$row['created'],
'preview_url' => get_absolute_root_url(true).'plugins/'.BRATONIEN_TOOLS_ID.'/customer-qr-admin-file.php?id='.$id,
);
}
$year_keys = array_keys($limits);
sort($year_keys, SORT_NUMERIC);
return array(
'enabled' => !empty($settings['enabled']),
'url' => get_absolute_root_url(true).'plugins/'.BRATONIEN_TOOLS_ID.'/customer-qr-upload.php',
'year_min' => (int)reset($year_keys),
'year_max' => (int)end($year_keys),
'default_year' => $current_year,
'years' => $years,
'uploads' => $uploads,
'total' => $total,
'current_year_total' => (int)$counts[$current_year],
'current_year_capacity' => (int)$limits[$current_year],
'max_files' => max(1, (int)ini_get('max_file_uploads')),
);
}

View File

@@ -20,6 +20,7 @@ function bratonien_tools_create_tables()
$profiles = bratonien_tools_table('watermark_profiles');
$rules = bratonien_tools_table('watermark_rules');
$nc_connections = bratonien_tools_table('nc_connections');
$customer_qr = bratonien_tools_table('customer_qr_uploads');
pwg_query("CREATE TABLE IF NOT EXISTS `$profiles` (
id int(11) NOT NULL AUTO_INCREMENT,
@@ -81,6 +82,20 @@ function bratonien_tools_create_tables()
UNIQUE KEY connection_key (connection_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
pwg_query("CREATE TABLE IF NOT EXISTS `$customer_qr` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
upload_year smallint(5) unsigned NOT NULL,
code_number varchar(32) NOT NULL,
original_name varchar(255) NOT NULL,
stored_name varchar(255) NOT NULL,
mime_type varchar(100) NOT NULL,
file_size bigint(20) unsigned NOT NULL DEFAULT 0,
created datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY year_code (upload_year, code_number),
KEY upload_year (upload_year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
if (function_exists('bratonien_tools_create_album_shares_table'))
{
bratonien_tools_create_album_shares_table();
@@ -92,6 +107,7 @@ function bratonien_tools_drop_tables()
pwg_query('DROP TABLE IF EXISTS `'.bratonien_tools_table('watermark_profiles').'`');
pwg_query('DROP TABLE IF EXISTS `'.bratonien_tools_table('watermark_rules').'`');
pwg_query('DROP TABLE IF EXISTS `'.bratonien_tools_table('nc_connections').'`');
pwg_query('DROP TABLE IF EXISTS `'.bratonien_tools_table('customer_qr_uploads').'`');
if (function_exists('bratonien_tools_drop_album_shares_table'))
{

View File

@@ -0,0 +1,320 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_friendship_code_table()
{
return $GLOBALS['prefixeTable'].'bratonien_tools_friendship_codes';
}
function bratonien_tools_friendship_code_storage_root()
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-friendship-codes';
}
function bratonien_tools_friendship_code_ensure_storage()
{
$table = bratonien_tools_friendship_code_table();
pwg_query("CREATE TABLE IF NOT EXISTS `$table` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
person_name varchar(190) NOT NULL,
normalized_name varchar(190) NOT NULL,
original_name varchar(255) NOT NULL,
stored_name varchar(255) NOT NULL,
mime_type varchar(100) NOT NULL,
file_size bigint(20) unsigned NOT NULL DEFAULT 0,
file_sha256 char(64) NOT NULL,
created datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY normalized_name (normalized_name),
UNIQUE KEY file_sha256 (file_sha256)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
$root = bratonien_tools_friendship_code_storage_root();
if (!is_dir($root) && !@mkdir($root, 0770, true) && !is_dir($root))
{
throw new RuntimeException('Verzeichnis für Freundschaftscodes konnte nicht angelegt werden.');
}
return $root;
}
function bratonien_tools_friendship_code_name($value)
{
$name = trim((string)$value);
$name = preg_replace('/\s+/u', ' ', $name);
if ($name === '')
{
throw new InvalidArgumentException('Bitte einen Namen eingeben.');
}
$length = function_exists('mb_strlen') ? mb_strlen($name, 'UTF-8') : strlen($name);
if ($length > 120)
{
throw new InvalidArgumentException('Der Name darf höchstens 120 Zeichen lang sein.');
}
if (preg_match('/[\x00-\x1F\x7F]/u', $name))
{
throw new InvalidArgumentException('Der Name enthält ungültige Steuerzeichen.');
}
return $name;
}
function bratonien_tools_friendship_code_normalized_name($value)
{
$name = bratonien_tools_friendship_code_name($value);
return function_exists('mb_strtolower')
? mb_strtolower($name, 'UTF-8')
: strtolower($name);
}
function bratonien_tools_friendship_code_exists_for_name($name)
{
bratonien_tools_friendship_code_ensure_storage();
$normalized = bratonien_tools_friendship_code_normalized_name($name);
$table = bratonien_tools_friendship_code_table();
$query = "SELECT id FROM `$table` WHERE normalized_name='"
.pwg_db_real_escape_string($normalized)."' LIMIT 1";
return pwg_db_num_rows(pwg_query($query)) > 0;
}
function bratonien_tools_friendship_code_validate_image(array $file)
{
if ((int)($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK)
{
throw new RuntimeException('Datei-Upload fehlgeschlagen (Code '.(int)($file['error'] ?? UPLOAD_ERR_NO_FILE).').');
}
if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name']))
{
throw new RuntimeException('Die hochgeladene Datei konnte nicht verifiziert werden.');
}
$image = @getimagesize($file['tmp_name']);
$mime = is_array($image) && !empty($image['mime']) ? strtolower((string)$image['mime']) : '';
$extensions = array(
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'image/gif' => 'gif',
);
if (!isset($extensions[$mime]))
{
throw new RuntimeException('Erlaubt sind PNG, JPG, WEBP und GIF.');
}
return array(
'mime' => $mime,
'extension' => $extensions[$mime],
);
}
function bratonien_tools_friendship_code_unique_name($extension)
{
try
{
$suffix = bin2hex(random_bytes(8));
}
catch (Throwable $e)
{
$suffix = str_replace('.', '', uniqid('', true));
}
return 'friendship-'.$suffix.'.'.$extension;
}
function bratonien_tools_friendship_code_process_upload($name, array $file)
{
$name = bratonien_tools_friendship_code_name($name);
$normalized = bratonien_tools_friendship_code_normalized_name($name);
$root = bratonien_tools_friendship_code_ensure_storage();
$table = bratonien_tools_friendship_code_table();
$lock_dir = $root.DIRECTORY_SEPARATOR.'.locks';
if (!is_dir($lock_dir) && !@mkdir($lock_dir, 0770, true) && !is_dir($lock_dir))
{
throw new RuntimeException('Lockverzeichnis für Freundschaftscodes konnte nicht angelegt werden.');
}
$lock_path = $lock_dir.DIRECTORY_SEPARATOR.sha1($normalized).'.lock';
$lock = @fopen($lock_path, 'c+');
if ($lock === false || !@flock($lock, LOCK_EX))
{
if (is_resource($lock)) fclose($lock);
throw new RuntimeException('Der Freundschaftscode konnte nicht sicher reserviert werden.');
}
try
{
if (bratonien_tools_friendship_code_exists_for_name($name))
{
return array(
'status' => 'duplicate',
'kind' => 'friendship',
'name' => $name,
'file' => trim((string)($file['name'] ?? '')),
'message' => 'Für diesen Namen ist bereits ein Freundschaftscode hinterlegt.',
);
}
$image = bratonien_tools_friendship_code_validate_image($file);
$sha256 = @hash_file('sha256', $file['tmp_name']);
if (!is_string($sha256) || !preg_match('/^[a-f0-9]{64}$/', $sha256))
{
throw new RuntimeException('Der Freundschaftscode konnte nicht eindeutig geprüft werden.');
}
$hash_query = "SELECT person_name FROM `$table` WHERE file_sha256='"
.pwg_db_real_escape_string($sha256)."' LIMIT 1";
$hash_result = pwg_query($hash_query);
if (pwg_db_num_rows($hash_result) > 0)
{
$existing = pwg_db_fetch_assoc($hash_result);
return array(
'status' => 'duplicate',
'kind' => 'friendship',
'name' => $name,
'file' => trim((string)($file['name'] ?? '')),
'message' => 'Dieser Freundschaftscode wurde bereits hinterlegt'.(!empty($existing['person_name']) ? ' ('.(string)$existing['person_name'].')' : '').'.',
);
}
$stored_name = bratonien_tools_friendship_code_unique_name($image['extension']);
$target = $root.DIRECTORY_SEPARATOR.$stored_name;
if (!@move_uploaded_file($file['tmp_name'], $target))
{
throw new RuntimeException('Die Datei konnte nicht dauerhaft gespeichert werden.');
}
@chmod($target, 0660);
$original_name = trim((string)($file['name'] ?? ''));
if ($original_name === '')
{
$original_name = 'Freundschaftscode.'.$image['extension'];
}
$query = "INSERT INTO `$table` (person_name, normalized_name, original_name, stored_name, mime_type, file_size, file_sha256, created) VALUES ('"
.pwg_db_real_escape_string($name)."', '"
.pwg_db_real_escape_string($normalized)."', '"
.pwg_db_real_escape_string($original_name)."', '"
.pwg_db_real_escape_string($stored_name)."', '"
.pwg_db_real_escape_string($image['mime'])."', "
.max(0, (int)($file['size'] ?? 0)).", '"
.pwg_db_real_escape_string($sha256)."', NOW())";
if (pwg_query($query) === false)
{
@unlink($target);
throw new RuntimeException('Der Freundschaftscode konnte nicht in der Liste gespeichert werden.');
}
return array(
'status' => 'ok',
'kind' => 'friendship',
'name' => $name,
'file' => $original_name,
'message' => 'Freundschaftscode erfolgreich gespeichert.',
);
}
finally
{
@flock($lock, LOCK_UN);
fclose($lock);
}
}
function bratonien_tools_friendship_code_delete_upload()
{
$id = isset($_POST['friendship_code_id']) ? (int)$_POST['friendship_code_id'] : 0;
if ($id < 1)
{
throw new InvalidArgumentException('Ungültiger Freundschaftscode.');
}
$root = bratonien_tools_friendship_code_ensure_storage();
$table = bratonien_tools_friendship_code_table();
$result = pwg_query('SELECT id, person_name, normalized_name, stored_name FROM `'.$table.'` WHERE id='.$id.' LIMIT 1');
$row = pwg_db_fetch_assoc($result);
if (!$row)
{
throw new RuntimeException('Der Freundschaftscode wurde nicht gefunden.');
}
$stored_name = (string)$row['stored_name'];
if ($stored_name === '' || basename($stored_name) !== $stored_name)
{
throw new RuntimeException('Der gespeicherte Dateiname ist ungültig.');
}
$normalized = (string)$row['normalized_name'];
$lock_dir = $root.DIRECTORY_SEPARATOR.'.locks';
if (!is_dir($lock_dir) && !@mkdir($lock_dir, 0770, true) && !is_dir($lock_dir))
{
throw new RuntimeException('Lockverzeichnis für Freundschaftscodes konnte nicht angelegt werden.');
}
$lock = @fopen($lock_dir.DIRECTORY_SEPARATOR.sha1($normalized).'.lock', 'c+');
if ($lock === false || !@flock($lock, LOCK_EX))
{
if (is_resource($lock)) fclose($lock);
throw new RuntimeException('Der Freundschaftscode konnte nicht sicher gesperrt werden.');
}
try
{
$file = $root.DIRECTORY_SEPARATOR.$stored_name;
if (is_file($file) && !@unlink($file))
{
throw new RuntimeException('Die Datei konnte nicht gelöscht werden. Der Datenbankeintrag bleibt erhalten.');
}
if (pwg_query('DELETE FROM `'.$table.'` WHERE id='.$id.' LIMIT 1') === false)
{
throw new RuntimeException('Der Datenbankeintrag konnte nicht gelöscht werden.');
}
}
finally
{
@flock($lock, LOCK_UN);
fclose($lock);
}
return array(
'message' => 'Freundschaftscode von '.(string)$row['person_name'].' gelöscht.',
);
}
function bratonien_tools_friendship_code_admin_data()
{
bratonien_tools_friendship_code_ensure_storage();
$table = bratonien_tools_friendship_code_table();
$uploads = array();
$result = pwg_query(
'SELECT id, person_name, original_name, file_size, created FROM `'.$table.'` ORDER BY person_name ASC, id ASC'
);
while ($row = pwg_db_fetch_assoc($result))
{
$id = (int)$row['id'];
$base_url = get_absolute_root_url(true).'plugins/'.BRATONIEN_TOOLS_ID.'/friendship-code-admin-file.php?id='.$id;
$uploads[] = array(
'id' => $id,
'name' => (string)$row['person_name'],
'original_name' => (string)$row['original_name'],
'file_size' => (int)$row['file_size'],
'file_size_label' => function_exists('bratonien_tools_customer_qr_size_label')
? bratonien_tools_customer_qr_size_label($row['file_size'])
: (string)((int)$row['file_size']).' B',
'created' => (string)$row['created'],
'preview_url' => $base_url,
'download_url' => $base_url.'&download=1',
);
}
return array(
'total' => count($uploads),
'uploads' => $uploads,
);
}

View File

@@ -4,7 +4,7 @@ if (!defined('PHPWG_ROOT_PATH'))
die('Hacking attempt!');
}
function bratonien_tools_nc_connector_systemctl_run(array $args)
function bratonien_tools_nc_connector_command_run(array $command)
{
$result = array('exit'=>-1, 'stdout'=>'', 'stderr'=>'');
if (!function_exists('proc_open'))
@@ -13,7 +13,6 @@ function bratonien_tools_nc_connector_systemctl_run(array $args)
return $result;
}
$command = array_merge(array('/usr/bin/systemctl'), $args);
$spec = array(
0=>array('file','/dev/null','r'),
1=>array('pipe','w'),
@@ -23,7 +22,7 @@ function bratonien_tools_nc_connector_systemctl_run(array $args)
$process = @proc_open($command, $spec, $pipes, null, $environment);
if (!is_resource($process))
{
$result['stderr'] = 'systemctl konnte nicht gestartet werden.';
$result['stderr'] = 'Prozess konnte nicht gestartet werden.';
return $result;
}
@@ -35,6 +34,11 @@ function bratonien_tools_nc_connector_systemctl_run(array $args)
return $result;
}
function bratonien_tools_nc_connector_systemctl_run(array $args)
{
return bratonien_tools_nc_connector_command_run(array_merge(array('/usr/bin/systemctl'), $args));
}
function bratonien_tools_nc_connector_systemctl_value(array $args)
{
$result = bratonien_tools_nc_connector_systemctl_run($args);
@@ -43,49 +47,51 @@ function bratonien_tools_nc_connector_systemctl_value(array $args)
function bratonien_tools_nc_connector_run_now()
{
if (!function_exists('proc_open'))
$service = 'bratonien-nc-connector.service';
$load_state = bratonien_tools_nc_connector_systemctl_value(array('show',$service,'--property=LoadState','--value'));
if ($load_state !== 'loaded')
{
throw new RuntimeException('WebDAV-Abgleich konnte nicht gestartet werden: proc_open ist in PHP nicht verfügbar.');
throw new RuntimeException('WebDAV-Abgleich konnte nicht gestartet werden: der privilegierte Connector-Dienst '.$service.' ist nicht geladen.');
}
$runtime_dir = realpath(dirname(__DIR__).'/runtime');
if ($runtime_dir === false || !is_dir($runtime_dir))
// Der Connector verwaltet bewusst geschützte Runtime-Daten unter /etc und
// /var/lib. Er darf deshalb niemals direkt als Webserver-Benutzer laufen.
// Der manuelle Admin-Button startet denselben privilegierten systemd-Dienst,
// den auch der Timer verwendet.
$result = bratonien_tools_nc_connector_systemctl_run(array('start',$service));
// Auf Installationen ohne passende polkit-Regel darf ausschließlich die
// eng begrenzte, vom Betreiber eingerichtete sudo-Freigabe für genau diesen
// systemd-Dienst verwendet werden. Es gibt absichtlich keinen Fallback auf
// runtime/run-all.sh als www-data.
if ($result['exit'] !== 0 && is_file('/usr/bin/sudo') && is_executable('/usr/bin/sudo'))
{
throw new RuntimeException('WebDAV-Abgleich konnte nicht gestartet werden: Runtime-Verzeichnis wurde nicht gefunden.');
$result = bratonien_tools_nc_connector_command_run(array(
'/usr/bin/sudo',
'-n',
'/usr/bin/systemctl',
'start',
$service,
));
}
$script = $runtime_dir.'/run-all.sh';
if (!is_file($script) || !is_readable($script))
if ($result['exit'] !== 0)
{
throw new RuntimeException('WebDAV-Abgleich konnte nicht gestartet werden: runtime/run-all.sh ist nicht lesbar.');
$detail = $result['stderr'] !== '' ? $result['stderr'] : $result['stdout'];
if ($detail === '') $detail = 'Exit-Code '.$result['exit'];
throw new RuntimeException(
'WebDAV-Abgleich konnte den privilegierten Connector-Dienst nicht starten. '
.'Der Connector wird aus Sicherheitsgründen nicht als Webserver-Benutzer direkt ausgeführt. '.$detail
);
}
$spec = array(
0=>array('file','/dev/null','r'),
1=>array('pipe','w'),
2=>array('pipe','w'),
);
$environment = array_merge($_ENV, array('LC_ALL'=>'C','LANG'=>'C'));
$process = @proc_open(array('/usr/bin/env','bash',$script), $spec, $pipes, $runtime_dir, $environment);
if (!is_resource($process))
$service_result = bratonien_tools_nc_connector_systemctl_value(array('show',$service,'--property=Result','--value'));
if ($service_result !== '' && $service_result !== 'success')
{
throw new RuntimeException('WebDAV-Abgleich konnte nicht gestartet werden: Connector-Prozess konnte nicht geöffnet werden.');
throw new RuntimeException('WebDAV-Abgleich wurde gestartet, der Connector-Dienst endete aber mit Ergebnis '.$service_result.'.');
}
$stdout = trim((string)stream_get_contents($pipes[1]));
$stderr = trim((string)stream_get_contents($pipes[2]));
fclose($pipes[1]);
fclose($pipes[2]);
$exit = (int)proc_close($process);
if ($exit !== 0)
{
$detail = $stderr !== '' ? $stderr : $stdout;
if ($detail === '') $detail = 'Exit-Code '.$exit;
throw new RuntimeException('WebDAV-Abgleich fehlgeschlagen: '.$detail);
}
return array('message'=>'WebDAV-Abgleich wurde ausgeführt.');
return array('message'=>'WebDAV-Abgleich wurde über den privilegierten Connector-Dienst ausgeführt.');
}
function bratonien_tools_nc_connector_parse_systemd_time($value)

View File

@@ -0,0 +1,161 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_presentation_refresh_queue_dir()
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-presentation-refresh';
}
function bratonien_tools_presentation_refresh_normalize_categories(array $category_ids)
{
$result = array();
foreach ($category_ids as $category_id)
{
$category_id = (int)$category_id;
if ($category_id > 0) $result[$category_id] = $category_id;
}
ksort($result, SORT_NUMERIC);
return array_values($result);
}
function bratonien_tools_presentation_refresh_start()
{
if (!function_exists('exec'))
{
error_log('Bratonien presentation refresh: PHP exec() ist deaktiviert; Auftrag bleibt in der Queue.');
return false;
}
$php = function_exists('bratonien_tools_webdav_warmup_php_cli')
? bratonien_tools_webdav_warmup_php_cli()
: null;
if (!$php)
{
foreach (array('/usr/bin/php', '/usr/bin/php8.4', '/usr/bin/php8.3', '/usr/local/bin/php') as $candidate)
{
if (is_file($candidate) && is_executable($candidate))
{
$php = $candidate;
break;
}
}
}
if (!$php)
{
error_log('Bratonien presentation refresh: PHP-CLI wurde nicht gefunden; Auftrag bleibt in der Queue.');
return false;
}
$worker = realpath(BRATONIEN_TOOLS_PATH.'runtime/piwigo-presentation-refresh.php');
if (!$worker || !is_file($worker))
{
error_log('Bratonien presentation refresh: Worker fehlt; Auftrag bleibt in der Queue.');
return false;
}
$log = PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-presentation-refresh.log';
$command = 'nohup '.escapeshellarg($php).' '.escapeshellarg($worker)
.' >> '.escapeshellarg($log).' 2>&1 < /dev/null & echo $!';
$output = array();
$exit = 1;
@exec($command, $output, $exit);
return $exit === 0 && !empty($output[0]) && (int)$output[0] > 0;
}
function bratonien_tools_presentation_refresh_enqueue(array $category_ids, $reason='presentation-changed', $all=false)
{
$category_ids = bratonien_tools_presentation_refresh_normalize_categories($category_ids);
$all = (bool)$all;
if (!$all && !$category_ids) return array('queued'=>false, 'started'=>false);
$dir = bratonien_tools_presentation_refresh_queue_dir();
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir))
{
throw new RuntimeException('Queue für die Vorschau-Aktualisierung konnte nicht angelegt werden.');
}
$payload = array(
'all'=>$all,
'categories'=>$category_ids,
'reason'=>(string)$reason,
'requested_at'=>time(),
);
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false) throw new RuntimeException('Vorschau-Auftrag konnte nicht serialisiert werden.');
$name = sprintf('%010d-%s.json', time(), bin2hex(random_bytes(6)));
$target = $dir.'/'.$name;
$tmp = $target.'.tmp';
if (@file_put_contents($tmp, $json."\n", LOCK_EX) === false)
{
throw new RuntimeException('Vorschau-Auftrag konnte nicht geschrieben werden.');
}
@chmod($tmp, 0664);
if (!@rename($tmp, $target))
{
@unlink($tmp);
throw new RuntimeException('Vorschau-Auftrag konnte nicht atomar bereitgestellt werden.');
}
return array('queued'=>true, 'started'=>bratonien_tools_presentation_refresh_start());
}
function bratonien_tools_presentation_refresh_enqueue_all($reason='presentation-changed')
{
return bratonien_tools_presentation_refresh_enqueue(array(), $reason, true);
}
function bratonien_tools_presentation_refresh_category_snapshot()
{
$snapshot = array();
$result = pwg_query('SELECT id, status, visible FROM '.CATEGORIES_TABLE.' ORDER BY id');
while ($row = pwg_db_fetch_assoc($result))
{
$snapshot[(int)$row['id']] = array(
'status'=>(string)$row['status'],
'visible'=>(string)$row['visible'],
);
}
return $snapshot;
}
function bratonien_tools_presentation_refresh_watch_admin_categories()
{
if (!defined('IN_ADMIN') || ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') return;
// Connector-Sync hat einen eigenen, priorisierten Bildcache-Pfad. Neue
// Kategorien werden dort bereits korrekt an den Worker übergeben.
if ((string)($_GET['page'] ?? '') === 'site_update' && (string)($_POST['bratonien_connector'] ?? '') === '1') return;
$before = bratonien_tools_presentation_refresh_category_snapshot();
register_shutdown_function(function () use ($before) {
try
{
$after = bratonien_tools_presentation_refresh_category_snapshot();
$changed = array();
foreach ($after as $category_id=>$state)
{
if (!isset($before[$category_id])) continue;
if (
(string)$before[$category_id]['status'] !== (string)$state['status']
|| (string)$before[$category_id]['visible'] !== (string)$state['visible']
)
{
$changed[] = (int)$category_id;
}
}
if ($changed)
{
bratonien_tools_presentation_refresh_enqueue($changed, 'album-status-or-visibility-changed');
}
}
catch (Throwable $e)
{
error_log('Bratonien presentation refresh watcher: '.$e->getMessage());
}
});
}

View File

@@ -12,10 +12,13 @@ require_once(BRATONIEN_TOOLS_PATH . 'tools/watermark_settings.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'tools/album_rules.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'tools/asset_manager.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/watermark_engine.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/presentation_refresh.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/public_selection.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/self_update.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/album_shares.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/album_lock.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/customer_qr_upload.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/friendship_code_upload.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_piwigo_api.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_wizard.inc.php');
@@ -47,6 +50,9 @@ function bratonien_tools_get_tools()
'watermark_defaults' => array('handler' => 'bratonien_tools_save_watermark_defaults'),
'watermark_rule' => array('handler' => 'bratonien_tools_save_album_rule'),
'public_selection_settings' => array('handler' => 'bratonien_tools_save_public_selection_settings'),
'customer_qr_settings' => array('handler' => 'bratonien_tools_customer_qr_save_settings'),
'customer_qr_delete' => array('handler' => 'bratonien_tools_customer_qr_delete_upload'),
'friendship_code_delete' => array('handler' => 'bratonien_tools_friendship_code_delete_upload'),
'asset_upload' => array('handler' => 'bratonien_tools_upload_asset'),
'asset_delete' => array('handler' => 'bratonien_tools_delete_asset'),
'asset_upload_limits' => array('handler' => 'bratonien_tools_save_upload_limits'),
@@ -81,9 +87,14 @@ function bratonien_tools_handle_watermark_engine()
$enabled = !empty($_POST['engine_enabled']);
bratonien_tools_set_watermark_engine($enabled);
if (function_exists('bratonien_tools_presentation_refresh_enqueue_all'))
{
bratonien_tools_presentation_refresh_enqueue_all('watermark-engine-changed');
}
return array(
'message' => $enabled
? 'Bratonien-Wasserzeichenverwaltung aktiviert. Piwigos bisherige Wasserzeichen-Nutzung wurde gesichert und unterdrueckt.'
: 'Bratonien-Wasserzeichenverwaltung deaktiviert. Die zuvor gesicherte Piwigo-Wasserzeichen-Nutzung wurde wiederhergestellt.',
? 'Bratonien-Wasserzeichenverwaltung aktiviert. Piwigos bisherige Wasserzeichen-Nutzung wurde gesichert und die Vorschauen werden aktualisiert.'
: 'Bratonien-Wasserzeichenverwaltung deaktiviert. Die zuvor gesicherte Piwigo-Wasserzeichen-Nutzung wurde wiederhergestellt und die Vorschauen werden aktualisiert.',
);
}
}

View File

@@ -82,16 +82,37 @@ function bratonien_tools_runtime_b64url_encode($value)
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
function bratonien_tools_profile_watermark_relative(array $profile)
{
$relative = ltrim(trim((string)($profile['watermark_file'] ?? '')), '/');
if ($relative !== '')
{
return $relative;
}
// Ein Profil ohne eigene Datei bedeutet bewusst: Einstellungen dieses
// Profils verwenden, aber die zentrale Basis-Wasserzeichendatei erben.
// Dadurch kann das Standardprofil "Oeffentlich" ohne doppelte Dateipflege
// fuer jedes öffentliche Album als Schutzregel dienen.
if (function_exists('bratonien_tools_get_base_watermark_config'))
{
$base = bratonien_tools_get_base_watermark_config();
$relative = ltrim(trim((string)($base['file'] ?? '')), '/');
}
return $relative;
}
function bratonien_tools_profile_watermark_path(array $profile)
{
static $paths = array();
if (empty($profile['watermark_file']))
$relative = bratonien_tools_profile_watermark_relative($profile);
if ($relative === '')
{
return null;
}
$relative = ltrim((string)$profile['watermark_file'], '/');
if (array_key_exists($relative, $paths))
{
return $paths[$relative];
@@ -119,9 +140,10 @@ function bratonien_tools_profile_watermark_path(array $profile)
function bratonien_tools_runtime_profile_version(array $profile)
{
$watermark_path = bratonien_tools_profile_watermark_path($profile);
$effective_file = bratonien_tools_profile_watermark_relative($profile);
$parts = array(
$profile['id'] ?? 0,
$profile['watermark_file'] ?? '',
$effective_file,
$profile['scale_percent'] ?? 100,
$profile['xpos'] ?? 90,
$profile['ypos'] ?? 90,

View File

@@ -4,6 +4,11 @@ if (!defined('PHPWG_ROOT_PATH'))
die('Hacking attempt!');
}
if (!defined('BRATONIEN_WEBDAV_GENERATION_VERSION'))
{
define('BRATONIEN_WEBDAV_GENERATION_VERSION', 2);
}
function bratonien_tools_webdav_source_index_file($state_dir, $connection_id=0)
{
// Der Quellenindex gehoert dem Piwigo-Worker und muss deshalb in einem
@@ -87,6 +92,7 @@ function bratonien_tools_webdav_source_index_key(array $source)
function bratonien_tools_webdav_source_index_signature(array $source)
{
return sha1(implode('|', array(
'generation-v'.(int)BRATONIEN_WEBDAV_GENERATION_VERSION,
(int)($source['connection_id'] ?? 0),
(int)($source['root_fileid'] ?? 0),
(int)($source['fileid'] ?? 0),
@@ -114,6 +120,7 @@ function bratonien_tools_webdav_source_index_metadata(array $source, $shadow_rel
'height'=>(int)($source['height'] ?? 0),
'shadow_relative'=>ltrim((string)$shadow_relative, '/'),
'source_signature'=>bratonien_tools_webdav_source_index_signature($source),
'generation_version'=>(int)BRATONIEN_WEBDAV_GENERATION_VERSION,
'last_seen_at'=>time(),
);
}
@@ -122,6 +129,7 @@ function bratonien_tools_webdav_source_index_is_current(array $entry, $signature
{
$signature = (string)$signature;
return isset($entry['source_signature'], $entry['stage1_signature'], $entry['stage2_signature'])
&& (int)($entry['generation_version'] ?? 0) === (int)BRATONIEN_WEBDAV_GENERATION_VERSION
&& hash_equals((string)$entry['source_signature'], $signature)
&& hash_equals((string)$entry['stage1_signature'], $signature)
&& hash_equals((string)$entry['stage2_signature'], $signature);

View File

@@ -121,6 +121,18 @@ foreach ($webdav as &$status)
$status['message'] = 'Worker wurde angefordert, hat aber innerhalb von 45 Sekunden keinen Scan gestartet.';
}
// Ältere Dispatcher-Versionen konnten den Zustand "waiting" schreiben und
// anschließend ohne Fortsetzungsauftrag enden. Dieser reine Statuswert ist
// kein Beweis für einen laufenden Connector und darf die Cache-Seite nicht
// dauerhaft blockieren. Neue Wartezustände tragen resume_scheduled=true und
// besitzen einen echten Hintergrund-Warter.
if ($state === 'waiting' && empty($status['resume_scheduled']) && $updated > 0 && (time() - $updated) > 45)
{
$state = 'idle';
$status['state'] = 'idle';
$status['message'] = 'Veralteter Connector-Wartezustand wurde verworfen; aktuell ist kein fortsetzbarer Cache-Auftrag registriert.';
}
if ($state === 'scan')
{
$selected = max(0, (int)($status['selected'] ?? 0));
@@ -160,7 +172,7 @@ foreach ($webdav as &$status)
if ($state === 'cancelled') $webdav_cancelled = true;
if ($state === 'preempted') $webdav_pending = true;
if (in_array($state, array('error','fatal'), true)) $webdav_error = true;
if ($state !== 'complete') $webdav_all_complete = false;
if ($state !== 'complete' && $state !== 'idle') $webdav_all_complete = false;
$webdav_lines[] = bratonien_tools_status_webdav_label($status);
}
unset($status);
@@ -217,7 +229,7 @@ elseif ($webdav_waiting)
$overall['state'] = 'queued';
$overall['total'] = $combined_total;
$overall['completed'] = $combined_completed;
$overall['message'] = 'Cache-Aufbau pausiert für laufende Connector-Synchronisierung'.($webdav_source_total > 0 ? ' · '.$webdav_source_total.' WebDAV-Quellen' : '').'. Der Indexstand bleibt erhalten.';
$overall['message'] = 'Cache-Aufbau pausiert für laufende Connector-Synchronisierung'.($webdav_source_total > 0 ? ' · '.$webdav_source_total.' WebDAV-Quellen' : '').'. Der Fortsetzungsauftrag bleibt registriert.';
$overall['current'] = implode(' ', $webdav_lines);
}
elseif ($webdav_partial)

View File

@@ -1,7 +1,7 @@
<?php
/*
Plugin Name: Bratonien Tools
Version: 0.9.7.1.30
Version: 0.9.7.1.51
Description: Erweiterbare Administrationswerkzeuge fuer die Bratonien-Piwigo-Installation.
Plugin URI: https://github.com/Terranom674/Piwigo_Bratonien_Tools
Author: Bratonien
@@ -19,12 +19,15 @@ require_once(BRATONIEN_TOOLS_PATH . 'include/watermark_runtime.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/webdav_image_runtime.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/webdav_cache_validation.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/webdav_materialize_runtime.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/presentation_refresh.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/public_selection.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/picture_navigation.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/batch_titles.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/album_shares.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_orphan_ws.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_productive_ws.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/customer_qr_upload.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/customer_qr_frontend_menu.inc.php');
add_event_handler('get_admin_plugin_menu_links', 'bratonien_tools_admin_menu');
add_event_handler('get_derivative_url', 'bratonien_tools_filter_derivative_url', EVENT_HANDLER_PRIORITY_NEUTRAL, 4);
@@ -36,8 +39,10 @@ add_event_handler('init', 'bratonien_tools_prepare_connector_private_import', EV
add_event_handler('init', 'bratonien_tools_prepare_private_album_permissions', EVENT_HANDLER_PRIORITY_NEUTRAL - 20);
add_event_handler('init', 'bratonien_tools_preserve_private_album_access', EVENT_HANDLER_PRIORITY_NEUTRAL - 10);
add_event_handler('init', 'bratonien_tools_preserve_connector_top_level_access', EVENT_HANDLER_PRIORITY_NEUTRAL - 9);
add_event_handler('init', 'bratonien_tools_presentation_refresh_watch_admin_categories', EVENT_HANDLER_PRIORITY_NEUTRAL - 8);
add_event_handler('init', 'bratonien_tools_album_shares_init');
add_event_handler('loc_end_intro', 'bratonien_tools_fix_admin_album_stat_tile');
add_event_handler('loc_end_page_tail', 'bratonien_tools_add_legal_footer_links');
add_event_handler('delete_categories', 'bratonien_tools_album_shares_on_delete_categories');
add_event_handler('ws_add_methods', 'bratonien_tools_register_nc_orphan_ws_methods');
add_event_handler('ws_add_methods', 'bratonien_tools_register_nc_productive_ws_methods');
@@ -181,6 +186,22 @@ function bratonien_tools_prefilter_admin_album_stat_tile($source)
return str_replace('{if $NB_ALBUMS > 1}', '{if $NB_ALBUMS > 0}', $source);
}
function bratonien_tools_add_legal_footer_links()
{
global $template;
if (defined('IN_ADMIN'))
{
return;
}
$template->append(
'footer_elements',
' - <a href="https://links.bratonien.de/@bratonien_cosplay#bratonien-popup-856170002" rel="noopener">Impressum</a>'
.' - <a href="https://links.bratonien.de/@bratonien_cosplay#bratonien-popup-907192090" rel="noopener">Datenschutzerklärung</a>'
);
}
function bratonien_tools_admin_menu($menu)
{
$menu[] = array(

View File

@@ -0,0 +1,46 @@
<?php
if (PHP_SAPI !== 'cli')
{
http_response_code(404);
exit;
}
$query = '';
foreach ($argv as $arg)
{
if (strpos($arg, '--query=') === 0)
{
$query = substr($arg, strlen('--query='));
}
}
if ($query === '')
{
fwrite(STDERR, "Parameter --query fehlt.\n");
exit(2);
}
$piwigo_root = realpath(dirname(__DIR__, 4));
$plugin_root = realpath(dirname(__DIR__, 2));
if ($piwigo_root === false || $plugin_root === false || !is_file($plugin_root.'/watermark.php'))
{
fwrite(STDERR, "Piwigo-Root oder Bratonien-Wasserzeichenmodul wurde nicht gefunden.\n");
exit(3);
}
$plugin_id = basename($plugin_root);
parse_str($query, $_GET);
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_NAME'] = 'localhost';
$_SERVER['HTTP_HOST'] = 'localhost';
$_SERVER['SERVER_PORT'] = '80';
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/plugins/'.$plugin_id.'/watermark.php?'.$query;
$_SERVER['SCRIPT_NAME'] = '/plugins/'.$plugin_id.'/watermark.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = $query;
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-Presentation-Refresh/0.9.7.1.41';
$_SERVER['HTTPS'] = 'off';
chdir($piwigo_root);
include $plugin_root.'/watermark.php';

View File

@@ -32,6 +32,17 @@ SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
PLACEHOLDER = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==")
class WebDavAuthenticationError(RuntimeError):
pass
class WebDavPathUnavailable(RuntimeError):
def __init__(self, relative: str, status: int) -> None:
self.relative = validate_relative(relative)
self.status = int(status)
super().__init__(f"Nextcloud path unavailable with HTTP {self.status}: {self.relative or '/'}")
def fail(message: str) -> None:
raise RuntimeError(message)
@@ -136,8 +147,10 @@ class WebDavClient:
status = response.status
payload = response.read()
except urllib.error.HTTPError as error:
if error.code in {401, 403}:
fail("Nextcloud rejected the WebDAV credentials or directory access")
if error.code == 401:
raise WebDavAuthenticationError("Nextcloud rejected the WebDAV credentials") from error
if error.code in {403, 404}:
raise WebDavPathUnavailable(relative, error.code) from error
fail(f"Nextcloud PROPFIND failed with HTTP {error.code}")
except urllib.error.URLError as error:
fail(f"Nextcloud WebDAV is unreachable: {error.reason}")
@@ -189,6 +202,25 @@ class WebDavClient:
return current, children
def confirmed_removed_root(client: WebDavClient, remote_root: str, error: WebDavPathUnavailable) -> bool:
remote_root = validate_relative(remote_root)
if remote_root == "":
return False
if error.status == 404:
return True
if error.status != 403:
return False
parent_path = PurePosixPath(remote_root).parent
parent = "" if str(parent_path) == "." else str(parent_path).strip("/")
_, siblings = client.list_collection(parent)
wanted = PurePosixPath(remote_root).name.casefold()
for sibling in siblings:
if str(sibling.get("display_name", "")).casefold() == wanted:
return False
return True
def link_placeholder(seed: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
try:
@@ -301,14 +333,34 @@ def main() -> int:
staging.mkdir(parents=True)
client = WebDavClient(args.base_url, args.user, password, max(1, args.timeout))
# A successful request against the user's WebDAV root proves that the
# credentials and the Nextcloud endpoint are healthy. Only after this
# guard may an individual configured root be interpreted as removed.
webdav_home, _ = client.list_collection("")
mapping: dict[str, dict[str, object]] = {}
manifest: list[str] = []
total_files = total_folders = total_skipped = 0
used_names: set[str] = set()
removed_roots: list[dict[str, object]] = []
for remote_root_raw in args.root:
remote_root = validate_relative(remote_root_raw)
current, _ = client.list_collection(remote_root)
try:
current, _ = client.list_collection(remote_root)
except WebDavPathUnavailable as error:
if not confirmed_removed_root(client, remote_root, error):
raise RuntimeError(
f"Configured WebDAV root cannot be read safely; existing mirror is preserved: {remote_root or '/'} (HTTP {error.status})"
) from error
removed_roots.append({"path": remote_root, "status": error.status})
print(
f"webdav-placeholder: configured root no longer available and will be removed from the mirror: {remote_root or '/'} (HTTP {error.status})",
file=sys.stderr,
)
continue
fileid = int(current["fileid"])
display = str(current.get("display_name", "")).strip() or (PurePosixPath(remote_root).name if remote_root else args.user)
local_name = f"root-{fileid}"
@@ -357,6 +409,19 @@ def main() -> int:
final_path = source_text + path[len(staging_text):] if path.startswith(staging_text) else path
final_mapping[final_path] = data
# Der Worker erwartet ein syntaktisch nicht-leeres Connector-Mapping,
# filtert aber selbst ausschließlich kind=file. Wenn alle ausgewählten
# Roots entzogen wurden, hält dieser echte WebDAV-Home-Verzeichniseintrag
# das Mapping gültig, während der Shadowtree bewusst null Bildquellen hat.
if not final_mapping:
final_mapping[source_text] = {
"kind": "folder",
"fileid": int(webdav_home["fileid"]),
"webdav_path": "",
"display_name": str(webdav_home.get("display_name", "")) or args.user,
"control_entry": True,
}
atomic_text(args.manifest, "\n".join(manifest) + "\n")
atomic_json(args.mapping, {
"version": 1,
@@ -366,6 +431,9 @@ def main() -> int:
})
print(json.dumps({
"roots": len(args.root),
"roots_available": len(args.root) - len(removed_roots),
"roots_removed": len(removed_roots),
"removed_root_details": removed_roots,
"files": total_files,
"folders": total_folders,
"skipped": total_skipped,

View File

@@ -23,11 +23,12 @@ $_SERVER['REQUEST_URI'] = '/';
$_SERVER['SCRIPT_NAME'] = '/plugins/bratonien_tools/runtime/lib/piwigo-cache-request-by-shadow.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-Piwigo-Shadow-Cache-Bridge/0.9.7.1.17';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-Piwigo-Shadow-Cache-Bridge/0.9.7.1.41';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
require_once(PHPWG_ROOT_PATH.'include/derivative.inc.php');
require_once(PHPWG_ROOT_PATH.'admin/include/image.class.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
fwrite(STDERR, "Bratonien Tools ist nicht aktiv.\n");
@@ -72,6 +73,43 @@ function bratonien_tools_shadow_bridge_call_i($request, &$detail=null)
return true;
}
function bratonien_tools_shadow_bridge_sync_actual_metadata(array &$row, $source_path)
{
$size = @getimagesize($source_path);
if (!is_array($size) || (int)($size[0] ?? 0) < 1 || (int)($size[1] ?? 0) < 1)
{
bratonien_tools_shadow_bridge_fail('Das temporär eingesetzte Original besitzt keine lesbaren Bildabmessungen.');
}
$width = (int)$size[0];
$height = (int)$size[1];
$rotation_angle = pwg_image::get_rotation_angle($source_path);
$rotation_angle = $rotation_angle === null ? 0 : (int)$rotation_angle;
$rotation = pwg_image::get_rotation_code_from_angle($rotation_angle);
if ($rotation === null) $rotation = 0;
$rotation = (int)$rotation;
if (
(int)($row['width'] ?? 0) === $width
&& (int)($row['height'] ?? 0) === $height
&& isset($row['rotation'])
&& (int)$row['rotation'] === $rotation
)
{
return false;
}
pwg_query(
'UPDATE '.IMAGES_TABLE
.' SET width='.$width.', height='.$height.', rotation='.$rotation
.' WHERE id='.(int)$row['id'].' LIMIT 1'
);
$row['width'] = $width;
$row['height'] = $height;
$row['rotation'] = $rotation;
return true;
}
function bratonien_tools_shadow_bridge_variants(array $row, $stage)
{
$src = new SrcImage($row);
@@ -151,10 +189,12 @@ if (count($rows) !== 1)
bratonien_tools_shadow_bridge_fail('Piwigo kann den Shadow-Pfad nicht eindeutig einem Bild zuordnen: '.$db_path);
}
$row = $rows[0];
$metadata_repaired = bratonien_tools_shadow_bridge_sync_actual_metadata($row, $shadow_real);
$variants = bratonien_tools_shadow_bridge_variants($row, $stage);
$generated = 0;
$already = 0;
$invalid_removed = 0;
foreach ($variants as $variant)
{
$reason = '';
@@ -164,6 +204,18 @@ foreach ($variants as $variant)
continue;
}
// Ein vorhandenes, aber geometrisch falsches Derivat darf nicht an i.php
// vorbeikommen. Piwigo entscheidet sonst allein anhand von mtimes und kann
// eine alte Datei trotz korrigierter Breite/Höhe/EXIF-Rotation behalten.
if (is_file($variant['target']))
{
if (!@unlink($variant['target']))
{
bratonien_tools_shadow_bridge_fail($variant['name'].': ungültiges Alt-Derivat konnte nicht entfernt werden ('.$reason.').');
}
$invalid_removed++;
}
$request = bratonien_tools_shadow_bridge_i_request($variant['target']);
if ($request === null) bratonien_tools_shadow_bridge_fail($variant['name'].': ungültiger Piwigo-Derivatpfad.');
@@ -187,8 +239,10 @@ echo json_encode(array(
'shadow_relative'=>$shadow_relative,
'stage'=>$stage,
'piwigo_image_id'=>(int)$row['id'],
'metadata_repaired'=>$metadata_repaired,
'variants'=>count($variants),
'generated'=>$generated,
'already_valid'=>$already,
'invalid_removed'=>$invalid_removed,
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n";
exit(0);

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -Eeuo pipefail
STATE_DIR="${1:-}"
shift || true
[[ -n "$STATE_DIR" ]] || { echo "Connector-State-Verzeichnis fehlt." >&2; exit 2; }
[[ $# -gt 0 ]] || { echo "Warmup-Kommando fehlt." >&2; exit 2; }
SYNC_LOCK="$STATE_DIR/webdav-sync.lock"
WAITER_LOCK="$STATE_DIR/webdav-cache-warmup-after-sync.lock"
# Pro Verbindung darf genau ein wartender Cache-Start existieren. Dadurch
# erzeugen wiederholte Klicks im Adminbereich keine Warteschlange aus Workern.
exec 8>"$WAITER_LOCK"
flock -n 8 || exit 0
# Der Connector hält diesen Lock exklusiv. Wir warten blockierend, ohne dabei
# Nextcloud-Dateien oder den Worker-Index anzufassen. Sobald der Connector den
# Lock freigibt, geben wir unseren kurzen Leselock sofort wieder frei und
# starten den normalen Worker. Dessen eigene Guards bleiben damit maßgeblich.
exec 9<>"$SYNC_LOCK"
flock -s 9
flock -u 9
exec "$@"

View File

@@ -25,19 +25,33 @@ if (!is_array($mapping) || !isset($mapping['files']) || !is_array($mapping['file
}
$dimensions = array();
$mapped_files = 0;
foreach ($mapping['files'] as $path => $entry)
{
if (!is_array($entry) || (string)($entry['kind'] ?? '') !== 'file') continue;
$mapped_files++;
$width = (int)($entry['width'] ?? 0);
$height = (int)($entry['height'] ?? 0);
if ($width < 1 || $height < 1) continue;
$dimensions[str_replace('\\', '/', (string)$path)] = array($width, $height);
}
if ($mapped_files === 0)
{
echo "WebDAV-Metadaten: keine aktuellen Bildquellen vorhanden.\n";
exit(0);
}
// Nextcloud liefert die Bildabmessungen ueber WebDAV nicht fuer jede Datei.
// Das ist kein Fehler des Connector-Syncs: die Dateien und ihr Shadow Tree
// sind trotzdem gueltig und Piwigo kann die Bildquelle spaeter bei Bedarf
// materialisieren. Vorhandene Abmessungen werden uebernommen, fehlende Werte
// bleiben unangetastet. Nur ein syntaktisch/technisch ungueltiges Mapping ist
// oben weiterhin fatal.
if (!$dimensions)
{
fwrite(STDERR, "WebDAV-Mapping enthaelt keine Originalabmessungen.\n");
exit(1);
echo 'WebDAV-Metadaten: bilder='.$mapped_files.' aktualisiert=0 ohne_masse='.$mapped_files." (Nextcloud lieferte keine Bildabmessungen; Synchronisierung bleibt gueltig)\n";
exit(0);
}
define('PHPWG_ROOT_PATH', $piwigo_root.'/');
@@ -51,7 +65,7 @@ $_SERVER['REQUEST_URI'] = '/';
$_SERVER['SCRIPT_NAME'] = '/plugins/bratonien_tools/runtime/lib/sync-webdav-metadata.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Metadata/0.9.6.8.20';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Metadata/0.9.7.1.47';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
@@ -102,5 +116,10 @@ if ($updated > 0)
if (function_exists('invalidate_user_cache')) invalidate_user_cache(true);
}
echo 'WebDAV-Metadaten: bilder='.$checked.' aktualisiert='.$updated.' ohne_masse='.$missing."\n";
exit($missing > 0 ? 1 : 0);
echo 'WebDAV-Metadaten: bilder='.$checked.' aktualisiert='.$updated.' ohne_masse='.$missing;
if ($missing > 0)
{
echo ' (fehlende WebDAV-Abmessungen sind nicht fatal)';
}
echo "\n";
exit(0);

View File

@@ -0,0 +1,324 @@
<?php
if (PHP_SAPI !== 'cli')
{
http_response_code(404);
exit;
}
$piwigo_root = realpath(dirname(__DIR__, 3));
if ($piwigo_root === false)
{
fwrite(STDERR, "Piwigo-Root wurde nicht gefunden.\n");
exit(1);
}
define('PHPWG_ROOT_PATH', rtrim($piwigo_root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR);
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_NAME'] = 'localhost';
$_SERVER['HTTP_HOST'] = 'localhost';
$_SERVER['SERVER_PORT'] = '80';
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['SCRIPT_NAME'] = '/plugins/bratonien_tools/runtime/piwigo-presentation-refresh.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-Presentation-Refresh/0.9.7.1.41';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
require_once(PHPWG_ROOT_PATH.'include/derivative.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
fwrite(STDERR, "Bratonien Tools ist nicht aktiv.\n");
exit(1);
}
require_once(BRATONIEN_TOOLS_PATH.'include/presentation_refresh.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/watermark_runtime.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_materialize_runtime.inc.php');
function bratonien_tools_presentation_worker_log($event, array $data=array())
{
$parts = array('[BRAT-PRESENTATION]', $event);
foreach ($data as $key=>$value)
{
if (is_array($value) || is_object($value)) $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$parts[] = $key.'='.str_replace(array("\r", "\n"), array('\\r', '\\n'), (string)$value);
}
fwrite(STDOUT, implode(' ', $parts)."\n");
}
function bratonien_tools_presentation_worker_categories(array $requested, $all)
{
$requested = array_fill_keys(array_map('intval', $requested), true);
$categories = array();
$result = pwg_query('SELECT id, uppercats FROM '.CATEGORIES_TABLE.' ORDER BY id');
while ($row = pwg_db_fetch_assoc($result))
{
$id = (int)$row['id'];
if ($id < 1) continue;
if ($all)
{
$categories[$id] = $id;
continue;
}
$uppercats = array_filter(array_map('intval', explode(',', (string)$row['uppercats'])));
foreach ($uppercats as $ancestor)
{
if (isset($requested[$ancestor]))
{
$categories[$id] = $id;
break;
}
}
}
ksort($categories, SORT_NUMERIC);
return array_values($categories);
}
function bratonien_tools_presentation_worker_variants(SrcImage $src)
{
$variants = array();
foreach (ImageStdParams::get_defined_type_map() as $type=>$params)
{
$variants['standard:'.$type] = new DerivativeImage($params, $src);
}
foreach (ImageStdParams::$custom as $key=>$last_used)
{
$params = bratonien_tools_webdav_materialize_custom_params($key);
if ($params) $variants['custom:'.$key] = new DerivativeImage($params, $src);
}
return $variants;
}
function bratonien_tools_presentation_worker_call_watermark($url, &$detail=null)
{
$detail = '';
$url = html_entity_decode((string)$url, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$path = (string)parse_url($url, PHP_URL_PATH);
$query = (string)parse_url($url, PHP_URL_QUERY);
$endpoint = '/plugins/'.trim(BRATONIEN_TOOLS_ID, '/').'/watermark.php';
if ($path === '' || substr($path, -strlen($endpoint)) !== $endpoint)
{
if (strpos($path, '/bratonien-watermark/') !== false)
{
$detail = 'Bereits vorhanden.';
return true;
}
$detail = 'Kein Bratonien-Wasserzeichenaufruf erforderlich.';
return true;
}
if ($query === '')
{
$detail = 'Wasserzeichen-URL enthält keine Parameter.';
return false;
}
if (!function_exists('exec'))
{
$detail = 'PHP exec() ist deaktiviert.';
return false;
}
$runner = BRATONIEN_TOOLS_PATH.'runtime/lib/bratonien-watermark-call.php';
if (!is_file($runner))
{
$detail = 'Lokaler Wasserzeichen-Aufrufer fehlt.';
return false;
}
$command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($runner)
.' --query='.escapeshellarg($query).' > /dev/null 2>&1';
$output = array();
$exit = 1;
@exec($command, $output, $exit);
if ($exit !== 0)
{
$detail = 'Wasserzeichen-Aufrufer Exit '.$exit.'.';
return false;
}
$detail = 'Wasserzeichen-Vorschau bereit.';
return true;
}
function bratonien_tools_presentation_worker_process(array $category_ids)
{
global $page;
if (!$category_ids) return array('images'=>0, 'profiles'=>0, 'variants'=>0, 'generated'=>0, 'missing_base'=>0, 'errors'=>0);
$category_lookup = array_fill_keys($category_ids, true);
$ids_sql = implode(',', array_map('intval', $category_ids));
$image_categories = array();
$result = pwg_query('SELECT image_id, category_id FROM '.IMAGE_CATEGORY_TABLE.' WHERE category_id IN ('.$ids_sql.') ORDER BY image_id, category_id');
while ($row = pwg_db_fetch_assoc($result))
{
$image_id = (int)$row['image_id'];
$category_id = (int)$row['category_id'];
if ($image_id > 0 && isset($category_lookup[$category_id])) $image_categories[$image_id][] = $category_id;
}
if (!$image_categories) return array('images'=>0, 'profiles'=>0, 'variants'=>0, 'generated'=>0, 'missing_base'=>0, 'errors'=>0);
$stats = array('images'=>0, 'profiles'=>0, 'variants'=>0, 'generated'=>0, 'missing_base'=>0, 'errors'=>0);
$image_ids = array_keys($image_categories);
foreach (array_chunk($image_ids, 500) as $chunk)
{
$rows = pwg_query('SELECT * FROM '.IMAGES_TABLE.' WHERE id IN ('.implode(',', array_map('intval', $chunk)).') ORDER BY id');
while ($row = pwg_db_fetch_assoc($rows))
{
$image_id = (int)$row['id'];
$stats['images']++;
$profile_contexts = array();
foreach ($image_categories[$image_id] ?? array() as $category_id)
{
$rule = bratonien_tools_runtime_effective_rule($category_id);
if ((string)($rule['mode'] ?? '') !== 'profile' || empty($rule['profile_id'])) continue;
$profile_id = (int)$rule['profile_id'];
if ($profile_id < 1 || isset($profile_contexts[$profile_id])) continue;
$profile = bratonien_tools_runtime_get_profile($profile_id);
if (!$profile || empty($profile['active']) || !bratonien_tools_profile_watermark_path($profile)) continue;
$profile_contexts[$profile_id] = $category_id;
}
if (!$profile_contexts) continue;
try
{
$src = new SrcImage($row);
$variants = bratonien_tools_presentation_worker_variants($src);
}
catch (Throwable $e)
{
$stats['errors']++;
bratonien_tools_presentation_worker_log('image_error', array('image_id'=>$image_id, 'message'=>$e->getMessage()));
continue;
}
foreach ($profile_contexts as $profile_id=>$category_id)
{
$stats['profiles']++;
$page['category'] = array('id'=>(int)$category_id);
foreach ($variants as $variant_name=>$derivative)
{
try
{
if ($derivative->same_as_source()) continue;
$stats['variants']++;
$target = $derivative->get_path();
if ($target === '' || !is_file($target) || !is_readable($target))
{
$stats['missing_base']++;
continue;
}
$detail = '';
if (!bratonien_tools_presentation_worker_call_watermark($derivative->get_url(), $detail))
{
$stats['errors']++;
bratonien_tools_presentation_worker_log('variant_error', array(
'image_id'=>$image_id,
'category_id'=>$category_id,
'profile_id'=>$profile_id,
'variant'=>$variant_name,
'message'=>$detail,
));
continue;
}
$stats['generated']++;
}
catch (Throwable $e)
{
$stats['errors']++;
bratonien_tools_presentation_worker_log('variant_error', array(
'image_id'=>$image_id,
'category_id'=>$category_id,
'profile_id'=>$profile_id,
'variant'=>$variant_name,
'message'=>$e->getMessage(),
));
}
}
}
}
}
unset($page['category']);
return $stats;
}
try
{
$queue_dir = bratonien_tools_presentation_refresh_queue_dir();
if (!is_dir($queue_dir)) exit(0);
$lock_path = $queue_dir.'/.worker.lock';
$lock = @fopen($lock_path, 'c');
if (!$lock) throw new RuntimeException('Presentation-Worker-Lock konnte nicht geöffnet werden.');
if (!@flock($lock, LOCK_EX)) throw new RuntimeException('Presentation-Worker-Lock konnte nicht gesetzt werden.');
try
{
while (true)
{
$request_files = glob($queue_dir.'/*.json') ?: array();
sort($request_files, SORT_STRING);
if (!$request_files) break;
$all = false;
$requested = array();
$valid_files = array();
$reasons = array();
foreach ($request_files as $file)
{
$data = json_decode((string)@file_get_contents($file), true);
if (!is_array($data))
{
bratonien_tools_presentation_worker_log('invalid_request', array('file'=>basename($file)));
@unlink($file);
continue;
}
$valid_files[] = $file;
if (!empty($data['all'])) $all = true;
foreach ((array)($data['categories'] ?? array()) as $category_id)
{
$category_id = (int)$category_id;
if ($category_id > 0) $requested[$category_id] = $category_id;
}
if (!empty($data['reason'])) $reasons[] = (string)$data['reason'];
}
if (!$valid_files) continue;
$categories = bratonien_tools_presentation_worker_categories(array_values($requested), $all);
$stats = bratonien_tools_presentation_worker_process($categories);
bratonien_tools_presentation_worker_log('complete', array(
'all'=>$all,
'requested'=>count($requested),
'categories'=>count($categories),
'reasons'=>array_values(array_unique($reasons)),
'images'=>$stats['images'],
'profiles'=>$stats['profiles'],
'variants'=>$stats['variants'],
'prepared'=>$stats['generated'],
'missing_base'=>$stats['missing_base'],
'errors'=>$stats['errors'],
));
foreach ($valid_files as $file) @unlink($file);
}
}
finally
{
@flock($lock, LOCK_UN);
fclose($lock);
}
exit(0);
}
catch (Throwable $e)
{
fwrite(STDERR, '[BRAT-PRESENTATION] fatal '.$e->getMessage()."\n");
exit(1);
}

View File

@@ -25,22 +25,8 @@ $_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
require_once(PHPWG_ROOT_PATH.'admin/include/functions.php');
$stateRoot = '/var/lib/bratonien-tools/nc-connector';
$marker = $stateRoot.'/.webdav-orphan-repair-0963.done';
try
{
if (is_file($marker))
{
echo "NC WebDAV Altlasten-Reparatur: bereits abgeschlossen.\n";
exit(0);
}
if (!is_dir($stateRoot) && !mkdir($stateRoot, 0750, true))
{
throw new RuntimeException('State-Verzeichnis konnte nicht angelegt werden: '.$stateRoot);
}
$relativePrefix = './_data/bratonien-tools/nc-webdav-gallery/connection-';
$absolutePrefix = rtrim(PHPWG_ROOT_PATH, '/').'/_data/bratonien-tools/nc-webdav-gallery/connection-';
$relativeLength = strlen($relativePrefix);
@@ -51,6 +37,7 @@ try
" OR LEFT(path,".$absoluteLength.")='".pwg_db_real_escape_string($absolutePrefix)."'";
$result = pwg_query($query);
$checked = 0;
$staleIds = array();
while ($row = pwg_db_fetch_assoc($result))
{
@@ -71,6 +58,7 @@ try
continue;
}
$checked++;
if (!is_file($filesystemPath))
{
$staleIds[] = $id;
@@ -80,22 +68,19 @@ try
$staleIds = array_values(array_unique(array_map('intval', $staleIds)));
if ($staleIds)
{
// Die Shadow-Quelle existiert nicht mehr. Der Bilddatensatz ist damit
// unabhängig von eventuellen virtuellen Album-Verknüpfungen ungültig.
// delete_elements(..., false) entfernt Piwigos Datensatz und Derivate,
// fasst aber keine bereits verschwundene Quelldatei an.
delete_elements($staleIds, false);
invalidate_user_cache(true);
}
$payload = date('c').' removed='.count($staleIds)."\n";
if (file_put_contents($marker, $payload, LOCK_EX) === false)
{
throw new RuntimeException('Abschlussmarker konnte nicht geschrieben werden: '.$marker);
}
@chmod($marker, 0640);
echo 'NC WebDAV Altlasten-Reparatur: entfernte verwaiste Bilder='.count($staleIds)."\n";
echo 'NC WebDAV laufende Bereinigung: geprueft='.$checked.' entfernte_verwaiste_bilder='.count($staleIds)."\n";
exit(0);
}
catch (Throwable $e)
{
fwrite(STDERR, 'NC WebDAV Altlasten-Reparatur: '.$e->getMessage()."\n");
fwrite(STDERR, 'NC WebDAV laufende Bereinigung: '.$e->getMessage()."\n");
exit(1);
}

View File

@@ -101,14 +101,34 @@ if [[ -r "$WEBDAV_MAPPING_FILE" ]]; then
OLD_MAPPING_HASH="$(sha256sum -- "$WEBDAV_MAPPING_FILE" | awk '{print $1}')"
fi
python3 "$SCRIPT_DIR/lib/build_webdav_placeholder_source.py" \
# Der Scanner liefert die eigentliche Fehlerursache auf stderr. Diese Ausgabe
# darf nicht vom allgemeinen ERR-Trap verschluckt werden, weil im Admin sonst
# nur die aufgerufene Python-Befehlszeile statt der Ursache sichtbar ist.
PLACEHOLDER_OUTPUT=""
PLACEHOLDER_EXIT=0
trap - ERR
if PLACEHOLDER_OUTPUT="$(python3 "$SCRIPT_DIR/lib/build_webdav_placeholder_source.py" \
--base-url "$WEBDAV_BASE_URL" \
--user "$WEBDAV_USER" \
--password-file "$WEBDAV_PASSWORD_FILE" \
"${ROOT_ARGS[@]}" \
--source-dir "$WEBDAV_SOURCE_DIR" \
--manifest "$MANIFEST" \
--mapping "$WEBDAV_MAPPING_FILE"
--mapping "$WEBDAV_MAPPING_FILE" 2>&1)"; then
PLACEHOLDER_EXIT=0
else
PLACEHOLDER_EXIT=$?
fi
trap 'failure $? "$BASH_COMMAND" "$LINENO"' ERR
[[ -z "$PLACEHOLDER_OUTPUT" ]] || printf '%s\n' "$PLACEHOLDER_OUTPUT"
if [[ "$PLACEHOLDER_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $PLACEHOLDER_EXIT"
if [[ -n "$PLACEHOLDER_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$PLACEHOLDER_OUTPUT" | compact_output)"
fi
write_status error "Nextcloud-WebDAV konnte nicht eingelesen werden" "$DETAIL"
exit "$PLACEHOLDER_EXIT"
fi
find "$WEBDAV_SOURCE_DIR" -type d -exec chgrp www-data -- {} +
find "$WEBDAV_SOURCE_DIR" -type d -exec chmod 0775 -- {} +
@@ -180,6 +200,23 @@ if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
exit "$PIWIGO_EXIT"
fi
CLEANUP_OUTPUT=""
CLEANUP_EXIT=0
if CLEANUP_OUTPUT="$(php "$SCRIPT_DIR/repair-webdav-orphans.php" 2>&1)"; then
CLEANUP_EXIT=0
else
CLEANUP_EXIT=$?
fi
[[ -z "$CLEANUP_OUTPUT" ]] || printf '%s\n' "$CLEANUP_OUTPUT"
if [[ "$CLEANUP_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $CLEANUP_EXIT"
if [[ -n "$CLEANUP_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$CLEANUP_OUTPUT" | compact_output)"
fi
write_status error "Verwaiste Piwigo-Bilder entfernter WebDAV-Quellen konnten nicht bereinigt werden" "$DETAIL"
exit "$CLEANUP_EXIT"
fi
METADATA_OUTPUT=""
METADATA_EXIT=0
if METADATA_OUTPUT="$(php "$SCRIPT_DIR/lib/sync-webdav-metadata.php" \

View File

@@ -23,7 +23,7 @@ $_SERVER['REQUEST_URI'] = '/';
$_SERVER['SCRIPT_NAME'] = '/plugins/bratonien_tools/runtime/webdav-warmup-dispatch.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Worker-Dispatcher/0.9.7.1.29';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Worker-Dispatcher/0.9.7.1.50';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
@@ -89,9 +89,56 @@ function bratonien_tools_webdav_dispatch_only_connector_deferrals(array $output)
return $seen;
}
function bratonien_tools_webdav_dispatch_connector_service_active()
{
if (!function_exists('exec') || !is_executable('/usr/bin/systemctl')) return false;
$output = array();
$exit = 1;
@exec('/usr/bin/systemctl is-active --quiet bratonien-nc-connector.service 2>/dev/null', $output, $exit);
return $exit === 0;
}
function bratonien_tools_webdav_dispatch_reset_orphan_sync_lock($state_dir)
{
$path = rtrim((string)$state_dir, '/').'/webdav-sync.lock';
if (!is_file($path)) return true;
$lock = @fopen($path, 'c');
if (!$lock) return false;
if (@flock($lock, LOCK_SH | LOCK_NB))
{
@flock($lock, LOCK_UN);
fclose($lock);
return true;
}
fclose($lock);
// Der Produktions-Connector darf ausschließlich über den systemd-Dienst
// laufen. Ist dieser nicht aktiv, ist ein weiterhin exklusiv belegter Lock
// kein gültiger Connector-Zustand. Den alten Inode hängen wir aus und legen
// eine frische Lockdatei an, damit ein verwaister Altprozess den Cache nicht
// dauerhaft blockiert. Ein späterer echter Connector benutzt automatisch die
// neue Datei.
if (bratonien_tools_webdav_dispatch_connector_service_active()) return false;
$orphan = $path.'.orphan-'.date('YmdHis').'-'.getmypid();
if (!@rename($path, $orphan)) return false;
$fresh = @fopen($path, 'c');
if (!$fresh)
{
@rename($orphan, $path);
return false;
}
@chmod($path, 0660);
fclose($fresh);
@file_put_contents(PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.log', '[BRAT-WORKER] orphan_sync_lock_reset old='.basename($orphan)."\n", FILE_APPEND | LOCK_EX);
return true;
}
function bratonien_tools_webdav_dispatch_connector_busy($state_dir)
{
$lock = @fopen(rtrim((string)$state_dir, '/').'/webdav-sync.lock', 'c');
$path = rtrim((string)$state_dir, '/').'/webdav-sync.lock';
$lock = @fopen($path, 'c');
if (!$lock) return false;
if (@flock($lock, LOCK_SH | LOCK_NB))
{
@@ -100,7 +147,30 @@ function bratonien_tools_webdav_dispatch_connector_busy($state_dir)
return false;
}
fclose($lock);
return true;
if (bratonien_tools_webdav_dispatch_connector_service_active()) return true;
// Genau der hier beobachtete Fehlerfall: Lock belegt, aber kein Connector-
// Dienst aktiv. Nicht als "Connector läuft" darstellen, sondern den
// verwaisten Lockzustand reparieren und den Cache normal starten lassen.
if (bratonien_tools_webdav_dispatch_reset_orphan_sync_lock($state_dir)) return false;
// Konnte der ungültige Lockzustand nicht sicher repariert werden, wird nicht
// behauptet, dass ein Connector läuft. Der Worker erhält stattdessen einen
// echten Fehler und kann den Zustand sichtbar machen.
throw new RuntimeException('WebDAV-Sync-Lock ist belegt, obwohl der Connector-Dienst nicht aktiv ist, und konnte nicht sicher zurückgesetzt werden.');
}
function bratonien_tools_webdav_dispatch_schedule_after_sync($waiter, $state_dir, $worker_command, $log, &$pid=null)
{
$pid = 0;
$deferred = escapeshellarg('/bin/bash').' '.escapeshellarg($waiter).' '.escapeshellarg($state_dir).' '.$worker_command;
$command = 'nohup '.$deferred.' >> '.escapeshellarg($log).' 2>&1 < /dev/null & echo $!';
$output = array();
$exit = 1;
@exec($command, $output, $exit);
$pid = isset($output[0]) ? (int)$output[0] : 0;
return $exit === 0 && $pid > 0;
}
$mode = 'periodic';
@@ -127,6 +197,7 @@ if (!function_exists('exec'))
$worker = realpath(BRATONIEN_TOOLS_PATH.'runtime/lib/webdav-cache-warmup.php');
$priority_waiter = realpath(BRATONIEN_TOOLS_PATH.'runtime/lib/run-webdav-priority-wait.sh');
$after_sync_waiter = realpath(BRATONIEN_TOOLS_PATH.'runtime/lib/run-webdav-after-sync.sh');
if (!$worker || !is_file($worker))
{
fwrite(STDERR, "WebDAV-Worker wurde nicht gefunden.\n");
@@ -137,6 +208,11 @@ if (!$priority_waiter || !is_file($priority_waiter))
fwrite(STDERR, "WebDAV-Prioritäts-Warter wurde nicht gefunden.\n");
exit(1);
}
if (!$after_sync_waiter || !is_file($after_sync_waiter))
{
fwrite(STDERR, "WebDAV-Connector-Warter wurde nicht gefunden.\n");
exit(1);
}
$log = PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.log';
$result = 0;
@@ -162,6 +238,25 @@ foreach (bratonien_tools_nc_connector_connections() as $connection)
continue;
}
$worker_command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($worker)
.' --connection-id='.$connection_id
.' --mode='.escapeshellarg($mode);
try
{
$connector_busy = $mode !== 'sync' && bratonien_tools_webdav_dispatch_connector_busy($state_dir);
}
catch (Throwable $e)
{
if (function_exists('bratonien_tools_webdav_warmup_write_status'))
{
bratonien_tools_webdav_warmup_write_status($connection_id, 'error', $e->getMessage(), array('mode'=>$mode, 'deferred_by_connector'=>false));
}
fwrite(STDERR, $e->getMessage()."\n");
$result = 1;
continue;
}
if ($mode === 'sync')
{
if (!is_dir($state_dir) && !@mkdir($state_dir, 0750, true) && !is_dir($state_dir))
@@ -184,24 +279,32 @@ foreach (bratonien_tools_nc_connector_connections() as $connection)
}
@chmod($priority_file, 0664);
}
elseif (bratonien_tools_webdav_dispatch_connector_busy($state_dir))
elseif ($connector_busy)
{
$resume_pid = 0;
$scheduled = bratonien_tools_webdav_dispatch_schedule_after_sync($after_sync_waiter, $state_dir, $worker_command, $log, $resume_pid);
if (function_exists('bratonien_tools_webdav_warmup_write_status'))
{
bratonien_tools_webdav_warmup_write_status(
$connection_id,
'waiting',
'Connector-Synchronisierung läuft. Der Cache-Worker startet erst danach; es werden vorher keine Nextcloud-Originale geladen.',
array('mode'=>$mode, 'deferred_by_connector'=>true)
$scheduled ? 'waiting' : 'error',
$scheduled
? 'Connector-Synchronisierung läuft. Der Cache-Worker ist vorgemerkt und startet automatisch unmittelbar danach; es werden vorher keine Nextcloud-Originale geladen.'
: 'Connector-Synchronisierung läuft, aber der automatische Cache-Fortsetzungsauftrag konnte nicht gestartet werden.',
array('mode'=>$mode, 'deferred_by_connector'=>true, 'resume_scheduled'=>$scheduled, 'resume_pid'=>$resume_pid)
);
}
fwrite(STDOUT, "WebDAV-Worker für Verbindung #{$connection_id} wartet auf den Connector-Sync.\n");
if (!$scheduled)
{
fwrite(STDERR, "Wartender WebDAV-Worker für Verbindung #{$connection_id} konnte nicht gestartet werden.\n");
$result = 1;
continue;
}
$started++;
fwrite(STDOUT, "WebDAV-Worker für Verbindung #{$connection_id} wartet auf den Connector-Sync und startet danach automatisch (PID {$resume_pid}).\n");
continue;
}
$worker_command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($worker)
.' --connection-id='.$connection_id
.' --mode='.escapeshellarg($mode);
$base = $mode === 'sync'
? escapeshellarg('/bin/bash').' '.escapeshellarg($priority_waiter).' '.escapeshellarg($state_dir).' '.$worker_command
: $worker_command;
@@ -221,6 +324,8 @@ foreach (bratonien_tools_nc_connector_connections() as $connection)
$deferred = bratonien_tools_webdav_dispatch_only_connector_deferrals($output);
if ($deferred)
{
$resume_pid = 0;
$scheduled = bratonien_tools_webdav_dispatch_schedule_after_sync($after_sync_waiter, $state_dir, $worker_command, $log, $resume_pid);
if (function_exists('bratonien_tools_webdav_warmup_write_status'))
{
$existing = bratonien_tools_webdav_dispatch_existing_status($connection_id);
@@ -228,13 +333,19 @@ foreach (bratonien_tools_nc_connector_connections() as $connection)
unset($extra['state'], $extra['message'], $extra['connection_id'], $extra['updated_at']);
$extra['worker_exit_code'] = (int)$exit;
$extra['deferred_by_connector'] = true;
$extra['resume_scheduled'] = $scheduled;
$extra['resume_pid'] = $resume_pid;
bratonien_tools_webdav_warmup_write_status(
$connection_id,
'waiting',
'Connector-Synchronisierung hat während des Batches Vorrang erhalten. Der bisherige Indexstand bleibt erhalten; die offenen Quellen werden später fortgesetzt und gelten nicht als Fehler.',
$scheduled ? 'waiting' : 'error',
$scheduled
? 'Connector-Synchronisierung hat während des Batches Vorrang erhalten. Der bisherige Indexstand bleibt erhalten; der Cache-Worker ist zur automatischen Fortsetzung vorgemerkt.'
: 'Connector-Synchronisierung hat während des Batches Vorrang erhalten, aber die automatische Fortsetzung konnte nicht vorgemerkt werden.',
$extra
);
}
if (!$scheduled) $result = 1;
else $started++;
continue;
}
@@ -307,4 +418,4 @@ if ($connection_filter > 0 && $matched === 0)
exit(1);
}
if (!$run_inline) fwrite(STDOUT, "Gestartete WebDAV-Worker: {$started}.\n");
exit($result);
exit($result);

View File

@@ -20,6 +20,7 @@
{ id:'wasserzeichen', label:'Wasserzeichen', sections:['uebersicht','wasserzeichen','regeln'] },
{ id:'auswahl-download', label:'Fotoauswahl & Downloads' },
{ id:'freigaben', label:'Geschützte Freigaben' },
{ id:'qr-upload', label:'QR-Upload' },
{ id:'nc-connector', label:'NC Connector' },
{ id:'bilddateien', label:'Bilddateien & Pfade' },
{ id:'wartung', label:'Wartung / Cache' },

View File

@@ -0,0 +1,198 @@
{literal}
<style>
.bratonien-qr-stock-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.bratonien-qr-stock-head h4{margin:0}
.bratonien-qr-legend{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin:0 0 16px}
.bratonien-qr-legend-item{display:inline-flex;gap:7px;align-items:center}
.bratonien-qr-legend-box{width:18px;height:18px;border-radius:4px;border:1px solid}
.bratonien-qr-legend-box.is-present{background:rgba(62,170,94,.28);border-color:#58c878}
.bratonien-qr-legend-box.is-missing{background:rgba(205,72,72,.25);border-color:#db6d6d}
.bratonien-qr-year{padding:14px 0;border-top:1px solid rgba(255,255,255,.10)}
.bratonien-qr-year:first-of-type{border-top:0;padding-top:0}
.bratonien-qr-year-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px}
.bratonien-qr-year-head strong{font-size:1.05rem}
.bratonien-qr-year-count{opacity:.78}
.bratonien-qr-year-download{margin-left:auto}
.bratonien-qr-slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(42px,1fr));gap:6px}
.bratonien-qr-slot{min-height:38px;display:flex;align-items:center;justify-content:center;border:1px solid;border-radius:5px;font-weight:700;text-decoration:none!important}
.bratonien-qr-slot.is-present{background:rgba(62,170,94,.28);border-color:#58c878;color:#dff7e6}
.bratonien-qr-slot.is-present:hover{background:rgba(62,170,94,.42);color:#fff}
.bratonien-qr-slot.is-missing{background:rgba(205,72,72,.25);border-color:#db6d6d;color:#ffdede}
@media(max-width:760px){.bratonien-qr-year-download{margin-left:0}.bratonien-qr-slots{grid-template-columns:repeat(auto-fill,minmax(38px,1fr))}}
</style>
{/literal}
<section class="bratonien-section" id="qr-upload">
<h3>QR-Upload</h3>
<p class="bratonien-section__intro">Aktivierbares Uploadformular für nummerierte QR-Codes und persönliche Freundschaftscodes.</p>
<div class="bratonien-grid">
<div class="bratonien-card">
<h4>Formular</h4>
<form method="post">
<input type="hidden" name="pwg_token" value="{$PWG_TOKEN|escape:html}">
<input type="hidden" name="bratonien_tool" value="customer_qr_settings">
<label style="display:flex;gap:10px;align-items:center;margin:0 0 14px">
<input type="checkbox" name="customer_qr_enabled" value="1" {if $CUSTOMER_QR.enabled}checked{/if}>
<strong>QR-Upload aktivieren</strong>
</label>
<button class="buttonLike" type="submit">Einstellung speichern</button>
</form>
<p class="bratonien-base-note" style="margin-top:14px">Ist das Formular deaktiviert, verschwindet der Menüpunkt und die Uploadseite nimmt keine Dateien an.</p>
</div>
<div class="bratonien-card">
<h4>Formular-Adresse</h4>
<div class="bratonien-form-grid">
<span class="bratonien-label">Status</span><strong>{if $CUSTOMER_QR.enabled}Aktiv{else}Deaktiviert{/if}</strong>
<span class="bratonien-label">Jahre</span><strong>{$CUSTOMER_QR.year_min}{$CUSTOMER_QR.year_max}</strong>
<span class="bratonien-label">Standardjahr</span><strong>{$CUSTOMER_QR.default_year}</strong>
<span class="bratonien-label">Server-Batchlimit</span><strong>{$CUSTOMER_QR.max_files} Dateien</strong>
<span class="bratonien-label">Freundschaftscodes</span><strong>{$CUSTOMER_QR.friendship_codes.total}</strong>
</div>
<p style="margin-top:14px;overflow-wrap:anywhere"><a href="{$CUSTOMER_QR.url|escape:html}" target="_blank" rel="noopener">{$CUSTOMER_QR.url|escape:html}</a></p>
</div>
<div class="bratonien-card">
<h4>Vorgesehene QR-Codes</h4>
<div class="bratonien-form-grid">
{foreach from=$CUSTOMER_QR.years item=qr_year}
<span class="bratonien-label">{$qr_year.year}</span>
<strong>{$qr_year.used} / {$qr_year.capacity} belegt · {$qr_year.remaining} frei</strong>
{/foreach}
</div>
<p class="bratonien-base-note">2023: Nummern 1100 · 2024: 150 · 2025 und 2026: jeweils 130. Weitere Jahre werden erst mit dem jeweiligen Jahresupdate ergänzt.</p>
</div>
<div class="bratonien-card">
<h4>Belegte QR-Code-Nummern</h4>
<div class="bratonien-form-grid">
<span class="bratonien-label">Gesamt</span><strong>{$CUSTOMER_QR.total}</strong>
<span class="bratonien-label">Im Standardjahr {$CUSTOMER_QR.default_year}</span><strong>{$CUSTOMER_QR.current_year_total} / {$CUSTOMER_QR.current_year_capacity}</strong>
</div>
<p class="bratonien-base-note">Eine QR-Code-Nummer ist pro Jahr eindeutig. Wird ein Upload gelöscht, ist die Nummer für dieses Jahr wieder frei.</p>
</div>
</div>
<div class="bratonien-card" style="margin-top:18px">
<div class="bratonien-qr-stock-head">
<h4>QR-Code-Bestand</h4>
{if $CUSTOMER_QR.batch_download_available and $CUSTOMER_QR.total > 0}
<a class="buttonLike" href="{$CUSTOMER_QR.batch_download_url|escape:html}">Alle QR-Codes als ZIP herunterladen</a>
{/if}
</div>
<div class="bratonien-qr-legend">
<span class="bratonien-qr-legend-item"><span class="bratonien-qr-legend-box is-present"></span>Vorhanden</span>
<span class="bratonien-qr-legend-item"><span class="bratonien-qr-legend-box is-missing"></span>Fehlt</span>
</div>
{foreach from=$CUSTOMER_QR.years item=qr_year}
<div class="bratonien-qr-year">
<div class="bratonien-qr-year-head">
<strong>{$qr_year.year}</strong>
<span class="bratonien-qr-year-count">{$qr_year.used} / {$qr_year.capacity} vorhanden · {$qr_year.remaining} fehlen</span>
{if $CUSTOMER_QR.batch_download_available and $qr_year.used > 0}
<a class="buttonLike bratonien-qr-year-download" href="{$qr_year.batch_download_url|escape:html}">{$qr_year.year} als ZIP</a>
{/if}
</div>
<div class="bratonien-qr-slots" aria-label="QR-Code-Bestand {$qr_year.year}">
{foreach from=$qr_year.slots item=qr_slot}
{if $qr_slot.present}
<a class="bratonien-qr-slot is-present" href="{$qr_slot.preview_url|escape:html}" target="_blank" rel="noopener" title="QR-Code #{$qr_slot.number} vorhanden Vorschau öffnen">{$qr_slot.number}</a>
{else}
<span class="bratonien-qr-slot is-missing" title="QR-Code #{$qr_slot.number} fehlt">{$qr_slot.number}</span>
{/if}
{/foreach}
</div>
</div>
{/foreach}
{if not $CUSTOMER_QR.batch_download_available}
<p class="bratonien-base-note" style="margin-top:14px">Batch-Download ist erst verfügbar, wenn die PHP-Erweiterung ZipArchive auf dem Server aktiv ist.</p>
{/if}
</div>
<div class="bratonien-card" style="margin-top:18px;overflow-x:auto">
<h4>QR-Codes verwalten</h4>
{if $CUSTOMER_QR.total > 0}
<table style="width:100%;border-collapse:collapse;min-width:820px">
<thead>
<tr>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Jahr</th>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">QR-Nr.</th>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Datei</th>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Größe</th>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Upload</th>
<th style="text-align:right;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Aktionen</th>
</tr>
</thead>
<tbody>
{foreach from=$CUSTOMER_QR.uploads item=qr_upload}
<tr>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08)"><strong>{$qr_upload.year}</strong></td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08)"><strong>#{$qr_upload.number|escape:html}</strong></td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08);max-width:300px;overflow-wrap:anywhere">{$qr_upload.original_name|escape:html}</td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08)">{$qr_upload.file_size_label|escape:html}</td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08)">{$qr_upload.created|escape:html}</td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08);text-align:right;white-space:nowrap">
<a class="buttonLike" href="{$qr_upload.preview_url|escape:html}" target="_blank" rel="noopener" style="display:inline-block;margin-right:6px">Vorschau</a>
<a class="buttonLike" href="{$qr_upload.preview_url|escape:html}&amp;download=1" style="display:inline-block;margin-right:6px">Herunterladen</a>
<form method="post" style="display:inline" onsubmit="return confirm('Diesen QR-Upload wirklich löschen? Die QR-Nummer wird danach wieder frei.');">
<input type="hidden" name="pwg_token" value="{$PWG_TOKEN|escape:html}">
<input type="hidden" name="bratonien_tool" value="customer_qr_delete">
<input type="hidden" name="customer_qr_id" value="{$qr_upload.id}">
<button class="buttonLike" type="submit">Löschen</button>
</form>
</td>
</tr>
{/foreach}
</tbody>
</table>
{else}
<p class="bratonien-base-note">Noch keine QR-Codes hochgeladen.</p>
{/if}
</div>
<div class="bratonien-card" style="margin-top:18px;overflow-x:auto">
<h4>Persönliche Freundschaftscodes</h4>
{if $CUSTOMER_QR.friendship_codes.total > 0}
<table style="width:100%;border-collapse:collapse;min-width:760px">
<thead>
<tr>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Name</th>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Datei</th>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Größe</th>
<th style="text-align:left;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Upload</th>
<th style="text-align:right;padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.14)">Aktionen</th>
</tr>
</thead>
<tbody>
{foreach from=$CUSTOMER_QR.friendship_codes.uploads item=friendship}
<tr>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08)"><strong>{$friendship.name|escape:html}</strong></td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08);max-width:300px;overflow-wrap:anywhere">{$friendship.original_name|escape:html}</td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08)">{$friendship.file_size_label|escape:html}</td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08)">{$friendship.created|escape:html}</td>
<td style="padding:9px 8px;border-bottom:1px solid rgba(255,255,255,.08);text-align:right;white-space:nowrap">
<a class="buttonLike" href="{$friendship.preview_url|escape:html}" target="_blank" rel="noopener" style="display:inline-block;margin-right:6px">Vorschau</a>
<a class="buttonLike" href="{$friendship.download_url|escape:html}" style="display:inline-block;margin-right:6px">Herunterladen</a>
<form method="post" style="display:inline" onsubmit="return confirm('Diesen Freundschaftscode wirklich löschen?');">
<input type="hidden" name="pwg_token" value="{$PWG_TOKEN|escape:html}">
<input type="hidden" name="bratonien_tool" value="friendship_code_delete">
<input type="hidden" name="friendship_code_id" value="{$friendship.id}">
<button class="buttonLike" type="submit">Löschen</button>
</form>
</td>
</tr>
{/foreach}
</tbody>
</table>
{else}
<p class="bratonien-base-note">Noch keine persönlichen Freundschaftscodes hochgeladen.</p>
{/if}
</div>
</section>

View File

@@ -61,7 +61,12 @@ function bratonien_tools_save_album_rule()
)));
}
return array('message'=>'Albumregel gespeichert.');
if (function_exists('bratonien_tools_presentation_refresh_enqueue'))
{
bratonien_tools_presentation_refresh_enqueue(array($category), 'album-watermark-rule-changed');
}
return array('message'=>'Albumregel gespeichert. Betroffene Vorschauen werden im Hintergrund aktualisiert.');
}
function bratonien_tools_get_category_tree()

View File

@@ -108,9 +108,10 @@ function bratonien_tools_save_watermark_profile()
throw new RuntimeException('Profilname darf nicht leer sein.');
}
$existing_profile = $id > 0 ? bratonien_tools_get_watermark_profile($id) : null;
if ($id > 0)
{
if (!bratonien_tools_get_watermark_profile($id))
if (!$existing_profile)
{
throw new RuntimeException('Wasserzeichenprofil nicht gefunden.');
}
@@ -128,7 +129,14 @@ function bratonien_tools_save_watermark_profile()
mass_inserts($table, array_keys($data), array($data));
}
return array('message'=>'Wasserzeichenprofil gespeichert.');
if ($id > 0 && function_exists('bratonien_tools_presentation_refresh_enqueue_all'))
{
bratonien_tools_presentation_refresh_enqueue_all('watermark-profile-changed');
}
return array('message'=>$id > 0
? 'Wasserzeichenprofil gespeichert. Betroffene Vorschauen werden im Hintergrund aktualisiert.'
: 'Wasserzeichenprofil gespeichert.');
}
function bratonien_tools_profile_is_referenced($id)

View File

@@ -4,15 +4,47 @@ if (!defined('PHPWG_ROOT_PATH'))
die('Hacking attempt!');
}
function bratonien_tools_default_public_watermark_profile_id()
{
if (!function_exists('bratonien_tools_create_default_watermark_profiles'))
{
return null;
}
bratonien_tools_create_default_watermark_profiles();
$table = bratonien_tools_table('watermark_profiles');
$row = pwg_db_fetch_assoc(pwg_query(
"SELECT id FROM ".$table.
" WHERE active=1 AND name='Oeffentlich' ORDER BY id ASC LIMIT 1"
));
return $row ? (int)$row['id'] : null;
}
function bratonien_tools_get_watermark_defaults()
{
$defaults = conf_get_param('bratonien_watermark_defaults', null);
$defaults = $defaults ? json_decode($defaults, true) : array();
return array_merge(array(
$defaults = array_merge(array(
'public_profile' => null,
'private_profile' => null,
), is_array($defaults) ? $defaults : array());
// Oeffentliche Alben sind standardmaessig geschuetzt. Nur eine explizite
// Albumregel (anderes Profil oder deaktiviert) darf dieses Verhalten
// ueberschreiben. Bestehende Installationen ohne gesetztes globales
// Oeffentlich-Profil werden automatisch auf das mitgelieferte Profil
// "Oeffentlich" migriert.
if (empty($defaults['public_profile']))
{
$public_profile = bratonien_tools_default_public_watermark_profile_id();
if ($public_profile)
{
$defaults['public_profile'] = $public_profile;
conf_update_param('bratonien_watermark_defaults', json_encode($defaults));
}
}
return $defaults;
}
function bratonien_tools_validate_default_profile($value)
@@ -34,12 +66,23 @@ function bratonien_tools_validate_default_profile($value)
function bratonien_tools_save_watermark_defaults()
{
$public_profile = bratonien_tools_validate_default_profile($_POST['public_profile'] ?? null);
if ($public_profile === null)
{
$public_profile = bratonien_tools_default_public_watermark_profile_id();
}
$config = array(
'public_profile' => bratonien_tools_validate_default_profile($_POST['public_profile'] ?? null),
'public_profile' => $public_profile,
'private_profile' => bratonien_tools_validate_default_profile($_POST['private_profile'] ?? null),
);
conf_update_param('bratonien_watermark_defaults', json_encode($config));
return array('message'=>'Globale Wasserzeichenregeln gespeichert.');
if (function_exists('bratonien_tools_presentation_refresh_enqueue_all'))
{
bratonien_tools_presentation_refresh_enqueue_all('global-watermark-rules-changed');
}
return array('message'=>'Globale Wasserzeichenregeln gespeichert. Oeffentliche Alben verwenden ohne abweichende Albumregel immer das Standard-Wasserzeichenprofil. Vorschauen werden im Hintergrund an die neuen Regeln angepasst.');
}