Compare commits

...

228 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
Terranom674
86a2a0fbec Bump Bratonien Tools version to 0.9.7.1.30 2026-09-03 23:20:45 +02:00
Terranom674
c058e48fb0 Keep partial WebDAV cache runs resumable instead of global error 2026-09-03 23:20:25 +02:00
Terranom674
d20be6a39a Make WebDAV warmup batch cancellation and connector deferral deterministic 2026-09-03 23:19:42 +02:00
Terranom674
3d7114d782 Bump Bratonien Tools to 0.9.7.1.29 2026-09-03 23:15:16 +02:00
Terranom674
c20795d108 Treat connector deferrals as waiting instead of cache errors 2026-09-03 23:14:41 +02:00
Terranom674
2863a51acf Do not collapse WebDAV warmup on connector lock deferrals 2026-09-03 23:14:08 +02:00
Terranom674
ec8036d226 Bump Bratonien Tools to 0.9.7.1.28 2026-09-03 23:04:28 +02:00
Terranom674
0094942f68 Expose WebDAV cancelling and cancelled states 2026-09-03 23:04:05 +02:00
Terranom674
12834041be Route cache cancel to combined local and WebDAV handler 2026-09-03 23:03:33 +02:00
Terranom674
1f2651b8f0 Wire combined cache cancel to WebDAV worker 2026-09-03 23:03:14 +02:00
Terranom674
791d67929b Make WebDAV cache cancel stop after current batch 2026-09-03 23:02:37 +02:00
Terranom674
ceea3b5483 Bump version for incremental WebDAV cache build 2026-09-03 22:55:10 +02:00
Terranom674
11c136439d Make cache build incremental from worker index 2026-09-03 22:54:51 +02:00
Terranom674
953bc92f1b Bump Bratonien Tools patch version to 0.9.7.1.26 2026-09-03 22:45:50 +02:00
Terranom674
7106765be6 Resume interrupted WebDAV cache builds without resetting completed stages 2026-09-03 22:45:27 +02:00
Terranom674
e0efb72a98 Bump version to 0.9.7.1.25 2026-09-03 22:39:50 +02:00
Terranom674
1ae9f2acc4 Dispatch priority warmup only for changed WebDAV content 2026-09-03 22:39:28 +02:00
Terranom674
c819861c91 Only prioritize WebDAV warmup when mapping changed 2026-09-03 22:39:14 +02:00
Terranom674
7fdb022126 Bump Bratonien Tools to 0.9.7.1.24 2026-09-03 22:29:16 +02:00
Terranom674
73de6a7a82 Fix WebDAV cache progress aggregation 2026-09-03 22:28:36 +02:00
Terranom674
00ae99e2a0 Bump version to 0.9.7.1.23 2026-09-03 22:08:36 +02:00
Terranom674
85fa5aeeb5 Preserve worker failure status and surface real failing sources 2026-09-03 22:08:18 +02:00
Terranom674
78f34fceb9 Bump patch version to 0.9.7.1.22 2026-09-03 20:41:24 +02:00
Terranom674
e790bff498 Prepare WebDAV lock files for Piwigo worker 2026-09-03 20:40:55 +02:00
Terranom674
a2b1d31f8c Store WebDAV worker index in Piwigo local state 2026-09-03 20:39:31 +02:00
Terranom674
59792988da Bump patch for WebDAV lock diagnostic 2026-09-03 20:35:10 +02:00
Terranom674
5222d12a5f Show WebDAV worker lock state in scan diagnostic 2026-09-03 20:34:45 +02:00
Terranom674
65316043d8 Extend WebDAV scan diagnostic with worker lock check 2026-09-03 20:34:33 +02:00
Terranom674
345ece63f3 Bump patch version for WebDAV scan diagnostic 2026-09-03 19:00:18 +02:00
Terranom674
b2d87d4033 Add WebDAV scan diagnostic button 2026-09-03 18:59:54 +02:00
Terranom674
424dff783f Register WebDAV scan diagnostic 2026-09-03 18:59:41 +02:00
Terranom674
621b4d8bfc Add admin handler for WebDAV scan diagnostic 2026-09-03 18:59:22 +02:00
Terranom674
5b89b453e4 Add read-only WebDAV shadow scan diagnostic 2026-09-03 18:59:11 +02:00
Terranom674
2a774dfb75 Bump patch version to 0.9.7.1.19 2026-09-03 18:53:33 +02:00
Terranom674
2f4345a9b6 Run manual WebDAV cache workers in dispatcher process 2026-09-03 18:52:57 +02:00
Terranom674
b4fef28869 Bump patch version to 0.9.7.1.18 2026-09-03 18:49:02 +02:00
Terranom674
006c62d154 Require current WebDAV worker completion before cache is marked done 2026-09-03 18:48:19 +02:00
Terranom674
db5f330e8e Invalidate WebDAV worker completion when cache is cleared 2026-09-03 18:47:50 +02:00
Terranom674
6d0df4acaa Fix WebDAV rebuild lifecycle and stale completion state 2026-09-03 18:47:30 +02:00
Terranom674
1dec85bbd0 Bump patch version to 0.9.7.1.17 2026-09-03 18:40:25 +02:00
Terranom674
e9cb1ce1f3 Show shadow-tree driven WebDAV worker status 2026-09-03 18:40:02 +02:00
Terranom674
2535040557 Dispatch WebDAV worker without legacy Piwigo state checks 2026-09-03 18:39:33 +02:00
Terranom674
4f4fd88a6a Make WebDAV worker index runtime-only and auto-built 2026-09-03 18:38:57 +02:00
Terranom674
dbcceb05c6 Drive WebDAV cache worker only from shadow tree and own index 2026-09-03 18:38:18 +02:00
Terranom674
ae5bc5c45b Add Piwigo-owned derivative request bridge for shadow paths 2026-09-03 18:37:11 +02:00
Terranom674
acfebe07fc Decouple WebDAV source index from Piwigo image index 2026-09-03 18:36:43 +02:00
Terranom674
bab54eccb9 Document cache worker handoff after architecture dead end 2026-09-03 18:29:41 +02:00
Terranom674
81e0a9e185 Bump patch version for WebDAV-only cache build routing 2026-09-03 18:26:58 +02:00
Terranom674
116b710d5d Skip legacy cache worker when only WebDAV sources exist 2026-09-03 18:26:42 +02:00
Terranom674
e2f687237e Bump patch version for automatic source-index initialization 2026-09-03 17:58:28 +02:00
Terranom674
479cdbc473 Allow warmup enable before source index exists 2026-09-03 17:58:06 +02:00
Terranom674
a20f9588f0 Bump patch version for combined cache status display 2026-09-03 17:56:45 +02:00
Terranom674
e831301650 Fix PHP branch syntax in combined cache status 2026-09-03 17:56:17 +02:00
Terranom674
030d0c1be7 Show combined cache rebuild phases in status 2026-09-03 17:55:50 +02:00
Terranom674
8c8d131fbb Explain independent WebDAV source index in admin 2026-09-03 17:46:43 +02:00
Terranom674
9f80c4aa97 Treat source index as current only after both stages 2026-09-03 17:46:25 +02:00
Terranom674
813c3d36a8 Bump patch version for independent WebDAV source index 2026-09-03 17:45:48 +02:00
Terranom674
3ff8c4f547 Use source index as WebDAV warmup baseline 2026-09-03 17:45:25 +02:00
Terranom674
6783974c7d Separate WebDAV source index from Piwigo cache state 2026-09-03 17:45:01 +02:00
Terranom674
ac6353b050 Add independent WebDAV source index 2026-09-03 17:43:46 +02:00
Terranom674
43178483e5 Exclude WebDAV images from legacy cache worker totals 2026-09-03 15:57:22 +02:00
Terranom674
5b0f7f93af Bump patch version to 0.9.7.1.12 2026-09-03 15:51:28 +02:00
Terranom674
5076a76d29 Route manual cache build through combined WebDAV rebuild 2026-09-03 15:51:08 +02:00
Terranom674
7094288534 Coordinate manual cache build with WebDAV rebuild 2026-09-03 15:48:53 +02:00
Terranom674
f7d0959eba Support full WebDAV cache rebuilds through Piwigo 2026-09-03 15:48:28 +02:00
Terranom674
2a96d0165c Add explicit WebDAV cache rebuild launcher 2026-09-03 15:47:17 +02:00
Terranom674
b6621d36db Add explicit WebDAV rebuild dispatch mode 2026-09-03 15:46:53 +02:00
Terranom674
41ffa29af7 Handle symlinks safely while removing detached cache trees 2026-09-03 15:45:54 +02:00
Terranom674
abd0cc5d21 Bump patch version for atomic cache clear 2026-09-03 14:46:11 +02:00
Terranom674
fced2eac49 Route cache clear button through atomic cutover 2026-09-03 14:45:43 +02:00
Terranom674
14aac0d7a4 Make cache clear use atomic directory cutover 2026-09-03 14:45:24 +02:00
Terranom674
3f2c1f8ff7 Load strict WebDAV derivative cache validator 2026-09-03 14:42:13 +02:00
Terranom674
7310d70b93 Add strict WebDAV derivative cache validation 2026-09-03 14:41:44 +02:00
Terranom674
ae73840d40 Bump patch version to 0.9.7.1.10 2026-09-03 14:39:56 +02:00
Terranom674
a0195b499c Keep legacy cache builder away from WebDAV placeholders 2026-09-03 14:39:26 +02:00
Terranom674
0372a74ae9 Bump patch version to 0.9.7.1.9 2026-09-03 14:33:05 +02:00
Terranom674
dbcc55351c Fix Piwigo root resolution in warmup dispatcher 2026-09-03 14:32:24 +02:00
Terranom674
f02d2f0239 Fix Piwigo root resolution in derivative caller 2026-09-03 14:31:54 +02:00
Terranom674
2617175158 Fix Piwigo root resolution in WebDAV warmup audit 2026-09-03 14:31:44 +02:00
Terranom674
362d24b89a Require warmup baseline before enabling automation 2026-09-03 14:19:48 +02:00
Terranom674
459f233b57 Refresh WebDAV mapping inside cache warmup workers 2026-09-03 14:19:03 +02:00
Terranom674
1368c8e5b7 Queue sync warmup until active worker yields 2026-09-03 14:18:04 +02:00
Terranom674
662d22c533 Queue priority warmup behind an active worker 2026-09-03 14:17:40 +02:00
Terranom674
f9188ac5db Remove obsolete full-run warmup guard 2026-09-03 14:17:14 +02:00
Terranom674
5aab3e52ed Route on-demand derivatives through sync guard 2026-09-03 14:17:03 +02:00
Terranom674
6bd14d2b9b Guard on-demand derivatives against connector tree swaps 2026-09-03 14:16:36 +02:00
Terranom674
a63d5fe3c3 Allow connector sync between warmup batches 2026-09-03 14:15:59 +02:00
Terranom674
84456de512 Preempt stage two warmup safely between batches 2026-09-03 14:15:23 +02:00
Terranom674
54a9c97525 Preserve warmup worker exit codes in guard 2026-09-03 12:58:54 +02:00
Terranom674
dfd180c11e Replay queued sync priority after active warmup 2026-09-03 12:56:56 +02:00
Terranom674
714c487ef2 Signal priority sync work to an active warmup 2026-09-03 12:54:52 +02:00
Terranom674
ccefc72c25 Document WebDAV cache warmup patch safety model 2026-09-03 12:53:42 +02:00
Terranom674
7e05e4e8a1 Remove superseded WebDAV warmup prototype 2026-09-03 12:53:08 +02:00
Terranom674
fae2daff4b Bump patch version to 0.9.7.1.8 2026-09-03 12:52:09 +02:00
Terranom674
ef4741872a Expose WebDAV warmup in cache administration 2026-09-03 12:51:53 +02:00
Terranom674
da74347c9b Trigger album warmup immediately per successful connector sync 2026-09-03 12:51:40 +02:00
Terranom674
c2408819f0 Gate periodic warmup before scanning Piwigo 2026-09-03 12:51:30 +02:00
Terranom674
4beb8f8420 Add WebDAV warmup cache administration 2026-09-03 12:51:27 +02:00
Terranom674
0ac34e3ad1 Allow connection-scoped WebDAV warmup dispatch 2026-09-03 12:50:51 +02:00
Terranom674
b81fd3afa7 Trigger safe WebDAV cache warmup after successful connector sync 2026-09-03 12:50:36 +02:00
Terranom674
87647f8f16 Hold connector sync guard for full warmup run 2026-09-03 12:50:23 +02:00
Terranom674
362229efaa Guard WebDAV warmup against connector tree swaps 2026-09-03 12:50:06 +02:00
Terranom674
8571e3ff1f Register WebDAV cache warmup controls 2026-09-03 12:49:44 +02:00
Terranom674
7e5d34e245 Expose WebDAV warmup settings in cache administration 2026-09-03 12:48:30 +02:00
Terranom674
4c73c43c60 Add manual warmup controls and read-only path audit 2026-09-03 12:48:10 +02:00
Terranom674
cc7fa01c10 Add WebDAV warmup dispatcher 2026-09-03 12:47:46 +02:00
Terranom674
3d768882d2 Add staged Piwigo-owned WebDAV cache warmup 2026-09-03 12:47:28 +02:00
Terranom674
3c822911f8 Add read-only WebDAV warmup path audit 2026-09-03 12:44:10 +02:00
Terranom674
5839d53f90 Add isolated Piwigo derivative caller 2026-09-03 12:43:43 +02:00
Terranom674
d8690fca95 Default WebDAV warmup to disabled during patch phase 2026-09-03 12:43:33 +02:00
Terranom674
fd1a9f1938 Add safe WebDAV batch warmup orchestrator 2026-09-03 12:41:22 +02:00
Terranom674
665768fb1f Add WebDAV warmup settings foundation 2026-09-03 12:40:29 +02:00
Terranom674
add913aab1 Update current status for 0.9.7.1.7 2026-08-20 20:52:44 +02:00
Terranom674
6a0851a30c Update README for 0.9.7.1.7 runtime state 2026-08-20 20:52:12 +02:00
Terranom674
c057f7922e Version 0.9.7.1.7 2026-08-20 20:48:09 +02:00
Terranom674
e3d55a1950 Version 9.7.1.7 2026-08-20 20:47:15 +02:00
Terranom674
84dce41b1c Version 0.9.7.1.6 2026-08-20 20:43:16 +02:00
Terranom674
71577f989f Keep Piwigo placeholder visible for pending picture thumbnails 2026-08-20 20:36:47 +02:00
Terranom674
9c8e44da09 Hide pending WebDAV picture thumbnails 2026-08-20 20:36:08 +02:00
Terranom674
09b6465aad Version 9.7.1.6 2026-08-20 20:26:50 +02:00
Terranom674
17a00eebdb Support Piwigo AJAX thumbnail loader responses 2026-08-20 20:26:32 +02:00
Terranom674
199d954409 Bump version to 9.7.1.5 2026-08-20 20:09:52 +02:00
Terranom674
db21cf2c5e Make WebDAV placeholder source writable by runtime group 2026-08-20 20:09:34 +02:00
Terranom674
f0f7556aaf Bump version to 9.7.1.4 2026-08-20 20:04:57 +02:00
Terranom674
68e4cd876d Fix Piwigo-relative source path resolution for WebDAV preview derivatives 2026-08-20 20:04:37 +02:00
Terranom674
43dc600b98 Bump version to 9.7.1.3 2026-08-20 19:57:40 +02:00
Terranom674
fdae62520e Fix Nextcloud preview endpoint path 2026-08-20 19:57:20 +02:00
Terranom674
8023f7b5d7 Bump version to 9.7.1.2 2026-08-20 19:47:45 +02:00
Terranom674
8a5435ea6b Keep WebDAV mapping readable by Piwigo runtime 2026-08-20 19:47:23 +02:00
Terranom674
880ff2a2b1 Fix WebDAV state permissions for runtime preview access 2026-08-20 19:46:56 +02:00
Terranom674
062d38ab08 Bump version to 9.7.1.1 2026-08-20 19:40:51 +02:00
Terranom674
10065ece46 Make WebDAV mapping lookup robust by webdav_path 2026-08-20 19:40:29 +02:00
Terranom674
bccc5cafda Set current status rebuild baseline to 9.7.1.0 2026-08-20 19:33:28 +02:00
Terranom674
2c52228265 Set README rebuild baseline to 9.7.1.0 2026-08-20 19:33:07 +02:00
Terranom674
7277a8b287 Start connector rebuild at version 9.7.1.0 2026-08-20 19:32:38 +02:00
Terranom674
9155a52290 Refresh README for preview-backed WebDAV derivatives 2026-08-20 19:30:34 +02:00
Terranom674
afa6d02c46 Update current status for preview-backed derivatives 2026-08-20 19:30:02 +02:00
Terranom674
9c94953267 Bump version to 0.9.6.8.25 2026-08-20 19:29:24 +02:00
Terranom674
66d62321d0 Use Nextcloud previews for small WebDAV derivatives 2026-08-20 19:28:55 +02:00
Terranom674
cb9a4b7d2a Expose WebDAV file metadata to materialize runtime 2026-08-20 19:27:48 +02:00
Terranom674
388351c356 Bump version to 0.9.6.8.24 2026-08-20 19:20:32 +02:00
Terranom674
ea71b54c94 Avoid HTTP self-request for WebDAV derivative generation 2026-08-20 19:20:06 +02:00
Terranom674
99edfc84d8 Bump version to 0.9.6.8.23 2026-08-20 19:06:30 +02:00
Terranom674
b224cd40f6 Fix Nextcloud image dimension XML parsing 2026-08-20 19:05:54 +02:00
54 changed files with 7172 additions and 469 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

@@ -2,28 +2,30 @@
## Version
**Bratonien Tools 0.9.6.8.19**
**Bratonien Tools 0.9.7.1.7**
Verbindliches Versionsschema: **0.x.x.x.x**.
Zielplattform der produktiven NC-Connector-Synchronisierung ist derzeit **Piwigo 16.4.0**.
## NC Connector
Der NC Connector besitzt einen produktiven Verbindungsweg: **Nextcloud per WebDAV**.
Der produktive Verbindungsweg ist **Nextcloud per WebDAV**.
### Verbindungsmodell
- `adapter=remote`
- `source_mode=webdav-placeholder`
- Nextcloud-Adresse, Benutzer und Passwort/App-Passwort werden pro Verbindung verschlüsselt gespeichert.
- Piwigo-API und Benutzername/Passwort-Fallback werden ebenfalls pro Verbindung gespeichert.
- Der Assistent zeigt ausschließlich Verzeichnisse, auf die der angemeldete Nextcloud-Benutzer per WebDAV zugreifen kann.
- Ein leerer `webdav_path` ist ein gültiger Root und bedeutet den Dateibereich des authentifizierten Nextcloud-Benutzers.
- Piwigo-API und Benutzername/Passwort-Fallback werden pro Verbindung gespeichert.
- Der Assistent zeigt ausschließlich per WebDAV sichtbare Verzeichnisse.
- Ein leerer `webdav_path` ist ein gültiger Benutzer-Root.
### Root-Verhalten
Bei `webdav_path=""` wird die technische Nextcloud-Benutzerebene nicht als Piwigo-Album angelegt.
Direkte Unterordner des Nextcloud-Benutzerroots werden als Top-Level-Piwigo-Alben gespiegelt. Direkt im Root liegende Einzelbilder verbleiben auf Root-Ebene des verbindungseigenen Shadow Trees und werden durch die bestehende Orphan-Logik behandelt.
Direkte Unterordner des Nextcloud-Benutzerroots werden als Top-Level-Piwigo-Alben gespiegelt. Direkt im Root liegende Einzelbilder verbleiben auf Root-Ebene des verbindungseigenen Shadow Trees und werden durch die Orphan-Logik verarbeitet.
Es gibt keine fest verdrahteten Album-Namen.
@@ -36,43 +38,64 @@ Der gemeinsame Runner verarbeitet WebDAV-Verbindungen in dieser Reihenfolge:
3. `runtime/cleanup-webdav-piwigo.php`
4. `runtime/sync-webdav.sh` für jede vorhandene WebDAV-Konfiguration
Die verbindungsspezifischen Runtime-Konfigurationen liegen unter `/etc/bratonien-tools/nc-connector/`. Zustandsdaten liegen unter `/var/lib/bratonien-tools/nc-connector/connection-ID`.
Verbindungsspezifische Runtime-Konfigurationen liegen unter `/etc/bratonien-tools/nc-connector/`. Zustandsdaten liegen unter `/var/lib/bratonien-tools/nc-connector/connection-ID`.
Der Reconcile- und Shell-Pfad unterstützt den leeren WebDAV-Root ausdrücklich und gibt ihn als `--root ""` an den Platzhalter-Builder weiter.
State-Verzeichnisse sind für die `www-data`-Gruppe traversierbar/lesbar. `webdav-map.json` wird auch nach atomaren Rewrites mit passenden Runtime-Rechten gehalten.
### Platzhalterquelle und Shadow Tree
### Platzhalterquelle, Metadaten und Shadow Tree
`runtime/lib/build_webdav_placeholder_source.py` bildet die Nextcloud-Inhalte als lokale Platzhalterquelle ab. Für Bilder existieren physische 1×1-Platzhalterdateien; die Originale werden dabei nicht dauerhaft heruntergeladen.
`runtime/lib/build_webdav_placeholder_source.py` bildet die Nextcloud-Inhalte als lokale Platzhalterquelle ab. Für Bilder existieren physische 1×1-Platzhalterdateien; Originale werden nicht dauerhaft heruntergeladen.
`runtime/lib/shadow_tree.py` erzeugt atomar die Dateisystemstruktur, die Piwigo als physische Site sieht. Shadow-Verzeichnisse werden so angelegt, dass der Webserver die für die On-Demand-Materialisierung nötigen Dateisystemoperationen ausführen kann.
Der WebDAV-Scan übernimmt `fileid`, `width`, `height`, MIME-Typ, Größe, ETag und WebDAV-Pfad in `webdav-map.json`. `runtime/lib/sync-webdav-metadata.php` überträgt die Originalmaße nach dem normalen Piwigo-Dateisync in `piwigo_images`.
Jede WebDAV-Verbindung besitzt ihre eigene Piwigo-Site. Es gibt keinen zusätzlichen Sync auf Site 1.
Die physische Source-Struktur wird für den Runtime-Swap mit `root:www-data` und schreibbaren Gruppenrechten angelegt bzw. nach dem Sync gesetzt. Damit kann der Webserver die Platzhalterquelle während der On-Demand-Erzeugung temporär austauschen und anschließend wiederherstellen.
`runtime/lib/shadow_tree.py` erzeugt atomar die Piwigo-kompatible Dateisystemstruktur. Jede WebDAV-Verbindung besitzt ihre eigene Piwigo-Site.
### Piwigo-Synchronisierung
`runtime/lib/piwigo-sync.php` bevorzugt `bratonien.nc.syncProductive` für Piwigo 16.4.0. Wenn für die Verbindung keine Piwigo-API eingerichtet ist, wird der gespeicherte Administrator-/Webmaster-Benutzername/Passwort-Fallback verwendet und Piwigos nativer `site_update` ausgeführt.
`runtime/lib/piwigo-sync.php` bevorzugt `bratonien.nc.syncProductive` für Piwigo 16.4.0. Wenn keine Piwigo-API eingerichtet ist, wird der gespeicherte Administrator-/Webmaster-Fallback verwendet und Piwigos nativer `site_update` ausgeführt.
Danach wird `bratonien.nc.syncOrphans` für die konkrete WebDAV-Site ausgeführt.
Danach läuft `bratonien.nc.syncOrphans` für die konkrete WebDAV-Site.
Der Shadow Tree stellt Piwigo ausschließlich die physische Album-/Bildstruktur bereit. Albumdatensätze, Bilddatensätze, Bild-Kategorie-Verknüpfungen und Piwigo-Derivate bleiben Aufgabe von Piwigo.
Albumdatensätze, Bilddatensätze, Bild-Kategorie-Verknüpfungen und Derivate bleiben Aufgabe von Piwigo.
### On-Demand-Materialisierung vor `i.php`
### On-Demand-Materialisierung und Derivaterzeugung
Die produktive WebDAV-Derivaterzeugung sitzt vor Piwigos `i.php`.
`include/webdav_materialize_runtime.inc.php` löst aus dem Piwigo-Bildpfad die konkrete Verbindung, den ausgewählten Nextcloud-Root und den relativen WebDAV-Pfad auf.
`include/webdav_materialize_runtime.inc.php` löst aus dem Piwigo-Bildpfad die konkrete Verbindung, den Nextcloud-Root, den relativen WebDAV-Pfad und die Mapping-Metadaten auf.
Für ein noch nicht vorhandenes Derivat läuft `webdav-derivative.php`:
1. Zugriffsprüfung für das Piwigo-Bild;
2. Download des Originals aus Nextcloud in eine temporäre Datei;
3. temporäres Bereitstellen des Originals am exakten von Piwigo erwarteten Quellpfad;
4. Aufruf des nativen Piwigo-`i.php`;
5. Piwigo erzeugt sein normales Derivat;
6. Wiederherstellung des Shadow-/Platzhalterzustands;
7. Auslieferung des Piwigo-Derivats.
2. Auswahl einer temporären Nextcloud-Quelle;
3. temporäres Bereitstellen dieser Quelle am exakten von Piwigo erwarteten Quellpfad;
4. Erzeugung des normalen Piwigo-Derivats mit Piwigos eigener `pwg_image`-/Derivative-Logik im selben PHP-Prozess;
5. Wiederherstellung des Shadow-/Platzhalterzustands;
6. Auslieferung des Piwigo-Derivats.
Das Original verbleibt in Nextcloud. Es gibt keine dauerhafte vollständige Originalkopie in Piwigo.
Ein interner HTTP-Self-Request auf `i.php` wird nicht verwendet.
Für `custom:s9999x250` und `standard:square` wird bevorzugt der authentifizierte Nextcloud-Endpunkt **`/index.php/core/preview`** anhand der `fileid` verwendet. Die Preview wird mit ungefähr doppelter benötigter Piwigo-Auflösung und unter Berücksichtigung des Original-Seitenverhältnisses angefordert. Varianten mit Bildrotation werden in dieser Stufe nicht über Preview erzeugt.
Ist keine geeignete Preview planbar oder schlägt der Preview-Abruf fehl, erfolgt der vollständige WebDAV-Originaldownload. Große/originale Anforderungen verwenden weiterhin die Originalquelle.
Die temporäre Preview bzw. das temporäre Original wird nach der Derivaterzeugung entfernt. Das fertige Derivat verbleibt ausschließlich in Piwigos normalem `_data/i`-Cache. Es gibt keinen parallelen permanenten Connector-Derivat-Cache.
### Piwigo-Lazyload
Piwigos nativer Thumbnail-Loader ruft nicht gecachte Derivate mit `ajaxload=true` per AJAX ab und erwartet JSON mit einer fertigen `url`.
`webdav-derivative.php` unterstützt dieses Protokoll für:
- bereits vorhandene Zielderivate;
- `target_created_while_waiting`;
- frisch erzeugte Derivate.
Normale direkte Bildanforderungen liefern weiterhin das Bild selbst. Dadurch kann Piwigos eigener Placeholder nach erfolgreicher Erzeugung ohne Seiten-Reload durch das echte Thumbnail ersetzt werden.
### Bilddetail-Navigation
Die eigene Bildnavigation liegt in `js/picture_navigation.js` und `css/picture_navigation.css`. Der aktuelle Stand enthält die Behandlung des sichtbaren Zwischenzustands für WebDAV-Nachbar-Thumbnails, damit während des Nachladens kein Browser-Symbol für ein kaputtes Bild stehen bleiben soll.
### Orphan-Logik
@@ -82,15 +105,13 @@ Direkt im WebDAV-Site-Root liegende Bilder werden nicht künstlich in ein Album
### Private Top-Level-Alben
Da die technische Nextcloud-Benutzerebene nicht mehr als Elternalbum existiert, können WebDAV-Unterordner echte private Top-Level-Piwigo-Alben sein. Beim Connector-Fallback-Sync sorgt `bratonien_tools_preserve_connector_top_level_access()` dafür, dass der verwendete Piwigo-Administrator auf diese privaten Top-Level-Alben zugreifen kann. Nach Änderungen wird der Benutzer-Cache invalidiert.
WebDAV-Unterordner können echte private Top-Level-Piwigo-Alben sein. Beim Connector-Fallback-Sync sorgt `bratonien_tools_preserve_connector_top_level_access()` dafür, dass der verwendete Piwigo-Administrator den Zugriff behält. Nach Änderungen wird der Benutzer-Cache invalidiert.
Die allgemeine bestehende Funktion `bratonien_tools_preserve_private_album_access()` in `include/album_shares.inc.php` bleibt davon getrennt.
Die allgemeine Funktion `bratonien_tools_preserve_private_album_access()` in `include/album_shares.inc.php` bleibt davon getrennt.
### Administrations-Dashboard
Piwigo 16.4.0 zeigt die Album-Kachel im Standardtemplate nur bei `NB_ALBUMS > 1` an. Dadurch verschwand die Kachel nach dem korrekten Entfernen der künstlichen Nextcloud-Benutzerebene, sobald nur noch ein echtes Album vorhanden war.
Bratonien Tools setzt für die Verwaltungsübersicht einen Template-Prefilter und ändert ausschließlich diese Anzeigebedingung auf `NB_ALBUMS > 0`. Die Albumzahl selbst stammt weiterhin unverändert aus Piwigos `get_pwg_general_statitics()`.
Für Piwigo 16.4.0 wird ausschließlich die Standardtemplate-Bedingung der Album-Kachel von `NB_ALBUMS > 1` auf `NB_ALBUMS > 0` angepasst. Die Albumzahl selbst stammt unverändert aus Piwigo.
### Bildauslieferung
@@ -98,36 +119,44 @@ Fehlende Piwigo-Derivate werden durch den On-Demand-Gate erzeugt. Bereits vorhan
Für direkte Originalanforderungen existiert weiterhin die serverseitige WebDAV-Originalauslieferung. Nextcloud-Zugangsdaten werden dem Browser nicht offengelegt.
### Löschen
Eine WebDAV-Verbindung kann unabhängig von ihrer Reihenfolge gelöscht werden. Beim Löschen werden die zugehörige Piwigo-Site und die zugehörigen Piwigo-Datensätze entfernt. Die Originaldateien in Nextcloud bleiben unangetastet. Verwaiste WebDAV-Runtime- und Piwigo-Daten werden vom gemeinsamen Lauf bereinigt.
### Diagnose
Der WebDAV-Derivative-Gate schreibt Request-bezogene `[BRAT-WD ...]`-Diagnoseeinträge. Der Verbindungsstatus wird pro Verbindung geführt. Runtime-Probleme lassen sich zusätzlich über `bratonien-nc-connector.service` nachvollziehen.
Der WebDAV-Derivative-Gate schreibt Request-bezogene `[BRAT-WD ...]`-Diagnoseeinträge. Relevante Ereignisse sind unter anderem `preview_start`, `preview_failed`, `download_done`, `swap_begin`, `swapped`, `generate_start`, `generate_done`, `restored`, `target_created_while_waiting` und `serve`.
## Self-Updater
`include/self_update.inc.php` liest den Zielstand von GitHub, bindet ihn an einen konkreten Commit, prüft die `main.inc.php` per SHA-256, erstellt vor dem Austausch ein Backup und aktualisiert nur auf eine **höhere** Plugin-Version.
Das verbindliche Versionsschema ist **0.x.x.x.x**.
## Aktuell relevante Dateien
- `main.inc.php` Plugin-Hooks, Connector-Sync-Hilfen, Dashboard-Prefilter
- `include/webdav_materialize_runtime.inc.php` WebDAV-Quellauflösung und Materialisierung
- `webdav-derivative.php` On-Demand-Gate vor Piwigos `i.php`
- `main.inc.php` Plugin-Hooks und Version
- `include/self_update.inc.php` integrierter Self-Updater
- `include/webdav_materialize_runtime.inc.php` WebDAV-Quellauflösung und Gate-URL-Erzeugung
- `webdav-derivative.php` On-Demand-Gate, Preview/Original-Fallback, Piwigo-Derivaterzeugung und AJAX-Lazyload-Antwort
- `webdav-image.php` direkte WebDAV-Originalauslieferung
- `include/nc_productive_ws.inc.php` direkter Piwigo-Core-Sync für 16.4.0
- `include/nc_orphan_ws.inc.php` Root-Orphan-Synchronisierung
- `runtime/reconcile-webdav.php` Runtime-Reconcile einschließlich leerem WebDAV-Root
- `runtime/reconcile-webdav.php` Runtime-Reconcile und State-Berechtigungen
- `runtime/repair-webdav-orphans.php` Orphan-Reparatur
- `runtime/cleanup-webdav-piwigo.php` Bereinigung verwaister Piwigo-Sites
- `runtime/sync-webdav.sh` verbindungsspezifischer Sync
- `runtime/sync-webdav.sh` verbindungsspezifischer Sync und Runtime-/Source-Berechtigungen
- `runtime/run-all.sh` gemeinsamer Runner
- `runtime/lib/build_webdav_placeholder_source.py` WebDAV-Scan und Platzhalterquelle
- `runtime/lib/build_webdav_placeholder_source.py` WebDAV-Scan, Mapping und Platzhalterquelle
- `runtime/lib/sync-webdav-metadata.php` Übernahme der Originalmaße nach Piwigo
- `runtime/lib/shadow_tree.py` atomarer Shadow Tree
- `runtime/lib/piwigo-sync.php` Piwigo-API/Fallback-Sync
- `js/picture_navigation.js` Bilddetail-Navigation und Thumbnail-Zwischenzustand
- `css/picture_navigation.css` Bilddetail-Navigationsdarstellung
## Nicht mehr das produktive WebDAV-Modell
Nicht mehr maßgeblich für die produktive WebDAV-Derivaterzeugung ist das frühere Modell, bei dem Bild-URLs einfach auf einen separaten WebDAV-Bildstream umgebogen wurden oder der Connector selbst einen parallelen vollständigen Derivatbestand verwalten sollte.
Nicht mehr maßgeblich sind ein separater permanenter WebDAV-Derivatbestand oder ein interner HTTP-Aufruf von `webdav-derivative.php` zurück auf Piwigos `i.php`.
Maßgeblich ist: **physischer Platzhalter + On-Demand-Original + natives Piwigo-`i.php` + Wiederherstellung des Platzhalters**.
Maßgeblich ist:
**physischer Platzhalter + temporäre Nextcloud-Preview bzw. Original-Fallback + Piwigos Bildverarbeitung im selben PHP-Prozess + normales Piwigo-Derivat + Wiederherstellung des Platzhalters**.
## Prüfpflicht vor Merge

