Compare commits

...

252 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
Terranom674
a437f5d205 Bump version to 0.9.6.8.22 2026-08-20 18:23:34 +02:00
Terranom674
f2b2845387 Remove obsolete WebDAV precache and derivative prebuild 2026-08-20 18:23:11 +02:00
Terranom674
b54cb039fb release: bump to 0.9.6.8.21 2026-08-20 18:11:58 +02:00
Terranom674
419fe56cde webdav: stop downloading originals for dimension metadata 2026-08-20 18:11:34 +02:00
Terranom674
8cb4abf81d webdav: read image dimensions from Nextcloud metadata 2026-08-20 18:11:07 +02:00
Terranom674
ae70e64c70 release: bump plugin version to 0.9.6.8.20 2026-08-20 18:00:08 +02:00
Terranom674
e5557eba24 webdav: sync original dimensions after piwigo import 2026-08-20 17:59:45 +02:00
Terranom674
5b3eabcb55 webdav: sync original dimensions into piwigo 2026-08-20 17:58:57 +02:00
Terranom674
d56aff39a2 webdav: persist original image dimensions 2026-08-20 17:58:43 +02:00
Terranom674
01243ea022 docs: refresh current development status 2026-08-20 17:42:06 +02:00
Terranom674
88f2949903 docs: update repository information for 0.9.6.8.19 2026-08-20 17:41:35 +02:00
Terranom674
136a6c235e Fix admin dashboard album tile for single album 2026-08-20 17:37:50 +02:00
Terranom674
fb211693ff Preserve connector top-level album access without name collision 2026-08-20 17:32:13 +02:00
Terranom674
b0885a0554 Fix duplicate private album access handler 2026-08-20 17:31:52 +02:00
Terranom674
bc798c5d65 Fix access for flattened WebDAV top-level albums 2026-08-20 17:23:26 +02:00
Terranom674
f958eca176 Bump version to 0.9.6.8.15 2026-08-20 16:54:35 +02:00
Terranom674
93af719143 Flatten empty WebDAV root into gallery root 2026-08-20 16:54:06 +02:00
Terranom674
11183f2a41 Bump version to 0.9.6.8.14 2026-08-20 16:41:27 +02:00
Terranom674
26bb454ba4 Preserve empty WebDAV root path in sync 2026-08-20 16:41:10 +02:00
Terranom674
a195421fed Allow empty WebDAV root path in reconcile 2026-08-20 16:40:47 +02:00
Terranom674
ec55220124 Bump plugin version to 0.9.6.8.13 2026-08-20 16:15:08 +02:00
Terranom674
f5d3b97339 Use absolute runtime path for manual WebDAV sync 2026-08-20 16:14:41 +02:00
Terranom674
a5b4a2329e Bump version to 0.9.6.8.12 2026-08-20 16:13:13 +02:00
Terranom674
97340a517b Run manual WebDAV sync directly from plugin 2026-08-20 16:12:52 +02:00
55 changed files with 7545 additions and 481 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,61 +2,168 @@
## Version ## Version
**Bratonien Tools 0.9.6.2** **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 ## NC Connector
Der NC Connector besitzt nur noch einen produktiven Verbindungsweg: **Nextcloud per WebDAV**. Der produktive Verbindungsweg ist **Nextcloud per WebDAV**.
### Verbindungsmodell ### Verbindungsmodell
- `adapter=remote` - `adapter=remote`
- `source_mode=webdav-placeholder` - `source_mode=webdav-placeholder`
- Nextcloud-Adresse, Benutzer und Passwort/App-Passwort werden pro Verbindung verschlüsselt gespeichert. - Nextcloud-Adresse, Benutzer und Passwort/App-Passwort werden pro Verbindung verschlüsselt gespeichert.
- Piwigo-API und Benutzername/Passwort-Fallback werden ebenfalls pro Verbindung gespeichert. - Piwigo-API und Benutzername/Passwort-Fallback werden pro Verbindung gespeichert.
- Der Assistent zeigt ausschließlich die Verzeichnisse, auf die der angemeldete Nextcloud-Benutzer per WebDAV zugreifen kann. - Der Assistent zeigt ausschließlich per WebDAV sichtbare Verzeichnisse.
- Mehrere Verzeichnisse können pro Verbindung ausgewählt werden. - 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 Orphan-Logik verarbeitet.
Es gibt keine fest verdrahteten Album-Namen.
### Laufzeit ### Laufzeit
Der gemeinsame Runner verarbeitet ausschließlich WebDAV-Verbindungen: Der gemeinsame Runner verarbeitet WebDAV-Verbindungen in dieser Reihenfolge:
1. `runtime/reconcile-webdav.php` 1. `runtime/reconcile-webdav.php`
2. `runtime/cleanup-webdav-piwigo.php` 2. `runtime/repair-webdav-orphans.php`
3. `runtime/sync-webdav.sh` für jede vorhandene WebDAV-Konfiguration 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`.
### Shadow Tree und Piwigo State-Verzeichnisse sind für die `www-data`-Gruppe traversierbar/lesbar. `webdav-map.json` wird auch nach atomaren Rewrites mit passenden Runtime-Rechten gehalten.
`runtime/lib/build_webdav_placeholder_source.py` bildet die ausgewählten Nextcloud-Inhalte als lokale Platzhalterquelle ab. `runtime/lib/shadow_tree.py` erzeugt daraus die Dateisystemstruktur, die Piwigo als physische Site sieht. ### Platzhalterquelle, Metadaten und Shadow Tree
Jede WebDAV-Verbindung besitzt ihre eigene Piwigo-Site. `runtime/lib/piwigo-sync.php` übergibt genau diese Site an `bratonien.nc.syncProductive` und `bratonien.nc.syncOrphans`. Es gibt keinen zusätzlichen Sync auf Site 1. `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.
Der Shadow Tree dient ausschließlich dazu, Piwigo die Alben und Bilder als Dateisystemstruktur bereitzustellen. Albumlogik, Dateisynchronisierung und die daraus folgenden Piwigo-Datensätze bleiben Aufgabe von Piwigo. 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`.
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 keine Piwigo-API eingerichtet ist, wird der gespeicherte Administrator-/Webmaster-Fallback verwendet und Piwigos nativer `site_update` ausgeführt.
Danach läuft `bratonien.nc.syncOrphans` für die konkrete WebDAV-Site.
Albumdatensätze, Bilddatensätze, Bild-Kategorie-Verknüpfungen und Derivate bleiben Aufgabe von Piwigo.
### On-Demand-Materialisierung und Derivaterzeugung
`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. 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.
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
Direkt im WebDAV-Site-Root liegende Bilder werden nicht künstlich in ein Album gezwungen. Sie werden durch `bratonien.nc.syncOrphans` als Piwigo-Orphans synchronisiert.
`runtime/repair-webdav-orphans.php` läuft vor den verbindungsspezifischen Syncs und repariert historische WebDAV-Orphan-Zustände.
### Private Top-Level-Alben
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 Funktion `bratonien_tools_preserve_private_album_access()` in `include/album_shares.inc.php` bleibt davon getrennt.
### Administrations-Dashboard
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 ### Bildauslieferung
Die Dateien im Shadow Tree sind Platzhalter. `include/webdav_image_runtime.inc.php` erkennt die WebDAV-Zuordnung und ersetzt die Bildquelle bei der Auslieferung durch den zugehörigen Nextcloud-Inhalt. `webdav-image.php` streamt die Bilddaten serverseitig, ohne Nextcloud-Zugangsdaten an den Browser weiterzugeben. Fehlende Piwigo-Derivate werden durch den On-Demand-Gate erzeugt. Bereits vorhandene Derivate werden direkt aus Piwigos eigenem Derivat-Cache ausgeliefert.
### Löschen Für direkte Originalanforderungen existiert weiterhin die serverseitige WebDAV-Originalauslieferung. Nextcloud-Zugangsdaten werden dem Browser nicht offengelegt.
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 ### Diagnose
Der Status wird pro Verbindung geführt. Fehler enthalten Zeitpunkt, betroffene Verbindung, Prozesszustand und technische Details. Die Administration zeigt keine lokalen Datenbank-, Storage- oder Mount-Einstellungen mehr an. 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`.
## Entfernte Connector-Komponenten ## Self-Updater
Nicht mehr Bestandteil des Plugins sind lokale Nextcloud-Datenbankanbindungen, PostgreSQL-Reader, Source-/Activity-Views, Storage-Mappings, Host-Mounts, lokale Reconcile-/Sync-Skripte, Cutover-/Migrationswerkzeuge und der frühere Simulations-Webservice `bratonien.nc.sync`. `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 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 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 und Runtime-/Source-Berechtigungen
- `runtime/run-all.sh` gemeinsamer Runner
- `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 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 + temporäre Nextcloud-Preview bzw. Original-Fallback + Piwigos Bildverarbeitung im selben PHP-Prozess + normales Piwigo-Derivat + Wiederherstellung des Platzhalters**.
## Prüfpflicht vor Merge ## Prüfpflicht vor Merge
Jede Änderung wird vor dem Merge mindestens mit folgenden GitHub-Actions-Prüfungen validiert: Die GitHub-Actions-Konfiguration prüft mindestens:
- PHP-Syntax für 8.2, 8.3, 8.4 und 8.5 - PHP-Syntax für 8.2, 8.3, 8.4 und 8.5
- Prüfung auf doppelte globale `bratonien_tools_*`-Funktionen - doppelte globale `bratonien_tools_*`-Funktionen
- JavaScript-Syntax - JavaScript-Syntax
- Python-Syntax/Compile - Python-Syntax/Compile
- Shell-Syntax mit `bash -n` - Shell-Syntax mit `bash -n`

123
README.md
View File

@@ -2,7 +2,7 @@
Modulares Piwigo-Plugin für Administration, Bildverarbeitung, geschützte Freigaben, Fotoauswahl und die Anbindung von Nextcloud an Piwigo. Modulares Piwigo-Plugin für Administration, Bildverarbeitung, geschützte Freigaben, Fotoauswahl und die Anbindung von Nextcloud an Piwigo.
Aktuelle Plugin-Version: **0.9.6.2** Aktuelle Plugin-Version: **0.9.7.1.7**
## Grundprinzip ## Grundprinzip
@@ -12,18 +12,43 @@ Für die Administration gilt: Der normale Nutzer sieht Aufgaben, Entscheidungen
## NC Connector ## NC Connector
Der NC Connector bindet ausdrücklich ausgewählte Nextcloud-Inhalte ausschließlich per WebDAV an Piwigo an. Der NC Connector bindet ausdrücklich ausgewählte Nextcloud-Inhalte per WebDAV an Piwigo an. Nextcloud bleibt dabei die Quelle der Originaldateien; Piwigo verwaltet Alben, Bilder und seine eigenen Derivate.
### Datenmodell ### Datenmodell
- Nextcloud bleibt die Quelle der Originaldateien. - Nextcloud bleibt die verbindliche Quelle der Originaldateien.
- Originalbilder werden nicht dauerhaft in eine zweite Bibliothek unter Piwigo kopiert. - Originalbilder werden nicht dauerhaft als zweite vollständige Bibliothek unter Piwigo gespeichert.
- Ein Shadow Tree erzeugt die für Piwigo benötigte Ordnerstruktur. - Eine lokale Platzhalterquelle enthält physische 1×1-Dateien, damit Piwigo die Bilder regulär als Dateisysteminhalte erkennen kann.
- Ein Shadow Tree bildet daraus die von Piwigo erwartete Galerie-Ordnerstruktur.
- Ordner werden als physische Piwigo-Alben synchronisiert. - Ordner werden als physische Piwigo-Alben synchronisiert.
- Root-Dateien können als Piwigo-Orphans registriert werden. - Direkt im ausgewählten Nextcloud-Root liegende Einzelbilder werden über die vorhandene Orphan-Logik behandelt.
- Neu importierte physische Connector-Alben werden privat angelegt. - Neu importierte physische Connector-Alben werden privat angelegt.
- Entfernte Quellen verschwinden aus Shadow Tree und Piwigo; die Nextcloud-Originale bleiben erhalten. - Entfernte Quellen verschwinden aus Shadow Tree und Piwigo; die Nextcloud-Originale bleiben erhalten.
### Nextcloud-Root ohne künstliches Benutzeralbum
Ein leerer `webdav_path` ist ein gültiger Root und bedeutet den Dateibereich des authentifizierten Nextcloud-Benutzers.
Dabei wird der Nextcloud-Benutzername nicht als künstliche Album-Ebene angelegt. Beispiel:
```text
Nextcloud
/
├── 400 Auswahl/
├── Zweites Album/
├── einzelbild-1.jpg
└── einzelbild-2.jpg
```
wird in Piwigo logisch als
```text
400 Auswahl
Zweites Album
```
abgebildet. Die direkt im Root liegenden Einzelbilder werden separat durch die Orphan-Synchronisierung erfasst.
### Voraussetzungen ### Voraussetzungen
Benötigt werden nur: Benötigt werden nur:
@@ -35,30 +60,57 @@ Benötigt werden nur:
Nicht benötigt werden Nextcloud-Datenbankzugriff, `occ`-Adminzugriff, Storage-IDs, Backend-Pfade, zusätzliche Host-Mounts, FUSE, davfs oder rclone. Nicht benötigt werden Nextcloud-Datenbankzugriff, `occ`-Adminzugriff, Storage-IDs, Backend-Pfade, zusätzliche Host-Mounts, FUSE, davfs oder rclone.
### WebDAV-Ablauf ### WebDAV-Scan und Shadow Tree
1. Der Assistent prüft Nextcloud und liest die sichtbaren Verzeichnisse per WebDAV. 1. Der Assistent prüft Nextcloud und liest die sichtbaren Verzeichnisse per WebDAV.
2. Ausgewählte Verzeichnisse werden rekursiv per PROPFIND eingelesen. 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. 4. `runtime/lib/build_webdav_placeholder_source.py` erzeugt eine lokale Platzhalterquelle ohne dauerhafte Originalkopie.
5. `runtime/lib/shadow_tree.py` baut daraus den verbindungseigenen Shadow Tree. 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`. 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. 7. Jede WebDAV-Verbindung wird als eigene physische Piwigo-Site registriert.
8. Piwigos Dateisynchronisierung erhält ausschließlich diese verbindungseigene Site. 8. Piwigos Dateisynchronisierung erhält ausschließlich diese verbindungseigene Site.
9. Die WebDAV-Bildzuordnung ersetzt beim Ausliefern Platzhalter-URLs durch die echte Nextcloud-Bildquelle. 9. `runtime/lib/sync-webdav-metadata.php` übernimmt die von Nextcloud gelieferten Originalmaße in Piwigos Bilddatensätze.
10. `webdav-image.php` streamt benötigte Bilder serverseitig; Zugangsdaten werden dem Browser nicht offengelegt.
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
`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. 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.
Für Fälle, in denen Piwigo direkt die Originalquelle benötigt, steht weiterhin die serverseitige WebDAV-Auslieferung zur Verfügung. Nextcloud-Zugangsdaten werden nicht an den Browser weitergegeben.
### Piwigo-Synchronisierung ### Piwigo-Synchronisierung
Der Connector verwendet die vorhandene Piwigo-Dateisynchronisierung für die verbindungseigene WebDAV-Site. Der Shadow Tree ist die Dateisystemdarstellung, die Piwigo für Alben und Bilder benötigt. Der Connector verwendet die vorhandene Piwigo-Dateisynchronisierung für die verbindungseigene WebDAV-Site. Der Shadow Tree ist ausschließlich die Dateisystemdarstellung, die Piwigo für Alben und Bilder benötigt.
`runtime/lib/piwigo-sync.php` ruft dafür `bratonien.nc.syncProductive` auf. Die Methode führt den Piwigo-Core-Sync für die konkrete Site aus. `bratonien.nc.syncOrphans` arbeitet ebenfalls nur auf der übergebenen WebDAV-Site. `runtime/lib/piwigo-sync.php` bevorzugt die Webservice-Methode `bratonien.nc.syncProductive`. Ist keine Piwigo-API für die Verbindung eingerichtet, wird der vorhandene Administrator-/Webmaster-Benutzername/Passwort-Fallback verwendet und Piwigos nativer `site_update` ausgeführt.
Nach der regulären Dateisynchronisierung läuft `bratonien.nc.syncOrphans` für direkt im Site-Root liegende Einzelbilder.
Die produktive Synchronisierung ist aktuell auf **Piwigo 16.4.0** abgestimmt. Die produktive Synchronisierung ist aktuell auf **Piwigo 16.4.0** abgestimmt.
### Piwigo-Zugang ### Private Top-Level-Alben
Der Zugang wird pro Verbindung gespeichert. Bevorzugt wird eine Piwigo-API. Wird keine API eingerichtet, kann ein Administrator-/Webmaster-Benutzer mit Passwort als Fallback verwendet werden. Durch das Überspringen der technischen Nextcloud-Benutzerebene können WebDAV-Unterordner zu echten Top-Level-Piwigo-Alben werden. Für den Connector-Sync wird deshalb der Zugriff des verwendeten Piwigo-Administrators auf neu erzeugte private Top-Level-Alben erhalten und der Benutzer-Cache anschließend invalidiert.
### Administrations-Dashboard
Piwigo 16.4.0 blendet seine Album-Statistik standardmäßig bei exakt einem Album aus (`NB_ALBUMS > 1`). Bratonien Tools passt ausschließlich diese Dashboard-Anzeige auf `NB_ALBUMS > 0` an. Dadurch wird auch ein einzelnes korrekt angelegtes Album in der Verwaltungsübersicht angezeigt.
### Statusanzeige ### Statusanzeige
@@ -78,11 +130,14 @@ Aktive WebDAV-Verbindungen werden über den gemeinsamen Runner verarbeitet:
Verbindungsspezifische Konfigurationen liegen unter `/etc/bratonien-tools/nc-connector/`, State-Daten unter `/var/lib/bratonien-tools/nc-connector/connection-ID`. Verbindungsspezifische Konfigurationen liegen unter `/etc/bratonien-tools/nc-connector/`, State-Daten unter `/var/lib/bratonien-tools/nc-connector/connection-ID`.
Der Lauf besteht aus: Der gemeinsame Lauf besteht aktuell aus:
1. WebDAV-Verbindungen reconciliieren; 1. `runtime/reconcile-webdav.php` gespeicherte Verbindungen in Runtime-Konfigurationen überführen;
2. verwaiste WebDAV-Piwigo-Inhalte bereinigen; 2. `runtime/repair-webdav-orphans.php` vorhandene WebDAV-Orphan-Zustände reparieren;
3. jede vorhandene WebDAV-Verbindung synchronisieren. 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, 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 ## Bildcache
@@ -125,34 +180,38 @@ Private Alben können über eigene Freigabelinks geteilt werden, optional mit Pa
## Erweiterte Bildnavigation ## 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 ## 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 ## Wichtige Dateien und Verzeichnisse
- `main.inc.php` Plugin-Einstieg und Runtime-Hooks - `main.inc.php` Plugin-Einstieg, Hooks, Connector-Sync-Hilfen und Dashboard-Prefilter
- `admin.php` zentraler Admin-Controller - `admin.php` zentraler Admin-Controller
- `include/nc_connector.inc.php` WebDAV-Verbindungsmodell - `include/nc_connector.inc.php` WebDAV-Verbindungsmodell
- `include/nc_connector_wizard.inc.php` Verbindungsassistent - `include/nc_connector_wizard.inc.php` Verbindungsassistent
- `include/nc_connector_wizard_webdav_flow.inc.php` WebDAV-Wizardablauf - `include/nc_connector_wizard_webdav_flow.inc.php` WebDAV-Wizardablauf
- `include/nc_connector_delete_safe.inc.php` Löschen einschließlich WebDAV-Piwigo-Inhalten - `include/nc_connector_delete_safe.inc.php` Löschen einschließlich WebDAV-Piwigo-Inhalten
- `include/nc_productive_ws.inc.php` Piwigo-Core-Dateisync - `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/nc_orphan_ws.inc.php` Orphan-Synchronisierung der konkreten WebDAV-Site
- `include/webdav_image_runtime.inc.php` WebDAV-Bildzuordnung und URL-Filter - `include/webdav_materialize_runtime.inc.php` WebDAV-Quellauflösung und On-Demand-Materialisierung einschließlich `fileid` und Originalmaßen
- `webdav-image.php` berechtigungsgeprüfter WebDAV-Bildstream - `include/webdav_image_runtime.inc.php` vorhandene WebDAV-Bildzuordnung/Originalauslieferung
- `runtime/reconcile-webdav.php` WebDAV-Runtime-Reconcile - `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 und Runtime-Berechtigungen
- `runtime/repair-webdav-orphans.php` Reparatur vorhandener Orphan-Zustände
- `runtime/cleanup-webdav-piwigo.php` Bereinigung verwaister WebDAV-Piwigo-Sites - `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/run-all.sh` gemeinsamer WebDAV-Runner
- `runtime/lib/build_webdav_placeholder_source.py` rekursiver WebDAV-Scan und Platzhalterquelle - `runtime/lib/build_webdav_placeholder_source.py` rekursiver WebDAV-Scan, Bildmetadaten und physische Platzhalterquelle
- `runtime/lib/shadow_tree.py` atomarer Shadow Tree - `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 - `runtime/lib/piwigo-sync.php` Piwigo-Sync mit API/Fallback
- `main-cache-build.php` allgemeiner Piwigo-Derivat-/Cache-Builder - `main-cache-build.php` allgemeiner Piwigo-Derivat-/Cache-Builder
- `include/self_update.inc.php` Self-Updater - `include/self_update.inc.php` Self-Updater
- `include/album_shares.inc.php` geschützte Albumfreigaben - `include/album_shares.inc.php` geschützte Albumfreigaben und private Albumzugriffe
- `include/public_selection.inc.php` Fotoauswahl - `include/public_selection.inc.php` Fotoauswahl
- `include/batch_titles.inc.php` fortlaufende Titel - `include/batch_titles.inc.php` fortlaufende Titel
- `include/watermark_*.inc.php` Wasserzeichen-Engine - `include/watermark_*.inc.php` Wasserzeichen-Engine
@@ -167,5 +226,7 @@ Der integrierte Updater liest den Zielstand aus GitHub, bindet ein Update an ein
- Wizard-Geheimnisse werden nicht im Browser-Web-Storage persistiert; - Wizard-Geheimnisse werden nicht im Browser-Web-Storage persistiert;
- produktive Piwigo-API ist versionsgebunden; - produktive Piwigo-API ist versionsgebunden;
- Originalbilder werden vom Connector nicht gelöscht; - Originalbilder werden vom Connector nicht gelöscht;
- WebDAV-Originalbilder werden nicht dauerhaft lokal gespeichert; - WebDAV-Originalbilder werden nur bei Bedarf temporär lokal materialisiert;
- 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. - 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(); $cache_workers = bratonien_tools_get_cache_worker_settings();
$public_selection = bratonien_tools_get_public_selection_settings(); $public_selection = bratonien_tools_get_public_selection_settings();
$public_selection_groups = bratonien_tools_get_piwigo_groups(); $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(); $assets = bratonien_tools_get_assets();
$asset_environment = bratonien_tools_get_asset_environment(); $asset_environment = bratonien_tools_get_asset_environment();
$album_shares = bratonien_tools_get_album_shares(); $album_shares = bratonien_tools_get_album_shares();
@@ -244,6 +285,7 @@ $template->assign(array(
'CACHE_WORKERS' => $cache_workers, 'CACHE_WORKERS' => $cache_workers,
'PUBLIC_SELECTION' => $public_selection, 'PUBLIC_SELECTION' => $public_selection,
'PUBLIC_SELECTION_GROUPS' => $public_selection_groups, 'PUBLIC_SELECTION_GROUPS' => $public_selection_groups,
'CUSTOMER_QR' => $customer_qr,
'BRATONIEN_ASSETS' => $assets, 'BRATONIEN_ASSETS' => $assets,
'ASSET_ENV' => $asset_environment, 'ASSET_ENV' => $asset_environment,
'BRATONIEN_ALBUM_SHARES' => $album_shares, '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('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('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('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('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('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'); $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('admin_tabs_content', true);
$admin_content .= $template->parse('plugin_admin_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('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('asset_manager_admin_content', true);
$admin_content .= $template->parse('album_shares_admin_content', true); $admin_content .= $template->parse('album_shares_admin_content', true);
$admin_content .= $template->parse('nc_connector_admin_content', true); $admin_content .= $template->parse('nc_connector_admin_content', true);

View File

@@ -17,3 +17,12 @@
.bratonien-picture-zone.is-active { .bratonien-picture-zone.is-active {
opacity: 1; 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!'); die('Hacking attempt!');
} }
require_once(BRATONIEN_TOOLS_PATH.'include/webdav_warmup_settings.inc.php');
function bratonien_tools_cache_worker_settings_file() function bratonien_tools_cache_worker_settings_file()
{ {
return PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-tools-cache-workers.json'; 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['auto_workers'] = min($settings['max_workers'], $settings['cpu_count']);
$settings['manual_workers'] = min($settings['max_workers'], $settings['manual_workers']); $settings['manual_workers'] = min($settings['max_workers'], $settings['manual_workers']);
$settings['worker_count'] = $settings['auto'] ? $settings['auto_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; 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'); $profiles = bratonien_tools_table('watermark_profiles');
$rules = bratonien_tools_table('watermark_rules'); $rules = bratonien_tools_table('watermark_rules');
$nc_connections = bratonien_tools_table('nc_connections'); $nc_connections = bratonien_tools_table('nc_connections');
$customer_qr = bratonien_tools_table('customer_qr_uploads');
pwg_query("CREATE TABLE IF NOT EXISTS `$profiles` ( pwg_query("CREATE TABLE IF NOT EXISTS `$profiles` (
id int(11) NOT NULL AUTO_INCREMENT, id int(11) NOT NULL AUTO_INCREMENT,
@@ -81,6 +82,20 @@ function bratonien_tools_create_tables()
UNIQUE KEY connection_key (connection_key) UNIQUE KEY connection_key (connection_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); ) 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')) if (function_exists('bratonien_tools_create_album_shares_table'))
{ {
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_profiles').'`');
pwg_query('DROP TABLE IF EXISTS `'.bratonien_tools_table('watermark_rules').'`'); 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('nc_connections').'`');
pwg_query('DROP TABLE IF EXISTS `'.bratonien_tools_table('customer_qr_uploads').'`');
if (function_exists('bratonien_tools_drop_album_shares_table')) 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!'); 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'=>''); $result = array('exit'=>-1, 'stdout'=>'', 'stderr'=>'');
if (!function_exists('proc_open')) if (!function_exists('proc_open'))
@@ -13,7 +13,6 @@ function bratonien_tools_nc_connector_systemctl_run(array $args)
return $result; return $result;
} }
$command = array_merge(array('/usr/bin/systemctl'), $args);
$spec = array( $spec = array(
0=>array('file','/dev/null','r'), 0=>array('file','/dev/null','r'),
1=>array('pipe','w'), 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); $process = @proc_open($command, $spec, $pipes, null, $environment);
if (!is_resource($process)) if (!is_resource($process))
{ {
$result['stderr'] = 'systemctl konnte nicht gestartet werden.'; $result['stderr'] = 'Prozess konnte nicht gestartet werden.';
return $result; return $result;
} }
@@ -35,6 +34,11 @@ function bratonien_tools_nc_connector_systemctl_run(array $args)
return $result; 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) function bratonien_tools_nc_connector_systemctl_value(array $args)
{ {
$result = bratonien_tools_nc_connector_systemctl_run($args); $result = bratonien_tools_nc_connector_systemctl_run($args);
@@ -44,15 +48,50 @@ function bratonien_tools_nc_connector_systemctl_value(array $args)
function bratonien_tools_nc_connector_run_now() function bratonien_tools_nc_connector_run_now()
{ {
$service = 'bratonien-nc-connector.service'; $service = 'bratonien-nc-connector.service';
$result = bratonien_tools_nc_connector_systemctl_run(array('start','--no-block',$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: der privilegierte Connector-Dienst '.$service.' ist nicht geladen.');
}
// 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'))
{
$result = bratonien_tools_nc_connector_command_run(array(
'/usr/bin/sudo',
'-n',
'/usr/bin/systemctl',
'start',
$service,
));
}
if ($result['exit'] !== 0) if ($result['exit'] !== 0)
{ {
$detail = $result['stderr'] !== '' ? $result['stderr'] : $result['stdout']; $detail = $result['stderr'] !== '' ? $result['stderr'] : $result['stdout'];
if ($detail === '') $detail = 'systemctl Exit-Code '.$result['exit']; if ($detail === '') $detail = 'Exit-Code '.$result['exit'];
throw new RuntimeException('WebDAV-Abgleich konnte nicht gestartet werden: '.$detail); 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
);
} }
return array('message'=>'WebDAV-Abgleich wurde gestartet.'); $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 wurde gestartet, der Connector-Dienst endete aber mit Ergebnis '.$service_result.'.');
}
return array('message'=>'WebDAV-Abgleich wurde über den privilegierten Connector-Dienst ausgeführt.');
} }
function bratonien_tools_nc_connector_parse_systemd_time($value) 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 . '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.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'tools/watermark_profiles.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/watermark_settings.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'tools/album_rules.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 . 'tools/asset_manager.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/watermark_engine.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/public_selection.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/self_update.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_shares.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/album_lock.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.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_piwigo_api.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'); 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_delete_safe.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_webdav.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/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() function bratonien_tools_get_tools()
{ {
return array( return array(
'image_cache_clear' => array('handler' => 'bratonien_tools_clear_image_cache'), 'image_cache_clear' => array('handler' => 'bratonien_tools_clear_image_cache_atomic'),
'image_cache_build' => array('handler' => 'bratonien_tools_start_main_cache_build'), 'image_cache_build' => array('handler' => 'bratonien_tools_start_combined_image_cache_build'),
'image_cache_cancel' => array('handler' => 'bratonien_tools_cancel_main_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_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_save' => array('handler' => 'bratonien_tools_save_watermark'),
'watermark_file_delete' => array('handler' => 'bratonien_tools_delete_watermark_file'), 'watermark_file_delete' => array('handler' => 'bratonien_tools_delete_watermark_file'),
'watermark_engine' => array('handler' => 'bratonien_tools_handle_watermark_engine'), '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_defaults' => array('handler' => 'bratonien_tools_save_watermark_defaults'),
'watermark_rule' => array('handler' => 'bratonien_tools_save_album_rule'), 'watermark_rule' => array('handler' => 'bratonien_tools_save_album_rule'),
'public_selection_settings' => array('handler' => 'bratonien_tools_save_public_selection_settings'), '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_upload' => array('handler' => 'bratonien_tools_upload_asset'),
'asset_delete' => array('handler' => 'bratonien_tools_delete_asset'), 'asset_delete' => array('handler' => 'bratonien_tools_delete_asset'),
'asset_upload_limits' => array('handler' => 'bratonien_tools_save_upload_limits'), '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']); $enabled = !empty($_POST['engine_enabled']);
bratonien_tools_set_watermark_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( return array(
'message' => $enabled 'message' => $enabled
? 'Bratonien-Wasserzeichenverwaltung aktiviert. Piwigos bisherige Wasserzeichen-Nutzung wurde gesichert und unterdrueckt.' ? '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.', : '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), '+/', '-_'), '='); 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) function bratonien_tools_profile_watermark_path(array $profile)
{ {
static $paths = array(); static $paths = array();
if (empty($profile['watermark_file'])) $relative = bratonien_tools_profile_watermark_relative($profile);
if ($relative === '')
{ {
return null; return null;
} }
$relative = ltrim((string)$profile['watermark_file'], '/');
if (array_key_exists($relative, $paths)) if (array_key_exists($relative, $paths))
{ {
return $paths[$relative]; return $paths[$relative];
@@ -119,9 +140,10 @@ function bratonien_tools_profile_watermark_path(array $profile)
function bratonien_tools_runtime_profile_version(array $profile) function bratonien_tools_runtime_profile_version(array $profile)
{ {
$watermark_path = bratonien_tools_profile_watermark_path($profile); $watermark_path = bratonien_tools_profile_watermark_path($profile);
$effective_file = bratonien_tools_profile_watermark_relative($profile);
$parts = array( $parts = array(
$profile['id'] ?? 0, $profile['id'] ?? 0,
$profile['watermark_file'] ?? '', $effective_file,
$profile['scale_percent'] ?? 100, $profile['scale_percent'] ?? 100,
$profile['xpos'] ?? 90, $profile['xpos'] ?? 90,
$profile['ypos'] ?? 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; $image_id = (int)$image_id;
if ($image_id < 1) return null; 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'); $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; 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 = ''; $content_type = '';
$size = 0; $size = 0;
$etag = ''; $etag = '';
$fileid = 0;
$width = 0;
$height = 0;
$state_dir = rtrim((string)($config['state_dir'] ?? ''), '/'); $state_dir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($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); $mapping = json_decode((string)file_get_contents($mapping_file), true);
if (is_array($mapping) && isset($mapping['files']) && is_array($mapping['files'])) 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') if (is_array($entry) && (string)($entry['kind'] ?? '') === 'file')
{ {
$mapped_path = trim((string)($entry['webdav_path'] ?? ''), '/'); $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'] ?? ''); $content_type = (string)($entry['content_type'] ?? '');
$size = (int)($entry['size'] ?? 0); $size = (int)($entry['size'] ?? 0);
$etag = (string)($entry['etag'] ?? ''); $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, 'content_type'=>$content_type,
'size'=>$size, 'size'=>$size,
'etag'=>$etag, 'etag'=>$etag,
'fileid'=>$fileid,
'width'=>$width,
'height'=>$height,
'state_dir'=>$state_dir,
'coi'=>$row['coi'] ?? null, '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='') 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 !== '') if ($after_url !== '')
{ {
$url .= '&after='.rawurlencode(bratonien_tools_webdav_materialize_b64url_encode($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); 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 () { $(function () {
var image = document.getElementById('theMainImage'); var image = document.getElementById('theMainImage');
if (!image) { if (!image) {
return; return;
} }
protectWebdavThumbnails(document.getElementById('theImageAndInfos') || document);
if (image.complete) { if (image.complete) {
updateMap(); updateMap();
} else { } else {
@@ -241,6 +334,35 @@
attributes: true, attributes: true,
attributeFilter: ['src', 'srcset', 'usemap', 'class', 'style'] 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); })(jQuery);

View File

@@ -23,7 +23,7 @@ $_SERVER['REQUEST_URI'] = '/';
$_SERVER['SCRIPT_NAME'] = '/plugins/bratonien_tools/main-cache-build.php'; $_SERVER['SCRIPT_NAME'] = '/plugins/bratonien_tools/main-cache-build.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME']; $_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = ''; $_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'; $_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php'); 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.'tools/image_cache.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/watermark_engine.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.'include/derivative.inc.php');
require_once(PHPWG_ROOT_PATH.'admin/include/image.class.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'); $result = pwg_query('SELECT * FROM '.IMAGES_TABLE.' ORDER BY id');
while ($row = pwg_db_fetch_assoc($result)) 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; $rows[] = $row;
} }
@@ -248,6 +260,7 @@ function bratonien_tools_cache_builder_worker($worker_index, $worker_count, $run
{ {
$derivative = new DerivativeImage($requested_params, $src); $derivative = new DerivativeImage($requested_params, $src);
$target_path = $derivative->get_path(); $target_path = $derivative->get_path();
if (strpos($target_path, PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR) !== 0) if (strpos($target_path, PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR) !== 0)
{ {
$skipped++; $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( bratonien_tools_write_main_cache_status(array(
'state'=>'running', 'state'=>'running',
'message'=>$running_message, 'message'=>$running_message,
@@ -522,7 +535,7 @@ $state = bratonien_tools_cache_builder_aggregate(
$worker_count, $worker_count,
$was_cancelled $was_cancelled
? 'Piwigo-Bildcache-Aufbau wurde abgebrochen.' ? '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++) for ($i=0; $i<$worker_count; $i++)
@@ -536,3 +549,4 @@ fclose($lock);
@unlink($lock_path); @unlink($lock_path);
exit(($state === 'error' || $exit_error) ? 1 : 0); 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'); 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( if (!is_file($file) || !is_readable($file)) return null;
'state'=>'idle','message'=>'Noch kein manueller Cache-Aufbau gestartet.', $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, '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); $webdav = array();
$data = $raw !== false ? json_decode($raw, true) : null; foreach ((array)glob(PHPWG_ROOT_PATH.PWG_LOCAL_DIR.'bratonien-webdav-warmup.status-*.json') as $file)
if (!is_array($data))
{ {
http_response_code(500); $status = bratonien_tools_status_read_json($file);
echo json_encode(array('state'=>'error','message'=>'Cache-Status ist nicht lesbar.')); if (!$status) continue;
exit; 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'); $webdav_active = false;
$updated = (int)($data['updated_at'] ?? 0); $webdav_waiting = false;
if (($state === 'running' || $state === 'queued') && $updated > 0 && (time() - $updated) > 45) $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'; $state = (string)($status['state'] ?? 'idle');
$data['message'] = 'Cache-Aufbau liefert seit mehr als 45 Sekunden keinen Fortschritt. Der Prozess ist vermutlich beendet oder festgefahren.'; $updated = (int)($status['updated_at'] ?? 0);
$data['errors'] = max(1, (int)($data['errors'] ?? 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 <?php
/* /*
Plugin Name: Bratonien Tools Plugin Name: Bratonien Tools
Version: 0.9.6.8.11 Version: 0.9.7.1.51
Description: Erweiterbare Administrationswerkzeuge fuer die Bratonien-Piwigo-Installation. Description: Erweiterbare Administrationswerkzeuge fuer die Bratonien-Piwigo-Installation.
Plugin URI: https://github.com/Terranom674/Piwigo_Bratonien_Tools Plugin URI: https://github.com/Terranom674/Piwigo_Bratonien_Tools
Author: Bratonien 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/watermark_runtime.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/webdav_image_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/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/public_selection.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/picture_navigation.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/batch_titles.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/album_shares.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_orphan_ws.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_productive_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_admin_plugin_menu_links', 'bratonien_tools_admin_menu');
add_event_handler('get_derivative_url', 'bratonien_tools_filter_derivative_url', EVENT_HANDLER_PRIORITY_NEUTRAL, 4); add_event_handler('get_derivative_url', 'bratonien_tools_filter_derivative_url', EVENT_HANDLER_PRIORITY_NEUTRAL, 4);
@@ -34,7 +38,11 @@ add_event_handler('element_set_global_action', 'bratonien_tools_batch_titles_app
add_event_handler('init', 'bratonien_tools_prepare_connector_private_import', EVENT_HANDLER_PRIORITY_NEUTRAL - 30); add_event_handler('init', 'bratonien_tools_prepare_connector_private_import', EVENT_HANDLER_PRIORITY_NEUTRAL - 30);
add_event_handler('init', 'bratonien_tools_prepare_private_album_permissions', EVENT_HANDLER_PRIORITY_NEUTRAL - 20); 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_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('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('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_orphan_ws_methods');
add_event_handler('ws_add_methods', 'bratonien_tools_register_nc_productive_ws_methods'); add_event_handler('ws_add_methods', 'bratonien_tools_register_nc_productive_ws_methods');
@@ -85,6 +93,57 @@ function bratonien_tools_prepare_private_album_permissions()
bratonien_tools_grant_private_album_access($category_id, (int)$user['id']); bratonien_tools_grant_private_album_access($category_id, (int)$user['id']);
} }
function bratonien_tools_preserve_connector_top_level_access()
{
global $user;
if (
!defined('IN_ADMIN')
|| ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST'
|| (string)($_GET['page'] ?? '') !== 'site_update'
|| (string)($_POST['bratonien_connector'] ?? '') !== '1'
|| empty($user['id'])
)
{
return;
}
$site_id = (int)($_GET['site'] ?? 0);
$user_id = (int)$user['id'];
if ($site_id < 1 || $user_id < 1)
{
return;
}
register_shutdown_function(function () use ($site_id, $user_id) {
$query = '\nSELECT id\n FROM '.CATEGORIES_TABLE.'\n WHERE site_id = '.$site_id.'\n AND dir IS NOT NULL\n AND id_uppercat IS NULL\n AND status = \'private\'\n;';
$result = pwg_query($query);
$changed = false;
while ($row = pwg_db_fetch_assoc($result))
{
$category_id = (int)$row['id'];
if ($category_id < 1)
{
continue;
}
$access_query = '\nSELECT 1\n FROM '.USER_ACCESS_TABLE.'\n WHERE user_id = '.$user_id.'\n AND cat_id = '.$category_id.'\n LIMIT 1\n;';
if (pwg_db_num_rows(pwg_query($access_query)) > 0)
{
continue;
}
bratonien_tools_grant_private_album_access($category_id, $user_id);
$changed = true;
}
if ($changed && function_exists('invalidate_user_cache'))
{
invalidate_user_cache(true);
}
});
}
function bratonien_tools_grant_private_album_access($category_id, $user_id) function bratonien_tools_grant_private_album_access($category_id, $user_id)
{ {
$category_id = (int)$category_id; $category_id = (int)$category_id;
@@ -94,13 +153,7 @@ function bratonien_tools_grant_private_album_access($category_id, $user_id)
return; return;
} }
$query = ' $query = '\nSELECT 1\n FROM '.USER_ACCESS_TABLE.'\n WHERE user_id = '.$user_id.'\n AND cat_id = '.$category_id.'\n LIMIT 1\n;';
SELECT 1
FROM '.USER_ACCESS_TABLE.'
WHERE user_id = '.$user_id.'
AND cat_id = '.$category_id.'
LIMIT 1
;';
$result = pwg_query($query); $result = pwg_query($query);
if (pwg_db_num_rows($result) > 0) if (pwg_db_num_rows($result) > 0)
{ {
@@ -116,6 +169,39 @@ SELECT 1
); );
} }
function bratonien_tools_fix_admin_album_stat_tile()
{
global $template;
if (!defined('IN_ADMIN'))
{
return;
}
$template->set_prefilter('intro', 'bratonien_tools_prefilter_admin_album_stat_tile');
}
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) function bratonien_tools_admin_menu($menu)
{ {
$menu[] = array( $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

@@ -25,12 +25,24 @@ from pathlib import Path, PurePosixPath
DAV = "DAV:" DAV = "DAV:"
OC = "http://owncloud.org/ns" OC = "http://owncloud.org/ns"
NC = "http://nextcloud.org/ns"
SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
# 1x1 transparent GIF, 34 bytes. The filename keeps the remote extension; # 1x1 transparent GIF, 34 bytes. The filename keeps the remote extension;
# the placeholder exists only so Piwigo can discover the logical image entry. # the placeholder exists only so Piwigo can discover the logical image entry.
PLACEHOLDER = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==") 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: def fail(message: str) -> None:
raise RuntimeError(message) raise RuntimeError(message)
@@ -54,6 +66,49 @@ def safe_local_name(name: str) -> str:
return name return name
def parse_image_dimensions(prop: ET.Element) -> tuple[int, int]:
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:
value = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if not isinstance(value, dict):
continue
try:
width = int(value.get("width", 0) or 0)
height = int(value.get("height", 0) or 0)
except (TypeError, ValueError):
continue
if width > 0 and height > 0:
return width, height
return 0, 0
class WebDavClient: class WebDavClient:
def __init__(self, base_url: str, user: str, password: str, timeout: int = 30) -> None: def __init__(self, base_url: str, user: str, password: str, timeout: int = 30) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
@@ -77,9 +132,11 @@ class WebDavClient:
url = self.collection_url(relative) url = self.collection_url(relative)
body = ( body = (
'<?xml version="1.0"?>' '<?xml version="1.0"?>'
'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">' '<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">'
'<d:prop><d:displayname/><d:resourcetype/><d:getcontenttype/>' '<d:prop><d:displayname/><d:resourcetype/><d:getcontenttype/>'
'<d:getcontentlength/><d:getetag/><oc:fileid/></d:prop></d:propfind>' '<d:getcontentlength/><d:getetag/><oc:fileid/>'
'<nc:file-metadata-size/><nc:metadata-photos-size/>'
'</d:prop></d:propfind>'
).encode("utf-8") ).encode("utf-8")
request = urllib.request.Request(url, data=body, method="PROPFIND") request = urllib.request.Request(url, data=body, method="PROPFIND")
request.add_header("Authorization", self.auth_header) request.add_header("Authorization", self.auth_header)
@@ -90,8 +147,10 @@ class WebDavClient:
status = response.status status = response.status
payload = response.read() payload = response.read()
except urllib.error.HTTPError as error: except urllib.error.HTTPError as error:
if error.code in {401, 403}: if error.code == 401:
fail("Nextcloud rejected the WebDAV credentials or directory access") 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}") fail(f"Nextcloud PROPFIND failed with HTTP {error.code}")
except urllib.error.URLError as error: except urllib.error.URLError as error:
fail(f"Nextcloud WebDAV is unreachable: {error.reason}") fail(f"Nextcloud WebDAV is unreachable: {error.reason}")
@@ -122,6 +181,7 @@ class WebDavClient:
fileid_text = prop.findtext(f"{{{OC}}}fileid", default="").strip() fileid_text = prop.findtext(f"{{{OC}}}fileid", default="").strip()
resource_type = prop.find(f"{{{DAV}}}resourcetype") resource_type = prop.find(f"{{{DAV}}}resourcetype")
is_dir = resource_type is not None and resource_type.find(f"{{{DAV}}}collection") is not None is_dir = resource_type is not None and resource_type.find(f"{{{DAV}}}collection") is not None
width, height = parse_image_dimensions(prop)
item = { item = {
"display_name": display, "display_name": display,
"fileid": int(fileid_text) if fileid_text.isdigit() else 0, "fileid": int(fileid_text) if fileid_text.isdigit() else 0,
@@ -129,6 +189,8 @@ class WebDavClient:
"content_type": prop.findtext(f"{{{DAV}}}getcontenttype", default=""), "content_type": prop.findtext(f"{{{DAV}}}getcontenttype", default=""),
"size": int(prop.findtext(f"{{{DAV}}}getcontentlength", default="0") or 0), "size": int(prop.findtext(f"{{{DAV}}}getcontentlength", default="0") or 0),
"etag": prop.findtext(f"{{{DAV}}}getetag", default="").strip('"'), "etag": prop.findtext(f"{{{DAV}}}getetag", default="").strip('"'),
"width": width,
"height": height,
} }
if href_path == base_path: if href_path == base_path:
current = item current = item
@@ -140,6 +202,25 @@ class WebDavClient:
return current, children 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: def link_placeholder(seed: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
try: try:
@@ -197,6 +278,8 @@ def build_root(
"content_type": str(child.get("content_type", "")), "content_type": str(child.get("content_type", "")),
"size": int(child.get("size", 0)), "size": int(child.get("size", 0)),
"etag": str(child.get("etag", "")), "etag": str(child.get("etag", "")),
"width": int(child.get("width", 0) or 0),
"height": int(child.get("height", 0) or 0),
} }
return files, folders, skipped return files, folders, skipped
@@ -250,14 +333,34 @@ def main() -> int:
staging.mkdir(parents=True) staging.mkdir(parents=True)
client = WebDavClient(args.base_url, args.user, password, max(1, args.timeout)) 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]] = {} mapping: dict[str, dict[str, object]] = {}
manifest: list[str] = [] manifest: list[str] = []
total_files = total_folders = total_skipped = 0 total_files = total_folders = total_skipped = 0
used_names: set[str] = set() used_names: set[str] = set()
removed_roots: list[dict[str, object]] = []
for remote_root_raw in args.root: for remote_root_raw in args.root:
remote_root = validate_relative(remote_root_raw) remote_root = validate_relative(remote_root_raw)
try:
current, _ = client.list_collection(remote_root) 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"]) fileid = int(current["fileid"])
display = str(current.get("display_name", "")).strip() or (PurePosixPath(remote_root).name if remote_root else args.user) display = str(current.get("display_name", "")).strip() or (PurePosixPath(remote_root).name if remote_root else args.user)
local_name = f"root-{fileid}" local_name = f"root-{fileid}"
@@ -269,6 +372,19 @@ def main() -> int:
total_files += files total_files += files
total_folders += folders total_folders += folders
total_skipped += skipped total_skipped += skipped
if remote_root == "":
for child in sorted(local_root.iterdir(), key=lambda item: (item.name.casefold(), item.name)):
child_data = mapping.get(str(child))
if not child_data:
continue
child_fileid = int(child_data.get("fileid", 0))
child_display = str(child_data.get("display_name", "")).strip() or child.name
child_type = "folder" if child.is_dir() else "file"
manifest.append(
f"webdav:{child_fileid}\t{child_type}\t{child_display}\t{source_dir / local_name / child.name}"
)
else:
manifest.append(f"webdav:{fileid}\tfolder\t{display}\t{source_dir / local_name}") manifest.append(f"webdav:{fileid}\tfolder\t{display}\t{source_dir / local_name}")
if previous.exists(): if previous.exists():
@@ -293,6 +409,19 @@ def main() -> int:
final_path = source_text + path[len(staging_text):] if path.startswith(staging_text) else path final_path = source_text + path[len(staging_text):] if path.startswith(staging_text) else path
final_mapping[final_path] = data 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_text(args.manifest, "\n".join(manifest) + "\n")
atomic_json(args.mapping, { atomic_json(args.mapping, {
"version": 1, "version": 1,
@@ -302,6 +431,9 @@ def main() -> int:
}) })
print(json.dumps({ print(json.dumps({
"roots": len(args.root), "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, "files": total_files,
"folders": total_folders, "folders": total_folders,
"skipped": total_skipped, "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

@@ -32,7 +32,7 @@ function fetch_remote_blob($url, $user, $password)
CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $user.':'.$password, CURLOPT_USERPWD => $user.':'.$password,
CURLOPT_FAILONERROR => false, CURLOPT_FAILONERROR => false,
CURLOPT_USERAGENT => 'Bratonien-Tools-WebDAV-Precache/0.9.6.1', CURLOPT_USERAGENT => 'Bratonien-Tools-WebDAV-Precache/0.9.6.8.21',
)); ));
$body = curl_exec($ch); $body = curl_exec($ch);
$errno = curl_errno($ch); $errno = curl_errno($ch);

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

@@ -0,0 +1,125 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
$options = getopt('', array('piwigo-root:', 'connection-id:', 'mapping:'));
$piwigo_root = rtrim((string)($options['piwigo-root'] ?? ''), '/');
$connection_id = (int)($options['connection-id'] ?? 0);
$mapping_file = (string)($options['mapping'] ?? '');
if ($piwigo_root === '' || $connection_id < 1 || $mapping_file === '' || !is_readable($mapping_file))
{
fwrite(STDERR, "Parameter --piwigo-root, --connection-id und --mapping werden benoetigt.\n");
exit(1);
}
$mapping = json_decode((string)file_get_contents($mapping_file), true);
if (!is_array($mapping) || !isset($mapping['files']) || !is_array($mapping['files']))
{
fwrite(STDERR, "WebDAV-Mapping ist ungueltig.\n");
exit(1);
}
$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)
{
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.'/');
$_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/sync-webdav-metadata.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Metadata/0.9.7.1.47';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
$checked = 0;
$updated = 0;
$missing = 0;
$result = pwg_query('SELECT id, path, width, height FROM '.IMAGES_TABLE.' ORDER BY id');
while ($row = pwg_db_fetch_assoc($result))
{
$path = (string)($row['path'] ?? '');
if ($path === '') continue;
$absolute = $path;
if (strpos($absolute, '/') !== 0)
{
$absolute = PHPWG_ROOT_PATH.ltrim(preg_replace('#^\./#', '', $absolute), '/');
}
$normalized_shadow = str_replace('\\', '/', $absolute);
if (strpos($normalized_shadow, '/nc-webdav-gallery/connection-'.$connection_id.'/') === false) continue;
$checked++;
$resolved = realpath($absolute);
if ($resolved === false)
{
$missing++;
continue;
}
$key = str_replace('\\', '/', $resolved);
if (!isset($dimensions[$key]))
{
$missing++;
continue;
}
list($width, $height) = $dimensions[$key];
if ((int)$row['width'] === $width && (int)$row['height'] === $height) continue;
pwg_query('UPDATE '.IMAGES_TABLE.' SET width='.$width.', height='.$height.' WHERE id='.(int)$row['id']);
$updated++;
}
if ($updated > 0)
{
if (function_exists('update_category')) update_category('all');
if (function_exists('invalidate_user_cache')) invalidate_user_cache(true);
}
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); $iv = base64_decode((string)($payload['iv'] ?? ''), true);
$tag = base64_decode((string)($payload['tag'] ?? ''), true); $tag = base64_decode((string)($payload['tag'] ?? ''), true);
$cipher = base64_decode((string)($payload['data'] ?? ''), 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); $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.'); if ($plain === false || $plain === '') fail_webdav_reconcile('Connector-Zugangsdaten konnten nicht entschluesselt werden.');
$decoded = json_decode($plain, true); $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)); 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__); $pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2); $piwigoRoot = dirname($pluginRoot, 2);
$dbConfig = $piwigoRoot.'/local/config/database.inc.php'; $dbConfig = $piwigoRoot.'/local/config/database.inc.php';
$configDir = '/etc/bratonien-tools/nc-connector'; $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'; $publicSourceRoot = rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-webdav-source';
$publicGalleryRoot = rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-webdav-gallery'; $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); 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($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($publicSourceRoot, 0755);
@chmod($publicGalleryRoot, 0755); @chmod($publicGalleryRoot, 0755);
@@ -151,7 +161,7 @@ try
$stateDir = rtrim((string)($config['state_dir'] ?? ''), '/'); $stateDir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($stateDir === '') $stateDir = $stateRoot.'/connection-'.$id; if ($stateDir === '') $stateDir = $stateRoot.'/connection-'.$id;
if (!is_dir($stateDir) && !mkdir($stateDir, 0750, true)) fail_webdav_reconcile('State-Verzeichnis konnte nicht angelegt werden.'); 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'] ?? ''), '/'); $galleryRoot = rtrim((string)($config['parallel_gallery_root'] ?? ''), '/');
$expectedGalleryRoot = $publicGalleryRoot.'/connection-'.$id; $expectedGalleryRoot = $publicGalleryRoot.'/connection-'.$id;
@@ -180,7 +190,7 @@ try
{ {
$path = trim((string)($root['webdav_path'] ?? ''), '/'); $path = trim((string)($root['webdav_path'] ?? ''), '/');
$display = trim((string)($root['display_name'] ?? '')); $display = trim((string)($root['display_name'] ?? ''));
if ($path === '' || $display === '' || preg_match('/[\t\r\n]/', $path.$display)) fail_webdav_reconcile('Eine gespeicherte WebDAV-Wurzel ist ungueltig.'); if ($display === '' || preg_match('/[\t\r\n]/', $path.$display)) fail_webdav_reconcile('Eine gespeicherte WebDAV-Wurzel ist ungueltig.');
$rootLines[] = $path."\t".$display; $rootLines[] = $path."\t".$display;
} }
file_put_contents($rootsPath, implode("\n", $rootLines)."\n", LOCK_EX); file_put_contents($rootsPath, implode("\n", $rootLines)."\n", LOCK_EX);

View File

@@ -25,22 +25,8 @@ $_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php'); require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
require_once(PHPWG_ROOT_PATH.'admin/include/functions.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 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-'; $relativePrefix = './_data/bratonien-tools/nc-webdav-gallery/connection-';
$absolutePrefix = rtrim(PHPWG_ROOT_PATH, '/').'/_data/bratonien-tools/nc-webdav-gallery/connection-'; $absolutePrefix = rtrim(PHPWG_ROOT_PATH, '/').'/_data/bratonien-tools/nc-webdav-gallery/connection-';
$relativeLength = strlen($relativePrefix); $relativeLength = strlen($relativePrefix);
@@ -51,6 +37,7 @@ try
" OR LEFT(path,".$absoluteLength.")='".pwg_db_real_escape_string($absolutePrefix)."'"; " OR LEFT(path,".$absoluteLength.")='".pwg_db_real_escape_string($absolutePrefix)."'";
$result = pwg_query($query); $result = pwg_query($query);
$checked = 0;
$staleIds = array(); $staleIds = array();
while ($row = pwg_db_fetch_assoc($result)) while ($row = pwg_db_fetch_assoc($result))
{ {
@@ -71,6 +58,7 @@ try
continue; continue;
} }
$checked++;
if (!is_file($filesystemPath)) if (!is_file($filesystemPath))
{ {
$staleIds[] = $id; $staleIds[] = $id;
@@ -80,22 +68,19 @@ try
$staleIds = array_values(array_unique(array_map('intval', $staleIds))); $staleIds = array_values(array_unique(array_map('intval', $staleIds)));
if ($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); delete_elements($staleIds, false);
invalidate_user_cache(true); invalidate_user_cache(true);
} }
$payload = date('c').' removed='.count($staleIds)."\n"; echo 'NC WebDAV laufende Bereinigung: geprueft='.$checked.' entfernte_verwaiste_bilder='.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";
exit(0); exit(0);
} }
catch (Throwable $e) catch (Throwable $e)
{ {
fwrite(STDERR, 'NC WebDAV Altlasten-Reparatur: '.$e->getMessage()."\n"); fwrite(STDERR, 'NC WebDAV laufende Bereinigung: '.$e->getMessage()."\n");
exit(1); exit(1);
} }

View File

@@ -31,9 +31,30 @@ result=0
for config in "${webdav_configs[@]}"; do for config in "${webdav_configs[@]}"; do
name="$(basename "$config")" name="$(basename "$config")"
echo "NC Connector WebDAV: $name" 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 result=1
fi fi
done 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" exit "$result"

View File

@@ -27,7 +27,20 @@ source "$CONFIG_FILE"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
LOCK_FILE="$STATE_DIR/webdav-sync.lock" 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" 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" exec 9>"$LOCK_FILE"
flock -n 9 || exit 0 flock -n 9 || exit 0
@@ -66,27 +79,76 @@ compact_output() {
failure() { failure() {
local code="$1" command="$2" line="$3" local code="$1" command="$2" line="$3"
trap - ERR trap - ERR
write_status error "WebDAV-Shadow-Tree fehlgeschlagen" "Exit-Code: $code; Zeile: $line; Befehl: $command" write_status error "WebDAV-Synchronisierung fehlgeschlagen" "Exit-Code: $code; Zeile: $line; Befehl: $command"
exit "$code" exit "$code"
} }
trap 'failure $? "$BASH_COMMAND" "$LINENO"' ERR trap 'failure $? "$BASH_COMMAND" "$LINENO"' ERR
write_status running "WebDAV-Synchronisierung läuft"
ROOT_ARGS=() ROOT_ARGS=()
while IFS=$'\t' read -r path display_name; do while IFS= read -r line; do
[[ -z "$path" || "$path" == \#* ]] && continue [[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" == *$'\t'* ]] || continue
path="${line%%$'\t'*}"
ROOT_ARGS+=(--root "$path") ROOT_ARGS+=(--root "$path")
done < "$WEBDAV_ROOTS_FILE" done < "$WEBDAV_ROOTS_FILE"
[[ ${#ROOT_ARGS[@]} -gt 0 ]] || { write_status error "Keine WebDAV-Wurzeln konfiguriert"; exit 1; } [[ ${#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" \ --base-url "$WEBDAV_BASE_URL" \
--user "$WEBDAV_USER" \ --user "$WEBDAV_USER" \
--password-file "$WEBDAV_PASSWORD_FILE" \ --password-file "$WEBDAV_PASSWORD_FILE" \
"${ROOT_ARGS[@]}" \ "${ROOT_ARGS[@]}" \
--source-dir "$WEBDAV_SOURCE_DIR" \ --source-dir "$WEBDAV_SOURCE_DIR" \
--manifest "$MANIFEST" \ --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" \ python3 "$SCRIPT_DIR/lib/shadow_tree.py" \
--manifest "$MANIFEST" \ --manifest "$MANIFEST" \
@@ -94,28 +156,6 @@ python3 "$SCRIPT_DIR/lib/shadow_tree.py" \
--state "$SHADOW_MAP_FILE" --state "$SHADOW_MAP_FILE"
trap - ERR trap - ERR
PREVIEW_CACHE="$PIWIGO_ROOT/_data/bratonien-tools/nc-webdav-preview/connection-$CONNECTION_ID"
PREVIEW_OUTPUT=""
PREVIEW_EXIT=0
if PREVIEW_OUTPUT="$(php "$SCRIPT_DIR/lib/precache-webdav-previews.php" \
--mapping="$WEBDAV_MAPPING_FILE" \
--base-url="$WEBDAV_BASE_URL" \
--user="$WEBDAV_USER" \
--password-file="$WEBDAV_PASSWORD_FILE" \
--cache-dir="$PREVIEW_CACHE" 2>&1)"; then
PREVIEW_EXIT=0
else
PREVIEW_EXIT=$?
fi
[[ -z "$PREVIEW_OUTPUT" ]] || printf '%s\n' "$PREVIEW_OUTPUT"
if [[ "$PREVIEW_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $PREVIEW_EXIT"
if [[ -n "$PREVIEW_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$PREVIEW_OUTPUT" | compact_output)"
fi
write_status error "WebDAV-Vorschaubilder konnten beim Einlesen nicht erzeugt werden" "$DETAIL"
exit "$PREVIEW_EXIT"
fi
if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
PIWIGO_OUTPUT="" PIWIGO_OUTPUT=""
@@ -160,28 +200,46 @@ if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
exit "$PIWIGO_EXIT" exit "$PIWIGO_EXIT"
fi fi
DERIVATIVE_OUTPUT="" CLEANUP_OUTPUT=""
DERIVATIVE_EXIT=0 CLEANUP_EXIT=0
if DERIVATIVE_OUTPUT="$(php "$SCRIPT_DIR/lib/build-webdav-derivatives.php" \ if CLEANUP_OUTPUT="$(php "$SCRIPT_DIR/repair-webdav-orphans.php" 2>&1)"; then
--piwigo-root="$PIWIGO_ROOT" \ CLEANUP_EXIT=0
--connection-id="$CONNECTION_ID" 2>&1)"; then
DERIVATIVE_EXIT=0
else else
DERIVATIVE_EXIT=$? CLEANUP_EXIT=$?
fi fi
[[ -z "$DERIVATIVE_OUTPUT" ]] || printf '%s\n' "$DERIVATIVE_OUTPUT" [[ -z "$CLEANUP_OUTPUT" ]] || printf '%s\n' "$CLEANUP_OUTPUT"
if [[ "$DERIVATIVE_EXIT" -ne 0 ]]; then if [[ "$CLEANUP_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $DERIVATIVE_EXIT" DETAIL="Exit-Code: $CLEANUP_EXIT"
if [[ -n "$DERIVATIVE_OUTPUT" ]]; then if [[ -n "$CLEANUP_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$DERIVATIVE_OUTPUT" | compact_output)" DETAIL+="; Ausgabe: $(printf '%s\n' "$CLEANUP_OUTPUT" | compact_output)"
fi fi
write_status error "Piwigo-Derivate für WebDAV-Bilder konnten nicht erzeugt werden" "$DETAIL" write_status error "Verwaiste Piwigo-Bilder entfernter WebDAV-Quellen konnten nicht bereinigt werden" "$DETAIL"
exit "$DERIVATIVE_EXIT" exit "$CLEANUP_EXIT"
fi
METADATA_OUTPUT=""
METADATA_EXIT=0
if METADATA_OUTPUT="$(php "$SCRIPT_DIR/lib/sync-webdav-metadata.php" \
--piwigo-root="$PIWIGO_ROOT" \
--connection-id="$CONNECTION_ID" \
--mapping="$WEBDAV_MAPPING_FILE" 2>&1)"; then
METADATA_EXIT=0
else
METADATA_EXIT=$?
fi
[[ -z "$METADATA_OUTPUT" ]] || printf '%s\n' "$METADATA_OUTPUT"
if [[ "$METADATA_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $METADATA_EXIT"
if [[ -n "$METADATA_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$METADATA_OUTPUT" | compact_output)"
fi
write_status error "Piwigo-Bildabmessungen konnten nicht mit den WebDAV-Metadaten abgeglichen werden" "$DETAIL"
exit "$METADATA_EXIT"
fi fi
if grep -q 'Piwigo-Synchronisierung per API erfolgreich' <<<"$PIWIGO_OUTPUT"; then if grep -q 'Piwigo-Synchronisierung per API erfolgreich' <<<"$PIWIGO_OUTPUT"; then
write_status ok \ write_status ok \
"WebDAV eingelesen, Piwigo synchronisiert und Derivate erzeugt" \ "WebDAV eingelesen und Piwigo synchronisiert" \
"" \ "" \
"api" \ "api" \
"ok" \ "ok" \
@@ -190,7 +248,7 @@ if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
"Fallback wurde nicht benötigt" "Fallback wurde nicht benötigt"
elif grep -q 'Piwigo-Datenbanksynchronisierung per Benutzername/Passwort-Fallback erfolgreich' <<<"$PIWIGO_OUTPUT"; then elif grep -q 'Piwigo-Datenbanksynchronisierung per Benutzername/Passwort-Fallback erfolgreich' <<<"$PIWIGO_OUTPUT"; then
write_status ok \ write_status ok \
"WebDAV eingelesen, Piwigo über Fallback synchronisiert und Derivate erzeugt" \ "WebDAV eingelesen und Piwigo über Fallback synchronisiert" \
"" \ "" \
"fallback" \ "fallback" \
"not_used" \ "not_used" \
@@ -198,8 +256,8 @@ if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
"ok" \ "ok" \
"Benutzername/Passwort-Fallback erfolgreich" "Benutzername/Passwort-Fallback erfolgreich"
else else
write_status ok "WebDAV eingelesen, Piwigo synchronisiert und Derivate erzeugt" write_status ok "WebDAV eingelesen und Piwigo synchronisiert"
fi fi
else else
write_status ok "WebDAV eingelesen und Vorschaubilder erzeugt; Registrierung erfolgt über den bestehenden produktiven Piwigo-Sync" write_status ok "WebDAV eingelesen; Registrierung erfolgt über den bestehenden produktiven Piwigo-Sync"
fi fi

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:'wasserzeichen', label:'Wasserzeichen', sections:['uebersicht','wasserzeichen','regeln'] },
{ id:'auswahl-download', label:'Fotoauswahl & Downloads' }, { id:'auswahl-download', label:'Fotoauswahl & Downloads' },
{ id:'freigaben', label:'Geschützte Freigaben' }, { id:'freigaben', label:'Geschützte Freigaben' },
{ id:'qr-upload', label:'QR-Upload' },
{ id:'nc-connector', label:'NC Connector' }, { id:'nc-connector', label:'NC Connector' },
{ id:'bilddateien', label:'Bilddateien & Pfade' }, { id:'bilddateien', label:'Bilddateien & Pfade' },
{ id:'wartung', label:'Wartung / Cache' }, { 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() 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.'); throw new RuntimeException('Profilname darf nicht leer sein.');
} }
$existing_profile = $id > 0 ? bratonien_tools_get_watermark_profile($id) : null;
if ($id > 0) if ($id > 0)
{ {
if (!bratonien_tools_get_watermark_profile($id)) if (!$existing_profile)
{ {
throw new RuntimeException('Wasserzeichenprofil nicht gefunden.'); throw new RuntimeException('Wasserzeichenprofil nicht gefunden.');
} }
@@ -128,7 +129,14 @@ function bratonien_tools_save_watermark_profile()
mass_inserts($table, array_keys($data), array($data)); 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) function bratonien_tools_profile_is_referenced($id)

View File

@@ -4,15 +4,47 @@ if (!defined('PHPWG_ROOT_PATH'))
die('Hacking attempt!'); 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() function bratonien_tools_get_watermark_defaults()
{ {
$defaults = conf_get_param('bratonien_watermark_defaults', null); $defaults = conf_get_param('bratonien_watermark_defaults', null);
$defaults = $defaults ? json_decode($defaults, true) : array(); $defaults = $defaults ? json_decode($defaults, true) : array();
$defaults = array_merge(array(
return array_merge(array(
'public_profile' => null, 'public_profile' => null,
'private_profile' => null, 'private_profile' => null,
), is_array($defaults) ? $defaults : array()); ), 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) function bratonien_tools_validate_default_profile($value)
@@ -34,12 +66,23 @@ function bratonien_tools_validate_default_profile($value)
function bratonien_tools_save_watermark_defaults() 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( $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), 'private_profile' => bratonien_tools_validate_default_profile($_POST['private_profile'] ?? null),
); );
conf_update_param('bratonien_watermark_defaults', json_encode($config)); 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; 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) function bratonien_tools_webdav_derivative_decrypt_secret($blob, $hex_key)
{ {
$hex_key = trim((string)$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)); 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 = ''; $detail = '';
$table = $GLOBALS['prefixeTable'].'bratonien_tools_nc_connections'; $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'); $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); $row = pwg_db_fetch_assoc($result);
$config = json_decode((string)$row['config_json'], true); $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"); $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); $key_row = pwg_db_fetch_assoc($key_result);
$credentials = bratonien_tools_webdav_derivative_decrypt_secret((string)$row['secret_blob'], (string)$key_row['value']); $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'] ?? ''), '/'); $base_url = rtrim((string)($config['nextcloud_url'] ?? ''), '/');
$user = trim((string)($credentials['nextcloud_user'] ?? '')); $user = trim((string)($credentials['nextcloud_user'] ?? ''));
$password = (string)($credentials['nextcloud_password'] ?? ''); $password = (string)($credentials['nextcloud_password'] ?? '');
$webdav_path = trim((string)($source['webdav_path'] ?? ''), '/'); if ($base_url === '' || $user === '' || $password === '') { $detail = 'WebDAV-Verbindung ist unvollstaendig.'; return null; }
if ($base_url === '' || $user === '' || $password === '' || $webdav_path === '') { $detail = 'WebDAV-Bildquelle ist unvollstaendig.'; return false; }
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'); $fp = @fopen($destination, 'xb');
if (!$fp) { $detail = 'Temporaere Quelldatei konnte nicht angelegt werden.'; return false; } 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); $ch = curl_init($url);
curl_setopt_array($ch, array( curl_setopt_array($ch, array(
CURLOPT_FOLLOWLOCATION => false, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 180, CURLOPT_TIMEOUT => 180,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $user.':'.$password, CURLOPT_USERPWD => $connection['user'].':'.$connection['password'],
CURLOPT_RETURNTRANSFER => false, CURLOPT_RETURNTRANSFER => false,
CURLOPT_FAILONERROR => false, CURLOPT_FAILONERROR => false,
CURLOPT_FILE => $fp, CURLOPT_FILE => $fp,
@@ -98,6 +120,7 @@ function bratonien_tools_webdav_derivative_download(array $source, $destination,
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
fclose($fp); fclose($fp);
if ($ok === false || $errno !== 0 || $http < 200 || $http >= 300) if ($ok === false || $errno !== 0 || $http < 200 || $http >= 300)
{ {
@unlink($destination); @unlink($destination);
@@ -113,256 +136,425 @@ function bratonien_tools_webdav_derivative_download(array $source, $destination,
return true; 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; $webdav_path = trim((string)($source['webdav_path'] ?? ''), '/');
if (strpos($derivative_path, $derivative_root) !== 0) return null; if ($webdav_path === '') { $detail = 'WebDAV-Bildquelle ist unvollstaendig.'; return false; }
$location = ltrim(substr($derivative_path, strlen($derivative_root)), '/');
if ($location === '') return null; $url = $connection['base_url'].'/remote.php/dav/files/'.rawurlencode($connection['user']).'/'.bratonien_tools_webdav_derivative_quote_path($webdav_path);
$segments = array_map('rawurlencode', explode('/', $location)); return bratonien_tools_webdav_derivative_download_url($url, $connection, $destination, $detail);
return rtrim(get_absolute_root_url(), '/').'/i.php?/'.implode('/', $segments);
} }
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 = ''; if ($variant !== 'custom:s9999x250' && $variant !== 'standard:square') return null;
$status = 500; if ((int)($source['fileid'] ?? 0) < 1) return null;
$ch = curl_init($url); if ((int)($source['width'] ?? 0) < 1 || (int)($source['height'] ?? 0) < 1) return null;
curl_setopt_array($ch, array( if ((int)($image_row['rotation'] ?? 0) !== 0) return null;
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10, $target_width = (int)($derivative_size[0] ?? 0);
CURLOPT_TIMEOUT => 180, $target_height = (int)($derivative_size[1] ?? 0);
CURLOPT_RETURNTRANSFER => false, if ($target_width < 1 || $target_height < 1 || max($target_width, $target_height) > 800) return null;
CURLOPT_FAILONERROR => false,
CURLOPT_USERAGENT => 'Bratonien-Tools-WebDAV-Materialize-Gate', $source_width = (int)$source['width'];
CURLOPT_WRITEFUNCTION => function($ch, $data) { return strlen($data); }, $source_height = (int)$source['height'];
)); $required_width = $target_width * 2;
$ok = curl_exec($ch); $required_height = $target_height * 2;
$errno = curl_errno($ch); $scale = max($required_width / $source_width, $required_height / $source_height);
$error = curl_error($ch); $preview_width = max(32, (int)ceil($source_width * $scale));
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $preview_height = max(32, (int)ceil($source_height * $scale));
curl_close($ch);
if ($ok === false || $errno !== 0) { $detail = 'Interner i.php-Aufruf fehlgeschlagen (cURL '.$errno.($error !== '' ? ': '.$error : '').').'; return false; } if (max($preview_width, $preview_height) > 1600) return null;
if ($status < 200 || $status >= 400) { $detail = 'i.php antwortete mit HTTP '.$status.'.'; return false; }
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; 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); global $conf;
if ($encoded === '') return null; $detail = '';
$raw = strtr($encoded, '-_', '+/'); include_once(PHPWG_ROOT_PATH.'admin/include/image.class.php');
$padding = strlen($raw) % 4;
if ($padding) $raw .= str_repeat('=', 4 - $padding); if (!is_object($params)) { $detail = 'Piwigo-Derivatparameter fehlen.'; return false; }
$url = base64_decode($raw, true); if (!is_file($source_path) || !is_readable($source_path)) { $detail = 'Materialisierte Bildquelle ist nicht lesbar.'; return false; }
if (!is_string($url) || $url === '' || preg_match('/[\r\n]/', $url)) return null; if (!bratonien_tools_webdav_derivative_make_dir(dirname($target_path))) { $detail = 'Piwigo-Derivatverzeichnis konnte nicht angelegt werden.'; return false; }
if (strpos($url, '//') === 0) return null;
if (preg_match('#^https?://#i', $url)) $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()); $defined = ImageStdParams::get_defined_type_map();
$url_parts = parse_url($url); if (count($defined) > 0)
if (!is_array($root_parts) || !is_array($url_parts)) return null; {
if (strcasecmp((string)($root_parts['scheme'] ?? ''), (string)($url_parts['scheme'] ?? '')) !== 0) return null; $sharpen = 0;
if (strcasecmp((string)($root_parts['host'] ?? ''), (string)($url_parts['host'] ?? '')) !== 0) return null; foreach ($defined as $std_params) $sharpen += $std_params->sharpen;
if ((int)($root_parts['port'] ?? 0) !== (int)($url_parts['port'] ?? 0)) return null; $params->sharpen = round($sharpen / count($defined));
return $url; }
}
$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); $image_id = (int)($_GET['id'] ?? 0);
$variant = trim((string)($_GET['variant'] ?? '')); $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)); 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'] ?? ''); if (!empty($_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)
{ {
$expected_sig = bratonien_tools_webdav_materialize_after_signature($image_id, $variant, $after_url); $raw_after = strtr((string)$_GET['after'], '-_', '+/');
if ($after_sig === '' || !hash_equals($expected_sig, $after_sig)) $after_url = null; $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); $result = pwg_query('SELECT id, path, width, height, rotation, coi FROM '.IMAGES_TABLE.' WHERE id='.$image_id.' LIMIT 1');
$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');
if (!pwg_db_num_rows($result)) bratonien_tools_webdav_derivative_abort(404, 'Bild nicht gefunden.'); if (!pwg_db_num_rows($result)) bratonien_tools_webdav_derivative_abort(404, 'Bild nicht gefunden.');
$image_row = pwg_db_fetch_assoc($result); $image_row = pwg_db_fetch_assoc($result);
$raw_source_path = (string)($image_row['path'] ?? ''); if (function_exists('get_sql_condition_FandF'))
$absolute_source_path = $raw_source_path;
if ($absolute_source_path !== '' && strpos($absolute_source_path, '/') !== 0)
{ {
$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); $source = bratonien_tools_webdav_materialize_source_info($image_id);
if (!$source) if (!$source) bratonien_tools_webdav_derivative_abort(404, 'WebDAV-Bildquelle nicht gefunden.');
{ bratonien_tools_webdav_derivative_debug($request_id, 'source_resolved', array(
bratonien_tools_webdav_derivative_debug($request_id, 'source_resolve_failed'); 'connection_id'=>$source['connection_id'],
bratonien_tools_webdav_derivative_abort(404, 'Keine WebDAV-Quelle fuer dieses Bild gefunden.'); 'root_fileid'=>$source['root_fileid'],
} 'fileid'=>$source['fileid'] ?? 0,
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'] ?? '')); 'webdav_path'=>$source['webdav_path'],
));
$params = bratonien_tools_webdav_materialize_params_from_variant($variant); $params = bratonien_tools_webdav_materialize_params_from_variant($variant);
if (!$params) bratonien_tools_webdav_derivative_abort(400, 'Unbekannte Derivat-Variante.'); if (!$params) bratonien_tools_webdav_derivative_abort(400, 'Unbekannte Derivatvariante.');
$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;
}
$src_image = new SrcImage($image_row);
$real_derivative = new DerivativeImage($params, $src_image);
$target_path = $real_derivative->get_path(); $target_path = $real_derivative->get_path();
$derivative_root = PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR; $derivative_size = $real_derivative->get_size();
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)) if ($target_path !== '' && is_file($target_path) && is_readable($target_path))
{ {
bratonien_tools_webdav_derivative_debug($request_id, 'target_exists', array('target'=>$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_send($target_path, $after_url);
bratonien_tools_webdav_derivative_serve($target_path);
} }
$shadow_path = $raw_source_path; $source_logical_path = (string)$image_row['path'];
if ($shadow_path === '') bratonien_tools_webdav_derivative_abort(500, 'Piwigo-Quellpfad fehlt.'); if (strpos($source_logical_path, '/') !== 0)
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))
{ {
if ($lock) fclose($lock); $source_logical_path = PHPWG_ROOT_PATH.ltrim(preg_replace('#^\./#', '', $source_logical_path), '/');
bratonien_tools_webdav_derivative_abort(503, 'WebDAV-Materialisierungslock konnte nicht gesetzt werden.');
} }
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); 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)); bratonien_tools_webdav_derivative_debug($request_id, 'target_created_while_waiting', array('target'=>$target_path));
@flock($lock, LOCK_UN); @fclose($lock); flock($lock_handle, LOCK_UN);
if ($redirect_after !== null) { header('Location: '.$redirect_after, true, 302); exit; } fclose($lock_handle);
bratonien_tools_webdav_derivative_serve($target_path); bratonien_tools_webdav_derivative_send($target_path, $after_url);
} }
$token = getmypid().'-'.bin2hex(random_bytes(6)); $detail = '';
$download_path = $work_root.'/source-'.$image_id.'-'.$token.'.tmp'; $connection = bratonien_tools_webdav_derivative_connection($source, $detail);
$staging_path = $shadow_path.'.bratonien-'.$token.'.tmp'; if (!$connection)
$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)
{ {
if ($cleaned) return; flock($lock_handle, LOCK_UN);
$cleaned = true; fclose($lock_handle);
if ($swapped) bratonien_tools_webdav_derivative_abort(502, $detail !== '' ? $detail : 'WebDAV-Verbindung konnte nicht geladen werden.');
{ }
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);
$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 try
{ {
if (is_link($placeholder_backup)) bratonien_tools_webdav_derivative_abort(503, 'WebDAV-Placeholder-Backup existiert bereits; vorheriger Materialisierungslauf ist nicht sauber abgeschlossen.'); bratonien_tools_webdav_derivative_debug($request_id, 'swap_begin', array('placeholder'=>$source_path, 'mode'=>$source_mode));
$detail = ''; if (!@rename($source_path, $placeholder_backup)) throw new RuntimeException('Platzhalter konnte nicht gesichert werden.');
$download_start = microtime(true); if (!@rename($staging_path, $source_path))
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))
{ {
@rename($placeholder_backup, $shadow_path); @rename($placeholder_backup, $source_path);
bratonien_tools_webdav_derivative_abort(500, 'Materialisiertes Original konnte nicht am Piwigo-Quellpfad aktiviert werden.'); throw new RuntimeException('Temporaere Bildquelle konnte nicht eingesetzt werden.');
} }
$swapped = true; $swapped = true;
clearstatcache(true, $shadow_path); bratonien_tools_webdav_derivative_debug($request_id, 'swapped', array('is_link'=>is_link($source_logical_path), 'mode'=>$source_mode));
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.');
$inner_url = bratonien_tools_webdav_derivative_inner_url($target_path); $generate_start = microtime(true);
if ($inner_url === null) bratonien_tools_webdav_derivative_abort(500, 'Piwigo-i.php-URL konnte nicht bestimmt werden.'); bratonien_tools_webdav_derivative_debug($request_id, 'generate_start', array('target'=>$target_path, 'mode'=>$source_mode));
$inner_status = 500; $ok = bratonien_tools_webdav_derivative_generate($source_path, $target_path, $params, $image_row, $detail);
$i_start = microtime(true); bratonien_tools_webdav_derivative_debug($request_id, 'generate_done', array(
bratonien_tools_webdav_derivative_debug($request_id, 'i_start', array('url'=>$inner_url)); 'ok'=>$ok,
$i_ok = bratonien_tools_webdav_derivative_call_i($inner_url, $inner_status, $detail); 'mode'=>$source_mode,
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)); 'ms'=>round((microtime(true) - $generate_start) * 1000, 1),
if (!$i_ok) bratonien_tools_webdav_derivative_abort(502, $detail); 'detail'=>$detail,
));
clearstatcache(true, $target_path); if (!$ok) throw new RuntimeException($detail !== '' ? $detail : 'Piwigo-Derivat konnte nicht erzeugt werden.');
$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);
} }
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);