View File

@@ -2,7 +2,7 @@
Modulares Piwigo-Plugin für Administration, Bildverarbeitung, geschützte Freigaben, Fotoauswahl und die Anbindung von Nextcloud an Piwigo.
Aktuelle Plugin-Version: **0.9.6.8.19**
Aktuelle Plugin-Version: **0.9.7.1.7**
## Grundprinzip
@@ -64,27 +64,31 @@ Nicht benötigt werden Nextcloud-Datenbankzugriff, `occ`-Adminzugriff, Storage-I
1. Der Assistent prüft Nextcloud und liest die sichtbaren Verzeichnisse per WebDAV.
2. Ausgewählte Verzeichnisse werden rekursiv per PROPFIND eingelesen.
3. Ordner, Dateiname, Nextcloud-Datei-ID, MIME-Typ, Größe, ETag und WebDAV-Pfad werden erfasst.
3. Ordner, Dateiname, Nextcloud-Datei-ID, MIME-Typ, Größe, ETag, Originalabmessungen und WebDAV-Pfad werden erfasst.
4. `runtime/lib/build_webdav_placeholder_source.py` erzeugt eine lokale Platzhalterquelle ohne dauerhafte Originalkopie.
5. `runtime/lib/shadow_tree.py` baut atomar den verbindungseigenen Shadow Tree.
6. Der WebDAV-Galeriebaum liegt unter `_data/bratonien-tools/nc-webdav-gallery/connection-ID`.
7. Jede WebDAV-Verbindung wird als eigene physische Piwigo-Site registriert.
8. Piwigos Dateisynchronisierung erhält ausschließlich diese verbindungseigene Site.
9. `runtime/lib/sync-webdav-metadata.php` übernimmt die von Nextcloud gelieferten Originalmaße in Piwigos Bilddatensätze.
State-Verzeichnisse und Mapping-Dateien werden so berechtigt, dass der Webserver die Laufzeitdaten lesen kann. Die physische Platzhalterquelle wird mit der für den temporären Source-Swap nötigen `www-data`-Gruppenberechtigung erzeugt bzw. nach dem Sync gesetzt.
### On-Demand-Bildauslieferung und Piwigo-Derivate
Die produktive Bildauslieferung sitzt vor Piwigos `i.php`.
`include/webdav_materialize_runtime.inc.php` erkennt anhand des Piwigo-Bildpfads die zugehörige WebDAV-Verbindung, den Nextcloud-Root und den relativen WebDAV-Pfad. Der Hook `get_derivative_url` leitet fehlende WebDAV-Derivate an `webdav-derivative.php` um.
`include/webdav_materialize_runtime.inc.php` erkennt anhand des Piwigo-Bildpfads die zugehörige WebDAV-Verbindung, den Nextcloud-Root und den relativen WebDAV-Pfad. Aus dem Mapping stehen zusätzlich `fileid`, Originalbreite/-höhe, MIME-Typ, Dateigröße und ETag zur Verfügung. Der Hook `get_derivative_url` leitet fehlende WebDAV-Derivate an `webdav-derivative.php` um.
Wenn ein benötigtes Piwigo-Derivat noch nicht existiert:
1. `webdav-derivative.php` prüft den Piwigo-Zugriff auf das Bild.
2. Das Original wird temporär direkt aus Nextcloud geladen.
3. Die physische 1×1-Platzhalterdatei bzw. der Shadow-Link am von Piwigo erwarteten Quellpfad wird für die Dauer der Erzeugung ersetzt.
4. Piwigos eigenes `i.php` erzeugt das normale Piwigo-Derivat.
5. Danach wird der temporäre Originalzustand entfernt und der Platzhalter-/Shadow-Zustand wiederhergestellt.
6. Das von Piwigo erzeugte Derivat wird ausgeliefert und künftig normal aus Piwigos Derivat-Cache verwendet.
2. Für `custom:s9999x250` und `standard:square` wird bevorzugt eine passende authentifizierte Nextcloud-Preview anhand der `fileid` über `/index.php/core/preview` geladen. Die Preview wird mit ungefähr doppelter benötigter Piwigo-Auflösung und erhaltenem Seitenverhältnis angefordert.
3. Ist keine passende Preview planbar oder schlägt der Preview-Abruf fehl, wird automatisch das vollständige Original per WebDAV geladen. Große bzw. originale Anforderungen verwenden weiterhin die Originalquelle.
4. Die temporäre Bildquelle ersetzt für die Dauer der Erzeugung die physische 1×1-Platzhalterdatei bzw. den Shadow-Link am von Piwigo erwarteten Quellpfad.
5. Piwigos eigene `pwg_image`-/Derivative-Logik erzeugt das normale Piwigo-Derivat direkt im selben PHP-Prozess. Ein interner HTTP-Self-Request auf `i.php` wird nicht verwendet.
6. Danach wird die temporäre Bildquelle entfernt und der Platzhalter-/Shadow-Zustand wiederhergestellt.
7. Das fertige Derivat wird ausgeliefert und künftig normal aus Piwigos `_data/i`-Derivat-Cache verwendet.
Piwigos nativer Thumbnail-Loader verwendet bei nicht gecachten Thumbnails `ajaxload=true` und erwartet eine JSON-Antwort mit der fertigen Derivat-URL. `webdav-derivative.php` unterstützt dieses Protokoll; normale direkte Bildanforderungen werden weiterhin als Bild ausgeliefert. Dadurch kann Piwigos eigener Placeholder nach der Erzeugung ohne Seiten-Reload durch das fertige Thumbnail ersetzt werden.
Damit bleibt Piwigo für Größen, Derivate und Lazy-Loading verantwortlich. Der Connector hält keinen parallelen eigenen vollständigen Derivat-Cache vor.
@@ -133,7 +137,7 @@ Der gemeinsame Lauf besteht aktuell aus:
3. `runtime/cleanup-webdav-piwigo.php` verwaiste WebDAV-Piwigo-Daten bereinigen;
4. `runtime/sync-webdav.sh` jede vorhandene WebDAV-Verbindung synchronisieren.
Der verbindungsspezifische Lauf erzeugt Platzhalterquelle und Shadow Tree, führt Piwigos Sync und Orphan-Sync aus und enthält weiterhin die vorhandenen Preview-/Derivative-Schritte. Die produktive Auslieferung fehlender WebDAV-Derivate erfolgt jedoch über den On-Demand-Gate vor `i.php`.
Der verbindungsspezifische Lauf erzeugt Platzhalterquelle und Shadow Tree, setzt die Laufzeitberechtigungen, führt Piwigos Sync und Orphan-Sync aus und überträgt anschließend die Originalabmessungen aus dem WebDAV-Mapping in Piwigo. Eine vollständige Vorab-Erzeugung von Connector-Derivaten findet nicht statt; fehlende Derivate werden On-Demand erzeugt.
## Bildcache
@@ -176,11 +180,11 @@ Private Alben können über eigene Freigabelinks geteilt werden, optional mit Pa
## Erweiterte Bildnavigation
Die Piwigo-Bilddetailseite erhält responsive Navigationszonen für vorheriges Bild, nächstes Bild, Rückkehr zur Übersicht und PhotoSwipe/Vollbild.
Die Piwigo-Bilddetailseite erhält responsive Navigationszonen für vorheriges Bild, nächstes Bild, Rückkehr zur Übersicht und PhotoSwipe/Vollbild. Für WebDAV-Nachbar-Thumbnails wird der sichtbare Ladezustand so behandelt, dass während eines noch laufenden Bildabrufs kein Browser-Symbol für ein kaputtes Bild stehen bleiben soll.
## Selbstaktualisierung
Der integrierte Updater liest den Zielstand aus GitHub, bindet ein Update an einen konkreten Commit, prüft Version und SHA-256, erstellt vor dem Austausch ein Backup und verlangt Webmaster-Rechte sowie die notwendigen Servervoraussetzungen.
Der integrierte Updater liest den Zielstand aus GitHub, bindet ein Update an einen konkreten Commit, prüft Version und SHA-256, erstellt vor dem Austausch ein Backup und verlangt Webmaster-Rechte sowie die notwendigen Servervoraussetzungen. Das verbindliche Versionsschema des Plugins ist **0.x.x.x.x**; Updates werden nur auf eine höhere Version angeboten.
## Wichtige Dateien und Verzeichnisse
@@ -192,16 +196,17 @@ Der integrierte Updater liest den Zielstand aus GitHub, bindet ein Update an ein
- `include/nc_connector_delete_safe.inc.php` Löschen einschließlich WebDAV-Piwigo-Inhalten
- `include/nc_productive_ws.inc.php` direkter Piwigo-Core-Dateisync für Piwigo 16.4.0
- `include/nc_orphan_ws.inc.php` Orphan-Synchronisierung der konkreten WebDAV-Site
- `include/webdav_materialize_runtime.inc.php` WebDAV-Quellauflösung und On-Demand-Materialisierung
- `include/webdav_materialize_runtime.inc.php` WebDAV-Quellauflösung und On-Demand-Materialisierung einschließlich `fileid` und Originalmaßen
- `include/webdav_image_runtime.inc.php` vorhandene WebDAV-Bildzuordnung/Originalauslieferung
- `webdav-derivative.php` Gate vor `i.php` für fehlende WebDAV-Derivate
- `webdav-derivative.php` On-Demand-Gate, Nextcloud-Preview/Original-Fallback, Piwigo-AJAX-Loader-Antwort und Piwigo-Derivaterzeugung
- `webdav-image.php` serverseitige WebDAV-Originalauslieferung
- `runtime/reconcile-webdav.php` WebDAV-Runtime-Reconcile, einschließlich gültigem leerem Root
- `runtime/reconcile-webdav.php` WebDAV-Runtime-Reconcile, einschließlich gültigem leerem Root und Runtime-Berechtigungen
- `runtime/repair-webdav-orphans.php` Reparatur vorhandener Orphan-Zustände
- `runtime/cleanup-webdav-piwigo.php` Bereinigung verwaister WebDAV-Piwigo-Sites
- `runtime/sync-webdav.sh` Ablauf einer WebDAV-Verbindung
- `runtime/sync-webdav.sh` Ablauf einer WebDAV-Verbindung einschließlich Mapping-/Source-Berechtigungen
- `runtime/run-all.sh` gemeinsamer WebDAV-Runner
- `runtime/lib/build_webdav_placeholder_source.py` rekursiver WebDAV-Scan und physische Platzhalterquelle
- `runtime/lib/build_webdav_placeholder_source.py` rekursiver WebDAV-Scan, Bildmetadaten und physische Platzhalterquelle
- `runtime/lib/sync-webdav-metadata.php` Übernahme der Originalabmessungen in Piwigo
- `runtime/lib/shadow_tree.py` atomarer, Piwigo-kompatibler Shadow Tree
- `runtime/lib/piwigo-sync.php` Piwigo-Sync mit API/Fallback
- `main-cache-build.php` allgemeiner Piwigo-Derivat-/Cache-Builder
@@ -222,5 +227,6 @@ Der integrierte Updater liest den Zielstand aus GitHub, bindet ein Update an ein
- produktive Piwigo-API ist versionsgebunden;
- Originalbilder werden vom Connector nicht gelöscht;
- WebDAV-Originalbilder werden nur bei Bedarf temporär lokal materialisiert;
- temporäre Originale werden nach der Piwigo-Derivaterzeugung wieder entfernt;
- kleine Derivate können stattdessen eine temporäre authentifizierte Nextcloud-Preview verwenden;
- temporäre Preview-/Originalquellen werden nach der Piwigo-Derivaterzeugung wieder entfernt;
- Update-Pakete werden an Commit und Hash gebunden.

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,
@@ -259,7 +301,9 @@ $template->assign(array(
$template->set_filename('admin_tabs_content', BRATONIEN_TOOLS_PATH . 'template/admin_tabs.tpl');
$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');
@@ -267,7 +311,9 @@ $template->set_filename('system_admin_content', BRATONIEN_TOOLS_PATH . 'template
$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);

View File

@@ -17,3 +17,12 @@
.bratonien-picture-zone.is-active {
opacity: 1;
}
#theImageAndInfos img[src*="plugins/bratonien_tools/webdav-derivative.php"]:not(#theMainImage):not(.bratonien-webdav-thumbnail-ready),
#theImageAndInfos img.bratonien-webdav-thumbnail-pending {
visibility: hidden;
}
#theImageAndInfos img.bratonien-webdav-thumbnail-ready {
visibility: visible;
}

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,111 @@
# HANDOFF Piwigo / WebDAV Cache-Worker 2026-09-03
## Warum hier gestoppt wurde
Die aktuelle Cache-Worker-Entwicklung ist in einen Architektur-/Debugging-Kreis geraten. Der beobachtete produktive Zustand ist eindeutig falsch: Nach dem Leeren des Bildcaches meldet der manuelle Cache-Aufbau praktisch sofort "fertig", obwohl der eigentliche Piwigo-Bildcache nicht aufgebaut wurde. Weitere Patchversuche an Anzeige, Statusaggregation oder Legacy-Worker-Zählung würden den Fehler nur weiter überdecken.
Ab hier **nicht weiterpatchen**, bevor der reale Ausführungspfad von Button bis Piwigo-Derivaterzeugung vollständig nachvollzogen ist.
## Gesicherte Architekturvorgaben
- Nextcloud ist die autoritative Quelle der Originalbilder.
- Piwigo arbeitet produktiv mit einem Shadow-Tree und 1x1-Placeholdern.
- Der Worker darf Piwigo nicht nachbauen und selbst keine Derivate rendern.
- Richtiger Ablauf: Worker erkennt Arbeit -> Original temporär materialisieren -> Piwigo nativen Derivatpfad aufrufen -> Ergebnis prüfen -> Original wieder entfernen / Placeholder restaurieren.
- Piwigo allein bestimmt Dimensionen, Crop, Sharpening, Wasserzeichen, Qualität, Format und Cachepfad.
- On-Demand bleibt Fallback.
- Materialisierung erfolgt batchweise, Standard 10 Originale pro Batch.
- Stage 1 vor Stage 2; neue Sync-Arbeit darf Stage 2 zwischen Batches priorisieren.
- Connector/Shadow-Tree bleibt Wahrheit darüber, welche Quellen existieren.
## Neuer Quellenindex Konzept weiterhin sinnvoll
Die Trennung des Worker-Entscheidungszustands vom Piwigo-Cache ist weiterhin sinnvoll:
- eigener kleiner Quellenindex je WebDAV-Verbindung
- Identität primär über `fileid`, Fallback Root/Pfad
- Vergleich über `etag`, Dateigröße, MIME/Format, Pfad, Breite/Höhe
- Quelle neu/geändert -> Worker darf Piwigo beauftragen
- Quelle unverändert -> normaler periodischer Lauf tut nichts
- gelöschte Quelle -> beim Abgleich aus Worker-Index entfernen; eigentliche Quellenentfernung bleibt Connector/Shadow-Tree-Aufgabe
- manueller Full-Rebuild darf den Quellenindex bewusst ignorieren und alle aktuellen Quellen erneut an Piwigo reichen
Datei dafür aktuell:
`include/webdav_source_index.inc.php`
## Aktueller problematischer Stand
### Version
Aktuell zuletzt auf `0.9.7.1.16` erhöht.
### Relevante Dateien
- `runtime/lib/webdav-cache-warmup.php`
- `runtime/webdav-warmup-dispatch.php`
- `include/webdav_source_index.inc.php`
- `include/webdav_warmup_settings.inc.php`
- `main-cache-build.php`
- `main-cache-status.php`
- `tools/image_cache.inc.php`
- `template/admin.tpl`
- `include/tool_registry.inc.php`
### Letzte Architekturänderungen
1. Legacy `main-cache-build.php` zählt WebDAV-Bilder nicht mehr als normale lokale Cachearbeit.
2. Eigener WebDAV-Quellenindex wurde eingeführt.
3. Warmup-Auswahl wurde auf diesen Quellenindex umgestellt.
4. Kombinierter Admin-Button startet lokalen Piwigo-Teil plus WebDAV-Rebuild.
5. Statusanzeige wurde zusammengeführt.
6. Bei reiner WebDAV-Installation sollte der lokale Legacy-Worker gar nicht mehr gestartet werden.
Trotzdem beobachtet der Nutzer nach Cache-Leeren weiterhin einen praktisch sofort fertigen Aufbau. Das beweist: **Der tatsächliche WebDAV-Rebuild-Pfad erzeugt die erwarteten Piwigo-Derivate nicht bzw. läuft nicht so, wie die Architektur es annimmt.**
## Wichtige letzte Commits
- `ac6353b050cdf30db79ab20716e66e6a6c841abb` eigener WebDAV-Quellenindex
- `6783974c7dfab9d11a0f1e43419de84e92141fd5` Warmup auf Quellenindex umgestellt
- `9f80c4aa974da01d606292c479487bce13b403fa` Quelle erst nach beiden Stufen vollständig
- `030d0c1be7ef8cafdab3f3d46e01ec90d5fd3bdc` kombinierte Statusanzeige
- `e831301650ee2a80c969e6c9b783d37a3c4f593d` Syntaxkorrektur Status
- `479cdbc4734605db3861908286426c76468d12a3` Quellenindex-Baseline blockiert Aktivierung nicht mehr
- `116b710d5d35311eeae261e5ae3cf405c5f51ce3` Legacy-Worker bei reiner WebDAV-Installation nicht mehr starten
- `81e0a9e185a14c08230b4f8fa0f36809c5b2b9a7` Version 0.9.7.1.16
## NICHT weiter tun
- Keine weiteren UI-/Statuspatches, bevor der echte Workerpfad bewiesen ist.
- Nicht wieder Cache-Dateiexistenz als Quelle für "neu/geändert" verwenden.
- Nicht den Placeholder selbst rendern.
- Nicht den Legacy-Full-Cache-Builder für WebDAV reaktivieren.
- Nicht weiter an Symptomen wie 45-Sekunden-Stall, "übersprungen" oder Prozentanzeige herumoptimieren.
## Nächster Einstieg zwingend
Der nächste Chat/Entwickler soll **zuerst den tatsächlichen Rebuild-Ausführungspfad beweisen**:
1. Admin-Handler `image_cache_build` verfolgen.
2. `bratonien_tools_start_combined_image_cache_build()` verfolgen.
3. Start des `runtime/webdav-warmup-dispatch.php --mode=rebuild` beweisen.
4. Verifizieren, dass Dispatcher die aktive WebDAV-Verbindung findet.
5. Verifizieren, dass `runtime/lib/webdav-cache-warmup.php --mode=rebuild` wirklich gestartet wird.
6. Verifizieren, wie viele Quellen `bratonien_tools_cache_warmup_scan()` tatsächlich liefert.
7. Verifizieren, wie viele Varianten Stage 1 und Stage 2 pro Bild tatsächlich liefern.
8. Erst an **einem einzigen Bild** nachweisen:
- Original wird aus Nextcloud heruntergeladen.
- Placeholder wird korrekt gesichert.
- Original wird am produktiven Quellpfad eingesetzt.
- `piwigo-derivative-call.php` ruft wirklich Piwigos nativen `i.php`-Pfad auf.
- Piwigo erzeugt tatsächlich eine konkrete Datei unter `_data/i`.
- strikter Validator erkennt sie als gültig.
- Placeholder wird exakt restauriert.
9. Erst wenn dieser Ein-Bild-Test nachweislich funktioniert, Batchbetrieb und UI wieder anfassen.
## Entscheidender Verdacht
Der derzeit wichtigste Verdacht ist **nicht mehr die Anzeige**, sondern dass der Rebuild intern sehr früh keine Arbeit findet oder Piwigo über den aufgerufenen `i.php`-Pfad keine Derivate erzeugt, obwohl der Worker dies erwartet. Genau diese Stelle muss mit realem Lauf/Log belegt werden.
## Produktionsvalidierung
Von ChatGPT wurde kein echter Serverlauf durchgeführt. Ohne reale Serverausgaben darf nicht behauptet werden, dass Pfadaudit, Materialisierung, Piwigo-Aufruf oder Cache-Erzeugung funktionieren.

View File

@@ -0,0 +1,68 @@
# WebDAV-Cache-Warmup Patch 0.9.7.1.8
## Ziel
Fehlende Piwigo-Derivate für neue Nextcloud-Inhalte im Hintergrund vorbereiten, ohne einen zweiten dauerhaften Bildbestand anzulegen und ohne Piwigo-Datenbankeinträge, Albumzuordnungen oder Bildpfade durch Bratonien Tools zu verändern.
## Zuständigkeiten
Bratonien Tools übernimmt ausschließlich Orchestrierung:
1. neue Alben bzw. neue/geänderte Bilder erkennen;
2. Originale in konfigurierbaren Batches temporär aus Nextcloud laden;
3. die bereits bestehende Piwigo-Quellzuordnung validieren;
4. die lokale Quelle für genau ein Bild atomar bereitstellen;
5. Piwigos eigenes `i.php` für die gewünschten, von Piwigo definierten Derivate aufrufen;
6. den ursprünglichen Placeholder exakt wiederherstellen und verifizieren;
7. temporäre Batch-Dateien löschen.
Piwigo bleibt allein zuständig für Derivatgrößen, Bildverarbeitung, Qualität, Format und Cache-Dateien.
## Trigger
- Neues Album: unmittelbar nach vollständig erfolgreichem Connector-Sync dieser Verbindung (`sync`-Modus).
- Einzelne neue/geänderte Bilder in bekannten Alben: periodische Eingangsprüfung, Standard 12 Stunden (`periodic`-Modus).
- Administration: `Jetzt auf neue Bilder prüfen` (`manual`-Modus).
- On-Demand bleibt parallel aktiv und wird nicht durch den Warmup ersetzt.
## Batches und Stufen
- Batchgröße ist konfigurierbar, Standard **10 Bilder**.
- Ein Batch wird zuerst vollständig aus Nextcloud geladen.
- Anschließend werden die Bilder einzeln am bereits bestehenden Piwigo-Pfad bereitgestellt und von Piwigo verarbeitet.
- Stufe 1: von Piwigo definierte Standard-/Custom-Derivate bis einschließlich 1920 px längster Kante.
- Erst wenn Stufe 1 abgearbeitet ist, folgt Stufe 2 mit den übrigen von Piwigo definierten Derivaten.
## Schutz produktiver Daten
Der Warmup darf keine Piwigo-Bild-/Albumdatensätze schreiben oder neue Piwigo-Sites/Zuordnungen anlegen.
Vor jedem Lauf wird die bestehende Pfadkette von Piwigo aus validiert:
`Piwigo image.id -> images.path -> realpath() -> nc-webdav-source/connection-ID/root-fileid -> webdav-map.json`
Unsichere oder widersprüchliche Zuordnungen führen zum Abbruch, nicht zu einer Rekonstruktion auf Verdacht.
Zusätzliche Sicherungen:
- vorhandener per-image Lock `upload/bratonien-webdav-materialize/image-ID.lock` wird auch vom Warmup benutzt;
- `webdav-sync.lock` wird für den gesamten Warmup-Lauf geteilt gehalten; der Connector-Sync benötigt denselben Lock exklusiv;
- zwei Piwigo-IDs auf derselben physischen Connector-Quelle führen zum Sicherheitsabbruch;
- Placeholder wird nie in-place überschrieben;
- Swap erfolgt über Dateien im selben Verzeichnis und `rename()`;
- Größe und SHA-1 des Placeholders werden vor dem Swap erfasst;
- nach jedem Restore müssen Größe und SHA-1 exakt dem Ausgangszustand entsprechen;
- Restore-Fehler beendet den Worker sofort als fatal;
- bestehende produktive Daten werden beim ersten Lauf nur als Baseline erfasst und nicht automatisch neu aufgebaut.
## Patchphase
Die automatische Warmup-Funktion ist in 0.9.7.1.8 standardmäßig **deaktiviert**. Vor Aktivierung sind vorgesehen:
1. Plugin aktualisieren;
2. schreibgeschützten `Produktive Pfade prüfen`-Audit ausführen;
3. ersten manuellen Lauf durchführen, der den vorhandenen Bestand nur als Baseline erfasst;
4. gezielten Test mit neuem Testalbum / neuen Bildern;
5. erst nach bestätigtem Restore- und Piwigo-Cache-Verhalten Automatik aktivieren.
Die Entwicklung bleibt bis zum vollständig validierten Endstand in Patch-Versionen. Die geplante Finalversion ist **0.10.1**.

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

@@ -0,0 +1,342 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_atomic_cache_remove_tree($root, array &$failed)
{
if (!file_exists($root) && !is_link($root)) return;
// Niemals einem Symlink folgen. Auch ein Link auf ein Verzeichnis ist nur
// ein einzelner Eintrag des ausgelagerten Cachebaums und wird per unlink()
// entfernt. So kann das Cleanup kein Ziel ausserhalb des Cachebaums beruehren.
if (is_link($root))
{
if (!@unlink($root)) $failed[] = $root;
return;
}
if (is_file($root))
{
if (!@unlink($root)) $failed[] = $root;
return;
}
if (!is_dir($root))
{
$failed[] = $root;
return;
}
$entries = @scandir($root);
if ($entries === false)
{
$failed[] = $root;
return;
}
foreach ($entries as $entry)
{
if ($entry === '.' || $entry === '..') continue;
$path = $root.DIRECTORY_SEPARATOR.$entry;
if (is_link($path) || is_file($path))
{
if (!@unlink($path)) $failed[] = $path;
continue;
}
if (is_dir($path))
{
bratonien_tools_atomic_cache_remove_tree($path, $failed);
continue;
}
// Sonderdateien werden wie einzelne Cacheeintraege behandelt. unlink()
// ist hier sicherer als ein rekursiver Iterator, der Verweise interpretieren
// koennte.
if (!@unlink($path)) $failed[] = $path;
}
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;
if (!defined('PWG_DERIVATIVE_DIR'))
{
throw new RuntimeException('PWG_DERIVATIVE_DIR ist nicht definiert.');
}
$held_locks = array();
$webdav_cancel_files = array();
$webdav_guards_ready = false;
try
{
// 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())
{
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());
$held_locks[] = bratonien_tools_atomic_cache_acquire_lock(
bratonien_tools_main_cache_lock_file(),
'Lokaler Piwigo-Cache-Worker',
10.0
);
// 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

@@ -4,6 +4,8 @@ if (!defined('PHPWG_ROOT_PATH'))
die('Hacking attempt!');
}
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_warmup_settings.inc.php');
function bratonien_tools_cache_worker_settings_file()
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-tools-cache-workers.json';
@@ -136,6 +138,8 @@ function bratonien_tools_get_cache_worker_settings()
$settings['auto_workers'] = min($settings['max_workers'], $settings['cpu_count']);
$settings['manual_workers'] = min($settings['max_workers'], $settings['manual_workers']);
$settings['worker_count'] = $settings['auto'] ? $settings['auto_workers'] : $settings['manual_workers'];
$settings['webdav_warmup'] = bratonien_tools_get_webdav_warmup_settings();
$settings['webdav_warmup']['status'] = bratonien_tools_get_webdav_warmup_status();
return $settings;
}

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

@@ -5,16 +5,20 @@ if (!defined('PHPWG_ROOT_PATH'))
}
require_once(BRATONIEN_TOOLS_PATH . 'tools/image_cache.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/cache_clear_atomic.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'tools/watermark.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'tools/watermark_profiles.inc.php');
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');
@@ -23,14 +27,20 @@ require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_connection_scope.inc.p
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_delete_safe.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_webdav.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_wizard_webdav_flow.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/webdav_warmup_settings.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/webdav_scan_diagnostic.inc.php');
function bratonien_tools_get_tools()
{
return array(
'image_cache_clear' => array('handler' => 'bratonien_tools_clear_image_cache'),
'image_cache_build' => array('handler' => 'bratonien_tools_start_main_cache_build'),
'image_cache_cancel' => array('handler' => 'bratonien_tools_cancel_main_cache_build'),
'image_cache_clear' => array('handler' => 'bratonien_tools_clear_image_cache_atomic'),
'image_cache_build' => array('handler' => 'bratonien_tools_start_combined_image_cache_build'),
'image_cache_cancel' => array('handler' => 'bratonien_tools_cancel_combined_image_cache_build'),
'image_cache_worker_settings' => array('handler' => 'bratonien_tools_save_cache_worker_settings'),
'image_cache_webdav_warmup_settings' => array('handler' => 'bratonien_tools_save_webdav_warmup_settings'),
'image_cache_webdav_warmup_manual' => array('handler' => 'bratonien_tools_start_webdav_warmup_manual'),
'image_cache_webdav_warmup_audit' => array('handler' => 'bratonien_tools_run_webdav_warmup_audit'),
'image_cache_webdav_scan_diagnostic' => array('handler' => 'bratonien_tools_run_webdav_scan_diagnostic'),
'watermark_save' => array('handler' => 'bratonien_tools_save_watermark'),
'watermark_file_delete' => array('handler' => 'bratonien_tools_delete_watermark_file'),
'watermark_engine' => array('handler' => 'bratonien_tools_handle_watermark_engine'),
@@ -40,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'),
@@ -74,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

@@ -0,0 +1,66 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_webdav_derivative_cache_valid($target_path, $derivative, $params=null, &$reason=null)
{
$reason = '';
$target_path = (string)$target_path;
if ($target_path === '' || !is_file($target_path) || !is_readable($target_path))
{
$reason = 'missing';
return false;
}
$cache_root = PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR;
if (strpos($target_path, $cache_root) !== 0)
{
$reason = 'outside-cache';
return false;
}
clearstatcache(true, $target_path);
$bytes = @filesize($target_path);
if ($bytes === false || $bytes < 1)
{
$reason = 'empty';
return false;
}
$actual = @getimagesize($target_path);
if (!is_array($actual) || (int)($actual[0] ?? 0) < 1 || (int)($actual[1] ?? 0) < 1)
{
$reason = 'not-image';
return false;
}
if (is_object($derivative) && method_exists($derivative, 'get_size'))
{
$expected = $derivative->get_size();
$expected_width = (int)($expected[0] ?? 0);
$expected_height = (int)($expected[1] ?? 0);
if ($expected_width > 0 && $expected_height > 0)
{
if ((int)$actual[0] !== $expected_width || (int)$actual[1] !== $expected_height)
{
$reason = 'dimension-mismatch:'.(int)$actual[0].'x'.(int)$actual[1].'!='.$expected_width.'x'.$expected_height;
return false;
}
}
}
if (is_object($params) && isset($params->last_mod_time))
{
$mtime = @filemtime($target_path);
if ($mtime === false || (int)$mtime < (int)$params->last_mod_time)
{
$reason = 'stale-params';
return false;
}
}
$reason = 'valid';
return true;
}

View File

@@ -10,7 +10,9 @@ function bratonien_tools_webdav_materialize_source_info($image_id)
$image_id = (int)$image_id;
if ($image_id < 1) return null;
if (array_key_exists($image_id, $cache)) return $cache[$image_id];
$warmup_cli = PHP_SAPI === 'cli'
&& strpos((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 'Bratonien-WebDAV-Cache-Warmup/') === 0;
if (!$warmup_cli && array_key_exists($image_id, $cache)) return $cache[$image_id];
$result = pwg_query('SELECT path, coi FROM '.IMAGES_TABLE.' WHERE id='.$image_id.' LIMIT 1');
if (!pwg_db_num_rows($result)) return $cache[$image_id] = null;
@@ -75,6 +77,9 @@ function bratonien_tools_webdav_materialize_source_info($image_id)
$content_type = '';
$size = 0;
$etag = '';
$fileid = 0;
$width = 0;
$height = 0;
$state_dir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($state_dir !== '')
{
@@ -84,7 +89,18 @@ function bratonien_tools_webdav_materialize_source_info($image_id)
$mapping = json_decode((string)file_get_contents($mapping_file), true);
if (is_array($mapping) && isset($mapping['files']) && is_array($mapping['files']))
{
$entry = $mapping['files'][$resolved] ?? $mapping['files'][$normalized] ?? null;
$files = $mapping['files'];
$entry = $files[$resolved] ?? $files[$normalized] ?? null;
if (!is_array($entry))
{
foreach ($files as $candidate)
{
if (!is_array($candidate) || (string)($candidate['kind'] ?? '') !== 'file') continue;
if (trim((string)($candidate['webdav_path'] ?? ''), '/') !== $webdav_path) continue;
$entry = $candidate;
break;
}
}
if (is_array($entry) && (string)($entry['kind'] ?? '') === 'file')
{
$mapped_path = trim((string)($entry['webdav_path'] ?? ''), '/');
@@ -92,6 +108,9 @@ function bratonien_tools_webdav_materialize_source_info($image_id)
$content_type = (string)($entry['content_type'] ?? '');
$size = (int)($entry['size'] ?? 0);
$etag = (string)($entry['etag'] ?? '');
$fileid = (int)($entry['fileid'] ?? 0);
$width = (int)($entry['width'] ?? 0);
$height = (int)($entry['height'] ?? 0);
}
}
}
@@ -106,6 +125,10 @@ function bratonien_tools_webdav_materialize_source_info($image_id)
'content_type'=>$content_type,
'size'=>$size,
'etag'=>$etag,
'fileid'=>$fileid,
'width'=>$width,
'height'=>$height,
'state_dir'=>$state_dir,
'coi'=>$row['coi'] ?? null,
);
}
@@ -224,7 +247,7 @@ function bratonien_tools_webdav_materialize_after_signature($image_id, $variant,
function bratonien_tools_webdav_materialize_derivative_url($image_id, $variant, array $info, $after_url='')
{
$url = get_root_url().'plugins/'.BRATONIEN_TOOLS_ID.'/webdav-derivative.php?id='.(int)$image_id.'&variant='.rawurlencode($variant);
$url = get_root_url().'plugins/'.BRATONIEN_TOOLS_ID.'/webdav-derivative-guard.php?id='.(int)$image_id.'&variant='.rawurlencode($variant);
if ($after_url !== '')
{
$url .= '&after='.rawurlencode(bratonien_tools_webdav_materialize_b64url_encode($after_url));

View File

@@ -0,0 +1,88 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_run_webdav_scan_diagnostic()
{
if (!function_exists('exec')) throw new RuntimeException('PHP exec() ist deaktiviert; der Scan-Test kann nicht gestartet werden.');
if (!function_exists('bratonien_tools_nc_connector_connections') || !function_exists('bratonien_tools_nc_connector_is_webdav'))
{
throw new RuntimeException('Connector-Verbindungen koennen nicht gelesen werden.');
}
$php = function_exists('bratonien_tools_webdav_warmup_php_cli') ? bratonien_tools_webdav_warmup_php_cli() : null;
if (!$php) throw new RuntimeException('PHP-CLI wurde fuer den Scan-Test nicht gefunden.');
$script = realpath(BRATONIEN_TOOLS_PATH.'runtime/lib/webdav-scan-diagnostic.php');
if (!$script || !is_file($script)) throw new RuntimeException('WebDAV-Scan-Diagnose wurde nicht gefunden.');
$reports = array();
$failed = false;
foreach (bratonien_tools_nc_connector_connections() as $connection)
{
if (empty($connection['enabled']) || !bratonien_tools_nc_connector_is_webdav($connection)) continue;
$connection_id = (int)($connection['id'] ?? 0);
if ($connection_id < 1) continue;
$output = array();
$exit = 1;
@exec(
escapeshellarg($php).' '.escapeshellarg($script).' --connection-id='.$connection_id.' 2>&1',
$output,
$exit
);
$text = trim(implode("\n", $output));
$decoded = $text !== '' ? json_decode($text, true) : null;
if (!is_array($decoded))
{
$decoded = array(
'ok'=>false,
'connection_id'=>$connection_id,
'fatal'=>'Diagnose lieferte kein gueltiges JSON.',
'raw'=>$text,
);
}
$decoded['exit_code'] = (int)$exit;
$reports[] = $decoded;
if ($exit !== 0 || empty($decoded['ok'])) $failed = true;
}
if (!$reports) throw new RuntimeException('Keine aktive WebDAV-Verbindung gefunden.');
$parts = array();
foreach ($reports as $report)
{
$id = (int)($report['connection_id'] ?? 0);
if (!empty($report['fatal']))
{
$parts[] = 'WebDAV #'.$id.': FEHLER: '.(string)$report['fatal'].' (Exit '.(int)$report['exit_code'].')';
continue;
}
$lock_state = (string)($report['worker_lock_state'] ?? 'unknown');
if ($lock_state === 'free') $lock_text = 'Worker-Lock frei';
elseif ($lock_state === 'busy') $lock_text = 'Worker-Lock BELEGT';
elseif ($lock_state === 'open_failed') $lock_text = 'Worker-Lock NICHT OEFFENBAR';
else $lock_text = 'Worker-Lock unbekannt';
$parts[] = sprintf(
'WebDAV #%d: Mapping %d Dateien, Shadow %d Links, %d passend, %d defekt, %d ohne Mapping, %d doppelt; %s%s (Exit %d).',
$id,
(int)($report['mapping_files'] ?? 0),
(int)($report['shadow_links'] ?? 0),
(int)($report['matched'] ?? 0),
(int)($report['broken'] ?? 0),
(int)($report['unmapped'] ?? 0),
(int)($report['duplicates'] ?? 0),
$lock_text,
!empty($report['worker_lock_detail']) ? ' - '.(string)$report['worker_lock_detail'] : '',
(int)$report['exit_code']
);
}
return array(
'message'=>($failed ? 'Scan-/Lock-Test auffaellig. ' : 'Scan-/Lock-Test erfolgreich. ').implode(' ', $parts),
);
}

View File

@@ -0,0 +1,151 @@
<?php
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
// Verzeichnis liegen, das derselbe Benutzer wie der Admin-/CLI-Worker
// schreiben kann. Das Connector-State-Verzeichnis ist fuer den Worker nur
// lesende Eingabe (Mapping/Priority), nicht sein eigener persistenter State.
$connection_id = (int)$connection_id;
if ($connection_id < 1) throw new RuntimeException('Verbindungs-ID fehlt fuer den WebDAV-Quellenindex.');
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-cache-index.connection-'.$connection_id.'.json';
}
function bratonien_tools_webdav_source_index_empty($connection_id)
{
return array(
'schema_version'=>3,
'connection_id'=>(int)$connection_id,
'updated_at'=>0,
'last_periodic_at'=>0,
'sources'=>array(),
);
}
function bratonien_tools_webdav_source_index_load($state_dir, $connection_id)
{
$file = bratonien_tools_webdav_source_index_file($state_dir, $connection_id);
if (!is_file($file) || !is_readable($file)) return null;
$raw = @file_get_contents($file);
$index = $raw !== false ? json_decode($raw, true) : null;
if (!is_array($index)) return null;
$base = bratonien_tools_webdav_source_index_empty($connection_id);
$index = array_merge($base, $index);
if (!isset($index['sources']) || !is_array($index['sources'])) $index['sources'] = array();
$index['connection_id'] = (int)$connection_id;
return $index;
}
function bratonien_tools_webdav_source_index_save($state_dir, $connection_id, array $index)
{
$file = bratonien_tools_webdav_source_index_file($state_dir, $connection_id);
$directory = dirname($file);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory))
{
throw new RuntimeException('WebDAV-Quellenindex-Verzeichnis konnte nicht angelegt werden.');
}
$index['schema_version'] = 3;
$index['connection_id'] = (int)$connection_id;
$index['updated_at'] = time();
if (!isset($index['sources']) || !is_array($index['sources'])) $index['sources'] = array();
ksort($index['sources'], SORT_STRING);
$json = json_encode($index, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false) throw new RuntimeException('WebDAV-Quellenindex konnte nicht serialisiert werden.');
$tmp = $file.'.tmp-'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $json."\n", LOCK_EX) === false)
{
throw new RuntimeException('WebDAV-Quellenindex konnte nicht geschrieben werden.');
}
@chmod($tmp, 0664);
if (!@rename($tmp, $file))
{
@unlink($tmp);
throw new RuntimeException('WebDAV-Quellenindex konnte nicht atomar gespeichert werden.');
}
}
function bratonien_tools_webdav_source_index_key(array $source)
{
$connection_id = (int)($source['connection_id'] ?? 0);
$fileid = (int)($source['fileid'] ?? 0);
if ($fileid > 0) return 'c'.$connection_id.':f'.$fileid;
$root_fileid = (int)($source['root_fileid'] ?? 0);
$path = trim((string)($source['webdav_path'] ?? ''), '/');
return 'c'.$connection_id.':r'.$root_fileid.':p'.sha1($path);
}
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),
trim((string)($source['webdav_path'] ?? ''), '/'),
(string)($source['etag'] ?? ''),
(int)($source['size'] ?? 0),
strtolower((string)($source['content_type'] ?? $source['mime'] ?? '')),
(int)($source['width'] ?? 0),
(int)($source['height'] ?? 0),
)));
}
function bratonien_tools_webdav_source_index_metadata(array $source, $shadow_relative)
{
$path = trim((string)($source['webdav_path'] ?? ''), '/');
return array(
'fileid'=>(int)($source['fileid'] ?? 0),
'root_fileid'=>(int)($source['root_fileid'] ?? 0),
'webdav_path'=>$path,
'name'=>basename($path),
'etag'=>(string)($source['etag'] ?? ''),
'size'=>(int)($source['size'] ?? 0),
'content_type'=>(string)($source['content_type'] ?? $source['mime'] ?? ''),
'width'=>(int)($source['width'] ?? 0),
'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(),
);
}
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);
}
function bratonien_tools_webdav_source_index_prune(array &$index, array $current_keys)
{
$keep = array_fill_keys($current_keys, true);
$removed = 0;
foreach (array_keys((array)$index['sources']) as $key)
{
if (!isset($keep[$key]))
{
unset($index['sources'][$key]);
$removed++;
}
}
return $removed;
}

View File

@@ -0,0 +1,394 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_source_index.inc.php');
function bratonien_tools_webdav_warmup_settings_file()
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.settings.json';
}
function bratonien_tools_get_webdav_warmup_settings()
{
$settings = array(
'enabled'=>false,
'batch_size'=>10,
'periodic_hours'=>12,
);
$file = bratonien_tools_webdav_warmup_settings_file();
if (is_file($file) && is_readable($file))
{
$raw = @file_get_contents($file);
$saved = $raw !== false ? json_decode($raw, true) : null;
if (is_array($saved))
{
if (array_key_exists('enabled', $saved)) $settings['enabled'] = (bool)$saved['enabled'];
if (isset($saved['batch_size'])) $settings['batch_size'] = max(1, min(50, (int)$saved['batch_size']));
if (isset($saved['periodic_hours'])) $settings['periodic_hours'] = max(1, min(168, (int)$saved['periodic_hours']));
}
}
return $settings;
}
function bratonien_tools_webdav_warmup_connections()
{
$result = array();
if (!function_exists('bratonien_tools_nc_connector_connections') || !function_exists('bratonien_tools_nc_connector_is_webdav')) return $result;
foreach (bratonien_tools_nc_connector_connections() as $connection)
{
if (empty($connection['enabled']) || !bratonien_tools_nc_connector_is_webdav($connection)) continue;
$connection_id = (int)($connection['id'] ?? 0);
if ($connection_id < 1) continue;
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$state_dir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($state_dir === '') continue;
$result[$connection_id] = array('id'=>$connection_id, 'state_dir'=>$state_dir, 'connection'=>$connection);
}
return $result;
}
function bratonien_tools_webdav_warmup_status_file_for_connection($connection_id)
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.status-'.(int)$connection_id.'.json';
}
function bratonien_tools_webdav_warmup_cancel_file_for_connection($connection_id)
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.cancel-'.(int)$connection_id;
}
function bratonien_tools_webdav_warmup_write_status($connection_id, $state, $message, array $extra=array())
{
$file = bratonien_tools_webdav_warmup_status_file_for_connection($connection_id);
$payload = array_merge(array(
'state'=>(string)$state,
'message'=>(string)$message,
'connection_id'=>(int)$connection_id,
'updated_at'=>time(),
), $extra);
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false) throw new RuntimeException('WebDAV-Worker-Status konnte nicht serialisiert werden.');
$directory = dirname($file);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) throw new RuntimeException('WebDAV-Worker-Statusverzeichnis konnte nicht angelegt werden.');
$tmp = $file.'.tmp-'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $json."\n", LOCK_EX) === false) throw new RuntimeException('WebDAV-Worker-Status konnte nicht geschrieben werden.');
@chmod($tmp, 0664);
if (!@rename($tmp, $file))
{
@unlink($tmp);
throw new RuntimeException('WebDAV-Worker-Status konnte nicht atomar gespeichert werden.');
}
}
function bratonien_tools_invalidate_webdav_cache_completion($reason='Piwigo-Bildcache wurde geleert.')
{
$invalidated = 0;
foreach (bratonien_tools_webdav_warmup_connections() as $connection_id=>$runtime)
{
$index = bratonien_tools_webdav_source_index_load($runtime['state_dir'], $connection_id);
if (is_array($index))
{
foreach ($index['sources'] as &$entry)
{
if (!is_array($entry)) continue;
unset($entry['stage1_signature'], $entry['stage2_signature']);
}
unset($entry);
bratonien_tools_webdav_source_index_save($runtime['state_dir'], $connection_id, $index);
$invalidated++;
}
bratonien_tools_webdav_warmup_write_status(
$connection_id,
'idle',
$reason.' Der Quellenindex bleibt erhalten; seine Cache-Fertigmarkierungen wurden verworfen.',
array('cache_invalidated'=>true)
);
}
return $invalidated;
}
function bratonien_tools_save_webdav_warmup_settings()
{
$current = bratonien_tools_get_webdav_warmup_settings();
$enabled = !empty($_POST['webdav_warmup_enabled']);
$batch_size = isset($_POST['webdav_warmup_batch_size']) ? (int)$_POST['webdav_warmup_batch_size'] : (int)$current['batch_size'];
$periodic_hours = isset($_POST['webdav_warmup_periodic_hours']) ? (int)$_POST['webdav_warmup_periodic_hours'] : (int)$current['periodic_hours'];
$batch_size = max(1, min(50, $batch_size));
$periodic_hours = max(1, min(168, $periodic_hours));
$payload = array(
'enabled'=>$enabled,
'batch_size'=>$batch_size,
'periodic_hours'=>$periodic_hours,
);
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false) throw new RuntimeException('Warmup-Einstellungen konnten nicht serialisiert werden.');
$file = bratonien_tools_webdav_warmup_settings_file();
$directory = dirname($file);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory))
{
throw new RuntimeException('Warmup-Einstellungsverzeichnis konnte nicht angelegt werden.');
}
$tmp = $file.'.tmp-'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $json."\n", LOCK_EX) === false)
{
throw new RuntimeException('Warmup-Einstellungen konnten nicht gespeichert werden.');
}
@chmod($tmp, 0664);
if (!@rename($tmp, $file))
{
@unlink($tmp);
throw new RuntimeException('Warmup-Einstellungen konnten nicht atomar gespeichert werden.');
}
return array('message'=>sprintf(
'WebDAV-Worker gespeichert: %s, %d Bilder pro Batch, Shadow-Tree-Abgleich alle %d Stunden. Der Worker-Index wird bei Bedarf automatisch aus dem Shadow-Tree aufgebaut.',
$enabled ? 'automatisch aktiv' : 'Automatik deaktiviert',
$batch_size,
$periodic_hours
));
}
function bratonien_tools_webdav_warmup_php_cli()
{
if (function_exists('bratonien_tools_find_php_cli'))
{
$php = bratonien_tools_find_php_cli();
if ($php) return $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)) return $candidate;
}
return null;
}
function bratonien_tools_start_webdav_warmup_mode($mode, $message)
{
if (!function_exists('exec')) throw new RuntimeException('PHP exec() ist deaktiviert; Worker kann nicht gestartet werden.');
$php = bratonien_tools_webdav_warmup_php_cli();
if (!$php) throw new RuntimeException('PHP-CLI wurde für den WebDAV-Worker nicht gefunden.');
$dispatcher = realpath(BRATONIEN_TOOLS_PATH.'runtime/webdav-warmup-dispatch.php');
if (!$dispatcher || !is_file($dispatcher)) throw new RuntimeException('WebDAV-Worker-Dispatcher wurde nicht gefunden.');
if (!in_array($mode, array('manual','rebuild'), true)) throw new RuntimeException('Ungültiger manueller Worker-Modus.');
$connections = bratonien_tools_webdav_warmup_connections();
if (!$connections) throw new RuntimeException('Keine aktive WebDAV-Verbindung mit vollständiger Runtime gefunden.');
$run_id = date('YmdHis').'-'.bin2hex(random_bytes(4));
foreach ($connections as $connection_id=>$runtime)
{
// Ein altes Abbruchsignal darf niemals den nächsten bewusst gestarteten
// Lauf stoppen. Es wird unmittelbar vor dem neuen Start entfernt.
@unlink(bratonien_tools_webdav_warmup_cancel_file_for_connection($connection_id));
bratonien_tools_webdav_warmup_write_status(
$connection_id,
'queued',
$mode === 'rebuild'
? 'Cache-Rebuild wurde angefordert. Der Worker wartet auf den Start und gleicht danach Shadow-Tree und eigenen Index ab.'
: 'Cache-Abgleich wurde angefordert. Bereits erfolgreich abgeschlossene Stufen bleiben erhalten; geladen werden nur Quellen mit fehlender Stufe 1 oder Stufe 2.',
array('mode'=>$mode, 'run_id'=>$run_id)
);
}
$log = PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup-dispatch.log';
$command = 'nohup '.escapeshellarg($php).' '.escapeshellarg($dispatcher).' --mode='.escapeshellarg($mode).' >> '.escapeshellarg($log).' 2>&1 < /dev/null & echo $!';
$output = array();
$exit = 1;
@exec($command, $output, $exit);
$pid = isset($output[0]) ? (int)$output[0] : 0;
if ($exit !== 0 || $pid <= 0)
{
foreach ($connections as $connection_id=>$runtime)
{
bratonien_tools_webdav_warmup_write_status($connection_id, 'error', 'WebDAV-Worker konnte nicht gestartet werden.', array('mode'=>$mode, 'run_id'=>$run_id));
}
throw new RuntimeException('WebDAV-Worker konnte nicht gestartet werden.');
}
return array('message'=>$message, 'pid'=>$pid, 'run_id'=>$run_id);
}
function bratonien_tools_start_webdav_warmup_manual()
{
return bratonien_tools_start_webdav_warmup_mode(
'manual',
'WebDAV-Worker: Shadow-Tree und Worker-Index werden abgeglichen; vollständig erledigte Quellen werden nicht aus Nextcloud geladen.'
);
}
function bratonien_tools_start_webdav_cache_rebuild()
{
return bratonien_tools_start_webdav_warmup_mode(
'rebuild',
'WebDAV-Bildcache: der vollständige aktuelle Shadow-Bestand wird erneut an Piwigo übergeben.'
);
}
function bratonien_tools_request_webdav_cache_cancel()
{
$requested = 0;
foreach (bratonien_tools_webdav_warmup_connections() as $connection_id=>$runtime)
{
$status_file = bratonien_tools_webdav_warmup_status_file_for_connection($connection_id);
$status = null;
if (is_file($status_file) && is_readable($status_file))
{
$raw = @file_get_contents($status_file);
$status = $raw !== false ? json_decode($raw, true) : null;
}
if (!is_array($status)) $status = array();
$state = (string)($status['state'] ?? 'idle');
if (!in_array($state, array('queued','scan','running','cancelling'), true)) continue;
$cancel = bratonien_tools_webdav_warmup_cancel_file_for_connection($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);
$extra = $status;
unset($extra['state'], $extra['message'], $extra['connection_id'], $extra['updated_at']);
$extra['cancel_requested'] = true;
bratonien_tools_webdav_warmup_write_status(
$connection_id,
'cancelling',
'Abbruch angefordert. Der aktuell laufende Batch wird noch vollständig beendet; danach werden keine weiteren Nextcloud-Originale geladen.',
$extra
);
$requested++;
}
return $requested;
}
function bratonien_tools_cancel_combined_image_cache_build()
{
$webdav = bratonien_tools_request_webdav_cache_cancel();
$local = false;
if (function_exists('bratonien_tools_main_cache_process_active') && function_exists('bratonien_tools_main_cache_is_running'))
{
$local = bratonien_tools_main_cache_process_active() || bratonien_tools_main_cache_is_running();
}
if ($local && function_exists('bratonien_tools_request_main_cache_cancel'))
{
bratonien_tools_request_main_cache_cancel();
}
if ($webdav > 0 && $local)
{
return array('message'=>'Cache-Abbruch angefordert. Der aktuelle WebDAV-Batch wird noch beendet; lokale Cache-Worker erhalten ebenfalls sofort das Abbruchsignal.');
}
if ($webdav > 0)
{
return array('message'=>'WebDAV-Cache-Abbruch angefordert. Der aktuell laufende Batch wird noch vollständig beendet; danach ist Schluss.');
}
if ($local)
{
return array('message'=>'Lokaler Piwigo-Cache-Abbruch wurde angefordert.');
}
return array('message'=>'Es läuft aktuell kein Cache-Aufbau.');
}
function bratonien_tools_has_local_cache_sources()
{
if (!function_exists('bratonien_tools_webdav_materialize_source_info')) return true;
$result = pwg_query('SELECT id FROM '.IMAGES_TABLE.' ORDER BY id');
while ($row = pwg_db_fetch_assoc($result))
{
$image_id = (int)($row['id'] ?? 0);
if ($image_id < 1) continue;
if (!bratonien_tools_webdav_materialize_source_info($image_id)) return true;
}
return false;
}
function bratonien_tools_start_combined_image_cache_build()
{
// Der normale "Piwigo-Bildcache aufbauen"-Lauf ist immer inkrementell.
// Der Worker liest zuerst ausschließlich Shadow-Tree/Mapping und seinen
// eigenen Index. Nur Quellen, denen laut Index Stufe 1 oder Stufe 2 fehlt,
// dürfen anschließend aus Nextcloud geladen werden. Ein kompletter
// Neuaufbau entsteht nur nach einer echten Cache-Invalidierung (z.B. Cache
// leeren), denn genau dort werden die Stage-Signaturen bewusst entfernt.
$webdav = bratonien_tools_start_webdav_warmup_manual();
$main = array('started'=>false, 'message'=>'');
if (bratonien_tools_has_local_cache_sources())
{
$main = bratonien_tools_start_main_cache_build();
}
else
{
bratonien_tools_write_main_cache_status(array(
'state'=>'complete',
'message'=>'Lokaler Piwigo-Teil nicht erforderlich. Der WebDAV-Worker arbeitet ausschließlich mit Shadow-Tree und eigenem Index.',
'total'=>0,
'completed'=>0,
'generated'=>0,
'cached'=>0,
'skipped'=>0,
'errors'=>0,
'current'=>'',
'worker_count'=>0,
));
$main['message'] = 'Lokaler Piwigo-Teil nicht gestartet, weil keine lokalen Bildquellen vorhanden sind.';
}
$parts = array();
if (!empty($main['message'])) $parts[] = $main['message'];
if (!empty($webdav['message'])) $parts[] = $webdav['message'];
return array(
'started'=>!empty($main['started']) || !empty($webdav['pid']),
'message'=>implode(' ', $parts),
);
}
function bratonien_tools_run_webdav_warmup_audit()
{
if (!function_exists('exec')) throw new RuntimeException('PHP exec() ist deaktiviert; Pfadaudit kann nicht gestartet werden.');
$php = bratonien_tools_webdav_warmup_php_cli();
if (!$php) throw new RuntimeException('PHP-CLI wurde für den Pfadaudit nicht gefunden.');
$audit = realpath(BRATONIEN_TOOLS_PATH.'runtime/lib/webdav-warmup-audit.php');
if (!$audit || !is_file($audit)) throw new RuntimeException('WebDAV-Pfadaudit wurde nicht gefunden.');
$output = array();
$exit = 1;
@exec(escapeshellarg($php).' '.escapeshellarg($audit).' 2>&1', $output, $exit);
$text = trim(implode(' | ', array_slice($output, -8)));
if ($exit !== 0) throw new RuntimeException('WebDAV-Pfadaudit meldet einen unsicheren Zustand'.($text !== '' ? ': '.$text : '.'));
return array('message'=>'WebDAV-Pfadaudit erfolgreich'.($text !== '' ? ': '.$text : '.'));
}
function bratonien_tools_get_webdav_warmup_status()
{
$status = array(
'state'=>'idle',
'message'=>'Noch kein Shadow-Tree-/Worker-Lauf protokolliert.',
'updated_at'=>0,
);
$files = glob(PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.status-*.json');
if (!$files) return $status;
$latest = null;
foreach ($files as $file)
{
if (!is_readable($file)) continue;
$raw = @file_get_contents($file);
$data = $raw !== false ? json_decode($raw, true) : null;
if (!is_array($data)) continue;
if ($latest === null || (int)($data['updated_at'] ?? 0) > (int)($latest['updated_at'] ?? 0)) $latest = $data;
}
return is_array($latest) ? array_merge($status, $latest) : $status;
}

View File

@@ -216,12 +216,105 @@
resizeTimer = window.setTimeout(updateMap, 60);
}
function isWebdavThumbnail(image) {
if (!image || image.id === 'theMainImage') {
return false;
}
var src = image.getAttribute('src') || '';
var dataSrc = image.getAttribute('data-src') || '';
return src.indexOf('plugins/bratonien_tools/webdav-derivative.php') !== -1 ||
dataSrc.indexOf('plugins/bratonien_tools/webdav-derivative.php') !== -1 ||
image.getAttribute('data-bratonien-webdav-thumbnail') === '1';
}
function setThumbnailPlaceholder(image) {
var parent = image.parentElement;
if (!parent || parent.getAttribute('data-bratonien-placeholder-active') === '1') {
return;
}
parent.setAttribute('data-bratonien-placeholder-active', '1');
parent.setAttribute('data-bratonien-placeholder-background-image', parent.style.backgroundImage || '');
parent.setAttribute('data-bratonien-placeholder-background-repeat', parent.style.backgroundRepeat || '');
parent.setAttribute('data-bratonien-placeholder-background-position', parent.style.backgroundPosition || '');
parent.setAttribute('data-bratonien-placeholder-background-size', parent.style.backgroundSize || '');
parent.style.backgroundImage = 'url("' + absoluteUrl('themes/default/icon/img_small.png') + '")';
parent.style.backgroundRepeat = 'no-repeat';
parent.style.backgroundPosition = 'center';
parent.style.backgroundSize = 'auto';
}
function clearThumbnailPlaceholder(image) {
var parent = image.parentElement;
if (!parent || parent.getAttribute('data-bratonien-placeholder-active') !== '1') {
return;
}
parent.style.backgroundImage = parent.getAttribute('data-bratonien-placeholder-background-image') || '';
parent.style.backgroundRepeat = parent.getAttribute('data-bratonien-placeholder-background-repeat') || '';
parent.style.backgroundPosition = parent.getAttribute('data-bratonien-placeholder-background-position') || '';
parent.style.backgroundSize = parent.getAttribute('data-bratonien-placeholder-background-size') || '';
parent.removeAttribute('data-bratonien-placeholder-active');
parent.removeAttribute('data-bratonien-placeholder-background-image');
parent.removeAttribute('data-bratonien-placeholder-background-repeat');
parent.removeAttribute('data-bratonien-placeholder-background-position');
parent.removeAttribute('data-bratonien-placeholder-background-size');
}
function markThumbnailPending(image) {
image.setAttribute('data-bratonien-webdav-thumbnail', '1');
image.classList.remove('bratonien-webdav-thumbnail-ready');
image.classList.add('bratonien-webdav-thumbnail-pending');
setThumbnailPlaceholder(image);
}
function markThumbnailReady(image) {
image.classList.remove('bratonien-webdav-thumbnail-pending');
image.classList.add('bratonien-webdav-thumbnail-ready');
clearThumbnailPlaceholder(image);
}
function protectWebdavThumbnail(image) {
if (!isWebdavThumbnail(image)) {
return;
}
if (image.getAttribute('data-bratonien-thumbnail-bound') !== '1') {
image.setAttribute('data-bratonien-thumbnail-bound', '1');
image.addEventListener('load', function () {
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
markThumbnailReady(image);
}
});
image.addEventListener('error', function () {
markThumbnailPending(image);
});
}
if (image.complete && image.naturalWidth > 0 && image.naturalHeight > 0) {
markThumbnailReady(image);
} else {
markThumbnailPending(image);
}
}
function protectWebdavThumbnails(root) {
var scope = root || document;
var images = scope.querySelectorAll ? scope.querySelectorAll('img') : [];
for (var i = 0; i < images.length; i += 1) {
protectWebdavThumbnail(images[i]);
}
}
$(function () {
var image = document.getElementById('theMainImage');
if (!image) {
return;
}
protectWebdavThumbnails(document.getElementById('theImageAndInfos') || document);
if (image.complete) {
updateMap();
} else {
@@ -241,6 +334,35 @@
attributes: true,
attributeFilter: ['src', 'srcset', 'usemap', 'class', 'style']
});
var thumbnailRoot = document.getElementById('theImageAndInfos');
if (thumbnailRoot) {
var thumbnailObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.target.tagName === 'IMG') {
protectWebdavThumbnail(mutation.target);
return;
}
for (var i = 0; i < mutation.addedNodes.length; i += 1) {
var node = mutation.addedNodes[i];
if (node.nodeType !== 1) {
continue;
}
if (node.tagName === 'IMG') {
protectWebdavThumbnail(node);
}
protectWebdavThumbnails(node);
}
});
});
thumbnailObserver.observe(thumbnailRoot, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'data-src']
});
}
}
});
})(jQuery);

View File

@@ -23,7 +23,7 @@ $_SERVER['REQUEST_URI'] = '/';
$_SERVER['SCRIPT_NAME'] = '/plugins/bratonien_tools/main-cache-build.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-Piwigo-Cache-Builder/1.4';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-Piwigo-Cache-Builder/1.5';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
@@ -35,6 +35,7 @@ if (!defined('BRATONIEN_TOOLS_PATH'))
require_once(BRATONIEN_TOOLS_PATH.'tools/image_cache.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/watermark_engine.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_materialize_runtime.inc.php');
require_once(PHPWG_ROOT_PATH.'include/derivative.inc.php');
require_once(PHPWG_ROOT_PATH.'admin/include/image.class.php');
@@ -207,7 +208,18 @@ function bratonien_tools_cache_builder_worker($worker_index, $worker_count, $run
$result = pwg_query('SELECT * FROM '.IMAGES_TABLE.' ORDER BY id');
while ($row = pwg_db_fetch_assoc($result))
{
if (((int)$row['id'] % $worker_count) === $worker_index)
$image_id = (int)$row['id'];
// WebDAV-Bilder gehören vollständig in den materialisierenden Rebuild-/Warmup-Pfad.
// Sie dürfen im Legacy-Worker weder erzeugt noch als "übersprungen" gezählt werden,
// weil das die Fortschrittsanzeige nach einem Cache-Leeren fälschlich wie einen
// abgeschlossenen Lauf aussehen lässt.
if (bratonien_tools_webdav_materialize_source_info($image_id))
{
continue;
}
if (($image_id % $worker_count) === $worker_index)
{
$rows[] = $row;
}
@@ -248,6 +260,7 @@ function bratonien_tools_cache_builder_worker($worker_index, $worker_count, $run
{
$derivative = new DerivativeImage($requested_params, $src);
$target_path = $derivative->get_path();
if (strpos($target_path, PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR) !== 0)
{
$skipped++;
@@ -473,7 +486,7 @@ for ($i=0; $i<$worker_count; $i++)
}
}
$running_message = sprintf('Piwigo-Bildcache wird mit %d parallelen Worker(n) aufgebaut.', $worker_count);
$running_message = sprintf('Piwigo-Bildcache wird mit %d parallelen Worker(n) aufgebaut. WebDAV-Bilder laufen getrennt über den Piwigo-Rebuild.', $worker_count);
bratonien_tools_write_main_cache_status(array(
'state'=>'running',
'message'=>$running_message,
@@ -522,7 +535,7 @@ $state = bratonien_tools_cache_builder_aggregate(
$worker_count,
$was_cancelled
? 'Piwigo-Bildcache-Aufbau wurde abgebrochen.'
: ($exit_error ? 'Piwigo-Bildcache beendet; mindestens ein Worker meldete einen Prozessfehler.' : 'Piwigo-Bildcache wurde aufgebaut.')
: ($exit_error ? 'Piwigo-Bildcache beendet; mindestens ein Worker meldete einen Prozessfehler.' : 'Lokaler Piwigo-Bildcache wurde aufgebaut; WebDAV-Bilder laufen getrennt über den Piwigo-Rebuild.')
);
for ($i=0; $i<$worker_count; $i++)
@@ -536,3 +549,4 @@ fclose($lock);
@unlink($lock_path);
exit(($state === 'error' || $exit_error) ? 1 : 0);
}

View File

@@ -25,32 +25,266 @@ if (!function_exists('is_admin') || !is_admin())
}
require_once(BRATONIEN_TOOLS_PATH.'tools/image_cache.inc.php');
$file = bratonien_tools_main_cache_status_file();
if (!is_file($file) || !is_readable($file))
function bratonien_tools_status_read_json($file)
{
echo json_encode(array(
'state'=>'idle','message'=>'Noch kein manueller Cache-Aufbau gestartet.',
if (!is_file($file) || !is_readable($file)) return null;
$raw = @file_get_contents($file);
$data = $raw !== false ? json_decode($raw, true) : null;
return is_array($data) ? $data : null;
}
function bratonien_tools_status_webdav_label(array $status)
{
$connection = (int)($status['connection_id'] ?? 0);
$state = (string)($status['state'] ?? 'idle');
$prefix = $connection > 0 ? 'WebDAV #'.$connection.': ' : 'WebDAV: ';
if ($state === 'queued' || $state === 'waiting') return $prefix.((string)($status['message'] ?? '') !== '' ? (string)$status['message'] : 'Worker wartet auf den nächsten sicheren Startpunkt.');
if ($state === 'scan')
{
$sources = (int)($status['shadow_sources'] ?? 0);
$selected = (int)($status['selected'] ?? 0);
$removed = (int)($status['removed_from_index'] ?? 0);
$created = !empty($status['index_created']);
return $prefix.'Shadow-Tree wird mit dem eigenen Worker-Index verglichen · '.$sources.' Quellen im Shadow-Tree · '.$selected.' zu verarbeiten'.($removed > 0 ? ' · '.$removed.' gelöschte Quelle(n) aus dem Worker-Index entfernt' : '').($created ? ' · Worker-Index wurde neu angelegt' : '').'.';
}
if ($state === 'running')
{
$stage = (int)($status['stage'] ?? 0);
$batch = (int)($status['batch'] ?? 0);
$requested = (int)($status['batch_requested'] ?? 0);
$downloaded = (int)($status['batch_downloaded'] ?? 0);
$completed = (int)($status['stage_completed'] ?? 0);
$total = (int)($status['selected_total'] ?? 0);
$parts = array($prefix.'Worker stellt Originale temporär bereit; Piwigo erzeugt die Derivate');
if ($stage > 0) $parts[] = 'Stufe '.$stage;
if ($batch > 0) $parts[] = 'Batch '.$batch;
if ($requested > 0) $parts[] = $downloaded.' / '.$requested.' Quellen dieses Batches geladen';
if ($total > 0) $parts[] = $completed.' / '.$total.' Quellen dieser Stufe abgeschlossen';
return implode(' · ', $parts).'.';
}
if ($state === 'cancelling') return $prefix.((string)($status['message'] ?? '') !== '' ? (string)$status['message'] : 'Abbruch angefordert; aktueller Batch wird noch beendet.');
if ($state === 'cancelled') return $prefix.((string)($status['message'] ?? '') !== '' ? (string)$status['message'] : 'Cache-Aufbau wurde abgebrochen.');
if ($state === 'partial') return $prefix.((string)($status['message'] ?? '') !== '' ? (string)$status['message'] : 'Einzelne Quellen sind noch offen und werden beim nächsten Lauf erneut versucht.');
if ($state === 'preempted') return $prefix.'Stufe 2 wurde nach einem vollständigen Batch unterbrochen, damit neue Connector-Inhalte zuerst verarbeitet werden können.';
if ($state === 'complete') return $prefix.((string)($status['message'] ?? '') !== '' ? (string)$status['message'] : 'Verarbeitung abgeschlossen.');
if ($state === 'error' || $state === 'fatal') return $prefix.'FEHLER: '.((string)($status['message'] ?? '') !== '' ? (string)$status['message'] : 'WebDAV-Verarbeitung fehlgeschlagen.');
return $prefix.((string)($status['message'] ?? '') !== '' ? (string)$status['message'] : 'wartet.');
}
$local_file = bratonien_tools_main_cache_status_file();
$local = bratonien_tools_status_read_json($local_file);
if ($local === null)
{
$local = array(
'state'=>'idle','message'=>'Noch kein lokaler Piwigo-Cache-Aufbau gestartet.',
'total'=>0,'completed'=>0,'generated'=>0,'cached'=>0,'skipped'=>0,'errors'=>0,'current'=>'','updated_at'=>0,
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
);
}
$raw = @file_get_contents($file);
$data = $raw !== false ? json_decode($raw, true) : null;
if (!is_array($data))
$webdav = array();
foreach ((array)glob(PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.status-*.json') as $file)
{
http_response_code(500);
echo json_encode(array('state'=>'error','message'=>'Cache-Status ist nicht lesbar.'));
exit;
$status = bratonien_tools_status_read_json($file);
if (!$status) continue;
if (!isset($status['connection_id']) && preg_match('/status-(\d+)\.json$/', $file, $m)) $status['connection_id'] = (int)$m[1];
$webdav[] = $status;
}
usort($webdav, function($a, $b) {
return (int)($a['connection_id'] ?? 0) <=> (int)($b['connection_id'] ?? 0);
});
$state = (string)($data['state'] ?? 'idle');
$updated = (int)($data['updated_at'] ?? 0);
if (($state === 'running' || $state === 'queued') && $updated > 0 && (time() - $updated) > 45)
$webdav_active = false;
$webdav_waiting = false;
$webdav_partial = false;
$webdav_cancelling = false;
$webdav_cancelled = false;
$webdav_error = false;
$webdav_pending = false;
$webdav_latest = 0;
$webdav_lines = array();
$webdav_all_complete = !empty($webdav);
$webdav_progress_total = 0;
$webdav_progress_completed = 0;
$webdav_source_total = 0;
foreach ($webdav as &$status)
{
$data['state'] = 'error';
$data['message'] = 'Cache-Aufbau liefert seit mehr als 45 Sekunden keinen Fortschritt. Der Prozess ist vermutlich beendet oder festgefahren.';
$data['errors'] = max(1, (int)($data['errors'] ?? 0));
$state = (string)($status['state'] ?? 'idle');
$updated = (int)($status['updated_at'] ?? 0);
$webdav_latest = max($webdav_latest, $updated);
if ($state === 'queued' && $updated > 0 && (time() - $updated) > 45)
{
$state = 'error';
$status['state'] = 'error';
$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));
$webdav_source_total += $selected;
$webdav_progress_total += $selected * 2;
}
elseif ($state === 'running' || $state === 'cancelling')
{
$selected = max(0, (int)($status['selected_total'] ?? $status['selected'] ?? 0));
$stage = max(1, (int)($status['stage'] ?? 1));
$stage_completed = max(0, min($selected, (int)($status['stage_completed'] ?? 0)));
$webdav_source_total += $selected;
$webdav_progress_total += $selected * 2;
$webdav_progress_completed += $stage >= 2 ? $selected + $stage_completed : $stage_completed;
}
elseif (in_array($state, array('complete','error','fatal','preempted','cancelled','waiting','partial'), true))
{
$selected = max(0, (int)($status['selected'] ?? 0));
if ($selected > 0)
{
$stage1_completed = max(0, (int)($status['stage1_completed'] ?? 0));
$stage2_completed = max(0, (int)($status['stage2_completed'] ?? 0));
$stage1_failed = max(0, (int)($status['stage1_failed'] ?? 0));
$stage2_failed = max(0, (int)($status['stage2_failed'] ?? 0));
$stage1_deferred = max(0, (int)($status['stage1_deferred'] ?? 0));
$stage2_deferred = max(0, (int)($status['stage2_deferred'] ?? 0));
$webdav_source_total += $selected;
$webdav_progress_total += $selected * 2;
$webdav_progress_completed += min($selected * 2, $stage1_completed + $stage2_completed + $stage1_failed + $stage2_failed + $stage1_deferred + $stage2_deferred);
}
}
if (in_array($state, array('queued','scan','running'), true)) $webdav_active = true;
if ($state === 'waiting') $webdav_waiting = true;
if ($state === 'partial') $webdav_partial = true;
if ($state === 'cancelling') $webdav_cancelling = true;
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' && $state !== 'idle') $webdav_all_complete = false;
$webdav_lines[] = bratonien_tools_status_webdav_label($status);
}
unset($status);
$local_state = (string)($local['state'] ?? 'idle');
$local_updated = (int)($local['updated_at'] ?? 0);
$local_stale = in_array($local_state, array('running','queued'), true) && $local_updated > 0 && (time() - $local_updated) > 45;
if ($local_stale && !$webdav_active && !$webdav_cancelling && !$webdav_waiting)
{
$local['state'] = 'error';
$local['message'] = 'Der lokale Piwigo-Teil liefert seit mehr als 45 Sekunden keinen Fortschritt.';
$local['errors'] = max(1, (int)($local['errors'] ?? 0));
$local_state = 'error';
}
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($local_state === 'running' || $local_state === 'queued') $local_label = 'Lokaler Piwigo-Teil läuft: '.((string)($local['message'] ?? '') !== '' ? (string)$local['message'] : 'Cache-Varianten werden verarbeitet.');
elseif ($local_state === 'complete') $local_label = 'Lokaler Piwigo-Teil fertig.';
elseif ($local_state === 'cancelled') $local_label = 'Lokaler Piwigo-Teil wurde abgebrochen.';
elseif ($local_state === 'error') $local_label = 'Lokaler Piwigo-Teil mit Fehler: '.((string)($local['message'] ?? '') !== '' ? (string)$local['message'] : 'unbekannter Fehler');
else $local_label = 'Lokaler Piwigo-Teil: keine relevanten lokalen Bildquellen aktiv.';
$overall = $local;
$overall['local'] = $local;
$overall['webdav'] = $webdav;
$overall['updated_at'] = max($local_updated, $webdav_latest);
$overall['source_total'] = $webdav_source_total;
$local_total = max(0, (int)($local['total'] ?? 0));
$local_completed = max(0, min($local_total, (int)($local['completed'] ?? 0)));
$combined_total = $local_total + $webdav_progress_total;
$combined_completed = $local_completed + min($webdav_progress_total, $webdav_progress_completed);
if ($webdav_cancelling || $local_state === 'cancelling')
{
$overall['state'] = 'cancelling';
$overall['total'] = $combined_total;
$overall['completed'] = $combined_completed;
$overall['message'] = 'Cache-Aufbau wird nach dem aktuell laufenden Batch beendet'.($webdav_source_total > 0 ? ' · '.$webdav_source_total.' WebDAV-Quellen' : '').'.';
$overall['current'] = implode(' ', $webdav_lines);
}
elseif ($webdav_active)
{
$overall['state'] = 'running';
$overall['total'] = $combined_total;
$overall['completed'] = $combined_completed;
$overall['generated'] = (int)($local['generated'] ?? 0);
$overall['cached'] = (int)($local['cached'] ?? 0);
$overall['skipped'] = (int)($local['skipped'] ?? 0);
$overall['message'] = 'Gesamtaufbau läuft'.($webdav_source_total > 0 ? ' · '.$webdav_source_total.' WebDAV-Quellen' : '').'. '.$local_label;
$overall['current'] = implode(' ', $webdav_lines);
}
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 Fortsetzungsauftrag bleibt registriert.';
$overall['current'] = implode(' ', $webdav_lines);
}
elseif ($webdav_partial)
{
$overall['state'] = 'queued';
$overall['total'] = $combined_total;
$overall['completed'] = $combined_completed;
$overall['errors'] = 0;
$overall['message'] = 'Cache-Aufbau bleibt fortsetzbar: einzelne Quellen sind noch offen; der restliche Bestand wurde weiterverarbeitet.';
$overall['current'] = implode(' ', $webdav_lines);
}
elseif ($webdav_error || $local_state === 'error')
{
$overall['state'] = 'error';
if ($combined_total > 0)
{
$overall['total'] = $combined_total;
$overall['completed'] = $combined_completed;
}
$overall['message'] = 'Cache-Aufbau mit Fehlern'.($webdav_source_total > 0 ? ' · '.$webdav_source_total.' WebDAV-Quellen' : '').'. '.$local_label;
$overall['current'] = implode(' ', $webdav_lines);
$overall['errors'] = max(1, (int)($overall['errors'] ?? 0));
}
elseif ($webdav_pending)
{
$overall['state'] = 'queued';
$overall['total'] = $combined_total;
$overall['completed'] = $combined_completed;
$overall['message'] = 'Cache-Aufbau wartet auf priorisierte Connector-Inhalte'.($webdav_source_total > 0 ? ' · '.$webdav_source_total.' WebDAV-Quellen' : '').'. '.$local_label;
$overall['current'] = implode(' ', $webdav_lines);
}
elseif ($webdav_cancelled || $local_state === 'cancelled')
{
$overall['state'] = 'cancelled';
$overall['total'] = $combined_total;
$overall['completed'] = $combined_completed;
$overall['message'] = 'Cache-Aufbau wurde nach dem letzten vollständig abgeschlossenen Batch beendet.';
$overall['current'] = implode(' ', $webdav_lines);
}
elseif ($webdav_all_complete && $local_state === 'complete')
{
$overall['state'] = 'complete';
if ($combined_total > 0)
{
$overall['total'] = $combined_total;
$overall['completed'] = $combined_total;
}
$overall['message'] = 'Cache-Aufbau abgeschlossen'.($webdav_source_total > 0 ? ' · '.$webdav_source_total.' WebDAV-Quellen' : '').'. '.$local_label;
$overall['current'] = implode(' ', $webdav_lines);
}
else
{
$overall['state'] = $local_state === 'complete' ? 'idle' : $local_state;
$overall['message'] = $local_label;
if ($webdav_lines) $overall['current'] = implode(' ', $webdav_lines);
}
echo json_encode($overall, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -1,7 +1,7 @@
<?php
/*
Plugin Name: Bratonien Tools
Version: 0.9.6.8.22
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
@@ -17,13 +17,17 @@ define('BRATONIEN_TOOLS_PATH', PHPWG_PLUGINS_PATH . BRATONIEN_TOOLS_ID . '/');
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);
@@ -35,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');
@@ -180,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)
@@ -56,8 +67,30 @@ def safe_local_name(name: str) -> str:
def parse_image_dimensions(prop: ET.Element) -> tuple[int, int]:
for property_name in ("file-metadata-size", "metadata-photos-size"):
raw = prop.findtext(f"{{{NC}}}{property_name}", default="").strip()
for property_name in ("metadata-photos-size", "file-metadata-size"):
element = prop.find(f"{{{NC}}}{property_name}")
if element is None:
continue
width = 0
height = 0
for child in list(element):
local_name = child.tag.rsplit("}", 1)[-1]
text = (child.text or "").strip()
if not text:
continue
try:
value = int(text)
except ValueError:
continue
if local_name == "width":
width = value
elif local_name == "height":
height = value
if width > 0 and height > 0:
return width, height
raw = (element.text or "").strip()
if not raw:
continue
try:
@@ -114,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}")
@@ -167,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:
@@ -279,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}"
@@ -335,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,
@@ -344,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

@@ -0,0 +1,248 @@
<?php
if (PHP_SAPI !== 'cli')
{
http_response_code(404);
exit;
}
$piwigo_root = realpath(dirname(__DIR__, 4));
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/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.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");
exit(1);
}
require_once(BRATONIEN_TOOLS_PATH.'include/nc_connector.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_cache_validation.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_materialize_runtime.inc.php');
function bratonien_tools_shadow_bridge_fail($message, $code=1)
{
fwrite(STDERR, $message."\n");
exit((int)$code);
}
function bratonien_tools_shadow_bridge_i_request($target)
{
$root = PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR;
$target = str_replace('\\', '/', (string)$target);
$root = str_replace('\\', '/', (string)$root);
if (strpos($target, $root) !== 0) return null;
$relative = ltrim(substr($target, strlen($root)), '/');
if ($relative === '') return null;
return '/i.php?/'.implode('/', array_map('rawurlencode', explode('/', $relative)));
}
function bratonien_tools_shadow_bridge_call_i($request, &$detail=null)
{
$detail = '';
$runner = BRATONIEN_TOOLS_PATH.'runtime/lib/piwigo-derivative-call.php';
if (!is_file($runner)) { $detail = 'Piwigo-Derivataufruf fehlt.'; return false; }
if (!function_exists('exec')) { $detail = 'PHP exec() ist deaktiviert.'; return false; }
$output = array();
$exit = 1;
@exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($runner).' --request='.escapeshellarg($request).' 2>&1', $output, $exit);
if ($exit !== 0)
{
$detail = 'Piwigo-Aufruf Exit '.$exit;
if ($output) $detail .= ': '.implode(' ', array_slice($output, -3));
return false;
}
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);
$variants = array();
foreach (ImageStdParams::get_defined_type_map() as $type=>$params)
{
$derivative = new DerivativeImage($params, $src);
if ($derivative->same_as_source()) continue;
$size = $derivative->get_size();
$max_edge = max((int)($size[0] ?? 0), (int)($size[1] ?? 0));
$priority = $max_edge > 0 && $max_edge <= 1920;
if (($stage === 1 && $priority) || ($stage === 2 && !$priority))
{
$variants[] = array('name'=>'standard:'.$type, 'derivative'=>$derivative, 'params'=>$params, 'target'=>$derivative->get_path());
}
}
foreach (ImageStdParams::$custom as $key=>$last_used)
{
$params = bratonien_tools_webdav_materialize_custom_params($key);
if (!$params) continue;
$derivative = new DerivativeImage($params, $src);
if ($derivative->same_as_source()) continue;
$size = $derivative->get_size();
$max_edge = max((int)($size[0] ?? 0), (int)($size[1] ?? 0));
$priority = $max_edge > 0 && $max_edge <= 1920;
if (($stage === 1 && $priority) || ($stage === 2 && !$priority))
{
$variants[] = array('name'=>'custom:'.$key, 'derivative'=>$derivative, 'params'=>$params, 'target'=>$derivative->get_path());
}
}
return $variants;
}
$connection_id = 0;
$shadow_relative = '';
$stage = 0;
foreach ($argv as $arg)
{
if (preg_match('/^--connection-id=(\d+)$/', $arg, $m)) $connection_id = (int)$m[1];
elseif (strpos($arg, '--shadow-relative=') === 0) $shadow_relative = ltrim(substr($arg, strlen('--shadow-relative=')), '/');
elseif (preg_match('/^--stage=([12])$/', $arg, $m)) $stage = (int)$m[1];
}
if ($connection_id < 1 || $shadow_relative === '' || !in_array($stage, array(1,2), true))
{
bratonien_tools_shadow_bridge_fail('Parameter --connection-id, --shadow-relative und --stage=1|2 werden benötigt.', 2);
}
if (strpos($shadow_relative, '..') !== false || strpos($shadow_relative, "\0") !== false)
{
bratonien_tools_shadow_bridge_fail('Unsicherer Shadow-Pfad.', 2);
}
$connection = bratonien_tools_nc_connector_connection($connection_id, false);
if (!$connection || empty($connection['enabled'])) bratonien_tools_shadow_bridge_fail('WebDAV-Verbindung wurde nicht gefunden.');
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$gallery_root = rtrim((string)($config['parallel_gallery_root'] ?? ''), '/');
if ($gallery_root === '' || !is_dir($gallery_root)) bratonien_tools_shadow_bridge_fail('Shadow-Tree der Verbindung fehlt.');
$shadow_absolute = $gallery_root.'/'.$shadow_relative;
if (!is_link($shadow_absolute)) bratonien_tools_shadow_bridge_fail('Shadow-Datei ist kein Symlink: '.$shadow_relative);
$shadow_real = realpath($shadow_absolute);
if ($shadow_real === false || !is_file($shadow_real)) bratonien_tools_shadow_bridge_fail('Shadow-Ziel ist nicht lesbar: '.$shadow_relative);
$root = rtrim(str_replace('\\', '/', PHPWG_ROOT_PATH), '/');
$gallery = rtrim(str_replace('\\', '/', $gallery_root), '/');
if (strpos($gallery.'/', $root.'/') !== 0) bratonien_tools_shadow_bridge_fail('Shadow-Tree liegt außerhalb von Piwigo.');
$relative_gallery = ltrim(substr($gallery, strlen($root)), '/');
$db_path = './'.$relative_gallery.'/'.$shadow_relative;
$result = pwg_query("SELECT * FROM ".IMAGES_TABLE." WHERE path='".pwg_db_real_escape_string($db_path)."' LIMIT 2");
$rows = array();
while ($row = pwg_db_fetch_assoc($result)) $rows[] = $row;
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 = '';
if (bratonien_tools_webdav_derivative_cache_valid($variant['target'], $variant['derivative'], $variant['params'], $reason))
{
$already++;
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.');
$detail = '';
if (!bratonien_tools_shadow_bridge_call_i($request, $detail))
{
bratonien_tools_shadow_bridge_fail($variant['name'].': '.$detail);
}
clearstatcache(true, $variant['target']);
$reason = '';
if (!bratonien_tools_webdav_derivative_cache_valid($variant['target'], $variant['derivative'], $variant['params'], $reason))
{
bratonien_tools_shadow_bridge_fail($variant['name'].': Piwigo hat kein gültiges Derivat erzeugt ('.$reason.').');
}
$generated++;
}
echo json_encode(array(
'ok'=>true,
'connection_id'=>$connection_id,
'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,46 @@
<?php
if (PHP_SAPI !== 'cli')
{
http_response_code(404);
exit;
}
$request = '';
foreach ($argv as $arg)
{
if (strpos($arg, '--request=') === 0)
{
$request = substr($arg, strlen('--request='));
}
}
if ($request === '' || strpos($request, '/i.php?/') !== 0)
{
fwrite(STDERR, "Ungültige Piwigo-Derivatanforderung.\n");
exit(2);
}
$piwigo_root = realpath(dirname(__DIR__, 4));
if ($piwigo_root === false || !is_file($piwigo_root.'/i.php'))
{
fwrite(STDERR, "Piwigo i.php wurde nicht gefunden.\n");
exit(3);
}
chdir($piwigo_root);
$_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'] = $request;
$_SERVER['SCRIPT_NAME'] = '/i.php';
$_SERVER['PHP_SELF'] = '/i.php';
$_SERVER['QUERY_STRING'] = ltrim((string)parse_url($request, PHP_URL_QUERY), '?');
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Warmup-Piwigo-Caller/0.9.7.1.8';
$_SERVER['HTTPS'] = 'off';
// Piwigo selbst erzeugt das Derivat. Der Warmup benötigt den ausgelieferten
// Bild-Body nicht und prüft danach ausschließlich Piwigos Cache-Datei.
ob_start(function ($buffer) { return ''; }, 1);
include $piwigo_root.'/i.php';

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

@@ -0,0 +1,41 @@
#!/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; }
PRIORITY_FILE="$STATE_DIR/webdav-cache-warmup-priority-sync"
ATTEMPTS=0
MAX_ATTEMPTS=3600
# Der Dispatcher setzt die Prioritätsmarke vor dem Start. Falls bereits ein
# Warmup läuft, beendet der zusätzliche Worker sich wegen seines Prozess-Locks
# sofort und lässt die Marke unangetastet. Wir probieren deshalb erst dann
# erneut, wenn die Marke noch vorhanden ist. Sobald ein Sync-Worker den Lock
# tatsächlich übernimmt, entfernt er die Marke selbst und arbeitet den neuen
# Bestand ab. Dadurch geht die Sofort-Priorität nicht verloren und es gibt nie
# zwei gleichzeitig arbeitende Warmup-Worker derselben Verbindung.
while [[ -f "$PRIORITY_FILE" ]]; do
ATTEMPTS=$((ATTEMPTS + 1))
if [[ "$ATTEMPTS" -gt "$MAX_ATTEMPTS" ]]; then
echo "Priorisierter WebDAV-Warmup wartet seit zu langer Zeit auf den aktiven Worker." >&2
exit 3
fi
set +e
"$@"
result=$?
set -e
if [[ "$result" -ne 0 ]]; then
exit "$result"
fi
[[ -f "$PRIORITY_FILE" ]] || exit 0
sleep 1
done
exit 0

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,844 @@
<?php
if (PHP_SAPI !== 'cli')
{
http_response_code(404);
exit;
}
$piwigo_root = realpath(dirname(__DIR__, 4));
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/lib/webdav-cache-warmup.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Cache-Worker/0.9.7.1.30';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
fwrite(STDERR, "Bratonien Tools ist nicht aktiv.\n");
exit(1);
}
require_once(BRATONIEN_TOOLS_PATH.'include/nc_connector.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_source_index.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_warmup_settings.inc.php');
function bratonien_tools_cache_warmup_log($event, array $fields=array())
{
$parts = array('[BRAT-WORKER]', $event);
foreach ($fields as $key=>$value)
{
if (is_bool($value)) $value = $value ? '1' : '0';
elseif ($value === null) $value = 'NULL';
elseif (is_array($value) || is_object($value)) $value = json_encode($value, JSON_UNESCAPED_SLASHES);
$parts[] = $key.'='.str_replace(array("\r", "\n"), array('\\r', '\\n'), (string)$value);
}
fwrite(STDOUT, implode(' ', $parts)."\n");
}
function bratonien_tools_cache_warmup_status_file($connection_id)
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.status-'.(int)$connection_id.'.json';
}
function bratonien_tools_cache_warmup_cancel_file($connection_id)
{
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.cancel-'.(int)$connection_id;
}
function bratonien_tools_cache_warmup_cancel_pending($connection_id)
{
return is_file(bratonien_tools_cache_warmup_cancel_file($connection_id));
}
function bratonien_tools_cache_warmup_priority_file($state_dir)
{
return rtrim((string)$state_dir, '/').'/webdav-cache-warmup-priority-sync';
}
function bratonien_tools_cache_warmup_priority_pending($priority_file)
{
return is_string($priority_file) && $priority_file !== '' && is_file($priority_file);
}
function bratonien_tools_cache_warmup_write_json($file, array $payload)
{
$directory = dirname($file);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory))
{
throw new RuntimeException('Worker-Statusverzeichnis konnte nicht angelegt werden.');
}
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false) throw new RuntimeException('Worker-Status konnte nicht serialisiert werden.');
$tmp = $file.'.tmp-'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $json."\n", LOCK_EX) === false) throw new RuntimeException('Worker-Status konnte nicht geschrieben werden.');
@chmod($tmp, 0664);
if (!@rename($tmp, $file))
{
@unlink($tmp);
throw new RuntimeException('Worker-Status konnte nicht atomar gespeichert werden.');
}
}
function bratonien_tools_cache_warmup_status($connection_id, $state, $message, array $extra=array())
{
bratonien_tools_cache_warmup_write_json(
bratonien_tools_cache_warmup_status_file($connection_id),
array_merge(array(
'state'=>$state,
'message'=>$message,
'connection_id'=>(int)$connection_id,
'updated_at'=>time(),
), $extra)
);
}
function bratonien_tools_cache_warmup_credentials($connection_id)
{
$connection = bratonien_tools_nc_connector_connection((int)$connection_id, true);
if (!$connection || empty($connection['enabled'])) return null;
$plain = bratonien_tools_nc_connector_decrypt_secret((string)($connection['secret_blob'] ?? ''));
$secret = json_decode($plain, true);
if (!is_array($secret)) return null;
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$base_url = rtrim((string)($config['nextcloud_url'] ?? ''), '/');
$user = trim((string)($secret['nextcloud_user'] ?? ''));
$password = (string)($secret['nextcloud_password'] ?? '');
$state_dir = rtrim((string)($config['state_dir'] ?? ''), '/');
$gallery_root = rtrim((string)($config['parallel_gallery_root'] ?? ''), '/');
if ($base_url === '' || $user === '' || $password === '' || $state_dir === '' || $gallery_root === '') return null;
return array(
'connection'=>$connection,
'config'=>$config,
'base_url'=>$base_url,
'user'=>$user,
'password'=>$password,
'state_dir'=>$state_dir,
'gallery_root'=>$gallery_root,
);
}
function bratonien_tools_cache_warmup_load_json($file, $label)
{
if (!is_file($file) || !is_readable($file)) throw new RuntimeException($label.' fehlt oder ist nicht lesbar: '.$file);
$raw = @file_get_contents($file);
$data = $raw !== false ? json_decode($raw, true) : null;
if (!is_array($data)) throw new RuntimeException($label.' enthält kein gültiges JSON.');
return $data;
}
function bratonien_tools_cache_warmup_normalize_path($path)
{
return rtrim(str_replace('\\', '/', (string)$path), '/');
}
function bratonien_tools_cache_warmup_scan($connection_id, array $credentials)
{
$state_dir = $credentials['state_dir'];
$gallery_root = $credentials['gallery_root'];
$mapping = bratonien_tools_cache_warmup_load_json($state_dir.'/webdav-map.json', 'WebDAV-Mapping');
$files = isset($mapping['files']) && is_array($mapping['files']) ? $mapping['files'] : array();
if (!$files) throw new RuntimeException('WebDAV-Mapping enthält keine Quellen.');
if (!is_dir($gallery_root)) throw new RuntimeException('Shadow-Tree fehlt: '.$gallery_root);
$by_source = array();
foreach ($files as $path=>$meta)
{
if (!is_array($meta) || (string)($meta['kind'] ?? '') !== 'file') continue;
$normalized = bratonien_tools_cache_warmup_normalize_path($path);
$by_source[$normalized] = $meta;
}
$sources = array();
$seen_targets = array();
$gallery_prefix = bratonien_tools_cache_warmup_normalize_path(realpath($gallery_root) ?: $gallery_root).'/';
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($gallery_root, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $item)
{
$shadow_absolute = $item->getPathname();
if (!is_link($shadow_absolute)) continue;
$resolved = realpath($shadow_absolute);
if ($resolved === false || !is_file($resolved)) throw new RuntimeException('Shadow-Link zeigt auf keine lesbare Quelle: '.$shadow_absolute);
$resolved = bratonien_tools_cache_warmup_normalize_path($resolved);
if (!isset($by_source[$resolved])) throw new RuntimeException('Shadow-Link besitzt keinen Eintrag im Connector-Mapping: '.$shadow_absolute);
if (isset($seen_targets[$resolved])) throw new RuntimeException('Sicherheitsabbruch: dieselbe Connector-Quelle ist mehrfach im Shadow-Tree verknüpft: '.$resolved);
$seen_targets[$resolved] = true;
$shadow_normalized = bratonien_tools_cache_warmup_normalize_path($shadow_absolute);
if (strpos($shadow_normalized.'/', $gallery_prefix) !== 0) throw new RuntimeException('Shadow-Datei liegt außerhalb der erwarteten Galerie-Wurzel.');
$shadow_relative = ltrim(substr($shadow_normalized, strlen(rtrim($gallery_prefix, '/'))), '/');
if ($shadow_relative === '') throw new RuntimeException('Leerer relativer Shadow-Pfad erkannt.');
$meta = $by_source[$resolved];
$root_fileid = 0;
if (preg_match('#/root-([0-9]+)/#', $resolved.'/', $m)) $root_fileid = (int)$m[1];
if ($root_fileid < 1) throw new RuntimeException('Connector-Wurzel konnte aus der Placeholder-Quelle nicht bestimmt werden: '.$resolved);
$source = array(
'connection_id'=>(int)$connection_id,
'root_fileid'=>$root_fileid,
'fileid'=>(int)($meta['fileid'] ?? 0),
'webdav_path'=>trim((string)($meta['webdav_path'] ?? ''), '/'),
'content_type'=>(string)($meta['content_type'] ?? ''),
'size'=>(int)($meta['size'] ?? 0),
'etag'=>(string)($meta['etag'] ?? ''),
'width'=>(int)($meta['width'] ?? 0),
'height'=>(int)($meta['height'] ?? 0),
);
if ($source['webdav_path'] === '') throw new RuntimeException('Connector-Mapping enthält für '.$shadow_relative.' keinen WebDAV-Pfad.');
$key = bratonien_tools_webdav_source_index_key($source);
if (isset($sources[$key])) throw new RuntimeException('Doppelte Quellenidentität im Shadow-Tree: '.$key);
$sources[$key] = array(
'index_key'=>$key,
'signature'=>bratonien_tools_webdav_source_index_signature($source),
'source'=>$source,
'source_path'=>$resolved,
'shadow_absolute'=>$shadow_absolute,
'shadow_relative'=>$shadow_relative,
);
}
return array('sources'=>$sources);
}
function bratonien_tools_cache_warmup_select(array $scan, array $index, $mode)
{
if ($mode === 'rebuild') return $scan['sources'];
$selected = array();
foreach ($scan['sources'] as $key=>$item)
{
$entry = isset($index['sources'][$key]) && is_array($index['sources'][$key]) ? $index['sources'][$key] : null;
if ($entry === null || !bratonien_tools_webdav_source_index_is_current($entry, $item['signature'])) $selected[$key] = $item;
}
return $selected;
}
function bratonien_tools_cache_warmup_quote_path($path)
{
$parts = array_values(array_filter(explode('/', trim((string)$path, '/')), 'strlen'));
return implode('/', array_map('rawurlencode', $parts));
}
function bratonien_tools_cache_warmup_download(array $source, array $credentials, $destination, &$detail=null)
{
$detail = '';
$webdav_path = trim((string)($source['webdav_path'] ?? ''), '/');
if ($webdav_path === '') { $detail = 'WebDAV-Pfad fehlt.'; return false; }
$fp = @fopen($destination, 'xb');
if (!$fp) { $detail = 'Temporäre Quelldatei konnte nicht angelegt werden.'; return false; }
$url = $credentials['base_url'].'/remote.php/dav/files/'.rawurlencode($credentials['user']).'/'.bratonien_tools_cache_warmup_quote_path($webdav_path);
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_FOLLOWLOCATION=>false,
CURLOPT_CONNECTTIMEOUT=>10,
CURLOPT_TIMEOUT=>300,
CURLOPT_HTTPAUTH=>CURLAUTH_BASIC,
CURLOPT_USERPWD=>$credentials['user'].':'.$credentials['password'],
CURLOPT_RETURNTRANSFER=>false,
CURLOPT_FAILONERROR=>false,
CURLOPT_FILE=>$fp,
CURLOPT_USERAGENT=>'Bratonien-WebDAV-Cache-Worker/0.9.7.1.30',
));
$ok = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
if ($ok === false || $errno !== 0 || $http < 200 || $http >= 300)
{
@unlink($destination);
$detail = 'Nextcloud-Download fehlgeschlagen (HTTP '.$http.', cURL '.$errno.($error !== '' ? ': '.$error : '').').';
return false;
}
if (!is_file($destination) || filesize($destination) < 1 || @getimagesize($destination) === false)
{
@unlink($destination);
$detail = 'Geladene Datei ist kein lesbares vollständiges Bild.';
return false;
}
return true;
}
function bratonien_tools_cache_warmup_sync_guard(array $credentials, &$handle=null, &$detail=null)
{
$handle = null;
$detail = '';
$handle = @fopen($credentials['state_dir'].'/webdav-sync.lock', 'c');
if (!$handle) { $detail = 'Connector-Sync-Lock konnte nicht geöffnet werden.'; return false; }
if (!@flock($handle, LOCK_SH | LOCK_NB))
{
fclose($handle);
$handle = null;
$detail = 'Connector-Synchronisierung läuft.';
return false;
}
return true;
}
function bratonien_tools_cache_warmup_source_lock($index_key, &$handle=null)
{
$dir = PHPWG_ROOT_PATH.'upload/bratonien-webdav-materialize';
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) return false;
$handle = @fopen($dir.'/source-'.sha1((string)$index_key).'.lock', 'c');
if (!$handle) return false;
return @flock($handle, LOCK_EX);
}
function bratonien_tools_cache_warmup_unlock(&$handle)
{
if (is_resource($handle))
{
@flock($handle, LOCK_UN);
fclose($handle);
}
$handle = null;
}
function bratonien_tools_cache_warmup_fresh_source(array $item, array $credentials)
{
$mapping = bratonien_tools_cache_warmup_load_json($credentials['state_dir'].'/webdav-map.json', 'WebDAV-Mapping');
$files = isset($mapping['files']) && is_array($mapping['files']) ? $mapping['files'] : array();
$wanted = bratonien_tools_cache_warmup_normalize_path($item['source_path']);
foreach ($files as $path=>$meta)
{
if (bratonien_tools_cache_warmup_normalize_path($path) !== $wanted || !is_array($meta)) continue;
$source = array(
'connection_id'=>(int)$item['source']['connection_id'],
'root_fileid'=>(int)$item['source']['root_fileid'],
'fileid'=>(int)($meta['fileid'] ?? 0),
'webdav_path'=>trim((string)($meta['webdav_path'] ?? ''), '/'),
'content_type'=>(string)($meta['content_type'] ?? ''),
'size'=>(int)($meta['size'] ?? 0),
'etag'=>(string)($meta['etag'] ?? ''),
'width'=>(int)($meta['width'] ?? 0),
'height'=>(int)($meta['height'] ?? 0),
);
return $source;
}
return null;
}
function bratonien_tools_cache_warmup_restore($source_path, $backup, $staging, $original_size, $original_hash, &$detail=null)
{
$detail = '';
@unlink($staging);
if (is_file($source_path) || is_link($source_path))
{
if (!@unlink($source_path)) { $detail = 'Temporäre Quelle konnte nicht entfernt werden.'; return false; }
}
if (!is_file($backup)) { $detail = 'Placeholder-Backup fehlt.'; return false; }
if (!@rename($backup, $source_path)) { $detail = 'Placeholder konnte nicht restauriert werden.'; return false; }
clearstatcache(true, $source_path);
if (!is_file($source_path)) { $detail = 'Restaurierter Placeholder fehlt.'; return false; }
if ((int)filesize($source_path) !== (int)$original_size) { $detail = 'Restaurierter Placeholder hat eine unerwartete Größe.'; return false; }
$hash = @sha1_file($source_path);
if (!is_string($hash) || !hash_equals($original_hash, $hash)) { $detail = 'Restaurierter Placeholder stimmt nicht mit dem Originalzustand überein.'; return false; }
return true;
}
function bratonien_tools_cache_warmup_call_piwigo(array $item, $stage, &$detail=null)
{
$detail = '';
$bridge = BRATONIEN_TOOLS_PATH.'runtime/lib/piwigo-cache-request-by-shadow.php';
if (!is_file($bridge)) { $detail = 'Piwigo-Shadow-Bridge fehlt.'; return false; }
if (!function_exists('exec')) { $detail = 'PHP exec() ist deaktiviert.'; return false; }
$output = array();
$exit = 1;
$command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($bridge)
.' --connection-id='.(int)$item['source']['connection_id']
.' --shadow-relative='.escapeshellarg($item['shadow_relative'])
.' --stage='.(int)$stage.' 2>&1';
@exec($command, $output, $exit);
if ($exit !== 0)
{
$detail = $output ? implode(' ', array_slice($output, -4)) : 'Piwigo-Shadow-Bridge Exit '.$exit;
return false;
}
$detail = $output ? (string)end($output) : '';
return true;
}
function bratonien_tools_cache_warmup_process(array $item, $temp_file, array $credentials, $stage)
{
$source_lock = null;
if (!bratonien_tools_cache_warmup_source_lock($item['index_key'], $source_lock))
{
return array('ok'=>false, 'defer'=>true, 'message'=>'Quellen-Lock konnte nicht gesetzt werden.');
}
$sync_lock = null;
$detail = '';
if (!bratonien_tools_cache_warmup_sync_guard($credentials, $sync_lock, $detail))
{
bratonien_tools_cache_warmup_unlock($source_lock);
return array('ok'=>false, 'defer'=>true, 'message'=>$detail);
}
$fresh = bratonien_tools_cache_warmup_fresh_source($item, $credentials);
if (!$fresh || bratonien_tools_webdav_source_index_signature($fresh) !== $item['signature'])
{
bratonien_tools_cache_warmup_unlock($sync_lock);
bratonien_tools_cache_warmup_unlock($source_lock);
return array('ok'=>false, 'defer'=>true, 'message'=>'Connector-Mapping hat sich seit dem Batch-Download geändert.');
}
if (!is_link($item['shadow_absolute']) || bratonien_tools_cache_warmup_normalize_path(realpath($item['shadow_absolute']) ?: '') !== bratonien_tools_cache_warmup_normalize_path($item['source_path']))
{
bratonien_tools_cache_warmup_unlock($sync_lock);
bratonien_tools_cache_warmup_unlock($source_lock);
return array('ok'=>false, 'defer'=>true, 'message'=>'Shadow-Tree hat sich seit dem Batch-Download geändert.');
}
$source_path = $item['source_path'];
if (!is_file($source_path) || !is_readable($source_path) || !is_file($temp_file) || @getimagesize($temp_file) === false)
{
bratonien_tools_cache_warmup_unlock($sync_lock);
bratonien_tools_cache_warmup_unlock($source_lock);
return array('ok'=>false, 'message'=>'Placeholder oder Batch-Quelle ist nicht vollständig lesbar.');
}
$placeholder_size = filesize($source_path);
$placeholder_hash = @sha1_file($source_path);
if (!is_string($placeholder_hash))
{
bratonien_tools_cache_warmup_unlock($sync_lock);
bratonien_tools_cache_warmup_unlock($source_lock);
return array('ok'=>false, 'fatal'=>true, 'message'=>'Placeholder konnte vor dem Swap nicht verifiziert werden.');
}
$request_id = substr(bin2hex(random_bytes(8)), 0, 8);
$backup = $source_path.'.bratonien-placeholder-'.$request_id;
$staging = $source_path.'.bratonien-source-'.$request_id;
if (file_exists($backup) || file_exists($staging))
{
bratonien_tools_cache_warmup_unlock($sync_lock);
bratonien_tools_cache_warmup_unlock($source_lock);
return array('ok'=>false, 'fatal'=>true, 'message'=>'Unerwartete Swap-Datei ist bereits vorhanden.');
}
if (!@copy($temp_file, $staging) || @getimagesize($staging) === false)
{
@unlink($staging);
bratonien_tools_cache_warmup_unlock($sync_lock);
bratonien_tools_cache_warmup_unlock($source_lock);
return array('ok'=>false, 'message'=>'Batch-Quelle konnte nicht sicher neben dem Placeholder bereitgestellt werden.');
}
$swapped = false;
try
{
if (!@rename($source_path, $backup)) throw new RuntimeException('Placeholder konnte nicht atomar gesichert werden.');
if (!@rename($staging, $source_path))
{
@rename($backup, $source_path);
throw new RuntimeException('Temporäre Quelle konnte nicht atomar eingesetzt werden.');
}
$swapped = true;
clearstatcache(true, $source_path);
if (@getimagesize($source_path) === false) throw new RuntimeException('Shadow-Quelle enthält nach dem Swap kein lesbares Bild.');
$bridge_detail = '';
if (!bratonien_tools_cache_warmup_call_piwigo($item, $stage, $bridge_detail))
{
throw new RuntimeException('Piwigo-Verarbeitung fehlgeschlagen: '.$bridge_detail);
}
}
catch (Throwable $e)
{
$restore_detail = '';
$restored = $swapped
? bratonien_tools_cache_warmup_restore($source_path, $backup, $staging, $placeholder_size, $placeholder_hash, $restore_detail)
: true;
if (!$swapped) @unlink($staging);
bratonien_tools_cache_warmup_unlock($sync_lock);
bratonien_tools_cache_warmup_unlock($source_lock);
if (!$restored) return array('ok'=>false, 'fatal'=>true, 'message'=>$e->getMessage().' RESTORE FEHLGESCHLAGEN: '.$restore_detail);
return array('ok'=>false, 'message'=>$e->getMessage());
}
$restore_detail = '';
$restored = bratonien_tools_cache_warmup_restore($source_path, $backup, $staging, $placeholder_size, $placeholder_hash, $restore_detail);
bratonien_tools_cache_warmup_unlock($sync_lock);
bratonien_tools_cache_warmup_unlock($source_lock);
if (!$restored) return array('ok'=>false, 'fatal'=>true, 'message'=>'RESTORE FEHLGESCHLAGEN: '.$restore_detail);
return array('ok'=>true, 'message'=>'Piwigo hat den Shadow-Pfad verarbeitet.');
}
function bratonien_tools_cache_warmup_stage_pending(array $selected, array $index, $stage)
{
$pending = array();
$field = $stage === 1 ? 'stage1_signature' : 'stage2_signature';
foreach ($selected as $key=>$item)
{
$entry = isset($index['sources'][$key]) && is_array($index['sources'][$key]) ? $index['sources'][$key] : array();
if (!isset($entry[$field]) || !hash_equals((string)$entry[$field], $item['signature'])) $pending[$key] = $item;
}
return $pending;
}
function bratonien_tools_cache_warmup_stage_completed_count(array $selected, array $index, $stage)
{
return count($selected) - count(bratonien_tools_cache_warmup_stage_pending($selected, $index, $stage));
}
function bratonien_tools_cache_warmup_run_stage($connection_id, $stage, array $selected, array &$index, array $credentials, $batch_size, $priority_file='')
{
$pending = bratonien_tools_cache_warmup_stage_pending($selected, $index, $stage);
if (!$pending) return array('failed'=>0, 'deferred'=>0, 'waiting'=>false, 'preempted'=>false, 'cancelled'=>false, 'completed'=>0);
if (bratonien_tools_cache_warmup_cancel_pending($connection_id))
{
return array('failed'=>0, 'deferred'=>0, 'waiting'=>false, 'preempted'=>false, 'cancelled'=>true, 'completed'=>0);
}
if ($stage === 2 && bratonien_tools_cache_warmup_priority_pending($priority_file))
{
return array('failed'=>0, 'deferred'=>0, 'waiting'=>false, 'preempted'=>true, 'cancelled'=>false, 'completed'=>0);
}
$temp_root = PHPWG_ROOT_PATH.'upload/bratonien-webdav-warmup';
if (!is_dir($temp_root) && !@mkdir($temp_root, 0775, true) && !is_dir($temp_root)) throw new RuntimeException('Worker-Temp-Verzeichnis konnte nicht angelegt werden.');
$failed = 0;
$deferred = 0;
$completed = 0;
$batch_number = 0;
foreach (array_chunk(array_keys($pending), max(1, (int)$batch_size)) as $keys)
{
// Harte Batch-Grenze: Sobald ein Abbruchsignal existiert, darf dieser
// Batch gar nicht mehr begonnen und damit auch kein weiteres Original
// aus Nextcloud geladen werden.
if (bratonien_tools_cache_warmup_cancel_pending($connection_id))
{
return array('failed'=>$failed, 'deferred'=>$deferred, 'waiting'=>false, 'preempted'=>false, 'cancelled'=>true, 'completed'=>$completed);
}
if ($stage === 2 && bratonien_tools_cache_warmup_priority_pending($priority_file))
{
return array('failed'=>$failed, 'deferred'=>$deferred, 'waiting'=>false, 'preempted'=>true, 'cancelled'=>false, 'completed'=>$completed);
}
// Der Connector-Lock wird VOR jedem Download geprueft und fuer den
// kompletten Batch gemeinsam gehalten. Ist der Connector aktiv, werden
// exakt null Bilder dieses Batches geladen.
$batch_sync_lock = null;
$batch_sync_detail = '';
if (!bratonien_tools_cache_warmup_sync_guard($credentials, $batch_sync_lock, $batch_sync_detail))
{
bratonien_tools_cache_warmup_status($connection_id, 'waiting', 'Connector-Synchronisierung läuft. Der nächste Batch startet erst danach; es wurden für diesen Batch keine Nextcloud-Originale geladen.', array(
'stage'=>$stage,
'batch'=>$batch_number + 1,
'selected_total'=>count($selected),
'stage_completed'=>$completed,
'deferred_by_connector'=>true,
));
return array('failed'=>$failed, 'deferred'=>$deferred, 'waiting'=>true, 'preempted'=>false, 'cancelled'=>false, 'completed'=>$completed);
}
$batch_number++;
$dir = $temp_root.'/connection-'.(int)$connection_id.'-stage'.$stage.'-'.date('YmdHis').'-'.bin2hex(random_bytes(4));
if (!@mkdir($dir, 0775, true) && !is_dir($dir))
{
bratonien_tools_cache_warmup_unlock($batch_sync_lock);
throw new RuntimeException('Worker-Batch-Verzeichnis konnte nicht angelegt werden.');
}
$downloads = array();
foreach ($keys as $key)
{
$file = $dir.'/'.sha1($key).'.img';
$detail = '';
if (bratonien_tools_cache_warmup_download($pending[$key]['source'], $credentials, $file, $detail)) $downloads[$key] = $file;
else
{
$failed++;
bratonien_tools_cache_warmup_log('download_failed', array('stage'=>$stage, 'source'=>$key, 'detail'=>$detail));
}
}
$cancel_requested = bratonien_tools_cache_warmup_cancel_pending($connection_id);
bratonien_tools_cache_warmup_status(
$connection_id,
$cancel_requested ? 'cancelling' : 'running',
$cancel_requested
? 'Abbruch angefordert. Der bereits geladene aktuelle Batch wird vollständig beendet; danach ist Schluss.'
: 'Piwigo verarbeitet WebDAV-Quellen aus dem Worker-Index.',
array(
'stage'=>$stage,
'batch'=>$batch_number,
'batch_requested'=>count($keys),
'batch_downloaded'=>count($downloads),
'selected_total'=>count($selected),
'stage_completed'=>$completed,
'cancel_requested'=>$cancel_requested,
)
);
foreach ($downloads as $key=>$file)
{
$result = bratonien_tools_cache_warmup_process($pending[$key], $file, $credentials, $stage);
bratonien_tools_cache_warmup_log('source', array('stage'=>$stage, 'source'=>$key, 'ok'=>!empty($result['ok']), 'defer'=>!empty($result['defer']), 'message'=>(string)($result['message'] ?? '')));
if (!empty($result['fatal']))
{
foreach ($downloads as $cleanup) @unlink($cleanup);
@rmdir($dir);
bratonien_tools_cache_warmup_unlock($batch_sync_lock);
throw new RuntimeException('Sicherheitsabbruch bei '.$key.': '.(string)$result['message']);
}
if (empty($result['ok']))
{
// Temporäre Sperren oder parallel belegte Quellen sind kein Fehler.
// Der Index bleibt offen und der nächste Lauf versucht nur diese
// Quelle erneut.
if (!empty($result['defer'])) $deferred++;
else $failed++;
continue;
}
$item = $pending[$key];
$existing = isset($index['sources'][$key]) && is_array($index['sources'][$key]) ? $index['sources'][$key] : array();
$metadata = bratonien_tools_webdav_source_index_metadata($item['source'], $item['shadow_relative']);
$index['sources'][$key] = array_merge($existing, $metadata);
$index['sources'][$key][$stage === 1 ? 'stage1_signature' : 'stage2_signature'] = $item['signature'];
bratonien_tools_webdav_source_index_save($credentials['state_dir'], $connection_id, $index);
$completed++;
}
foreach ($downloads as $file) @unlink($file);
@rmdir($dir);
bratonien_tools_cache_warmup_unlock($batch_sync_lock);
if (bratonien_tools_cache_warmup_cancel_pending($connection_id))
{
return array('failed'=>$failed, 'deferred'=>$deferred, 'waiting'=>false, 'preempted'=>false, 'cancelled'=>true, 'completed'=>$completed);
}
if ($stage === 2 && bratonien_tools_cache_warmup_priority_pending($priority_file))
{
return array('failed'=>$failed, 'deferred'=>$deferred, 'waiting'=>false, 'preempted'=>true, 'cancelled'=>false, 'completed'=>$completed);
}
}
return array('failed'=>$failed, 'deferred'=>$deferred, 'waiting'=>false, 'preempted'=>false, 'cancelled'=>false, 'completed'=>$completed);
}
function bratonien_tools_cache_warmup_run($connection_id, $mode)
{
$settings = bratonien_tools_get_webdav_warmup_settings();
if (!in_array($mode, array('manual','rebuild'), true) && empty($settings['enabled'])) return 0;
$credentials = bratonien_tools_cache_warmup_credentials($connection_id);
if (!$credentials) throw new RuntimeException('WebDAV-Verbindung, Runtime-Pfade oder Zugangsdaten konnten nicht geladen werden.');
$state_dir = $credentials['state_dir'];
if (!is_dir($state_dir) && !@mkdir($state_dir, 0750, true) && !is_dir($state_dir)) throw new RuntimeException('Connector-State-Verzeichnis konnte nicht angelegt werden.');
$priority_file = bratonien_tools_cache_warmup_priority_file($state_dir);
$process_lock = @fopen($state_dir.'/webdav-cache-warmup.lock', 'c');
if (!$process_lock || !@flock($process_lock, LOCK_EX | LOCK_NB))
{
if (is_resource($process_lock)) fclose($process_lock);
bratonien_tools_cache_warmup_log('already_running', array('connection_id'=>$connection_id));
return 0;
}
try
{
if ($mode === 'sync' && is_file($priority_file)) @unlink($priority_file);
$index = bratonien_tools_webdav_source_index_load($state_dir, $connection_id);
$index_created = false;
if ($index === null)
{
$index = bratonien_tools_webdav_source_index_empty($connection_id);
$index_created = true;
}
if ($mode === 'periodic')
{
$last = (int)($index['last_periodic_at'] ?? 0);
$interval = max(1, (int)$settings['periodic_hours']) * 3600;
if ($last > 0 && time() - $last < $interval) return 0;
}
$scan = bratonien_tools_cache_warmup_scan($connection_id, $credentials);
$removed = bratonien_tools_webdav_source_index_prune($index, array_keys($scan['sources']));
$selected = bratonien_tools_cache_warmup_select($scan, $index, $mode);
bratonien_tools_cache_warmup_status($connection_id, 'scan', 'Worker-Index wurde ausschließlich mit dem aktuellen Shadow-Tree abgeglichen.', array(
'mode'=>$mode,
'shadow_sources'=>count($scan['sources']),
'selected'=>count($selected),
'removed_from_index'=>$removed,
'index_created'=>$index_created,
));
if (!$selected)
{
if ($mode === 'periodic' || $mode === 'manual') $index['last_periodic_at'] = time();
bratonien_tools_webdav_source_index_save($state_dir, $connection_id, $index);
@unlink(bratonien_tools_cache_warmup_cancel_file($connection_id));
bratonien_tools_cache_warmup_status($connection_id, 'complete', 'Keine neuen oder geänderten Shadow-Quellen gefunden.', array('mode'=>$mode, 'shadow_sources'=>count($scan['sources'])));
return 0;
}
if ($mode === 'rebuild')
{
foreach ($selected as $key=>$item)
{
if (!isset($index['sources'][$key]) || !is_array($index['sources'][$key])) $index['sources'][$key] = array();
unset($index['sources'][$key]['stage1_signature'], $index['sources'][$key]['stage2_signature']);
}
}
$stage1 = bratonien_tools_cache_warmup_run_stage($connection_id, 1, $selected, $index, $credentials, $settings['batch_size'], $priority_file);
if (!empty($stage1['cancelled']))
{
bratonien_tools_webdav_source_index_save($state_dir, $connection_id, $index);
@unlink(bratonien_tools_cache_warmup_cancel_file($connection_id));
bratonien_tools_cache_warmup_status($connection_id, 'cancelled', 'Cache-Aufbau nach dem vollständig abgeschlossenen aktuellen Batch beendet.', array(
'mode'=>$mode,
'shadow_sources'=>count($scan['sources']),
'selected'=>count($selected),
'stage1_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 1),
'stage2_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 2),
'stage1_failed'=>(int)$stage1['failed'],
'stage1_deferred'=>(int)$stage1['deferred'],
'stage2_failed'=>0,
'stage2_deferred'=>0,
));
return 0;
}
if (!empty($stage1['waiting']))
{
bratonien_tools_webdav_source_index_save($state_dir, $connection_id, $index);
return 0;
}
$stage2_candidates = array();
foreach ($selected as $key=>$item)
{
$entry = isset($index['sources'][$key]) && is_array($index['sources'][$key]) ? $index['sources'][$key] : array();
if (isset($entry['stage1_signature']) && hash_equals((string)$entry['stage1_signature'], $item['signature'])) $stage2_candidates[$key] = $item;
}
$stage2 = bratonien_tools_cache_warmup_run_stage($connection_id, 2, $stage2_candidates, $index, $credentials, $settings['batch_size'], $priority_file);
if (!empty($stage2['cancelled']))
{
bratonien_tools_webdav_source_index_save($state_dir, $connection_id, $index);
@unlink(bratonien_tools_cache_warmup_cancel_file($connection_id));
bratonien_tools_cache_warmup_status($connection_id, 'cancelled', 'Cache-Aufbau nach dem vollständig abgeschlossenen aktuellen Batch beendet.', array(
'mode'=>$mode,
'shadow_sources'=>count($scan['sources']),
'selected'=>count($selected),
'stage1_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 1),
'stage2_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 2),
'stage1_failed'=>(int)$stage1['failed'],
'stage1_deferred'=>(int)$stage1['deferred'],
'stage2_failed'=>(int)$stage2['failed'],
'stage2_deferred'=>(int)$stage2['deferred'],
));
return 0;
}
if (!empty($stage2['waiting']))
{
bratonien_tools_webdav_source_index_save($state_dir, $connection_id, $index);
return 0;
}
if (!empty($stage2['preempted']))
{
bratonien_tools_webdav_source_index_save($state_dir, $connection_id, $index);
bratonien_tools_cache_warmup_status($connection_id, 'preempted', 'Stufe 2 wurde nach einem vollständigen Batch für neue Connector-Inhalte freigegeben.', array(
'mode'=>$mode,
'selected'=>count($selected),
'stage1_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 1),
'stage2_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 2),
'stage1_failed'=>(int)$stage1['failed'],
'stage1_deferred'=>(int)$stage1['deferred'],
'stage2_failed'=>(int)$stage2['failed'],
'stage2_deferred'=>(int)$stage2['deferred'],
));
return 0;
}
if ($mode === 'periodic' || $mode === 'manual') $index['last_periodic_at'] = time();
bratonien_tools_webdav_source_index_save($state_dir, $connection_id, $index);
@unlink(bratonien_tools_cache_warmup_cancel_file($connection_id));
$failed = (int)$stage1['failed'] + (int)$stage2['failed'];
$deferred = (int)$stage1['deferred'] + (int)$stage2['deferred'];
if ($failed > 0 || $deferred > 0)
{
bratonien_tools_cache_warmup_status($connection_id, 'partial', 'Worker-Lauf beendet; einzelne Quellen bleiben offen und werden beim nächsten Lauf erneut versucht. Der übrige Bestand wurde weiterverarbeitet.', array(
'mode'=>$mode,
'shadow_sources'=>count($scan['sources']),
'selected'=>count($selected),
'stage1_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 1),
'stage2_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 2),
'stage1_failed'=>(int)$stage1['failed'],
'stage1_deferred'=>(int)$stage1['deferred'],
'stage2_failed'=>(int)$stage2['failed'],
'stage2_deferred'=>(int)$stage2['deferred'],
));
return 0;
}
bratonien_tools_cache_warmup_status($connection_id, 'complete', $mode === 'rebuild' ? 'Piwigo hat den vollständigen Shadow-Bestand für den Cache-Rebuild verarbeitet.' : 'Worker-Lauf vollständig beendet.', array(
'mode'=>$mode,
'shadow_sources'=>count($scan['sources']),
'selected'=>count($selected),
'stage1_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 1),
'stage2_completed'=>bratonien_tools_cache_warmup_stage_completed_count($selected, $index, 2),
'stage1_failed'=>0,
'stage1_deferred'=>0,
'stage2_failed'=>0,
'stage2_deferred'=>0,
));
return 0;
}
finally
{
@flock($process_lock, LOCK_UN);
fclose($process_lock);
}
}
$connection_id = 0;
$mode = 'periodic';
foreach ($argv as $arg)
{
if (preg_match('/^--connection-id=(\d+)$/', $arg, $m)) $connection_id = (int)$m[1];
elseif (preg_match('/^--mode=(sync|periodic|manual|rebuild)$/', $arg, $m)) $mode = $m[1];
}
if ($connection_id < 1)
{
fwrite(STDERR, "--connection-id fehlt oder ist ungültig.\n");
exit(1);
}
try
{
exit(bratonien_tools_cache_warmup_run($connection_id, $mode));
}
catch (Throwable $e)
{
try { bratonien_tools_cache_warmup_status($connection_id, 'fatal', $e->getMessage()); } catch (Throwable $ignored) {}
fwrite(STDERR, '[BRAT-WORKER] fatal '.$e->getMessage()."\n");
exit(1);
}

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
$connection_id = 0;
foreach ($argv as $arg)
{
if (preg_match('/^--connection-id=(\d+)$/', $arg, $m)) $connection_id = (int)$m[1];
}
if ($connection_id < 1)
{
fwrite(STDERR, "--connection-id fehlt oder ist ungueltig.\n");
exit(2);
}
$piwigo_root = realpath(dirname(__DIR__, 4));
if ($piwigo_root === false)
{
fwrite(STDERR, "Piwigo-Root wurde nicht gefunden.\n");
exit(3);
}
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/lib/webdav-scan-diagnostic.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Scan-Diagnostic/0.9.7.1.21';
$_SERVER['HTTPS'] = 'off';
try
{
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH')) throw new RuntimeException('Bratonien Tools ist nicht aktiv.');
require_once(BRATONIEN_TOOLS_PATH.'include/nc_connector.inc.php');
$connection = bratonien_tools_nc_connector_connection($connection_id, true);
if (!$connection || empty($connection['enabled'])) throw new RuntimeException('WebDAV-Verbindung wurde nicht gefunden oder ist deaktiviert.');
if (!bratonien_tools_nc_connector_is_webdav($connection)) throw new RuntimeException('Verbindung ist keine WebDAV-Placeholder-Verbindung.');
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$state_dir = rtrim((string)($config['state_dir'] ?? ''), '/');
$gallery_root = rtrim((string)($config['parallel_gallery_root'] ?? ''), '/');
if ($state_dir === '') throw new RuntimeException('Connector-State-Verzeichnis fehlt.');
if ($gallery_root === '') throw new RuntimeException('Shadow-Tree-Pfad fehlt in der Connector-Konfiguration.');
$lock_file = $state_dir.'/webdav-cache-warmup.lock';
$worker_lock_state = 'open_failed';
$worker_lock_detail = '';
$lock_handle = @fopen($lock_file, 'c');
if (is_resource($lock_handle))
{
if (@flock($lock_handle, LOCK_EX | LOCK_NB))
{
$worker_lock_state = 'free';
@flock($lock_handle, LOCK_UN);
}
else
{
$worker_lock_state = 'busy';
$worker_lock_detail = 'Ein anderer Prozess haelt den WebDAV-Worker-Lock.';
}
fclose($lock_handle);
}
else
{
$worker_lock_detail = 'Worker-Lock-Datei konnte nicht geoeffnet werden.';
}
$mapping_file = $state_dir.'/webdav-map.json';
if (!is_file($mapping_file) || !is_readable($mapping_file)) throw new RuntimeException('WebDAV-Mapping fehlt oder ist nicht lesbar: '.$mapping_file);
$mapping = json_decode((string)file_get_contents($mapping_file), true);
if (!is_array($mapping) || !isset($mapping['files']) || !is_array($mapping['files'])) throw new RuntimeException('WebDAV-Mapping ist ungueltig.');
if (!is_dir($gallery_root)) throw new RuntimeException('Shadow-Tree fehlt: '.$gallery_root);
$normalize = static function ($path) {
return rtrim(str_replace('\\', '/', (string)$path), '/');
};
$mapped_files = array();
foreach ($mapping['files'] as $path=>$meta)
{
if (!is_array($meta) || (string)($meta['kind'] ?? '') !== 'file') continue;
$mapped_files[$normalize($path)] = $meta;
}
$shadow_links = 0;
$matched = 0;
$broken = 0;
$unmapped = 0;
$duplicates = 0;
$seen = array();
$examples = array();
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($gallery_root, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $item)
{
$shadow = $item->getPathname();
if (!is_link($shadow)) continue;
$shadow_links++;
$resolved = realpath($shadow);
if ($resolved === false || !is_file($resolved))
{
$broken++;
if (count($examples) < 8) $examples[] = array('state'=>'broken','shadow'=>$shadow);
continue;
}
$resolved = $normalize($resolved);
if (!isset($mapped_files[$resolved]))
{
$unmapped++;
if (count($examples) < 8) $examples[] = array('state'=>'unmapped','shadow'=>$shadow,'target'=>$resolved);
continue;
}
if (isset($seen[$resolved]))
{
$duplicates++;
if (count($examples) < 8) $examples[] = array('state'=>'duplicate','shadow'=>$shadow,'target'=>$resolved);
continue;
}
$seen[$resolved] = true;
$matched++;
if (count($examples) < 8)
{
$meta = $mapped_files[$resolved];
$examples[] = array(
'state'=>'matched',
'shadow'=>$shadow,
'target'=>$resolved,
'fileid'=>(int)($meta['fileid'] ?? 0),
'webdav_path'=>(string)($meta['webdav_path'] ?? ''),
'etag'=>(string)($meta['etag'] ?? ''),
);
}
}
$scan_ok = ($shadow_links > 0 && $matched === $shadow_links && $broken === 0 && $unmapped === 0 && $duplicates === 0);
$payload = array(
'ok'=>$scan_ok && $worker_lock_state !== 'open_failed',
'scan_ok'=>$scan_ok,
'connection_id'=>$connection_id,
'state_dir'=>$state_dir,
'gallery_root'=>$gallery_root,
'mapping_file'=>$mapping_file,
'worker_lock_file'=>$lock_file,
'worker_lock_state'=>$worker_lock_state,
'worker_lock_detail'=>$worker_lock_detail,
'mapping_files'=>count($mapped_files),
'shadow_links'=>$shadow_links,
'matched'=>$matched,
'broken'=>$broken,
'unmapped'=>$unmapped,
'duplicates'=>$duplicates,
'examples'=>$examples,
);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)."\n";
exit($payload['ok'] ? 0 : 4);
}
catch (Throwable $e)
{
echo json_encode(array(
'ok'=>false,
'connection_id'=>$connection_id,
'fatal'=>$e->getMessage(),
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)."\n";
exit(1);
}

View File

@@ -0,0 +1,112 @@
<?php
if (PHP_SAPI !== 'cli')
{
http_response_code(404);
exit;
}
$piwigo_root = realpath(dirname(__DIR__, 4));
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/lib/webdav-warmup-audit.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Warmup-Audit/0.9.7.1.8';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
fwrite(STDERR, "Bratonien Tools ist nicht aktiv.\n");
exit(1);
}
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_materialize_runtime.inc.php');
$total = 0;
$connector = 0;
$errors = array();
$warnings = array();
$resolved_to_ids = array();
$result = pwg_query('SELECT id, path FROM '.IMAGES_TABLE.' ORDER BY id');
while ($row = pwg_db_fetch_assoc($result))
{
$total++;
$image_id = (int)$row['id'];
$info = bratonien_tools_webdav_materialize_source_info($image_id);
if (!$info) continue;
$connector++;
$logical = (string)$row['path'];
if ($logical === '')
{
$errors[] = 'Bild #'.$image_id.': Piwigo-Pfad ist leer.';
continue;
}
if (strpos($logical, '/') !== 0)
{
$logical = PHPWG_ROOT_PATH.ltrim(preg_replace('#^\./#', '', $logical), '/');
}
$resolved = realpath($logical);
if ($resolved === false || !is_file($resolved))
{
$errors[] = 'Bild #'.$image_id.': Piwigo-Pfad kann nicht auf eine Datei aufgelöst werden: '.$logical;
continue;
}
$normalized = str_replace('\\', '/', $resolved);
$expected = '/nc-webdav-source/connection-'.(int)$info['connection_id'].'/root-'.(int)$info['root_fileid'].'/';
if (strpos($normalized, $expected) === false)
{
$errors[] = 'Bild #'.$image_id.': realpath liegt nicht in der erwarteten Connector-Wurzel: '.$normalized;
}
$webdav_path = trim((string)($info['webdav_path'] ?? ''), '/');
if ($webdav_path === '')
{
$errors[] = 'Bild #'.$image_id.': WebDAV-Pfad fehlt.';
}
if ((int)($info['connection_id'] ?? 0) < 1 || (int)($info['root_fileid'] ?? 0) < 1)
{
$errors[] = 'Bild #'.$image_id.': Verbindung oder Root-fileid ist ungültig.';
}
if ((int)($info['fileid'] ?? 0) < 1)
{
$warnings[] = 'Bild #'.$image_id.': Nextcloud-fileid fehlt; Original-Fallback bleibt möglich.';
}
if (!isset($resolved_to_ids[$normalized])) $resolved_to_ids[$normalized] = array();
$resolved_to_ids[$normalized][] = $image_id;
}
foreach ($resolved_to_ids as $path => $ids)
{
if (count($ids) > 1)
{
$warnings[] = 'Mehrere Piwigo-Bild-IDs zeigen auf dieselbe physische Connector-Quelle: '.implode(',', $ids).' -> '.$path;
}
}
printf("WebDAV-Warmup-Pfadaudit\n");
printf("Piwigo-Bilder gesamt: %d\n", $total);
printf("Connector-Bilder: %d\n", $connector);
printf("Fehler: %d\n", count($errors));
printf("Warnungen: %d\n", count($warnings));
foreach ($errors as $error) fwrite(STDERR, "ERROR: ".$error."\n");
foreach ($warnings as $warning) fwrite(STDOUT, "WARN: ".$warning."\n");
exit($errors ? 2 : 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

@@ -20,6 +20,7 @@ function decrypt_webdav_credentials($blob, $hexKey)
$iv = base64_decode((string)($payload['iv'] ?? ''), true);
$tag = base64_decode((string)($payload['tag'] ?? ''), true);
$cipher = base64_decode((string)($payload['data'] ?? ''), true);
if (!is_string($iv) || !is_string($tag) || !is_string($cipher)) fail_webdav_reconcile('Connector-Zugangsdaten sind unvollstaendig.');
$plain = openssl_decrypt($cipher, 'aes-256-gcm', hex2bin($hexKey), OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false || $plain === '') fail_webdav_reconcile('Connector-Zugangsdaten konnten nicht entschluesselt werden.');
$decoded = json_decode($plain, true);
@@ -75,11 +76,19 @@ function webdav_source_fingerprint($baseUrl, $user, array $roots)
return hash('sha256', strtolower(rtrim((string)$baseUrl, '/'))."\n".strtolower(trim((string)$user))."\n".json_encode($normalized));
}
function webdav_set_runtime_dir_permissions($dir)
{
if (!is_dir($dir)) return false;
if (!@chgrp($dir, 'www-data')) return false;
return @chmod($dir, 0750);
}
$pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2);
$dbConfig = $piwigoRoot.'/local/config/database.inc.php';
$configDir = '/etc/bratonien-tools/nc-connector';
$stateRoot = '/var/lib/bratonien-tools/nc-connector';
$stateBase = '/var/lib/bratonien-tools';
$stateRoot = $stateBase.'/nc-connector';
$publicSourceRoot = rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-webdav-source';
$publicGalleryRoot = rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-webdav-gallery';
@@ -111,7 +120,8 @@ try
if (!is_dir($dir) && !mkdir($dir, $dir === $configDir ? 0700 : 0750, true)) fail_webdav_reconcile('Runtime-Verzeichnis konnte nicht angelegt werden: '.$dir);
}
@chmod($configDir, 0700);
@chmod($stateRoot, 0750);
if (!webdav_set_runtime_dir_permissions($stateBase)) fail_webdav_reconcile('Runtime-State-Basis konnte nicht fuer den Webserver freigegeben werden.');
if (!webdav_set_runtime_dir_permissions($stateRoot)) fail_webdav_reconcile('Runtime-State-Verzeichnis konnte nicht fuer den Webserver freigegeben werden.');
@chmod($publicSourceRoot, 0755);
@chmod($publicGalleryRoot, 0755);
@@ -151,7 +161,7 @@ try
$stateDir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($stateDir === '') $stateDir = $stateRoot.'/connection-'.$id;
if (!is_dir($stateDir) && !mkdir($stateDir, 0750, true)) fail_webdav_reconcile('State-Verzeichnis konnte nicht angelegt werden.');
@chmod($stateDir, 0750);
if (!webdav_set_runtime_dir_permissions($stateDir)) fail_webdav_reconcile('State-Verzeichnis konnte nicht fuer den Webserver freigegeben werden.');
$galleryRoot = rtrim((string)($config['parallel_gallery_root'] ?? ''), '/');
$expectedGalleryRoot = $publicGalleryRoot.'/connection-'.$id;

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

@@ -31,9 +31,30 @@ result=0
for config in "${webdav_configs[@]}"; do
name="$(basename "$config")"
echo "NC Connector WebDAV: $name"
if ! env PIWIGO_CONFIG="$config" bash "$SCRIPT_DIR/sync-webdav.sh"; then
state_dir="$(PIWIGO_CONFIG="$config" bash -c 'source "$PIWIGO_CONFIG"; printf "%s" "${STATE_DIR:-}"')"
change_file="${state_dir%/}/webdav-sync-content-changed"
if env PIWIGO_CONFIG="$config" bash "$SCRIPT_DIR/sync-webdav.sh"; then
# Nur ein tatsaechlich geaenderter Connector-Bestand darf einen
# laufenden Cache-Rebuild priorisiert unterbrechen. Ein erfolgreicher
# Sync ohne Aenderung ist kein neuer Inhalt.
if [[ -f "$change_file" && -f "$SCRIPT_DIR/webdav-warmup-dispatch.php" && "$name" =~ ^webdav-connection-([0-9]+)\.conf$ ]]; then
connection_id="${BASH_REMATCH[1]}"
php "$SCRIPT_DIR/webdav-warmup-dispatch.php" --mode=sync --connection-id="$connection_id" || result=1
rm -f -- "$change_file"
else
echo "NC Connector WebDAV: keine Inhaltsaenderung, kein priorisierter Warmup erforderlich."
fi
else
result=1
fi
done
# Einzelne neue/geänderte Bilder in bereits bekannten Alben werden weiterhin
# über die periodische Eingangsprüfung behandelt. Der Dispatcher startet die
# eigentliche Prüfung erst, wenn das konfigurierte Intervall fällig ist.
if [[ "$result" -eq 0 && -f "$SCRIPT_DIR/webdav-warmup-dispatch.php" ]]; then
php "$SCRIPT_DIR/webdav-warmup-dispatch.php" --mode=periodic || result=1
fi
exit "$result"

View File

@@ -27,7 +27,20 @@ source "$CONFIG_FILE"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
LOCK_FILE="$STATE_DIR/webdav-sync.lock"
WORKER_LOCK_FILE="$STATE_DIR/webdav-cache-warmup.lock"
CHANGE_FILE="$STATE_DIR/webdav-sync-content-changed"
PRIORITY_FILE="$STATE_DIR/webdav-cache-warmup-priority-sync"
mkdir -p -- "$STATE_DIR"
chgrp www-data -- "$STATE_DIR"
chmod 0750 -- "$STATE_DIR"
# Das Connector-State-Verzeichnis bleibt fuer www-data bewusst nicht
# beschreibbar. Die beiden gemeinsam verwendeten Lock-Dateien werden deshalb
# vom privilegierten Connector explizit angelegt und gruppenschreibbar gemacht.
touch -- "$LOCK_FILE" "$WORKER_LOCK_FILE"
chgrp www-data -- "$LOCK_FILE" "$WORKER_LOCK_FILE"
chmod 0660 -- "$LOCK_FILE" "$WORKER_LOCK_FILE"
exec 9>"$LOCK_FILE"
flock -n 9 || exit 0
@@ -83,14 +96,59 @@ done < "$WEBDAV_ROOTS_FILE"
[[ ${#ROOT_ARGS[@]} -gt 0 ]] || { write_status error "Keine WebDAV-Wurzeln konfiguriert"; exit 1; }
python3 "$SCRIPT_DIR/lib/build_webdav_placeholder_source.py" \
OLD_MAPPING_HASH=""
if [[ -r "$WEBDAV_MAPPING_FILE" ]]; then
OLD_MAPPING_HASH="$(sha256sum -- "$WEBDAV_MAPPING_FILE" | awk '{print $1}')"
fi
# 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 -- {} +
find "$WEBDAV_SOURCE_DIR" -type f -exec chgrp www-data -- {} +
find "$WEBDAV_SOURCE_DIR" -type f -exec chmod 0664 -- {} +
chgrp www-data -- "$WEBDAV_MAPPING_FILE"
chmod 0640 -- "$WEBDAV_MAPPING_FILE"
NEW_MAPPING_HASH="$(sha256sum -- "$WEBDAV_MAPPING_FILE" | awk '{print $1}')"
if [[ -z "$OLD_MAPPING_HASH" || "$OLD_MAPPING_HASH" != "$NEW_MAPPING_HASH" ]]; then
printf '%s\n' "$NEW_MAPPING_HASH" > "$CHANGE_FILE"
chmod 0640 -- "$CHANGE_FILE"
else
rm -f -- "$CHANGE_FILE"
# Eine alte Prioritaetsmarke darf einen laufenden Rebuild nicht abbrechen,
# wenn der gerade abgeschlossene Connector-Sync keinerlei Aenderung
# festgestellt hat.
rm -f -- "$PRIORITY_FILE"
fi
python3 "$SCRIPT_DIR/lib/shadow_tree.py" \
--manifest "$MANIFEST" \
@@ -142,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

@@ -0,0 +1,421 @@
<?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/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.50';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
fwrite(STDERR, "Bratonien Tools ist nicht aktiv.\n");
exit(1);
}
require_once(BRATONIEN_TOOLS_PATH.'include/nc_connector.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_warmup_settings.inc.php');
function bratonien_tools_webdav_dispatch_existing_status($connection_id)
{
if (!function_exists('bratonien_tools_webdav_warmup_status_file_for_connection')) return null;
$file = bratonien_tools_webdav_warmup_status_file_for_connection($connection_id);
if (!is_file($file) || !is_readable($file)) return null;
$raw = @file_get_contents($file);
$data = $raw !== false ? json_decode($raw, true) : null;
return is_array($data) ? $data : null;
}
function bratonien_tools_webdav_dispatch_failure_lines(array $output)
{
$failures = array();
foreach ($output as $line)
{
$line = trim((string)$line);
if ($line === '') continue;
if (
strpos($line, 'download_failed') !== false
|| strpos($line, ' ok=0 ') !== false
|| strpos($line, '[BRAT-WORKER] fatal ') !== false
|| stripos($line, 'RESTORE FEHLGESCHLAGEN') !== false
)
{
$failures[] = $line;
if (count($failures) >= 8) break;
}
}
return $failures;
}
function bratonien_tools_webdav_dispatch_only_connector_deferrals(array $output)
{
$seen = false;
foreach ($output as $line)
{
$line = trim((string)$line);
if ($line === '') continue;
if (strpos($line, '[BRAT-WORKER] fatal ') !== false || strpos($line, 'download_failed') !== false || stripos($line, 'RESTORE FEHLGESCHLAGEN') !== false)
{
return false;
}
if (strpos($line, ' ok=0 ') !== false)
{
$seen = true;
if (strpos($line, 'message=Connector-Synchronisierung läuft.') === false && strpos($line, 'message=Connector-Mapping hat sich seit dem Batch-Download geändert.') === false && strpos($line, 'message=Shadow-Tree hat sich seit dem Batch-Download geändert.') === false)
{
return false;
}
}
}
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)
{
$path = rtrim((string)$state_dir, '/').'/webdav-sync.lock';
$lock = @fopen($path, 'c');
if (!$lock) return false;
if (@flock($lock, LOCK_SH | LOCK_NB))
{
@flock($lock, LOCK_UN);
fclose($lock);
return false;
}
fclose($lock);
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';
$wait = false;
$connection_filter = 0;
foreach ($argv as $arg)
{
if (preg_match('/^--mode=(sync|periodic|manual|rebuild)$/', $arg, $m)) $mode = $m[1];
elseif (preg_match('/^--connection-id=(\d+)$/', $arg, $m)) $connection_filter = (int)$m[1];
elseif ($arg === '--wait') $wait = true;
}
$settings = bratonien_tools_get_webdav_warmup_settings();
if (!in_array($mode, array('manual','rebuild'), true) && empty($settings['enabled']))
{
fwrite(STDOUT, "WebDAV-Worker ist deaktiviert.\n");
exit(0);
}
if (!function_exists('exec'))
{
fwrite(STDERR, "PHP exec() ist deaktiviert; Worker kann nicht gestartet werden.\n");
exit(1);
}
$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");
exit(1);
}
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;
$started = 0;
$matched = 0;
$run_inline = $wait || in_array($mode, array('manual','rebuild'), true);
foreach (bratonien_tools_nc_connector_connections() as $connection)
{
if (empty($connection['enabled']) || !bratonien_tools_nc_connector_is_webdav($connection)) continue;
$connection_id = (int)$connection['id'];
if ($connection_id < 1) continue;
if ($connection_filter > 0 && $connection_id !== $connection_filter) continue;
$matched++;
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$state_dir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($state_dir === '')
{
fwrite(STDERR, "Worker für Verbindung #{$connection_id} übersprungen: Connector-State-Verzeichnis fehlt.\n");
$result = 1;
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))
{
fwrite(STDERR, "Worker-Priorität für Verbindung #{$connection_id} konnte nicht signalisiert werden: State-Verzeichnis fehlt.\n");
$result = 1;
continue;
}
$priority_file = $state_dir.'/webdav-cache-warmup-priority-sync';
$priority_payload = json_encode(array(
'connection_id'=>$connection_id,
'requested_at'=>time(),
'reason'=>'connector-sync-complete',
), JSON_UNESCAPED_SLASHES);
if (!is_string($priority_payload) || @file_put_contents($priority_file, $priority_payload."\n", LOCK_EX) === false)
{
fwrite(STDERR, "Worker-Priorität für Verbindung #{$connection_id} konnte nicht geschrieben werden.\n");
$result = 1;
continue;
}
@chmod($priority_file, 0664);
}
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,
$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)
);
}
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;
}
$base = $mode === 'sync'
? escapeshellarg('/bin/bash').' '.escapeshellarg($priority_waiter).' '.escapeshellarg($state_dir).' '.$worker_command
: $worker_command;
if ($run_inline)
{
$output = array();
$exit = 1;
@exec($base.' 2>&1', $output, $exit);
foreach ($output as $line)
{
fwrite(STDOUT, $line."\n");
@file_put_contents($log, $line."\n", FILE_APPEND | LOCK_EX);
}
if ($exit !== 0)
{
$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);
$extra = is_array($existing) ? $existing : array();
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,
$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;
}
$result = $exit;
if (function_exists('bratonien_tools_webdav_warmup_write_status'))
{
$existing = bratonien_tools_webdav_dispatch_existing_status($connection_id);
$failures = bratonien_tools_webdav_dispatch_failure_lines($output);
$terminal = is_array($existing) && in_array((string)($existing['state'] ?? ''), array('error','fatal','preempted','cancelled'), true);
if ($terminal)
{
$message = trim((string)($existing['message'] ?? 'WebDAV-Worker wurde mit Fehlern beendet.'));
if ($failures)
{
$message .= ' Fehlerbeispiele: '.implode(' | ', $failures);
}
$extra = $existing;
unset($extra['state'], $extra['message'], $extra['connection_id'], $extra['updated_at']);
$extra['worker_exit_code'] = (int)$exit;
$extra['dispatcher_preserved_worker_status'] = true;
if ($failures) $extra['failure_examples'] = $failures;
bratonien_tools_webdav_warmup_write_status(
$connection_id,
(string)$existing['state'],
$message,
$extra
);
}
else
{
$detail = $failures
? implode(' | ', $failures)
: ($output ? implode(' ', array_slice($output, -8)) : 'keine Worker-Ausgabe');
bratonien_tools_webdav_warmup_write_status(
$connection_id,
'error',
'WebDAV-Worker Exit '.(int)$exit.'. Ursache: '.$detail,
array('mode'=>$mode, 'worker_exit_code'=>(int)$exit, 'failure_examples'=>$failures)
);
}
}
}
else
{
$started++;
}
}
else
{
$command = 'nohup '.$base.' >> '.escapeshellarg($log).' 2>&1 < /dev/null & echo $!';
$output = array();
$exit = 1;
@exec($command, $output, $exit);
$pid = isset($output[0]) ? (int)$output[0] : 0;
if ($exit !== 0 || $pid <= 0)
{
fwrite(STDERR, "Worker für Verbindung #{$connection_id} konnte nicht gestartet werden.\n");
$result = 1;
continue;
}
$started++;
fwrite(STDOUT, "WebDAV-Worker {$mode} für Verbindung #{$connection_id} gestartet (PID {$pid}).\n");
}
}
if ($connection_filter > 0 && $matched === 0)
{
fwrite(STDERR, "WebDAV-Verbindung #{$connection_filter} wurde nicht als aktive Connector-Verbindung gefunden.\n");
exit(1);
}
if (!$run_inline) fwrite(STDOUT, "Gestartete WebDAV-Worker: {$started}.\n");
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

@@ -0,0 +1,50 @@
<div id="bratonien-webdav-warmup-card" class="bratonien-card" style="margin-top:16px;">
<h4>WebDAV-Quellenindex &amp; Cache-Warmup</h4>
<p>Bratonien Tools vergleicht den aktuellen Shadow-Tree mit einem eigenen kompakten Quellenindex. Nur neue, geänderte oder noch nicht vollständig verarbeitete Quellen werden an Piwigo übergeben. Der Piwigo-Bildcache selbst entscheidet nicht, ob eine Quelle neu ist.</p>
<p class="bratonien-main-cache__warning"><strong>Diagnose:</strong> Der Scan-Test startet synchron eine eigene PHP-CLI-Prüfung und liest ausschließlich Connector-Konfiguration, <code>webdav-map.json</code> und den Shadow-Tree. Er schreibt keinen Index, lädt keine Originale und erzeugt keine Derivate. Damit sehen wir direkt, ob der Scan-Prozess überhaupt starten kann und wie viele Shadow-Links er tatsächlich findet.</p>
<form method="post" class="bratonien-worker-settings">
<input type="hidden" name="pwg_token" value="{$PWG_TOKEN|escape:html}">
<label><input type="checkbox" name="webdav_warmup_enabled" value="1" {if $CACHE_WORKERS.webdav_warmup.enabled}checked{/if}> Automatischen WebDAV-Warmup aktivieren</label>
<label>Bilder pro Batch: <input type="number" name="webdav_warmup_batch_size" value="{$CACHE_WORKERS.webdav_warmup.batch_size}" min="1" max="50" step="1"></label>
<label>Index-Abgleich: <input type="number" name="webdav_warmup_periodic_hours" value="{$CACHE_WORKERS.webdav_warmup.periodic_hours}" min="1" max="168" step="1"> Stunden</label>
<button class="buttonLike" type="submit" name="bratonien_tool" value="image_cache_webdav_warmup_settings">Warmup-Einstellungen speichern</button>
</form>
<div class="bratonien-form-grid" style="margin-top:12px;">
<span class="bratonien-label">Änderungserkennung</span><span>Shadow-Tree ↔ eigener Quellenindex; Piwigo-Index und Cache-Dateien sind dafür nicht maßgeblich</span>
<span class="bratonien-label">Stufe 1</span><span>Von Piwigo definierte Derivate bis einschließlich 1920 px längster Kante</span>
<span class="bratonien-label">Stufe 2</span><span>Danach alle übrigen von Piwigo definierten Derivate</span>
<span class="bratonien-label">Letzter Status</span><span><strong>{$CACHE_WORKERS.webdav_warmup.status.state|default:'idle'|escape:html}</strong>{if $CACHE_WORKERS.webdav_warmup.status.message} · {$CACHE_WORKERS.webdav_warmup.status.message|escape:html}{/if}</span>
</div>
<div class="bratonien-actions">
<form method="post">
<input type="hidden" name="pwg_token" value="{$PWG_TOKEN|escape:html}">
<button class="buttonLike" type="submit" name="bratonien_tool" value="image_cache_webdav_scan_diagnostic">Scan jetzt testen</button>
</form>
<form method="post">
<input type="hidden" name="pwg_token" value="{$PWG_TOKEN|escape:html}">
<button class="buttonLike" type="submit" name="bratonien_tool" value="image_cache_webdav_warmup_manual">Quellenindex jetzt abgleichen</button>
</form>
<form method="post">
<input type="hidden" name="pwg_token" value="{$PWG_TOKEN|escape:html}">
<button class="buttonLike" type="submit" name="bratonien_tool" value="image_cache_webdav_warmup_audit">Produktive Pfade prüfen</button>
</form>
</div>
<p class="bratonien-base-note">Der Scan-Test und der Pfadaudit sind schreibgeschützt. Warmup und On-Demand verwenden denselben Bild-Lock; während der produktiven Materialisierung wird zusätzlich der Connector-Sync-Lock geteilt gehalten, damit Source- und Shadow-Tree nicht ausgetauscht werden können.</p>
</div>
{literal}
<script>
(function(){
function moveWarmupCard(){
var card=document.getElementById('bratonien-webdav-warmup-card');
var section=document.getElementById('wartung');
if(!card||!section||card.parentNode===section)return;
section.appendChild(card);
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',moveWarmupCard);else moveWarmupCard();
})();
</script>
{/literal}

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.');
}

View File

@@ -0,0 +1,70 @@
<?php
define('PHPWG_ROOT_PATH', '../../');
include_once(PHPWG_ROOT_PATH.'include/common.inc.php');
require_once(PHPWG_ROOT_PATH.'include/derivative.inc.php');
if (!defined('BRATONIEN_TOOLS_PATH'))
{
define('BRATONIEN_TOOLS_ID', basename(__DIR__));
define('BRATONIEN_TOOLS_PATH', PHPWG_ROOT_PATH.'plugins/'.BRATONIEN_TOOLS_ID.'/');
}
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_materialize_runtime.inc.php');
$image_id = (int)($_GET['id'] ?? 0);
if ($image_id < 1)
{
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo 'Ungueltige Derivatanforderung.';
exit;
}
$source = bratonien_tools_webdav_materialize_source_info($image_id);
if (!$source)
{
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'WebDAV-Bildquelle nicht gefunden.';
exit;
}
$state_dir = rtrim((string)($source['state_dir'] ?? ''), '/');
if ($state_dir === '')
{
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
echo 'Connector-State-Verzeichnis fehlt.';
exit;
}
$lock = @fopen($state_dir.'/webdav-sync.lock', 'c');
if (!$lock)
{
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
echo 'Connector-Sync-Lock konnte nicht geoeffnet werden.';
exit;
}
// On-Demand bleibt parallel zu anderen Bildabrufen moeglich, verhindert aber
// fuer die Dauer dieses einzelnen Derivataufrufs einen exklusiven Connector-
// Tree-Swap. Der bestehende Bild-Lock im eigentlichen Endpoint koordiniert
// weiterhin On-Demand und Warmup fuer dieselbe Bild-ID.
if (!@flock($lock, LOCK_SH))
{
fclose($lock);
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
echo 'Connector-Sync-Schutz konnte nicht gesetzt werden.';
exit;
}
try
{
include BRATONIEN_TOOLS_PATH.'webdav-derivative.php';
}
finally
{
@flock($lock, LOCK_UN);
fclose($lock);
}

View File

@@ -32,6 +32,22 @@ function bratonien_tools_webdav_derivative_abort($status, $message)
exit;
}
function bratonien_tools_webdav_derivative_send($target_path, $after_url='')
{
if (isset($_GET['ajaxload']) && (string)$_GET['ajaxload'] === 'true')
{
if ($after_url === '') bratonien_tools_webdav_derivative_abort(400, 'Lazyload-Ziel fehlt.');
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode(array('url'=>$after_url), JSON_UNESCAPED_SLASHES);
exit;
}
header('Content-Type: '.(function_exists('get_mimetype') ? get_mimetype($target_path) : 'image/jpeg'));
readfile($target_path);
exit;
}
function bratonien_tools_webdav_derivative_decrypt_secret($blob, $hex_key)
{
$hex_key = trim((string)$hex_key);
@@ -55,38 +71,44 @@ function bratonien_tools_webdav_derivative_quote_path($path)
return implode('/', array_map('rawurlencode', $parts));
}
function bratonien_tools_webdav_derivative_download(array $source, $destination, &$detail=null)
function bratonien_tools_webdav_derivative_connection(array $source, &$detail=null)
{
$detail = '';
$table = $GLOBALS['prefixeTable'].'bratonien_tools_nc_connections';
$result = pwg_query('SELECT config_json, secret_blob FROM `'.$table.'` WHERE id='.(int)$source['connection_id'].' LIMIT 1');
if (!pwg_db_num_rows($result)) { $detail = 'WebDAV-Verbindung nicht gefunden.'; return false; }
if (!pwg_db_num_rows($result)) { $detail = 'WebDAV-Verbindung nicht gefunden.'; return null; }
$row = pwg_db_fetch_assoc($result);
$config = json_decode((string)$row['config_json'], true);
if (!is_array($config)) { $detail = 'WebDAV-Konfiguration ist ungueltig.'; return false; }
if (!is_array($config)) { $detail = 'WebDAV-Konfiguration ist ungueltig.'; return null; }
$key_result = pwg_query("SELECT value FROM ".$GLOBALS['prefixeTable']."config WHERE param='bratonien_nc_connector_secret' LIMIT 1");
if (!pwg_db_num_rows($key_result)) { $detail = 'Connector-Schluessel fehlt.'; return false; }
if (!pwg_db_num_rows($key_result)) { $detail = 'Connector-Schluessel fehlt.'; return null; }
$key_row = pwg_db_fetch_assoc($key_result);
$credentials = bratonien_tools_webdav_derivative_decrypt_secret((string)$row['secret_blob'], (string)$key_row['value']);
if (!is_array($credentials)) { $detail = 'WebDAV-Zugangsdaten konnten nicht gelesen werden.'; return false; }
if (!is_array($credentials)) { $detail = 'WebDAV-Zugangsdaten konnten nicht gelesen werden.'; return null; }
$base_url = rtrim((string)($config['nextcloud_url'] ?? ''), '/');
$user = trim((string)($credentials['nextcloud_user'] ?? ''));
$password = (string)($credentials['nextcloud_password'] ?? '');
$webdav_path = trim((string)($source['webdav_path'] ?? ''), '/');
if ($base_url === '' || $user === '' || $password === '' || $webdav_path === '') { $detail = 'WebDAV-Bildquelle ist unvollstaendig.'; return false; }
if ($base_url === '' || $user === '' || $password === '') { $detail = 'WebDAV-Verbindung ist unvollstaendig.'; return null; }
return array('base_url'=>$base_url, 'user'=>$user, 'password'=>$password);
}
function bratonien_tools_webdav_derivative_download_url($url, array $connection, $destination, &$detail=null)
{
$detail = '';
$fp = @fopen($destination, 'xb');
if (!$fp) { $detail = 'Temporaere Quelldatei konnte nicht angelegt werden.'; return false; }
$url = $base_url.'/remote.php/dav/files/'.rawurlencode($user).'/'.bratonien_tools_webdav_derivative_quote_path($webdav_path);
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 180,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $user.':'.$password,
CURLOPT_USERPWD => $connection['user'].':'.$connection['password'],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_FAILONERROR => false,
CURLOPT_FILE => $fp,
@@ -98,6 +120,7 @@ function bratonien_tools_webdav_derivative_download(array $source, $destination,
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
if ($ok === false || $errno !== 0 || $http < 200 || $http >= 300)
{
@unlink($destination);
@@ -113,256 +136,425 @@ function bratonien_tools_webdav_derivative_download(array $source, $destination,
return true;
}
function bratonien_tools_webdav_derivative_inner_url($derivative_path)
function bratonien_tools_webdav_derivative_download(array $source, array $connection, $destination, &$detail=null)
{
$derivative_root = PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR;
if (strpos($derivative_path, $derivative_root) !== 0) return null;
$location = ltrim(substr($derivative_path, strlen($derivative_root)), '/');
if ($location === '') return null;
$segments = array_map('rawurlencode', explode('/', $location));
return rtrim(get_absolute_root_url(), '/').'/i.php?/'.implode('/', $segments);
$webdav_path = trim((string)($source['webdav_path'] ?? ''), '/');
if ($webdav_path === '') { $detail = 'WebDAV-Bildquelle ist unvollstaendig.'; return false; }
$url = $connection['base_url'].'/remote.php/dav/files/'.rawurlencode($connection['user']).'/'.bratonien_tools_webdav_derivative_quote_path($webdav_path);
return bratonien_tools_webdav_derivative_download_url($url, $connection, $destination, $detail);
}
function bratonien_tools_webdav_derivative_call_i($url, &$status, &$detail=null)
function bratonien_tools_webdav_derivative_preview_plan($variant, array $derivative_size, array $source, array $image_row)
{
$detail = '';
$status = 500;
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 180,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_FAILONERROR => false,
CURLOPT_USERAGENT => 'Bratonien-Tools-WebDAV-Materialize-Gate',
CURLOPT_WRITEFUNCTION => function($ch, $data) { return strlen($data); },
));
$ok = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($ok === false || $errno !== 0) { $detail = 'Interner i.php-Aufruf fehlgeschlagen (cURL '.$errno.($error !== '' ? ': '.$error : '').').'; return false; }
if ($status < 200 || $status >= 400) { $detail = 'i.php antwortete mit HTTP '.$status.'.'; return false; }
if ($variant !== 'custom:s9999x250' && $variant !== 'standard:square') return null;
if ((int)($source['fileid'] ?? 0) < 1) return null;
if ((int)($source['width'] ?? 0) < 1 || (int)($source['height'] ?? 0) < 1) return null;
if ((int)($image_row['rotation'] ?? 0) !== 0) return null;
$target_width = (int)($derivative_size[0] ?? 0);
$target_height = (int)($derivative_size[1] ?? 0);
if ($target_width < 1 || $target_height < 1 || max($target_width, $target_height) > 800) return null;
$source_width = (int)$source['width'];
$source_height = (int)$source['height'];
$required_width = $target_width * 2;
$required_height = $target_height * 2;
$scale = max($required_width / $source_width, $required_height / $source_height);
$preview_width = max(32, (int)ceil($source_width * $scale));
$preview_height = max(32, (int)ceil($source_height * $scale));
if (max($preview_width, $preview_height) > 1600) return null;
return array(
'fileid'=>(int)$source['fileid'],
'width'=>$preview_width,
'height'=>$preview_height,
);
}
function bratonien_tools_webdav_derivative_download_preview(array $plan, array $connection, $destination, &$detail=null)
{
$query = http_build_query(array(
'fileId'=>(int)$plan['fileid'],
'x'=>(int)$plan['width'],
'y'=>(int)$plan['height'],
'a'=>'true',
'forceIcon'=>'false',
'mode'=>'fill',
'mimeFallback'=>'false',
), '', '&', PHP_QUERY_RFC3986);
$url = $connection['base_url'].'/index.php/core/preview?'.$query;
return bratonien_tools_webdav_derivative_download_url($url, $connection, $destination, $detail);
}
function bratonien_tools_webdav_derivative_make_dir($dir)
{
global $conf;
if (!is_dir($dir))
{
$mode = isset($conf['chmod_value']) ? (int)$conf['chmod_value'] : 0755;
$umask = umask(0);
$ok = @mkdir($dir, $mode, true);
umask($umask);
if (!$ok && !is_dir($dir)) return false;
}
if (!is_writable($dir)) return false;
$index = rtrim($dir, '/').'/index.htm';
if (!file_exists($index)) @file_put_contents($index, 'Not allowed!');
return true;
}
function bratonien_tools_webdav_derivative_after_url($encoded)
function bratonien_tools_webdav_derivative_generate($source_path, $target_path, $params, array $image_row, &$detail=null)
{
$encoded = trim((string)$encoded);
if ($encoded === '') return null;
$raw = strtr($encoded, '-_', '+/');
$padding = strlen($raw) % 4;
if ($padding) $raw .= str_repeat('=', 4 - $padding);
$url = base64_decode($raw, true);
if (!is_string($url) || $url === '' || preg_match('/[\r\n]/', $url)) return null;
if (strpos($url, '//') === 0) return null;
if (preg_match('#^https?://#i', $url))
global $conf;
$detail = '';
include_once(PHPWG_ROOT_PATH.'admin/include/image.class.php');
if (!is_object($params)) { $detail = 'Piwigo-Derivatparameter fehlen.'; return false; }
if (!is_file($source_path) || !is_readable($source_path)) { $detail = 'Materialisierte Bildquelle ist nicht lesbar.'; return false; }
if (!bratonien_tools_webdav_derivative_make_dir(dirname($target_path))) { $detail = 'Piwigo-Derivatverzeichnis konnte nicht angelegt werden.'; return false; }
$rotation_angle = isset($image_row['rotation'])
? pwg_image::get_rotation_angle_from_code($image_row['rotation'])
: pwg_image::get_rotation_angle($source_path);
$coi = $image_row['coi'] ?? null;
if (($params->type ?? '') === IMG_CUSTOM)
{
$root_parts = parse_url(get_absolute_root_url());
$url_parts = parse_url($url);
if (!is_array($root_parts) || !is_array($url_parts)) return null;
if (strcasecmp((string)($root_parts['scheme'] ?? ''), (string)($url_parts['scheme'] ?? '')) !== 0) return null;
if (strcasecmp((string)($root_parts['host'] ?? ''), (string)($url_parts['host'] ?? '')) !== 0) return null;
if ((int)($root_parts['port'] ?? 0) !== (int)($url_parts['port'] ?? 0)) return null;
return $url;
$defined = ImageStdParams::get_defined_type_map();
if (count($defined) > 0)
{
$sharpen = 0;
foreach ($defined as $std_params) $sharpen += $std_params->sharpen;
$params->sharpen = round($sharpen / count($defined));
}
}
$image = null;
$wm_image = null;
try
{
$image = new pwg_image($source_path);
$changes = 0;
if (0 != $rotation_angle)
{
$image->rotate($rotation_angle);
$changes++;
}
$o_size = $d_size = array($image->get_width(), $image->get_height());
$crop_rect = null;
$scaled_size = null;
$params->sizing->compute($o_size, $coi, $crop_rect, $scaled_size);
if ($crop_rect)
{
$image->crop($crop_rect->width(), $crop_rect->height(), $crop_rect->l, $crop_rect->t);
$changes++;
}
if ($scaled_size)
{
$image->resize($scaled_size[0], $scaled_size[1]);
$d_size = $scaled_size;
$changes++;
}
if ($params->sharpen)
{
$changes += $image->sharpen($params->sharpen);
}
if ($params->will_watermark($d_size))
{
$wm = ImageStdParams::get_watermark();
$wm_image = new pwg_image(PHPWG_ROOT_PATH.$wm->file);
$wm_size = array($wm_image->get_width(), $wm_image->get_height());
if ($d_size[0] < $wm_size[0] || $d_size[1] < $wm_size[1])
{
$wm_scaling_params = SizingParams::classic($d_size[0], $d_size[1]);
$tmp = null;
$wm_scaled_size = null;
$wm_scaling_params->compute($wm_size, null, $tmp, $wm_scaled_size);
$wm_size = $wm_scaled_size;
$wm_image->resize($wm_scaled_size[0], $wm_scaled_size[1]);
}
$x = round(($wm->xpos / 100) * ($d_size[0] - $wm_size[0]));
$y = round(($wm->ypos / 100) * ($d_size[1] - $wm_size[1]));
if ($image->compose($wm_image, $x, $y, $wm->opacity))
{
$changes++;
if ($wm->xrepeat || $wm->yrepeat)
{
$xpad = $wm_size[0] + max(30, round($wm_size[0] / 4));
$ypad = $wm_size[1] + max(30, round($wm_size[1] / 4));
for ($i = -$wm->xrepeat; $i <= $wm->xrepeat; $i++)
{
for ($j = -$wm->yrepeat; $j <= $wm->yrepeat; $j++)
{
if (!$i && !$j) continue;
$x2 = $x + $i * $xpad;
$y2 = $y + $j * $ypad;
if ($x2 >= 0 && $x2 + $wm_size[0] < $d_size[0] && $y2 >= 0 && $y2 + $wm_size[1] < $d_size[1])
{
if (!$image->compose($wm_image, $x2, $y2, $wm->opacity)) break;
}
}
}
}
}
$wm_image->destroy();
$wm_image = null;
}
if (!$changes)
{
$detail = 'Piwigo-Derivat benoetigt keine Bildaenderung.';
return false;
}
if ($d_size[0] * $d_size[1] < ($conf['derivatives_strip_metadata_threshold'] ?? PHP_INT_MAX))
{
$image->strip();
}
$image->write($target_path);
@chmod($target_path, 0644);
clearstatcache(true, $target_path);
if (!is_file($target_path) || !is_readable($target_path) || @getimagesize($target_path) === false)
{
$detail = 'Piwigo hat kein gueltiges finales Derivat erzeugt.';
return false;
}
return true;
}
catch (Throwable $e)
{
$detail = 'Piwigo-Derivaterzeugung fehlgeschlagen: '.$e->getMessage();
return false;
}
finally
{
if ($wm_image) $wm_image->destroy();
if ($image) $image->destroy();
}
if (strpos($url, ':') !== false && !preg_match('#^[A-Za-z0-9_./?&=%+-]+$#', $url)) return null;
return $url;
}
function bratonien_tools_webdav_derivative_needs_after_redirect($url)
{
if (!is_string($url) || $url === '') return false;
return strpos($url, '/plugins/'.BRATONIEN_TOOLS_ID.'/watermark.php') !== false
|| strpos($url, 'plugins/'.BRATONIEN_TOOLS_ID.'/watermark.php') === 0;
}
function bratonien_tools_webdav_derivative_serve($path)
{
clearstatcache(true, $path);
if (!is_file($path) || !is_readable($path)) bratonien_tools_webdav_derivative_abort(500, 'Piwigo-Derivat ist nicht lesbar.');
$size = @getimagesize($path);
$content_type = is_array($size) && !empty($size['mime']) ? (string)$size['mime'] : 'application/octet-stream';
header('Content-Type: '.$content_type);
header('Content-Length: '.(int)filesize($path));
header('Cache-Control: public, max-age=31536000');
header('X-Content-Type-Options: nosniff');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'HEAD') readfile($path);
exit;
}
$request_id = substr(bin2hex(random_bytes(8)), 0, 8);
$image_id = (int)($_GET['id'] ?? 0);
$variant = trim((string)($_GET['variant'] ?? ''));
$request_id = bin2hex(random_bytes(4));
$after_url = '';
if ($image_id < 1 || $variant === '') bratonien_tools_webdav_derivative_abort(400, 'Ungueltige Derivatanforderung.');
bratonien_tools_webdav_derivative_debug($request_id, 'start', array('image_id'=>$image_id, 'variant'=>$variant));
$after_url = bratonien_tools_webdav_derivative_after_url($_GET['after'] ?? '');
$after_sig = trim((string)($_GET['sig'] ?? ''));
if ($image_id < 1) bratonien_tools_webdav_derivative_abort(400, 'Bild-ID fehlt.');
if ($variant === '') bratonien_tools_webdav_derivative_abort(400, 'Derivat-Variante fehlt.');
if ($after_url !== null)
if (!empty($_GET['after']))
{
$expected_sig = bratonien_tools_webdav_materialize_after_signature($image_id, $variant, $after_url);
if ($after_sig === '' || !hash_equals($expected_sig, $after_sig)) $after_url = null;
$raw_after = strtr((string)$_GET['after'], '-_', '+/');
$padding = strlen($raw_after) % 4;
if ($padding) $raw_after .= str_repeat('=', 4 - $padding);
$decoded_after = base64_decode($raw_after, true);
if (is_string($decoded_after)) $after_url = $decoded_after;
}
if ($after_url !== '')
{
$expected = bratonien_tools_webdav_materialize_after_signature($image_id, $variant, $after_url);
if (!hash_equals($expected, (string)($_GET['sig'] ?? ''))) bratonien_tools_webdav_derivative_abort(403, 'Ungueltige Derivatsignatur.');
}
$redirect_after = bratonien_tools_webdav_derivative_needs_after_redirect($after_url) ? $after_url : null;
$permission_condition = get_sql_condition_FandF(array('forbidden_categories'=>'category_id'), null, true);
$access_result = pwg_query('SELECT 1 FROM '.IMAGE_CATEGORY_TABLE.' WHERE image_id='.$image_id.' AND '.$permission_condition.' LIMIT 1');
if (!pwg_db_num_rows($access_result)) bratonien_tools_webdav_derivative_abort(403, 'Kein Zugriff auf dieses Bild.');
$result = pwg_query('SELECT * FROM '.IMAGES_TABLE.' WHERE id='.$image_id.' LIMIT 1');
$result = pwg_query('SELECT id, path, width, height, rotation, coi FROM '.IMAGES_TABLE.' WHERE id='.$image_id.' LIMIT 1');
if (!pwg_db_num_rows($result)) bratonien_tools_webdav_derivative_abort(404, 'Bild nicht gefunden.');
$image_row = pwg_db_fetch_assoc($result);
$raw_source_path = (string)($image_row['path'] ?? '');
$absolute_source_path = $raw_source_path;
if ($absolute_source_path !== '' && strpos($absolute_source_path, '/') !== 0)
if (function_exists('get_sql_condition_FandF'))
{
$absolute_source_path = PHPWG_ROOT_PATH.ltrim(preg_replace('#^\./#', '', $absolute_source_path), '/');
$forbidden = get_sql_condition_FandF(array('visible_images'=>'id'), ' AND');
if ($forbidden !== '')
{
$visible = pwg_query('SELECT id FROM '.IMAGES_TABLE.' WHERE id='.$image_id.$forbidden.' LIMIT 1');
if (!pwg_db_num_rows($visible)) bratonien_tools_webdav_derivative_abort(403, 'Kein Zugriff auf das Bild.');
}
}
bratonien_tools_webdav_derivative_debug($request_id, 'source_before_resolve', array(
'path'=>$raw_source_path,
'is_link'=>$absolute_source_path !== '' && is_link($absolute_source_path),
'realpath'=>$absolute_source_path !== '' ? (realpath($absolute_source_path) ?: 'FALSE') : 'EMPTY',
));
$source = bratonien_tools_webdav_materialize_source_info($image_id);
if (!$source)
{
bratonien_tools_webdav_derivative_debug($request_id, 'source_resolve_failed');
bratonien_tools_webdav_derivative_abort(404, 'Keine WebDAV-Quelle fuer dieses Bild gefunden.');
}
bratonien_tools_webdav_derivative_debug($request_id, 'source_resolved', array('connection_id'=>$source['connection_id'] ?? 0, 'root_fileid'=>$source['root_fileid'] ?? 0, 'webdav_path'=>$source['webdav_path'] ?? ''));
if (!$source) bratonien_tools_webdav_derivative_abort(404, 'WebDAV-Bildquelle nicht gefunden.');
bratonien_tools_webdav_derivative_debug($request_id, 'source_resolved', array(
'connection_id'=>$source['connection_id'],
'root_fileid'=>$source['root_fileid'],
'fileid'=>$source['fileid'] ?? 0,
'webdav_path'=>$source['webdav_path'],
));
$params = bratonien_tools_webdav_materialize_params_from_variant($variant);
if (!$params) bratonien_tools_webdav_derivative_abort(400, 'Unbekannte Derivat-Variante.');
$real_src = new SrcImage($image_row);
$real_derivative = new DerivativeImage($params, $real_src);
if ($real_derivative->same_as_source())
{
$source_url = bratonien_tools_webdav_materialize_image_url($image_id);
if (!$source_url) bratonien_tools_webdav_derivative_abort(404, 'WebDAV-Original konnte nicht bestimmt werden.');
header('Location: '.$source_url, true, 302);
exit;
}
if (!$params) bratonien_tools_webdav_derivative_abort(400, 'Unbekannte Derivatvariante.');
$src_image = new SrcImage($image_row);
$real_derivative = new DerivativeImage($params, $src_image);
$target_path = $real_derivative->get_path();
$derivative_root = PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR;
if ($target_path === '' || strpos($target_path, $derivative_root) !== 0) bratonien_tools_webdav_derivative_abort(500, 'Ungueltiger Piwigo-Derivatpfad.');
if (is_file($target_path) && is_readable($target_path))
$derivative_size = $real_derivative->get_size();
if ($target_path !== '' && is_file($target_path) && is_readable($target_path))
{
bratonien_tools_webdav_derivative_debug($request_id, 'target_exists', array('target'=>$target_path));
if ($redirect_after !== null) { header('Location: '.$redirect_after, true, 302); exit; }
bratonien_tools_webdav_derivative_serve($target_path);
bratonien_tools_webdav_derivative_send($target_path, $after_url);
}
$shadow_path = $raw_source_path;
if ($shadow_path === '') bratonien_tools_webdav_derivative_abort(500, 'Piwigo-Quellpfad fehlt.');
if (strpos($shadow_path, '/') !== 0) $shadow_path = PHPWG_ROOT_PATH.ltrim(preg_replace('#^\./#', '', $shadow_path), '/');
$shadow_normalized = str_replace('\\', '/', $shadow_path);
if (!preg_match('#/_data/bratonien-tools/nc-webdav-gallery/connection-'.(int)$source['connection_id'].'/#', $shadow_normalized)) bratonien_tools_webdav_derivative_abort(500, 'Piwigo-Quellpfad liegt nicht im WebDAV-Shadow-Baum.');
if (!is_link($shadow_path)) bratonien_tools_webdav_derivative_abort(500, 'WebDAV-Shadow-Quelle ist kein Placeholder-Symlink.');
$placeholder_target = realpath($shadow_path);
$placeholder_normalized = $placeholder_target === false ? '' : str_replace('\\', '/', $placeholder_target);
if ($placeholder_target === false || !preg_match('#/nc-webdav-source/connection-'.(int)$source['connection_id'].'/root-'.(int)$source['root_fileid'].'/#', $placeholder_normalized)) bratonien_tools_webdav_derivative_abort(500, 'WebDAV-Placeholder-Ziel ist ungueltig.');
$shadow_dir = dirname($shadow_path);
if (!is_dir($shadow_dir) || !is_writable($shadow_dir)) bratonien_tools_webdav_derivative_abort(500, 'WebDAV-Shadow-Verzeichnis ist fuer PHP nicht beschreibbar.');
$upload_dir = rtrim((string)($GLOBALS['conf']['upload_dir'] ?? './upload'), '/');
$work_rel_dir = $upload_dir.'/bratonien-webdav-materialize';
$work_root = PHPWG_ROOT_PATH.$work_rel_dir;
if (!is_dir($work_root) && !@mkdir($work_root, 0755, true)) bratonien_tools_webdav_derivative_abort(500, 'WebDAV-Materialisierungsbereich konnte nicht angelegt werden.');
if (!is_writable($work_root)) bratonien_tools_webdav_derivative_abort(500, 'WebDAV-Materialisierungsbereich ist fuer PHP nicht beschreibbar.');
$lock_path = $work_root.'/image-'.$image_id.'.lock';
bratonien_tools_webdav_derivative_debug($request_id, 'waiting_lock', array('lock'=>$lock_path, 'is_link'=>is_link($shadow_path), 'realpath'=>realpath($shadow_path) ?: 'FALSE'));
$lock = @fopen($lock_path, 'c');
if (!$lock || !flock($lock, LOCK_EX))
$source_logical_path = (string)$image_row['path'];
if (strpos($source_logical_path, '/') !== 0)
{
if ($lock) fclose($lock);
bratonien_tools_webdav_derivative_abort(503, 'WebDAV-Materialisierungslock konnte nicht gesetzt werden.');
$source_logical_path = PHPWG_ROOT_PATH.ltrim(preg_replace('#^\./#', '', $source_logical_path), '/');
}
bratonien_tools_webdav_derivative_debug($request_id, 'lock_acquired', array('is_link'=>is_link($shadow_path), 'realpath'=>realpath($shadow_path) ?: 'FALSE'));
$lock_dir = PHPWG_ROOT_PATH.'upload/bratonien-webdav-materialize';
if (!is_dir($lock_dir)) @mkdir($lock_dir, 0755, true);
$lock_path = $lock_dir.'/image-'.$image_id.'.lock';
$lock_handle = fopen($lock_path, 'c');
if (!$lock_handle) bratonien_tools_webdav_derivative_abort(500, 'Derivat-Lock konnte nicht geoeffnet werden.');
bratonien_tools_webdav_derivative_debug($request_id, 'waiting_lock', array('lock'=>$lock_path, 'is_link'=>is_link($source_logical_path)));
flock($lock_handle, LOCK_EX);
bratonien_tools_webdav_derivative_debug($request_id, 'lock_acquired', array('is_link'=>is_link($source_logical_path)));
clearstatcache(true, $target_path);
if (is_file($target_path) && is_readable($target_path))
if ($target_path !== '' && is_file($target_path) && is_readable($target_path))
{
bratonien_tools_webdav_derivative_debug($request_id, 'target_created_while_waiting', array('target'=>$target_path));
@flock($lock, LOCK_UN); @fclose($lock);
if ($redirect_after !== null) { header('Location: '.$redirect_after, true, 302); exit; }
bratonien_tools_webdav_derivative_serve($target_path);
flock($lock_handle, LOCK_UN);
fclose($lock_handle);
bratonien_tools_webdav_derivative_send($target_path, $after_url);
}
$token = getmypid().'-'.bin2hex(random_bytes(6));
$download_path = $work_root.'/source-'.$image_id.'-'.$token.'.tmp';
$staging_path = $shadow_path.'.bratonien-'.$token.'.tmp';
$placeholder_backup = $shadow_path.'.bratonien-placeholder';
$swapped = false;
$cleaned = false;
$cleanup = function() use (&$cleaned, &$swapped, $shadow_path, $staging_path, $placeholder_backup, $download_path, $lock, $request_id)
$detail = '';
$connection = bratonien_tools_webdav_derivative_connection($source, $detail);
if (!$connection)
{
if ($cleaned) return;
$cleaned = true;
if ($swapped)
{
if (is_file($shadow_path) && !is_link($shadow_path)) @unlink($shadow_path);
if (is_link($placeholder_backup)) @rename($placeholder_backup, $shadow_path);
$swapped = false;
}
if (is_file($staging_path) || is_link($staging_path)) @unlink($staging_path);
if (is_file($download_path)) @unlink($download_path);
bratonien_tools_webdav_derivative_debug($request_id, 'restored', array('is_link'=>is_link($shadow_path), 'realpath'=>realpath($shadow_path) ?: 'FALSE'));
@flock($lock, LOCK_UN);
@fclose($lock);
};
register_shutdown_function($cleanup);
flock($lock_handle, LOCK_UN);
fclose($lock_handle);
bratonien_tools_webdav_derivative_abort(502, $detail !== '' ? $detail : 'WebDAV-Verbindung konnte nicht geladen werden.');
}
$temp_dir = $lock_dir.'/tmp';
if (!is_dir($temp_dir)) @mkdir($temp_dir, 0755, true);
$temp_source = $temp_dir.'/source-'.$image_id.'-'.$request_id.'.img';
$source_mode = 'original';
$preview_plan = bratonien_tools_webdav_derivative_preview_plan($variant, $derivative_size, $source, $image_row);
if ($preview_plan)
{
bratonien_tools_webdav_derivative_debug($request_id, 'preview_start', array(
'fileid'=>$preview_plan['fileid'],
'width'=>$preview_plan['width'],
'height'=>$preview_plan['height'],
));
$download_start = microtime(true);
if (bratonien_tools_webdav_derivative_download_preview($preview_plan, $connection, $temp_source, $detail))
{
$source_mode = 'preview';
bratonien_tools_webdav_derivative_debug($request_id, 'download_done', array(
'mode'=>$source_mode,
'ms'=>round((microtime(true) - $download_start) * 1000, 1),
'bytes'=>filesize($temp_source),
));
}
else
{
bratonien_tools_webdav_derivative_debug($request_id, 'preview_failed', array('detail'=>$detail));
$detail = '';
}
}
if ($source_mode !== 'preview')
{
@unlink($temp_source);
$download_start = microtime(true);
if (!bratonien_tools_webdav_derivative_download($source, $connection, $temp_source, $detail))
{
flock($lock_handle, LOCK_UN);
fclose($lock_handle);
bratonien_tools_webdav_derivative_abort(502, $detail !== '' ? $detail : 'WebDAV-Original konnte nicht geladen werden.');
}
bratonien_tools_webdav_derivative_debug($request_id, 'download_done', array(
'mode'=>'original',
'ms'=>round((microtime(true) - $download_start) * 1000, 1),
'bytes'=>filesize($temp_source),
));
}
$source_path = realpath($source_logical_path);
if ($source_path === false || !is_file($source_path))
{
@unlink($temp_source);
flock($lock_handle, LOCK_UN);
fclose($lock_handle);
bratonien_tools_webdav_derivative_abort(500, 'Physischer Platzhalter konnte nicht aufgeloest werden.');
}
$placeholder_backup = $source_path.'.bratonien-placeholder-'.$request_id;
$staging_path = $source_logical_path.'.bratonien-source-'.$request_id;
@unlink($placeholder_backup);
@unlink($staging_path);
if (!@copy($temp_source, $staging_path))
{
@unlink($temp_source);
flock($lock_handle, LOCK_UN);
fclose($lock_handle);
bratonien_tools_webdav_derivative_abort(500, 'Temporaere Bildquelle konnte nicht bereitgestellt werden.');
}
$swapped = false;
try
{
if (is_link($placeholder_backup)) bratonien_tools_webdav_derivative_abort(503, 'WebDAV-Placeholder-Backup existiert bereits; vorheriger Materialisierungslauf ist nicht sauber abgeschlossen.');
$detail = '';
$download_start = microtime(true);
if (!bratonien_tools_webdav_derivative_download($source, $download_path, $detail)) bratonien_tools_webdav_derivative_abort(502, $detail);
bratonien_tools_webdav_derivative_debug($request_id, 'download_done', array('ms'=>round((microtime(true)-$download_start)*1000, 1), 'bytes'=>@filesize($download_path) ?: 0));
if (!@copy($download_path, $staging_path)) bratonien_tools_webdav_derivative_abort(500, 'Original konnte nicht in den WebDAV-Shadow-Baum kopiert werden.');
@chmod($staging_path, 0644);
if (@getimagesize($staging_path) === false) bratonien_tools_webdav_derivative_abort(500, 'Materialisierte Shadow-Quelle ist kein gueltiges Bild.');
bratonien_tools_webdav_derivative_debug($request_id, 'swap_begin', array('placeholder'=>$placeholder_target));
if (!@rename($shadow_path, $placeholder_backup)) bratonien_tools_webdav_derivative_abort(500, 'WebDAV-Placeholder konnte nicht temporaer gesichert werden.');
if (!@rename($staging_path, $shadow_path))
bratonien_tools_webdav_derivative_debug($request_id, 'swap_begin', array('placeholder'=>$source_path, 'mode'=>$source_mode));
if (!@rename($source_path, $placeholder_backup)) throw new RuntimeException('Platzhalter konnte nicht gesichert werden.');
if (!@rename($staging_path, $source_path))
{
@rename($placeholder_backup, $shadow_path);
bratonien_tools_webdav_derivative_abort(500, 'Materialisiertes Original konnte nicht am Piwigo-Quellpfad aktiviert werden.');
@rename($placeholder_backup, $source_path);
throw new RuntimeException('Temporaere Bildquelle konnte nicht eingesetzt werden.');
}
$swapped = true;
clearstatcache(true, $shadow_path);
bratonien_tools_webdav_derivative_debug($request_id, 'swapped', array('is_link'=>is_link($shadow_path), 'realpath'=>realpath($shadow_path) ?: 'FALSE'));
if (!is_file($shadow_path) || is_link($shadow_path) || @getimagesize($shadow_path) === false) bratonien_tools_webdav_derivative_abort(500, 'Piwigo-Quellpfad enthaelt nach Materialisierung kein gueltiges Original.');
bratonien_tools_webdav_derivative_debug($request_id, 'swapped', array('is_link'=>is_link($source_logical_path), 'mode'=>$source_mode));
$inner_url = bratonien_tools_webdav_derivative_inner_url($target_path);
if ($inner_url === null) bratonien_tools_webdav_derivative_abort(500, 'Piwigo-i.php-URL konnte nicht bestimmt werden.');
$inner_status = 500;
$i_start = microtime(true);
bratonien_tools_webdav_derivative_debug($request_id, 'i_start', array('url'=>$inner_url));
$i_ok = bratonien_tools_webdav_derivative_call_i($inner_url, $inner_status, $detail);
bratonien_tools_webdav_derivative_debug($request_id, 'i_done', array('status'=>$inner_status, 'ok'=>$i_ok, 'ms'=>round((microtime(true)-$i_start)*1000, 1), 'detail'=>$detail));
if (!$i_ok) bratonien_tools_webdav_derivative_abort(502, $detail);
clearstatcache(true, $target_path);
$final_size = @getimagesize($target_path);
if (!is_file($target_path) || !is_readable($target_path) || !is_array($final_size)) bratonien_tools_webdav_derivative_abort(500, 'Piwigo hat kein gueltiges finales Derivat erzeugt.');
$cleanup();
bratonien_tools_webdav_derivative_debug($request_id, 'serve', array('target'=>$target_path, 'width'=>$final_size[0] ?? 0, 'height'=>$final_size[1] ?? 0));
if ($redirect_after !== null) { header('Location: '.$redirect_after, true, 302); exit; }
bratonien_tools_webdav_derivative_serve($target_path);
$generate_start = microtime(true);
bratonien_tools_webdav_derivative_debug($request_id, 'generate_start', array('target'=>$target_path, 'mode'=>$source_mode));
$ok = bratonien_tools_webdav_derivative_generate($source_path, $target_path, $params, $image_row, $detail);
bratonien_tools_webdav_derivative_debug($request_id, 'generate_done', array(
'ok'=>$ok,
'mode'=>$source_mode,
'ms'=>round((microtime(true) - $generate_start) * 1000, 1),
'detail'=>$detail,
));
if (!$ok) throw new RuntimeException($detail !== '' ? $detail : 'Piwigo-Derivat konnte nicht erzeugt werden.');
}
finally
catch (Throwable $e)
{
$cleanup();
if ($swapped)
{
@unlink($source_path);
@rename($placeholder_backup, $source_path);
}
else
{
@unlink($staging_path);
}
@unlink($temp_source);
flock($lock_handle, LOCK_UN);
fclose($lock_handle);
bratonien_tools_webdav_derivative_abort(500, $e->getMessage());
}
@unlink($source_path);
@rename($placeholder_backup, $source_path);
@unlink($temp_source);
bratonien_tools_webdav_derivative_debug($request_id, 'restored', array('is_link'=>is_link($source_logical_path)));
flock($lock_handle, LOCK_UN);
fclose($lock_handle);
if (!is_file($target_path) || !is_readable($target_path)) bratonien_tools_webdav_derivative_abort(500, 'Erzeugtes Derivat fehlt.');
$final_size = @getimagesize($target_path);
bratonien_tools_webdav_derivative_debug($request_id, 'serve', array(
'target'=>$target_path,
'width'=>(int)($final_size[0] ?? 0),
'height'=>(int)($final_size[1] ?? 0),
'mode'=>$source_mode,
));
bratonien_tools_webdav_derivative_send($target_path, $after_url);