Compare commits

...

377 Commits

Author SHA1 Message Date
Terranom674
29990aee4f 0.9.7.24: Version anheben 2026-08-20 09:00:53 +02:00
Terranom674
9c551c8dfc 0.9.7.24: Piwigo ajaxload fuer WebDAV-Platzhalter bedienen 2026-08-20 09:00:36 +02:00
Terranom674
e5bc260622 Merge pull request #45 from Terranom674/fix/09723-visible-run-status
0.9.7.23: Abgleich sichtbar ausführen und Fehlerdetails anzeigen
2026-08-20 08:34:32 +02:00
Terranom674
5b883706c7 0.9.7.23: Version anheben 2026-08-20 08:33:27 +02:00
Terranom674
66f463422e 0.9.7.23: Konkrete Shadow-Tree-Fehler in Diagnose übernehmen 2026-08-20 08:32:51 +02:00
Terranom674
df9e9e6424 0.9.7.23: Verbindungsbezogenen Live-Status bereitstellen 2026-08-20 08:32:23 +02:00
Terranom674
26b602783f 0.9.7.23: Abgleich sichtbar und ohne Seitenreload ausführen 2026-08-20 08:32:04 +02:00
Terranom674
ae1b47c4c4 Merge pull request #44 from Terranom674/fix/09722-placeholder-dimension-fallback
0.9.7.22: Originalmaße robust aus Nextcloud übernehmen
2026-08-20 08:24:54 +02:00
Terranom674
6f2f39cd37 0.9.7.22: Version anheben 2026-08-20 08:23:51 +02:00
Terranom674
2794719ff3 0.9.7.22: Originalmaße bei fehlenden NC-Metadaten aus Bildkopf lesen 2026-08-20 08:23:11 +02:00
Terranom674
941d0cd7bb Merge pull request #43 from Terranom674/fix/09721-placeholder-original-dimensions
0.9.7.21: Originalmaße direkt in Platzhalter tragen
2026-08-20 08:13:55 +02:00
Terranom674
392038ce7e 0.9.7.21: Version anheben 2026-08-20 08:12:29 +02:00
Terranom674
3aa82b18f4 0.9.7.21: Originalmasse in WebDAV-Platzhalter schreiben 2026-08-20 08:11:49 +02:00
Terranom674
0a76919a21 Merge pull request #42 from Terranom674/fix/09720-restore-pre-ui-image-path
0.9.7.20: NC-Bildpfad auf funktionierenden Pre-UI-Stand zurück
2026-08-20 08:03:49 +02:00
Terranom674
d7dacdae56 0.9.7.20: Medien-Sonderlauf aus WebDAV-Sync entfernen 2026-08-20 08:03:04 +02:00
Terranom674
15b489714b 0.9.7.20: nachgelagerte Medien-Sonderlogik deaktivieren 2026-08-20 08:00:43 +02:00
Terranom674
f3b52c55ef 0.9.7.20: WebDAV-Derivat-URL-Hook wiederherstellen 2026-08-20 08:00:20 +02:00
Terranom674
67c1e814c8 0.9.7.20: funktionierenden NC-Kern vor UI-Umbau wiederherstellen 2026-08-20 07:59:44 +02:00
Terranom674
a5a8856e48 Merge pull request #41 from Terranom674/fix/09719-restore-run-now-and-clean-owned-albums
0.9.7.19: Jetzt abgleichen wiederherstellen
2026-08-20 07:39:47 +02:00
Terranom674
2af46d1820 0.9.7.19: Versionsstand setzen 2026-08-20 07:38:28 +02:00
Terranom674
e5d754fdab 0.9.7.19: Jetzt-abgleichen-Button über normalen Tool-POST wiederherstellen 2026-08-20 07:38:10 +02:00
Terranom674
0adcc5ca54 Merge pull request #40 from Terranom674/fix/09718-restore-pre-ui-behavior
0.9.7.18: konsistente Kernfunktionen und einheitliche NC-UI wiederherstellen
2026-08-20 00:02:17 +02:00
Terranom674
61988db337 Restore single-path NC admin UI 2026-08-19 23:59:56 +02:00
Terranom674
bdfea105cf Keep request-local watermark performance cache 2026-08-19 23:58:13 +02:00
Terranom674
6f64cb9fe6 Restore stable admin tab behavior 2026-08-19 23:57:24 +02:00
Terranom674
65b068814f Unify stale WebDAV cleanup across historical connector paths 2026-08-19 23:55:04 +02:00
Terranom674
e58d2c700a Clean missing images across all connection path generations 2026-08-19 23:54:35 +02:00
Terranom674
afaea3b424 Delete all known path variants for one WebDAV connection 2026-08-19 23:54:17 +02:00
Terranom674
7a763ba62a Reduce WebDAV builder to metadata refresh only 2026-08-19 23:53:38 +02:00
Terranom674
31e29db7c2 Remove obsolete destructive legacy reset 2026-08-19 23:53:11 +02:00
Terranom674
ab116d29c2 Remove destructive legacy reset from updates 2026-08-19 23:49:54 +02:00
Terranom674
46924db618 Remove parallel NC source picker endpoint 2026-08-19 23:49:36 +02:00
Terranom674
6e96b0adc3 Remove parallel NC edit save endpoint 2026-08-19 23:49:28 +02:00
Terranom674
b8f3ed44a3 Remove parallel NC edit data endpoint 2026-08-19 23:49:21 +02:00
Terranom674
cb3b2f258c Disable parallel NC editor layer 2026-08-19 23:49:13 +02:00
Terranom674
ef1c1265a9 0.9.7.18: restore stable non-connector hooks 2026-08-19 23:48:40 +02:00
Terranom674
47d7000cc8 Restore stable watermark settings 2026-08-19 23:48:16 +02:00
Terranom674
c358c157e4 Restore stable watermark profile behavior 2026-08-19 23:48:06 +02:00
Terranom674
1d62dc17e8 Restore stable album watermark rules 2026-08-19 23:47:46 +02:00
Terranom674
ad4c281fdc Restore stable watermark engine 2026-08-19 23:47:31 +02:00
Terranom674
a43a15a9d3 Restore stable watermark runtime 2026-08-19 23:47:13 +02:00
Terranom674
31b8590e4a Merge pull request #39 from Terranom674/fix/09717-remove-missing-webdav-images
0.9.7.17: entfernte Freigaben sicher aus Piwigo bereinigen
2026-08-19 23:28:36 +02:00
Terranom674
c772010137 Version 0.9.7.17 2026-08-19 23:27:41 +02:00
Terranom674
d075d05708 0.9.7.17: Verbindungsloeschung ueber eigene Gallery-Root 2026-08-19 23:27:09 +02:00
Terranom674
a4fb7421f0 0.9.7.17: Bereinigung nur nach erfolgreichem WebDAV-Abruf 2026-08-19 23:26:53 +02:00
Terranom674
9da08b5d8e 0.9.7.17: fehlende Connector-Bilder nach erfolgreichem Abruf bereinigen 2026-08-19 23:26:37 +02:00
Terranom674
f28365b0ca Merge pull request #38 from Terranom674/fix/09716-native-piwigo-source
0.9.7.16: WebDAV-Bilder wieder über Piwigos nativen Derivatpfad laden
2026-08-19 23:07:42 +02:00
Terranom674
fb6c1a03d2 0.9.7.16: vorhandene Preview-Caches ohne Massendownload weiterverwenden 2026-08-19 23:06:11 +02:00
Terranom674
98d0471683 0.9.7.16: WebDAV-Bildendpoint wieder auf Originalauslieferung begrenzen 2026-08-19 23:05:14 +02:00
Terranom674
63eba888f8 0.9.7.16: native Piwigo-Derivatlogik wiederherstellen 2026-08-19 23:04:51 +02:00
Terranom674
748b0e986d 0.9.7.16: widersprüchlichen WebDAV-Derivat-Hook entfernen 2026-08-19 23:04:34 +02:00
Terranom674
c1281ad077 0.9.7.16: eigenen WebDAV-Derivat-Endpunkt entfernen 2026-08-19 23:04:26 +02:00
Terranom674
bc0593642c 0.9.7.16: echte NC-Previews als lokale Piwigo-Quelle verwenden 2026-08-19 23:04:06 +02:00
Terranom674
9052076fd7 Merge pull request #37 from Terranom674/fix/09715-piwigo-ajax-loader
0.9.7.15: Piwigo-AJAX-Lader für WebDAV-Bilder reparieren
2026-08-19 22:59:33 +02:00
Terranom674
5772a59b55 Bump version to 0.9.7.15 2026-08-19 22:58:34 +02:00
Terranom674
84762154d6 Return JSON for Piwigo AJAX image loader 2026-08-19 22:58:17 +02:00
Terranom674
1580c235a8 Fix Piwigo AJAX loader response for WebDAV derivatives 2026-08-19 22:57:56 +02:00
Terranom674
4aac5ca4a2 Merge pull request #36 from Terranom674/fix/09714-cache-permissions
0.9.7.14: WebDAV-Cache-Rechte reparieren
2026-08-19 22:48:56 +02:00
Terranom674
ab1f20beef 0.9.7.14: Einmalige Cache-Rechtereparatur 2026-08-19 22:48:00 +02:00
Terranom674
f94a001006 Version 0.9.7.14 2026-08-19 22:47:45 +02:00
Terranom674
3f64895f8e 0.9.7.14: Connector-Cache-Rechte normalisieren 2026-08-19 22:47:20 +02:00
Terranom674
48f1eacce7 Merge pull request #35 from Terranom674/fix/09713-watermark-album-overview
0.9.7.13: Wasserzeichen in Albumübersicht nach Albumregel
2026-08-19 22:43:07 +02:00
Terranom674
7c8c7ab98a Purge stale watermark cache once on 0.9.7.13 2026-08-19 22:42:24 +02:00
Terranom674
ade38ff2d1 Clear watermark cache after profile changes 2026-08-19 22:42:01 +02:00
Terranom674
6a013fe875 Clear watermark cache after album rule changes 2026-08-19 22:41:36 +02:00
Terranom674
c71112d94e Invalidate watermark cache on rule changes 2026-08-19 22:41:20 +02:00
Terranom674
7d97e1b515 Register album overview watermark context 2026-08-19 22:39:08 +02:00
Terranom674
47afbc7f43 Fix watermark context for album overview covers 2026-08-19 22:38:54 +02:00
Terranom674
706639a57e Merge pull request #34 from Terranom674/fix/09712-ondemand-missing-preview
0.9.7.12: On-Demand-Altbilder nicht mehr auf 404 hängen lassen
2026-08-19 22:36:50 +02:00
Terranom674
c7b14add33 0.9.7.12: Version anheben 2026-08-19 22:35:53 +02:00
Terranom674
f9e87df305 0.9.7.12: fehlende On-Demand-Previews auf echtes WebDAV-Bild zurueckfallen 2026-08-19 22:35:25 +02:00
Terranom674
6ba5eb4d7f Merge pull request #33 from Terranom674/fix/09711-fotorama-ondemand
0.9.7.11: Fotorama-Derivate on demand erzeugen
2026-08-19 22:32:15 +02:00
Terranom674
85369fa15e Version 0.9.7.11 2026-08-19 22:31:13 +02:00
Terranom674
981ba6c18b 0.9.7.11: On-Demand WebDAV Derivat Endpoint 2026-08-19 22:30:52 +02:00
Terranom674
77e20af977 0.9.7.11: Fotorama Derivate erst bei Abruf erzeugen 2026-08-19 22:30:34 +02:00
Terranom674
e187c517da Merge pull request #32 from Terranom674/fix/09710-never-serve-connector-placeholders
0.9.7.10: Connector-Platzhalter nie im Frontend ausliefern
2026-08-19 22:27:47 +02:00
Terranom674
28e34d6c73 0.9.7.10: Version anheben 2026-08-19 22:26:54 +02:00
Terranom674
b8852310bd 0.9.7.10: Connector-Platzhalter nie als Derivat ausliefern 2026-08-19 22:26:37 +02:00
Terranom674
ae96fc2e3e Merge pull request #31 from Terranom674/fix/0979-picture-derivatives
0.9.7.9: Picture-Viewer lädt WebDAV-Derivate lokal
2026-08-19 22:17:54 +02:00
Terranom674
ebf4fc7e74 0.9.7.9: Plugin-Version anheben 2026-08-19 22:16:46 +02:00
Terranom674
d798b82ec7 0.9.7.9: Picture-Derivate lokal aus NC-Preview erzeugen 2026-08-19 22:16:26 +02:00
Terranom674
9e282af1b4 Merge pull request #30 from Terranom674/fix/0978-modus-gallery-derivative
0.9.7.8: Modus-Galeriederivat beim Sync vorbauen
2026-08-19 22:03:53 +02:00
Terranom674
d7628d1dac Version auf 0.9.7.8 setzen 2026-08-19 22:02:59 +02:00
Terranom674
7e9c3ae05f 0.9.7.8: Modus-Galeriederivat vorab erzeugen 2026-08-19 22:02:43 +02:00
Terranom674
b64e46a549 Merge pull request #29 from Terranom674/fix/0974-gallery-derivative-only
0.9.7.7: Connector-Altbestand einmalig zurücksetzen
2026-08-19 21:50:57 +02:00
Terranom674
779ba80dfe Jetzt-abgleichen global und verbindungsbezogen erlauben 2026-08-19 21:50:18 +02:00
Terranom674
ebbc183f87 Globalen Jetzt-abgleichen-Lauf erhalten 2026-08-19 21:49:45 +02:00
Terranom674
f3b5002f4d run-all auf angeforderte Verbindung filtern 2026-08-19 21:48:05 +02:00
Terranom674
6c393bbcfc Native Runner auf einzelne Verbindung begrenzen 2026-08-19 21:47:40 +02:00
Terranom674
829381676d Manuellen Abgleich auf ausgewählte Verbindung begrenzen 2026-08-19 21:47:25 +02:00
Terranom674
6382cee491 Jetzt-abgleichen verbindungsbezogen starten 2026-08-19 21:47:01 +02:00
Terranom674
69c3932e9b CI für 0.9.7.7 auslösen 2026-08-19 21:45:16 +02:00
Terranom674
747a76c066 Altbestand beim Update auf 0.9.7.7 einmalig bereinigen 2026-08-19 21:45:06 +02:00
Terranom674
5024757136 Einmaligen Connector-Altbestand-Reset ergänzen 2026-08-19 21:44:52 +02:00
Terranom674
5125d2e247 0.9.7.7 vorbereiten 2026-08-19 21:44:39 +02:00
Terranom674
6607c4aefe Merge pull request #28 from Terranom674/fix/0974-gallery-derivative-only
0.9.7.6: Galerie-Hotpath entlasten
2026-08-19 21:38:09 +02:00
Terranom674
32f2ceaaec cleanup: temporaeren CI-Trigger entfernen 2026-08-19 21:37:19 +02:00
Terranom674
5a5669d976 ci: trigger 0.9.7.6 2026-08-19 21:36:46 +02:00
Terranom674
e4ab277cfc 0.9.7.6: Wasserzeichen-Engine pro Request nur einmal initialisieren 2026-08-19 21:36:37 +02:00
Terranom674
9e07cd8042 0.9.7.6: Request-Scheduler aus Galeriepfad entfernen 2026-08-19 21:35:50 +02:00
Terranom674
588f1fc100 0.9.7.6: WebDAV-Hotpath pro Request cachen 2026-08-19 21:35:06 +02:00
Terranom674
a23fc81190 Merge pull request #27 from Terranom674/fix/0974-gallery-derivative-only
0.9.7.5: Alt-Derivate reparieren und Galerie beschleunigen
2026-08-19 21:30:24 +02:00
Terranom674
ff5af83e65 Rebuild only mismatched legacy gallery derivatives 2026-08-19 21:29:31 +02:00
Terranom674
c61d8e32cc Use fast WebDAV gallery derivative filter 2026-08-19 21:28:47 +02:00
Terranom674
38c4955f70 Add fast WebDAV gallery derivative path 2026-08-19 21:28:29 +02:00
Terranom674
5c217288a8 Bump version to 0.9.7.5 2026-08-19 21:27:17 +02:00
Terranom674
17c79ce8ab Restore branch to 0.9.7.4 baseline 2026-08-19 21:27:03 +02:00
Terranom674
7746d26c9f Remove accidental temporary file 2026-08-19 21:26:47 +02:00
Terranom674
d05ab28549 noop 2026-08-19 21:26:19 +02:00
Terranom674
55b5be56b6 Merge pull request #26 from Terranom674/fix/0974-gallery-derivative-only
0.9.7.4: nur Galeriederivat beim WebDAV-Import
2026-08-19 21:23:35 +02:00
Terranom674
ba0b5af417 Release 0.9.7.4 2026-08-19 21:12:39 +02:00
Terranom674
315af9641c Build only gallery derivative from WebDAV preview 2026-08-19 21:12:24 +02:00
Terranom674
77ae007a28 Merge pull request #25 from Terranom674/fix/0973-webdav-root-altimports
0.9.7.3: Altimporte aus WebDAV-Stammverzeichnis reparieren
2026-08-19 21:02:13 +02:00
Terranom674
e5be2b3043 Release 0.9.7.3 2026-08-19 21:01:26 +02:00
Terranom674
3fe9527537 Fix root WebDAV alt-import derivative detection 2026-08-19 21:01:00 +02:00
Terranom674
fb1f12d608 0.9.7.2: sichtbarer Abgleich und sicherer WebDAV-Import
0.9.7.2: sichtbarer Abgleich und sicherer WebDAV-Import
2026-08-19 20:53:28 +02:00
Terranom674
017cbdbd3b 0.9.7.2: private Alben nicht von öffentlichen Regeln erben lassen 2026-08-19 20:52:16 +02:00
Terranom674
9aaa1a47b3 0.9.7.2: WebDAV-Sync nicht-destruktiv machen 2026-08-19 20:51:23 +02:00
Terranom674
fc3d855e5e 0.9.7.2: Version anheben 2026-08-19 20:49:18 +02:00
Terranom674
4847d2ff11 0.9.7.2: aktuellen Schedulerstatus in Statuskarte spiegeln 2026-08-19 20:48:48 +02:00
Terranom674
0b405e9efb 0.9.7.2: Abgleich sofort als angefordert markieren 2026-08-19 20:48:25 +02:00
Terranom674
2c302b356d Merge pull request #23 from Terranom674/fix/0971-webdav-public-ip
0.9.7.1: WebDAV über öffentliche Ziel-IP
2026-08-19 20:43:19 +02:00
Terranom674
5c62dbd9be 0.9.7.1: clean pinned WebDAV resolver implementation 2026-08-19 20:41:46 +02:00
Terranom674
5f81cc9ce9 0.9.7.1: bump plugin version 2026-08-19 20:40:57 +02:00
Terranom674
a5be16f88e 0.9.7.1: stream WebDAV originals through resolved target IP 2026-08-19 20:40:40 +02:00
Terranom674
6b082ba8b5 0.9.7.1: fetch WebDAV previews through resolved target IP 2026-08-19 20:40:19 +02:00
Terranom674
5396f50bc0 0.9.7.1: resolve remote Nextcloud target before WebDAV scan 2026-08-19 20:39:54 +02:00
Terranom674
a54df74e67 0.9.7.1: pin WebDAV runtime to resolved target IP 2026-08-19 20:39:27 +02:00
Terranom674
986eb530d7 0.9.7.1: scan remote Nextcloud through resolved IP transport 2026-08-19 20:38:52 +02:00
Terranom674
b9adcbddac 0.9.7.1: use transport-aware WebDAV wizard handlers 2026-08-19 20:38:25 +02:00
Terranom674
bb434b9b02 0.9.7.1: route wizard and WebDAV calls through resolved target IP 2026-08-19 20:37:59 +02:00
Terranom674
aa5c2f7f0c 0.9.7.1: add runtime Nextcloud target resolver 2026-08-19 20:37:23 +02:00
Terranom674
aa803a4149 0.9.7.1: add generic Nextcloud transport resolver 2026-08-19 20:35:40 +02:00
Terranom674
ad39b9f0d2 Merge pull request #22 from Terranom674/fix/09630-native-piwigo-scheduler
0.9.6.30: NC Connector nativ in Piwigo ausführen
2026-08-19 20:18:39 +02:00
Terranom674
aa8beb3794 0.9.6.30: globales Lauf-Lock gegen Parallelstarts 2026-08-19 20:17:47 +02:00
Terranom674
3ad6e9eb8e 0.9.6.30: globales Lauf-Lock an run-all übergeben 2026-08-19 20:17:24 +02:00
Terranom674
21118f6fb3 0.9.6.30: Bildaufbereitung ohne systemd im nativen Modus 2026-08-19 20:16:48 +02:00
Terranom674
c23302197a 0.9.6.30: WebDAV-Runtime unter Piwigo _data ablegen 2026-08-19 20:16:19 +02:00
Terranom674
d54aa482f7 0.9.6.30: Runtime für nativen Piwigo-Scheduler vorbereiten 2026-08-19 20:15:41 +02:00
Terranom674
25133bc895 0.9.6.30: Jetzt-abgleichen auf nativen Scheduler umstellen 2026-08-19 20:15:22 +02:00
Terranom674
62cc1af8bf 0.9.6.30: Status auf nativen Piwigo-Scheduler umstellen 2026-08-19 20:14:56 +02:00
Terranom674
cf300bc5c3 0.9.6.30: Scheduler beim Plugin-Lifecycle initialisieren 2026-08-19 20:14:33 +02:00
Terranom674
0dde87cda0 0.9.6.30: nativen Scheduler beim Plugin laden 2026-08-19 20:14:21 +02:00
Terranom674
17a76c4de2 0.9.6.30: nativen Connector-Runner hinzufügen 2026-08-19 20:14:05 +02:00
Terranom674
2366f6366c 0.9.6.30: nativen Piwigo-Scheduler hinzufügen 2026-08-19 20:13:47 +02:00
Terranom674
43780cb9a3 Merge pull request #21 from Terranom674/fix/09629-stability-rollback
0.9.6.29: Shadowtree stabilisieren
2026-08-19 19:59:15 +02:00
Terranom674
7b915c310a 0.9.6.29: PHP-Abfrage stabilisieren 2026-08-19 19:58:28 +02:00
Terranom674
6df469ab7a 0.9.6.29: Shadowtree stabilisieren und Namensprüfung entfernen 2026-08-19 19:55:32 +02:00
Terranom674
55294643b4 0.9.6.29: Shadowtree- und Sync-Umbau zurückrollen 2026-08-19 19:54:15 +02:00
Terranom674
0f3c78f02c Merge pull request #18 from Terranom674/fix/09628-single-piwigo-structure
0.9.6.28: nur ein Piwigo-Strukturpfad
2026-08-19 19:44:36 +02:00
Terranom674
d80163a704 0.9.6.28: Version fuer Strukturfix anheben 2026-08-19 19:43:48 +02:00
Terranom674
9e57c4ea3d 0.9.6.28: Fallback nutzt denselben Piwigo-Syncpfad 2026-08-19 19:43:30 +02:00
Terranom674
30f1c0af18 Merge pull request #17 from Terranom674/fix/09627-webdav-album-structure
0.9.6.27: WebDAV ohne Parallel-Albumstruktur
2026-08-19 19:39:07 +02:00
Terranom674
00d14db57d 0.9.6.27: WebDAV in bestehende Albumstruktur synchronisieren 2026-08-19 19:37:45 +02:00
Terranom674
6e52ba824c 0.9.6.27: transparentes WebDAV-Stammverzeichnis spiegeln 2026-08-19 19:35:32 +02:00
Terranom674
a1bac43150 0.9.6.27: Nextcloud-Stamm ohne Benutzerwrapper abbilden 2026-08-19 19:35:10 +02:00
Terranom674
06646ea37f 0.9.6.27: einen NC-Anlageweg und Jetzt-abgleichen
0.9.6.27: einen NC-Anlageweg und Jetzt-abgleichen
2026-08-19 19:29:08 +02:00
Terranom674
8f2b22a376 0.9.6.27: CI-Marker entfernen 2026-08-19 19:28:20 +02:00
Terranom674
2122ab88ac 0.9.6.27: CI-Stand markieren 2026-08-19 19:28:11 +02:00
Terranom674
babe95fae3 0.9.6.27: versehentliche Hilfsdatei entfernen 2026-08-19 19:28:03 +02:00
Terranom674
14a83be190 noop 2026-08-19 19:27:53 +02:00
Terranom674
a1d2163aab 0.9.6.27: CI auf einen NC-JavaScript-Pfad zuruecksetzen 2026-08-19 19:27:06 +02:00
Terranom674
a0606b2553 0.9.6.27: paralleles Run-now-JavaScript entfernen 2026-08-19 19:26:51 +02:00
Terranom674
9543af50d8 0.9.6.27: parallelen NC-Anlageweg entfernen 2026-08-19 19:26:37 +02:00
Terranom674
4af1972353 0.9.6.27: einen einzigen NC-Verbindungsweg anzeigen 2026-08-19 19:26:11 +02:00
Terranom674
0e7070fb04 0.9.6.27: parallelen sudoers-Installer entfernen 2026-08-19 19:24:41 +02:00
Terranom674
ba53bfebe9 0.9.6.27: parallele sudoers-Hilfsstruktur entfernen 2026-08-19 19:24:36 +02:00
Terranom674
4b2fc93e3d 0.9.6.27: unnoetige Markerdatei entfernen 2026-08-19 19:24:06 +02:00
Terranom674
e356d43e5b 0.9.6.27: unnoetige Run-now-Hinweisdatei entfernen 2026-08-19 19:23:56 +02:00
Terranom674
980efc6700 0.9.6.27: Run-now-Setup markieren 2026-08-19 19:23:30 +02:00
Terranom674
a814c18d18 0.9.6.27: sudoers-Setup fuer Jetzt-abgleichen vorbereiten 2026-08-19 19:23:14 +02:00
Terranom674
6737c8989d 0.9.6.27: minimale sudoers-Regel fuer manuellen Abgleich dokumentieren 2026-08-19 19:23:04 +02:00
Terranom674
2e308867ad 0.9.6.27: Hinweis zur manuellen Service-Ausloesung 2026-08-19 19:22:54 +02:00
Terranom674
1d3768b051 0.9.6.27: Jetzt-abgleichen-JavaScript in CI pruefen 2026-08-19 19:22:48 +02:00
Terranom674
cfea5546de 0.9.6.27: Version anheben 2026-08-19 19:22:32 +02:00
Terranom674
8c30edc0ee 0.9.6.27: Jetzt-abgleichen-JavaScript laden 2026-08-19 19:22:11 +02:00
Terranom674
76a182af82 0.9.6.27: Jetzt-abgleichen-Button anzeigen 2026-08-19 19:21:41 +02:00
Terranom674
21e046cf37 0.9.6.27: manuellen NC-Abgleich registrieren 2026-08-19 19:21:16 +02:00
Terranom674
7728e368db Merge pull request #15 from Terranom674/fix/remove-nc-status-card-09625
0.9.6.26: Connector-Timer nicht mehr durch Bildaufbereitung blockieren
2026-08-19 18:57:07 +02:00
Terranom674
074aa8d99a 0.9.6.26: Version anheben 2026-08-19 18:56:13 +02:00
Terranom674
030634c39d 0.9.6.26: Connector-Lauf nicht mehr durch Bildaufbereitung blockieren 2026-08-19 18:55:50 +02:00
Terranom674
dea1a0ffb2 0.9.6.26: Bildaufbereitung aus Connector-Hauptlauf auslagern 2026-08-19 18:55:30 +02:00
Terranom674
3a3539e55b 0.9.6.25: NC-Status fuer laufenden Sync korrigieren
0.9.6.25: NC-Status fuer laufenden Sync korrigieren
2026-08-19 18:45:58 +02:00
Terranom674
49e74a019b CI: entfernte NC-Migrations-JavaScriptdatei nicht mehr pruefen 2026-08-19 18:45:22 +02:00
Terranom674
f2f7d39b22 Version 0.9.6.25 2026-08-19 18:44:19 +02:00
Terranom674
2c5bcf6ba2 NC-Status: laufenden Service und naechsten Lauf korrekt anzeigen 2026-08-19 18:43:46 +02:00
Terranom674
2d29cfacfe 0.9.6.24: Migration vollstaendig aus aktivem Connectorpfad entfernen 2026-08-19 18:34:43 +02:00
Terranom674
c5e1b33c2f 0.9.6.24: Uebergabe- und Migrationslogik entfernen 2026-08-19 18:34:21 +02:00
Terranom674
675ccd1df8 0.9.6.24: Migrations-UI-Datei entfernen 2026-08-19 18:34:11 +02:00
Terranom674
aefbe76595 0.9.6.24: WebDAV-Runtime von Migration entkoppeln 2026-08-19 18:33:41 +02:00
Terranom674
65cbc0060f 0.9.6.24: Runtime-Migrationsrouting entfernen 2026-08-19 18:33:04 +02:00
Terranom674
5dce180217 0.9.6.24: Migrationsstatus aus Edit-API entfernen 2026-08-19 18:31:51 +02:00
Terranom674
25ee22b1ef 0.9.6.24: Migrations-UI aus Verbindungseditor entfernen 2026-08-19 18:31:28 +02:00
Terranom674
992ddd3b9b 0.9.6.24: Migrationsfunktionen aus Registry entfernen 2026-08-19 18:30:30 +02:00
Terranom674
0f85958dff 0.9.6.24: WebDAV ohne Migrationspaarung 2026-08-19 18:27:44 +02:00
Terranom674
82af31435e 0.9.6.24: gekoppelte Migrationsloeschung entfernen 2026-08-19 18:25:01 +02:00
Terranom674
462197fc78 0.9.6.23: Versionsnummer aktualisieren 2026-08-19 18:20:30 +02:00
Terranom674
cb445c3f04 0.9.6.23: Status logischer NC-Verbindungen korrekt aggregieren 2026-08-19 18:20:10 +02:00
Terranom674
e1306c110c Version auf 0.9.6.22 anheben 2026-08-19 18:10:19 +02:00
Terranom674
bbae8ac984 Migration als einmaligen Uebergang begrenzen 2026-08-19 18:10:01 +02:00
Terranom674
5a7f72b24e Fallback im NC-Verbindungseditor bearbeitbar machen 2026-08-19 18:07:08 +02:00
Terranom674
09027322ef Piwigo-Fallback im Editor pruefen und speichern 2026-08-19 18:06:34 +02:00
Terranom674
a38152108c Piwigo-Fallback im Verbindungseditor bereitstellen 2026-08-19 18:05:50 +02:00
Terranom674
036f2a51e6 Version auf 0.9.6.21 anheben 2026-08-19 18:04:00 +02:00
Terranom674
8cabeb3079 Wizard-Dialog ueber Fehler und Redirects offen halten 2026-08-19 18:03:38 +02:00
Terranom674
0767c4fd8f Gekoppelte NC-Verbindung als Einheit loeschen 2026-08-19 18:03:04 +02:00
Terranom674
de649235ff NC Connector als logische Verbindungen darstellen 2026-08-19 18:02:44 +02:00
Terranom674
3896933081 Merge pull request #13 from Terranom674/fix/nc-ui-mutation-loop-09620
0.9.6.20: NC-Connector UI-Hänger beheben
2026-08-19 17:38:42 +02:00
Terranom674
72a3267ed3 Bump version to 0.9.6.20 2026-08-19 17:32:12 +02:00
Terranom674
99479d5d03 Fix NC connector MutationObserver loop 2026-08-19 17:31:57 +02:00
Terranom674
51089c3446 Merge 0.9.6.19 migration stabilization
WebDAV migration now routes per connection, preserves exact legacy fallbacks, supports API or connection-scoped fallback auth, and passes the final PHP 8.2-8.5 plus script syntax matrix.
2026-08-19 16:11:18 +02:00
Terranom674
c8d846f289 Bump version to 0.9.6.19 2026-08-19 16:09:24 +02:00
Terranom674
2603a8929c 0.9.6.19 preserve fallback-only migration auth 2026-08-19 16:08:57 +02:00
Terranom674
afd8620859 0.9.6.19 allow migration with connection-scoped fallback 2026-08-19 16:08:24 +02:00
Terranom674
72db8b48eb 0.9.6.19 make migration pairing atomic 2026-08-19 16:06:29 +02:00
Terranom674
1a0f8c61e8 0.9.6.19 test supported PHP branches and all admin JS 2026-08-19 16:05:19 +02:00
Terranom674
d4e3e7faab 0.9.6.19 route WebDAV and legacy per connection 2026-08-19 16:04:27 +02:00
Terranom674
03e92b74a7 0.9.6.19 expose exact migration fallback to runtime 2026-08-19 16:03:50 +02:00
Terranom674
88c787c956 0.9.6.19 preserve Nextcloud root selection 2026-08-19 16:02:02 +02:00
Terranom674
3f5f8dc4b4 0.9.6.19 pair migration with selected legacy connection 2026-08-19 16:01:16 +02:00
Terranom674
495edbbb70 Merge pull request #11 from Terranom674/fix/migration-runtime-09618
0.9.6.18: Migration wirklich abschließen und Root-Runtime erzeugen
2026-08-19 15:31:33 +02:00
Terranom674
3d5e6c82ea 0.9.6.18 bump version for migration runtime fix 2026-08-19 15:29:30 +02:00
Terranom674
ae815bc077 0.9.6.18 make selected path lead directly to final migration confirmation 2026-08-19 15:28:41 +02:00
Terranom674
936a1cf3c4 0.9.6.18 allow Nextcloud root in WebDAV runtime 2026-08-19 15:27:39 +02:00
Terranom674
ff0c1e0bec 0.9.6.18 complete migration after WebDAV source selection 2026-08-19 15:26:54 +02:00
Terranom674
a047fc0d83 Merge pull request #10 from Terranom674/fix/root-selection-09617
0.9.6.17: Nextcloud-Stammverzeichnis als WebDAV-Quelle zulassen
2026-08-19 15:19:39 +02:00
Terranom674
2029059720 0.9.6.17 bump version for root selection fix 2026-08-19 15:18:27 +02:00
Terranom674
1eca236509 0.9.6.17 preserve root selection when creating WebDAV connection 2026-08-19 15:17:57 +02:00
Terranom674
e464668343 0.9.6.17 allow Nextcloud root as WebDAV source 2026-08-19 15:17:23 +02:00
Terranom674
47813b0b0e Merge pull request #9 from Terranom674/fix/edit-webdav-path-picker-09616
0.9.6.16: automatische WebDAV-Pfadauswahl direkt beim Bearbeiten
2026-08-19 15:02:21 +02:00
Terranom674
26af1b3019 0.9.6.16 bump version for automatic WebDAV path picker 2026-08-19 15:01:18 +02:00
Terranom674
b1b75676a8 0.9.6.16 prepare WebDAV source picker from edited legacy connection 2026-08-19 15:00:48 +02:00
Terranom674
12880b6c41 0.9.6.16 add automatic Nextcloud path picker to editor 2026-08-19 15:00:27 +02:00
Terranom674
32abc7250d Merge pull request #8 from Terranom674/fix/webdav-source-selection-09615
0.9.6.15: WebDAV-Quellordner klar von Legacy-SMB trennen
2026-08-19 14:44:34 +02:00
Terranom674
76de2b037c 0.9.6.15 bump version 2026-08-19 14:43:23 +02:00
Terranom674
e2e82162c6 0.9.6.15 load source selection clarification UI 2026-08-19 14:42:54 +02:00
Terranom674
7a9eb1baa6 0.9.6.15 separate legacy storage from WebDAV source selection 2026-08-19 14:42:28 +02:00
Terranom674
e800f11f64 Merge pull request #7 from Terranom674/fix/adapter-ui-detection-09614
0.9.6.14: Verbindungstyp nicht mehr aus UI-Text erraten
2026-08-19 14:34:37 +02:00
Terranom674
4808f08590 0.9.6.14 bump version for adapter-safe edit actions 2026-08-19 14:33:33 +02:00
Terranom674
6fd89df5d0 0.9.6.14 determine connection type from backend, not display text 2026-08-19 14:33:03 +02:00
Terranom674
99126088e7 Remove accidental noop file 2026-08-19 14:31:02 +02:00
Terranom674
e53a3848bb noop 2026-08-19 14:30:49 +02:00
Terranom674
0f0c71acaf Merge pull request #6 from Terranom674/fix/webdav-scheme-fallback-09613
0.9.6.13: Nextcloud-Protokoll automatisch erkennen
2026-08-19 14:21:56 +02:00
Terranom674
9847690f50 0.9.6.13 bump version for WebDAV scheme detection 2026-08-19 14:21:02 +02:00
Terranom674
a2f5dc3919 0.9.6.13 try HTTP when scheme is omitted 2026-08-19 14:20:37 +02:00
Terranom674
cd3bf7c8ee Merge pull request #5 from Terranom674/fix/dialog-diagnostics-09612
0.9.6.12: konkrete Fehlerursachen im Verbindungsdialog
2026-08-19 14:14:55 +02:00
Terranom674
79ea403555 0.9.6.12 bump version for actionable diagnostics 2026-08-19 14:14:03 +02:00
Terranom674
33783dd30f 0.9.6.12 include stage and technical cause in visible error 2026-08-19 14:13:35 +02:00
Terranom674
ca6a5cf96f 0.9.6.12 return actionable connection diagnostics 2026-08-19 14:12:58 +02:00
Terranom674
62ada617fb Merge pull request #4 from Terranom674/fix/dialog-validation-09611
0.9.6.11: Dialog bei Fehler offen halten und Felder markieren
2026-08-19 13:59:01 +02:00
Terranom674
898f9b1bc9 0.9.6.11 bump version for dialog validation 2026-08-19 13:58:08 +02:00
Terranom674
5b48e1f6f2 0.9.6.11 keep edit dialog open and mark invalid fields 2026-08-19 13:57:39 +02:00
Terranom674
fdf4584028 0.9.6.11 keep connection editor open on validation errors 2026-08-19 13:56:59 +02:00
Terranom674
880744a8b7 Merge pull request #3 from Terranom674/fix/connection-editing-09610
0.9.6.10: vollständige Verbindungsdaten und migrationsfähiger WebDAV-Start
2026-08-19 13:49:19 +02:00
Terranom674
2ed84ad089 0.9.6.10 lint standalone connector editor JavaScript 2026-08-19 13:48:26 +02:00
Terranom674
60c0a51f01 0.9.6.10 bump plugin version 2026-08-19 13:48:07 +02:00
Terranom674
84d263b16b 0.9.6.10 load complete connector editor on admin page 2026-08-19 13:47:48 +02:00
Terranom674
94c81789ed 0.9.6.10 rebuild connection editor around complete connection data 2026-08-19 13:46:26 +02:00
Terranom674
e3b1f06be3 0.9.6.10 expose complete safe edit metadata and migration readiness 2026-08-19 13:44:46 +02:00
Terranom674
15a3ceb01c 0.9.6.10 complete legacy edit credentials and migration readiness 2026-08-19 13:44:26 +02:00
Terranom674
0f5812a179 0.9.6.9: Verbindung bearbeiten und Migration trennen
* 0.9.6.9 separate edit and migration flows

* 0.9.6.9 register separate edit and migration actions

* 0.9.6.9 add safe connection edit data endpoint

* 0.9.6.9 make connection editing explicit and in-place

* Bump version to 0.9.6.9

* 0.9.6.9 catch duplicate functions and admin JavaScript syntax
2026-08-19 13:35:12 +02:00
Terranom674
377b29a127 Bump Bratonien Tools to 0.9.6.8 2026-08-19 13:21:11 +02:00
Terranom674
d9696172c5 Fix duplicate connector edit handlers 2026-08-19 13:20:55 +02:00
Terranom674
3f2a85fb0b Bump version to 0.9.6.7 2026-08-19 13:17:34 +02:00
Terranom674
c882ba994b Expose editor for every connector connection 2026-08-19 13:17:13 +02:00
Terranom674
cd2c9e350b Make WebDAV connections editable in place 2026-08-19 13:16:29 +02:00
Terranom674
394f375e91 Register connection editor 2026-08-19 13:16:00 +02:00
Terranom674
3b1ccd99a5 Add end-user connection editor 2026-08-19 13:15:30 +02:00
Terranom674
04d88cd40f Bump version to 0.9.6.6 2026-08-19 13:05:02 +02:00
Terranom674
a4f953551c Report real WebDAV failure cause and missing WebDAV configuration 2026-08-19 13:04:36 +02:00
Terranom674
b4161471ca Bump version to 0.9.6.5 2026-08-19 13:00:28 +02:00
Terranom674
71fdadb7f8 Show WebDAV failure reason in connector status 2026-08-19 13:00:10 +02:00
Terranom674
98134b5ab8 Expose WebDAV failure reason before legacy fallback 2026-08-19 12:59:55 +02:00
Terranom674
5f82f2ad71 Bump version to 0.9.6.4 2026-08-19 12:56:17 +02:00
Terranom674
494f8bf192 Show active connector route in status polling 2026-08-19 12:55:41 +02:00
Terranom674
c46f9bb5bd Bump Bratonien Tools to 0.9.6.3 2026-08-19 12:12:57 +02:00
Terranom674
992cb55714 0.9.6.2 show WebDAV vs legacy fallback 2026-08-19 11:43:12 +02:00
Terranom674
46782ea9a3 0.9.6.2 expose active connector route 2026-08-19 11:42:48 +02:00
Terranom674
d03410c9ef 0.9.6.2 make WebDAV primary and legacy local true fallback 2026-08-19 11:24:59 +02:00
Terranom674
aa2a6e4c02 0.9.6.2 pair WebDAV migration with legacy fallback 2026-08-19 11:11:39 +02:00
Terranom674
ebdd77fb0a Release 0.9.6.2 migration phase one 2026-08-19 11:07:01 +02:00
Terranom674
6c36aaeb8a Begin 0.9.6.2 legacy connector migration 2026-08-19 11:06:07 +02:00
Terranom674
2c7cd9e780 Align WebDAV image stream version with 0.9.6.1 2026-08-19 00:13:11 +02:00
Terranom674
6155955eee Align WebDAV preview runtime version with 0.9.6.1 2026-08-19 00:12:39 +02:00
Terranom674
5179c1cc33 Align derivative builder version with 0.9.6.1 2026-08-19 00:12:08 +02:00
Terranom674
facb270b13 Refresh README for 0.9.6.1 2026-08-19 00:11:53 +02:00
Terranom674
b4f56ac04b Update current status for 0.9.6.1 2026-08-19 00:11:27 +02:00
Terranom674
16571f9322 Bump version to 0.9.6.1 2026-08-18 23:46:33 +02:00
Terranom674
d7fd99eb7c Clean stale WebDAV Piwigo content in runner 2026-08-18 23:46:16 +02:00
Terranom674
207c2ae502 Clean stale WebDAV Piwigo sites after connection deletion 2026-08-18 23:46:03 +02:00
Terranom674
4d04f115e1 Remove WebDAV Piwigo content when deleting connection 2026-08-18 23:45:42 +02:00
Terranom674
ed9088a8e2 Keep WebDAV precache on 0.9.5.x 2026-08-18 23:38:22 +02:00
Terranom674
8c7476a5d2 Keep derivative builder on 0.9.5.x 2026-08-18 23:37:54 +02:00
Terranom674
bc9d378bfd Restore 0.9.5.x version line 2026-08-18 23:37:35 +02:00
Terranom674
3da61079f2 Release 0.9.6.0 WebDAV derivative rebuild 2026-08-18 23:33:16 +02:00
Terranom674
f380ee4aec Serve WebDAV previews with their real MIME type 2026-08-18 23:32:01 +02:00
Terranom674
96dda18fc9 Build WebDAV derivatives with validated Piwigo image sources 2026-08-18 23:31:26 +02:00
Terranom674
3767620867 Make WebDAV derivatives safe and Piwigo-compatible 2026-08-18 23:30:56 +02:00
Terranom674
d18e7108dd Rebuild WebDAV previews in Piwigo-compatible formats 2026-08-18 23:30:22 +02:00
Terranom674
450f9a0ac5 Bump version to 0.9.5.22 2026-08-18 23:17:53 +02:00
Terranom674
47182a86a2 Hotfix: never build WebDAV derivatives during page rendering 2026-08-18 23:16:43 +02:00
Terranom674
907fba8c8f Expose WebDAV derivative builder runtime version 2026-08-18 23:15:24 +02:00
Terranom674
bf03fea647 Bump version to 0.9.5.21 2026-08-18 23:10:12 +02:00
Terranom674
b3aef2744f Handle WebDAV derivative identity correctly 2026-08-18 23:09:53 +02:00
Terranom674
bcf98c63ef Fix WebDAV derivative metadata and identity handling 2026-08-18 23:09:23 +02:00
Terranom674
fa3ce09589 Bump version to 0.9.5.20 2026-08-18 22:59:19 +02:00
Terranom674
fde03e66f9 Build WebDAV Piwigo derivatives during sync 2026-08-18 22:59:04 +02:00
Terranom674
b780636791 Prebuild Piwigo derivatives for WebDAV images 2026-08-18 22:58:32 +02:00
Terranom674
8785a1ccc5 Generate WebDAV derivatives from prepared previews 2026-08-18 22:58:14 +02:00
Terranom674
554778601f Bump version to 0.9.5.19 2026-08-18 22:48:46 +02:00
Terranom674
c1665e53ad Preload WebDAV previews during sync 2026-08-18 22:48:27 +02:00
Terranom674
047045ad23 Serve precached WebDAV previews 2026-08-18 22:48:03 +02:00
Terranom674
0355f4a555 Use precached WebDAV previews for derivatives 2026-08-18 22:47:44 +02:00
Terranom674
a71c07cbce Precache WebDAV previews during sync 2026-08-18 22:47:29 +02:00
Terranom674
69213adabf Bump version to 0.9.5.18 2026-08-18 22:42:38 +02:00
Terranom674
272930ead5 Fix WebDAV image lookup without private runtime map dependency 2026-08-18 22:42:23 +02:00
Terranom674
5a115e3b4a Route WebDAV photos to live Nextcloud image stream 2026-08-18 22:30:35 +02:00
Terranom674
6791b65e50 Stream Nextcloud image bytes for WebDAV-backed photos 2026-08-18 22:29:53 +02:00
Terranom674
2238845579 Serve WebDAV originals through Piwigo image URLs 2026-08-18 22:29:32 +02:00
Terranom674
4c862c77b3 Bump version to 0.9.5.16 2026-08-18 22:23:48 +02:00
Terranom674
370572c77d Register WebDAV roots as dedicated Piwigo sites 2026-08-18 22:23:30 +02:00
Terranom674
74911e473c Use dedicated WebDAV gallery roots and suppress duplicate sources 2026-08-18 22:22:51 +02:00
Terranom674
65cd8898d3 Bump version for wizard validation lifecycle hardening 2026-08-18 22:18:54 +02:00
Terranom674
e46196c143 Keep NC wizard open across validation errors and form posts 2026-08-18 22:18:34 +02:00
Terranom674
6a71532839 Bump plugin version to 0.9.5.14 2026-08-18 22:14:57 +02:00
Terranom674
3cd647ea6b Clarify WebDAV Piwigo fallback authentication failures 2026-08-18 22:14:41 +02:00
Terranom674
639e50afb5 Bump Bratonien Tools to 0.9.5.13 2026-08-18 22:12:33 +02:00
Terranom674
17f7fa8d52 Validate WebDAV wizard Piwigo fallback before saving 2026-08-18 22:12:11 +02:00
Terranom674
af527e496b Validate connection-scoped Piwigo fallback credentials 2026-08-18 22:11:36 +02:00
Terranom674
577d1ebf81 Bump plugin version to 0.9.5.12 2026-08-18 21:56:49 +02:00
Terranom674
3bd3158817 Improve WebDAV sync failure diagnostics 2026-08-18 21:56:30 +02:00
Terranom674
c916d742f5 Bump version to 0.9.5.11 2026-08-18 21:53:40 +02:00
Terranom674
6f0891f6a8 Enable Piwigo registration for parallel WebDAV connections 2026-08-18 21:53:13 +02:00
Terranom674
b8ea6b1fe2 Bump plugin version to 0.9.5.10 2026-08-18 21:45:51 +02:00
Terranom674
aeba24c6fa Clarify WebDAV registration through existing productive sync 2026-08-18 21:45:22 +02:00
Terranom674
0d81d83d12 Build WebDAV shadow trees before productive Piwigo sync 2026-08-18 21:45:04 +02:00
Terranom674
dbedb83c14 Bump plugin version for WebDAV-first wizard 2026-08-18 21:39:03 +02:00
Terranom674
04620f20a2 Persist wizard auth for WebDAV connections 2026-08-18 21:38:38 +02:00
Terranom674
2127649209 Route wizard through WebDAV-first flow 2026-08-18 21:38:18 +02:00
Terranom674
fd5b55ac53 Add WebDAV-first wizard flow 2026-08-18 21:37:55 +02:00
Terranom674
933db21d88 Release 0.9.5.8 2026-08-18 21:34:48 +02:00
Terranom674
a854bda115 Keep connector status polling from reloading admin page 2026-08-18 21:34:32 +02:00
Terranom674
08040edf8f Document WebDAV parallel runtime in 0.9.5.7 2026-08-18 21:27:24 +02:00
Terranom674
bb024000f2 Bump plugin version to 0.9.5.7 2026-08-18 21:26:27 +02:00
Terranom674
539605b19a Run WebDAV path beside existing connector runtime 2026-08-18 21:25:57 +02:00
Terranom674
a550bbd01a Add isolated WebDAV shadow tree runner 2026-08-18 21:25:44 +02:00
Terranom674
e344455e0c Add isolated WebDAV runtime reconciliation 2026-08-18 21:25:29 +02:00
Terranom674
88edd5aee9 Update README for 0.9.5.6 WebDAV parallel path 2026-08-18 21:23:05 +02:00
Terranom674
aa7ddefa0b Record parallel WebDAV connector foundation 2026-08-18 21:22:32 +02:00
Terranom674
ef37e3b612 Bump plugin version to 0.9.5.6 2026-08-18 21:21:44 +02:00
Terranom674
eadee3cc24 Preserve WebDAV credentials in scoped secret updates 2026-08-18 21:21:26 +02:00
Terranom674
d05a6f4f46 Register parallel WebDAV connector action 2026-08-18 21:20:50 +02:00
Terranom674
04b32309df Add parallel WebDAV connector mode 2026-08-18 21:20:08 +02:00
Terranom674
db28692cfb Pass WebDAV credentials from wizard into connection secret 2026-08-18 21:19:45 +02:00
Terranom674
5866824398 Store Nextcloud WebDAV credentials per connection 2026-08-18 21:19:26 +02:00
Terranom674
ac419ac56f Bring README to current 0.9.5.5 connector architecture 2026-08-18 21:17:27 +02:00
Terranom674
65910375d2 Document current connector status and WebDAV migration plan 2026-08-18 21:16:56 +02:00
Terranom674
c547c9b089 Bump plugin version to 0.9.5.5 2026-08-18 21:13:48 +02:00
Terranom674
21caddcec5 Clean deleted connector runtime before sync 2026-08-18 21:13:30 +02:00
Terranom674
237500a8fa Use safe connector deletion handler 2026-08-18 21:13:21 +02:00
Terranom674
f5dae306db Clean stale connector runtime files before sync 2026-08-18 21:13:02 +02:00
Terranom674
20bd5105f3 Fix connector deletion across runtime ownership boundaries 2026-08-18 21:12:50 +02:00
Terranom674
82019fb14d Bump plugin version for WebDAV placeholder work 2026-08-18 21:10:51 +02:00
Terranom674
17e4f66126 Add WebDAV placeholder source builder 2026-08-18 21:08:50 +02:00
Terranom674
1bb2088d06 Release 0.9.5.3 2026-08-18 19:12:15 +02:00
Terranom674
d97ac4b5db NC runtime: reconcile legacy and selected sources generically 2026-08-18 19:10:46 +02:00
Terranom674
e733b6f6e8 NC connector: register generic source scope 2026-08-18 19:09:59 +02:00
Terranom674
c7966db8f2 NC connector: persist generic fileid source scope 2026-08-18 19:09:40 +02:00
Terranom674
e1d01d02f5 NC wizard: route technical check through generic connector views 2026-08-18 19:09:17 +02:00
Terranom674
28f8bb7d96 NC wizard: resolve WebDAV selections by fileid and storage adapter 2026-08-18 19:09:03 +02:00
Terranom674
cd7d8aa2f1 NC runtime: use generic user and fileid source modes 2026-08-18 19:07:56 +02:00
Terranom674
69815b1cf4 NC runtime: scope activity checks per connection user 2026-08-18 19:07:20 +02:00
Terranom674
b301853ea2 NC runtime: make share manifest user-filterable 2026-08-18 19:07:01 +02:00
Terranom674
0e1c4faedd NC runtime: build manifest from selected Nextcloud file IDs 2026-08-18 19:06:33 +02:00
Terranom674
cfa2322d75 NC runtime: remove abandoned user filesystem manifest 2026-08-18 18:37:14 +02:00
Terranom674
1513500e2e NC runtime: remove abandoned user-filesystem mapper 2026-08-18 18:37:05 +02:00
Terranom674
2b16604e6e NC runtime: remove experimental user-filesystem reconcile 2026-08-18 18:36:54 +02:00
50 changed files with 6249 additions and 1292 deletions

1
.ci-trigger-0977 Normal file
View File

@@ -0,0 +1 @@
0.9.7.7

View File

@@ -5,8 +5,12 @@ on:
pull_request:
jobs:
syntax:
php-syntax:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version: ['8.2', '8.3', '8.4', '8.5']
steps:
- name: Repository auschecken
uses: actions/checkout@v4
@@ -14,7 +18,7 @@ jobs:
- name: PHP installieren
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
php-version: ${{ matrix.php-version }}
coverage: none
- name: PHP Syntax pruefen
@@ -25,6 +29,31 @@ jobs:
php -l "$file"
done < <(find . -type f -name '*.php' -print0)
- name: Doppelte Bratonien-Funktionen pruefen
shell: bash
run: |
set -euo pipefail
duplicates="$(grep -RhoE 'function[[:space:]]+bratonien_tools_[A-Za-z0-9_]+' --include='*.php' . | awk '{print $2}' | sort | uniq -d || true)"
if [[ -n "$duplicates" ]]; then
echo "Doppelte globale Bratonien-Funktionen gefunden:" >&2
printf '%s\n' "$duplicates" >&2
exit 1
fi
script-syntax:
runs-on: ubuntu-latest
steps:
- name: Repository auschecken
uses: actions/checkout@v4
- name: Admin JavaScript Syntax pruefen
shell: bash
run: |
set -euo pipefail
sed -n '/<script>/,/<\/script>/p' template/admin_tabs.tpl | sed '1d;$d' > /tmp/bratonien-admin-tabs.js
node --check /tmp/bratonien-admin-tabs.js
node --check js/nc_connector_edit_v2.js
- name: Python Syntax pruefen
shell: bash
run: |

View File

@@ -1,122 +1,139 @@
# Aktueller Entwicklungsstand
Stand: 18.08.2026
Stand: 19.08.2026
## Plugin
- Aktuelle Plugin-Version: **0.9.4.4**
- Aktueller Entwicklungsblock: **NC Connector Endnutzer-Assistent und Konsolidierung**
- `0.9.3.43` markiert den abgeschlossenen vorherigen Meilenstein.
- `0.9.4.x` ist der neue Entwicklungsblock.
- Aktuelle Plugin-Version: **0.9.6.1**
- Aktueller Entwicklungsblock: **NC Connector WebDAV-End-to-End-Pfad mit Piwigo-Registrierung, Preview-/Derivatstrecke und sauberem Verbindungs-Lebenszyklus**
- GitHub ist das führende Repository.
- Der bestehende lokale Connector bleibt vollständig erhalten und wird nicht automatisch migriert.
## Produktiver Stand
## Nicht verhandelbare Architekturregeln
Der lokale NC Connector ist im aktuellen Bratonien-Einsatz End-to-End funktionsfähig:
- Nextcloud bleibt die Quelle der Originalbilder.
- Originalbilder werden **nicht dauerhaft nach Piwigo kopiert**.
- Der bestehende lokale Produktionsweg bleibt funktionsfähig.
- WebDAV benötigt nur Nextcloud-Adresse, Benutzer/App-Passwort und normale WebDAV-Berechtigungen des Benutzers.
- Kein Nextcloud-PostgreSQL-Zugriff, kein `occ`, keine Storage-IDs, keine Host-Mounts, kein FUSE, davfs oder rclone als Voraussetzung.
- WebDAV-Verbindungen werden voneinander isoliert verarbeitet.
- Fehler der WebDAV-Bildverarbeitung dürfen keinen normalen Piwigo-Seitenaufruf zum Absturz bringen.
- Nextcloud-Freigaben `folder` und `file` werden verarbeitet.
- Ordnerfreigaben werden als physische Piwigo-Alben synchronisiert.
- Einzeldateifreigaben werden als echte Piwigo-Orphans ohne Albumzuordnung registriert.
- Entfernte Freigaben werden aus Piwigo entfernt, Originaldateien bleiben erhalten.
- Neue physische Connector-Alben werden privat angelegt.
- API-first-Synchronisierung ist produktiv für Piwigo **16.4.0** freigegeben.
- Piwigo-Album- und Fotoinformationen werden nach dem direkten Sync aktualisiert.
- Der gemeinsame systemd-Timer verarbeitet aktive Verbindungen regelmäßig.
## Bestehende Quellenmodi
## 0.9.4.1
Weiterhin vorhanden:
Die Statusaktualisierung im Admin-UI wurde von Seiten-Reload auf gezielte DOM-Aktualisierung umgestellt. Der Poller aktualisiert nur noch **Letzter Lauf** und **Nächster Lauf**. Offene Dialoge, Details und der Assistent bleiben dadurch erhalten.
- `legacy-view`
- `user-shares`
- `selected-fileids`
## 0.9.4.2
Neuer WebDAV-Modus:
### Verbindungsassistent
- `webdav-placeholder`
Der Assistent wurde aus Endnutzersicht konsolidiert. Technische Werte werden nur gezielt abgefragt, wenn eine automatische Prüfung sie nicht bestätigen kann.
Es gibt keine automatische Migration bestehender Verbindungen auf WebDAV.
### Storage-Mappings
## WebDAV-Ablauf in 0.9.6.1
Das Storage-Format erlaubt einen leeren `source_prefix`. Damit kann ein kompletter Storage direkt auf einen lokalen Mount zeigen.
### Native Aktivierung
`nc-connector-install.php` unterstützt API-only-Verbindungen ohne dauerhaft gespeicherten Login-Fallback. Ein Fallback ist nur nötig, wenn keine nutzbare API gespeichert ist.
## 0.9.4.3
- Syntaxfehler im Wizard-Abschluss korrigiert.
- Neue Wizard-Durchläufe werden von alten serverseitigen Zuständen getrennt.
- Abbrechen und Schließen verwerfen den laufenden Wizard-State.
## 0.9.4.4
Der Connector-Benutzer aus Schritt 1 ist zugleich der lesende Datenbank-Benutzer. Der Assistent fragt deshalb Benutzername und Passwort nicht erneut ab.
Ablauf:
1. Nextcloud-Adresse + Connector-Benutzer + Passwort.
2. Web-Zugang und OCS werden geprüft.
3. Derselbe Benutzer und dasselbe Passwort werden automatisch für PostgreSQL verwendet.
4. Der Assistent prüft den üblichen Weg mit Nextcloud-Host, Port 5432 und Datenbank `nextcloud` bzw. bekannte passende Verbindungswerte.
5. Nur wenn die Datenbank dort nicht erreichbar ist, werden Host, Port und Datenbankname abgefragt. Benutzer und Passwort bleiben unverändert aus Schritt 1.
6. Danach folgen gegebenenfalls Storage-Zuordnung, Showcase-Benutzer, Piwigo-API und Fallback.
Die PHP-Datei `include/nc_connector_wizard.inc.php` wurde vor dem Versionsbump mit dem PHP-Parser geprüft.
## Architektur NC Connector
- Nextcloud ist Quelle der Originalbilder.
- Piwigo erhält einen Shadow Tree aus Verzeichnissen und Symlinks.
- PostgreSQL-Reader und die Views `piwigo_showcase_sources` / `piwigo_showcase_activity` liefern Quellen und Aktivität.
- Runtime-Konfigurationen: `/etc/bratonien-tools/nc-connector/connection-*.conf`
- State: `/var/lib/bratonien-tools/nc-connector/connection-ID`
- Öffentlicher Admin-Status: Piwigo `_data/bratonien-tools/nc-connector-status/`
- Runner: `runtime/run-all.sh`
- Einzelverbindung: `runtime/sync.sh`
1. Der Assistent prüft Nextcloud über WebDAV und lässt sichtbare Verzeichnisse auswählen.
2. `runtime/reconcile-webdav.php` erzeugt die verbindungseigene Runtime-Konfiguration.
3. `runtime/lib/build_webdav_placeholder_source.py` liest die ausgewählten Verzeichnisse rekursiv per PROPFIND.
4. Ordner, Dateinamen, Nextcloud-Datei-ID, MIME-Typ, Größe, ETag und WebDAV-Pfad werden erfasst.
5. Eine lokale Platzhalterquelle wird erzeugt, ohne Originalbilder dauerhaft zu speichern.
6. `runtime/lib/shadow_tree.py` baut daraus den verbindungseigenen Shadow Tree.
7. Der WebDAV-Galeriebaum liegt unter `_data/bratonien-tools/nc-webdav-gallery/connection-ID` und wird als eigene physische Piwigo-Site registriert.
8. Dadurch erscheint die ausgewählte Nextcloud-Wurzel als normales Piwigo-Album; technische `bratonien-webdav-ID`-Wrapper werden nicht angezeigt.
9. Vorbereitete Arbeitsbilder liegen unter `_data/bratonien-tools/nc-webdav-preview/connection-ID`.
10. Arbeitsbilder werden als JPEG beziehungsweise PNG erzeugt und maximal auf 4096 px Kantenlänge begrenzt, damit Piwigos Bildbibliothek sie zuverlässig verarbeiten kann.
11. Der CLI-Derivat-Builder korrigiert die Piwigo-Bildabmessungen anhand des Arbeitsbilds und erzeugt die aktuell konfigurierten Standard- und Custom-Derivate.
12. Der normale Frontend-Aufruf erzeugt **keine** Derivate. Fehlt ein Derivat, wird das vorbereitete Bild als sicherer Fallback ausgeliefert.
13. Originalbilder werden bei echtem Originalabruf serverseitig über `webdav-image.php` aus Nextcloud gestreamt; Nextcloud-Zugangsdaten erscheinen nicht im Browser.
## Piwigo-Synchronisierung
Priorität:
WebDAV verwendet denselben Piwigo-Synchronisationspfad wie der bestehende Connector, aber mit einer verbindungseigenen physischen Site.
1. gespeicherter Piwigo-API-Key;
2. bei nicht nutzbarer API gespeicherter Benutzername/Passwort-Fallback;
3. ohne beide Wege Abbruch mit Fehlerstatus.
Nach der Dateisynchronisierung laufen die vorhandenen Piwigo-Nacharbeiten, darunter Metadaten-, Integritäts-, Kategorie-, Pfad-, Rang- und Cache-Aktualisierungen sowie der Orphan-Abgleich.
Produktive API-Methoden:
Die direkte produktive Synchronisierung ist derzeit ausdrücklich auf **Piwigo 16.4.0** abgestimmt.
- `bratonien.nc.syncProductive`
- `bratonien.nc.syncOrphans`
## WebDAV-Bildausgabe und Derivate
`bratonien.nc.syncProductive` ist auf Piwigo 16.4.0 versionsgebunden. Der direkte Sync verwendet Bratonien-eigene Logik auf Basis der Piwigo-Core-Funktionen, nicht das Rendern oder Fernsteuern einer Admin-Seite.
Wichtige Dateien:
## Activity Gate und Shadow Tree
- `include/webdav_image_runtime.inc.php` Zuordnung Piwigo-Bild ↔ WebDAV-Quelle, Preview-/Derivat-Helfer und URL-Filter.
- `webdav-image.php` berechtigungsgeprüfter serverseitiger Original-/Preview-Abruf.
- `runtime/lib/precache-webdav-previews.php` vorbereitete JPEG-/PNG-Arbeitsbilder ohne dauerhafte Originalkopie.
- `runtime/lib/build-webdav-derivatives.php` CLI-Aufbau der konfigurierten Piwigo-Derivate.
Das Activity Gate berücksichtigt:
Der Frontend-Hook führt keine Bildgenerierung mehr aus. Damit bleibt ein Fehler in GD/Imagick/External ImageMagick auf den Hintergrundlauf begrenzt.
- Aktivitätsstand;
- Share-Fingerprint;
- Quiet-Time;
- Max-Wartezeit;
- periodischen Full-Sync;
- Reparaturbedarf bei beschädigter lokaler Struktur.
## Löschen einer WebDAV-Verbindung seit 0.9.6.1
Der Shadow-Tree-Wechsel besitzt einen Rollback auf den vorherigen Baum, falls das Umschalten fehlschlägt.
Eine gelöschte WebDAV-Verbindung darf keine weiterhin sichtbaren oder über alte Piwigo-Datensätze erreichbaren Bilder hinterlassen.
Beim Löschen über die Oberfläche:
- werden die zur Verbindung gehörende Piwigo-Site, ihre Alben und Bilddatensätze entfernt;
- werden zugehörige Piwigo-Derivate entfernt;
- bleiben die Nextcloud-Originaldateien unangetastet;
- werden Laufzeit- und Preview-Daten anschließend bereinigt.
Zusätzlich läuft `runtime/cleanup-webdav-piwigo.php` im gemeinsamen Runner. Er erkennt verwaiste WebDAV-Piwigo-Sites, für die keine Connector-Verbindung mehr existiert, und entfernt deren Piwigo-Inhalte sowie Gallery-, Source-, Preview-, State-, Status- und Runtime-Reste. Damit werden auch bereits vor 0.9.6.1 gelöschte Verbindungen nachträglich bereinigt.
## Gemeinsamer Runner
`runtime/run-all.sh` verarbeitet die Schritte in kontrollierter Reihenfolge:
1. lokale Verbindungen reconciliieren;
2. WebDAV-Verbindungen reconciliieren;
3. verwaiste WebDAV-Piwigo-Inhalte bereinigen;
4. verwaiste allgemeine Runtime-Dateien bereinigen;
5. aktive WebDAV-Verbindungen synchronisieren;
6. aktive lokale Verbindungen synchronisieren.
Der vorhandene systemd-Timer ruft direkt die Runtime aus dem installierten Plugin auf.
## Status- und UI-Regeln
- Hintergrund-Polling lädt die Admin-Seite nicht neu.
- Ein geöffneter Verbindungsassistent bleibt bei Statusaktualisierungen geöffnet.
- Fehlgeschlagene Fallback-/Credential-Prüfungen schließen den Assistenten nicht.
- Laufzeitfehler unterscheiden WebDAV-Lesen, Piwigo-Synchronisierung, Preview- und Derivatfehler.
## Sicherheit
- Nextcloud-Originale werden nicht gelöscht.
- Connector-Zugangsdaten werden verschlüsselt gespeichert.
- Wizard-Secrets bleiben serverseitig.
- Storage-Pfade werden validiert.
- SQL-View-Namen werden validiert.
- Piwigo-API ist versionsgebunden.
- Mutierende Admin-Aktionen verwenden Post/Redirect/Get, sodass Browser-Reloads sie nicht erneut ausführen.
- Self-Updates sind an einen konkreten Commit und SHA-256 gebunden.
- WebDAV-Credentials werden nur serverseitig verwendet.
- Bildzugriffe über `webdav-image.php` werden gegen Piwigos Zugriffsrechte geprüft.
- Nextcloud-Originale werden weder beim Sync noch beim Löschen einer Verbindung verändert.
- Derivate und Arbeitsbilder sind lokale Cache-/Arbeitsdaten und dürfen entfernt beziehungsweise neu aufgebaut werden.
## Noch offen
## Aktueller Prüfstand
- vollständiger Remote-Nextcloud-Adapter ohne direkten PostgreSQL-/Storage-Zugriff;
- weitere Bereinigung alter Legacy-Migrationshelfer, sobald sie nicht mehr benötigt werden;
- weitere Endnutzer-Optimierung des Assistenten auf Basis realer Fehler- und Installationspfade.
Bereits umgesetzt:
## Repository-Fallback
- WebDAV-Verbindungsassistent;
- Verzeichnisauswahl;
- rekursiver WebDAV-Scan;
- Shadow Tree;
- separate Piwigo-Site je WebDAV-Verbindung;
- sichtbare Albumstruktur ohne technische Wrapper;
- echte Bildausgabe aus Nextcloud;
- lokale vorbereitete Arbeitsbilder;
- CLI-Derivatstrecke;
- Piwigo-Nacharbeiten und Orphan-Abgleich;
- ETag-basierte Wiederverwendung vorbereiteter Bilder;
- Löschen einer Verbindung einschließlich Piwigo-Inhalten;
- nachträgliche Bereinigung bereits verwaister WebDAV-Piwigo-Sites.
GitHub bleibt das führende Repository. Das private Gitea-System dient weiterhin als Mirror/Fallback.
Weiter zu prüfen beziehungsweise unter realer Last zu härten:
- vollständiger Derivataufbau für alle in der Installation vorkommenden Standard- und Custom-Größen;
- Verhalten bei großen Bildmengen und vielen gleichzeitig noch nicht vorbereiteten Bildern;
- Fehlerfälle verschiedener Bildformate und Bild-Backends;
- langfristige Änderungserkennung und Performance im produktiven Einsatz.
## Versionsregel
Die aktuelle Versionslinie ist **0.9.6.x**. Weitere Sprünge auf eine neue Minor-/Major-Linie erfolgen nur bewusst und ausdrücklich.

214
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.
Aktuelle Plugin-Version: **0.9.4.4**
Aktuelle Plugin-Version: **0.9.6.1**
## Grundprinzip
@@ -12,113 +12,131 @@ Für die Administration gilt: Der normale Nutzer sieht Aufgaben, Entscheidungen
## NC Connector
Der NC Connector synchronisiert ausdrücklich freigegebene Nextcloud-Inhalte mit Piwigo.
Der NC Connector synchronisiert ausdrücklich ausgewählte Nextcloud-Inhalte mit Piwigo.
### Datenmodell
- Nextcloud bleibt die Quelle der Originaldateien.
- Originalbilder werden nicht in eine zweite dauerhafte Bibliothek kopiert.
- Ein Shadow Tree erzeugt die für Piwigo benötigte Verzeichnisstruktur mit Symlinks.
- Ordnerfreigaben werden als physische Piwigo-Alben synchronisiert.
- Einzeldateifreigaben werden im Galerie-Root verlinkt und als echte Piwigo-Orphans ohne Albumzuordnung registriert.
- Entfernte Freigaben werden aus Shadow Tree und Piwigo entfernt, das Nextcloud-Original bleibt erhalten.
- Originalbilder werden nicht dauerhaft in eine zweite Bibliothek unter Piwigo kopiert.
- Ein Shadow Tree erzeugt die für Piwigo benötigte Ordnerstruktur.
- Ordner werden als physische Piwigo-Alben synchronisiert.
- Root-Dateien können als Piwigo-Orphans registriert werden.
- Neu importierte physische Connector-Alben werden privat angelegt.
- Entfernte Quellen werden aus Shadow Tree und Piwigo entfernt; die Nextcloud-Originale bleiben erhalten.
### Verbindungsassistent
### Bestehende lokale Quellenmodi
Neue Verbindungen werden bevorzugt mit einem Endnutzer-Assistenten angelegt.
Der bestehende Connector unterstützt weiterhin:
Ablauf:
- `legacy-view`
- `user-shares`
- `selected-fileids`
1. Nextcloud-Adresse, Benutzer und Passwort eingeben.
2. Der Assistent prüft HTTP/HTTPS, Nextcloud-Status und Anmeldung.
3. Derselbe in Schritt 1 angegebene Connector-Benutzer und dasselbe Passwort werden auch für den lesenden PostgreSQL-Zugriff verwendet; es gibt im normalen Ablauf keine zweite Benutzer-/Passwortabfrage.
4. Vorhandene passende Connector-Werte für Host, Port, Datenbank und Storage-Zuordnungen werden wiederverwendet, aber erneut mit dem aktuellen Zugang geprüft.
5. Nur wenn der übliche PostgreSQL-Weg nicht erreichbar ist, werden abweichende Datenbank-Adresse, Port und Datenbankname gezielt abgefragt. Benutzer und Passwort bleiben dabei die Angaben aus Schritt 1.
6. Nicht automatisch zuordenbare Storage-Mounts werden einzeln bestätigt.
7. Danach wird der Nextcloud-Benutzer gewählt, dessen Freigaben verwendet werden sollen. Empfohlen wird ein eigener `showcase`-Benutzer.
8. Piwigo-API-Zugang testen oder bewusst überspringen.
9. Optionaler bzw. bei übersprungener API verpflichtender Login-Fallback.
10. Erst der Abschluss legt die Verbindung dauerhaft an.
Diese Modi bleiben erhalten. Es gibt keine automatische Migration auf WebDAV.
Wizard-Geheimnisse werden während der Einrichtung serverseitig in der Sitzung gehalten. Ein fehlgeschlagener Test leert die Eingaben nicht. Ein API-Test verändert die dauerhafte API-Konfiguration erst beim erfolgreichen Abschluss des Assistenten.
### WebDAV-Modus
Die vollständige technische Maske bleibt weiterhin über **Ohne Assistent anlegen** sowie bei bestehenden Verbindungen unter **Technische Einstellungen** erreichbar.
Der neue Endnutzerpfad verwendet `source_mode=webdav-placeholder` und `adapter=remote`.
### Lokaler Adapter
Benötigt werden nur:
Der derzeit produktive Adapter verwendet:
- Nextcloud-Adresse;
- Nextcloud-Benutzer bzw. App-Passwort;
- normaler WebDAV-Zugriff auf die Inhalte dieses Benutzers;
- eine normale Piwigo-Installation mit ihrer vorhandenen PHP-/Bildverarbeitungsumgebung.
- PostgreSQL-Reader mit minimalen Leserechten;
- Source-View `piwigo_showcase_sources`;
- Activity-View `piwigo_showcase_activity`;
- explizite Storage-Zuordnungen auf bereits vorhandene lokale Mounts;
- Plugin-eigene Runtime und getrennten State pro Verbindung.
Nicht vorausgesetzt werden:
Ein Storage-Mapping besteht aus `storage_id`, einem optionalen `source_prefix` und dem lokalen Mount. Ein leerer Prefix ist zulässig und bedeutet, dass der Mount direkt dem Storage-Root entspricht.
- PostgreSQL-Zugriff auf Nextcloud;
- `occ`-Adminzugriff;
- Storage-IDs oder Backend-Pfade;
- zusätzliche Host-Mounts;
- FUSE;
- davfs;
- rclone.
### Activity Gate
### WebDAV-Ablauf
Der regelmäßige Lauf berücksichtigt:
- Nextcloud-Aktivität;
- Signatur der sichtbaren Freigaben;
- Quiet-Time;
- maximale Wartezeit;
- periodischen Full-Sync;
- notwendigen Reparaturlauf bei beschädigtem Shadow Tree oder fehlenden Piwigo-Alben.
1. Der Assistent prüft Nextcloud und liest die sichtbaren Verzeichnisse per WebDAV.
2. Ausgewählte Verzeichnisse werden rekursiv per PROPFIND eingelesen.
3. Ordner, Dateiname, Nextcloud-Datei-ID, MIME-Typ, Größe, ETag und WebDAV-Pfad werden erfasst.
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.
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. Die ausgewählte Nextcloud-Wurzel erscheint dadurch direkt als Album; technische `bratonien-webdav-ID`-Wrapper bleiben unsichtbar.
8. `runtime/lib/precache-webdav-previews.php` erzeugt vorbereitete Arbeitsbilder als JPEG beziehungsweise PNG mit maximal 4096 px Kantenlänge.
9. `runtime/lib/build-webdav-derivatives.php` korrigiert die Piwigo-Bildabmessungen und erzeugt die konfigurierten Standard- und Custom-Derivate im Hintergrundlauf.
10. Der Frontend-Aufruf erzeugt keine Derivate. Fehlt eines, wird das vorbereitete Arbeitsbild als sicherer Fallback ausgeliefert.
11. Wenn das Original benötigt wird, streamt `webdav-image.php` es serverseitig direkt aus Nextcloud. Zugangsdaten werden dem Browser nicht offengelegt.
### Piwigo-Synchronisierung
Der produktive Lauf ist API-first:
Der WebDAV-Pfad nutzt die vorhandene Piwigo-Synchronisationslogik mit einer verbindungseigenen Site.
1. `bratonien.nc.syncProductive` für physische Alben und Bilder;
2. `bratonien.nc.syncOrphans` für Root-Dateien;
3. Benutzername/Passwort nur als optionaler Fallback.
Nach dem Dateiabgleich laufen die vorhandenen Nacharbeiten, darunter:
Die produktive direkte API-Synchronisierung ist aktuell ausdrücklich für **Piwigo 16.4.0** freigegeben. Bei einer anderen Piwigo-Version wird sie nicht stillschweigend als kompatibel angenommen.
- Metadaten-Synchronisierung;
- Bild- und Kategorieintegrität;
- Uppercats/Kategoriestruktur;
- globale Ränge;
- Pfadpflege;
- Rating-Score;
- Benutzer-Cache-Invalidierung;
- Orphan-Abgleich.
Der direkte produktive Sync führt außerdem die für den normalen Piwigo-Import relevanten Nacharbeiten aus, darunter Albuminformationen, globale Ränge, Bildinformationen, Pfade und Cache-Invalidierung. Er ruft dafür keine Admin-Seite fernsteuernd auf.
Die direkte produktive Synchronisierung ist aktuell ausdrücklich auf **Piwigo 16.4.0** abgestimmt.
### Fallback-Zugang
Piwigo wird API-first angesprochen. Ist für eine Verbindung keine API konfiguriert, kann Benutzername/Passwort als Fallback verwendet werden.
Der Assistent prüft Fallback-Zugangsdaten vor dem Speichern. Eine fehlgeschlagene Prüfung schließt oder leert den Assistenten nicht.
### Statusanzeige
Die Administration zeigt den Laufzeitstatus regelmäßig im Hintergrund an. Dafür wird die Admin-Seite nicht neu geladen; offene Dialoge, Assistenten und aufgeklappte Bereiche bleiben erhalten.
Fehler werden nach Prozessschritt getrennt dargestellt, unter anderem für WebDAV-Einlesen, Piwigo-Synchronisierung, Preview-Erzeugung und Derivat-Erzeugung.
### Löschen einer Verbindung
Seit **0.9.6.1** besitzt der WebDAV-Pfad einen vollständigen Lösch-Lebenszyklus:
- Beim Löschen einer WebDAV-Verbindung werden ihre Piwigo-Site, Alben und Bilddatensätze entfernt.
- Zugehörige Piwigo-Derivate werden entfernt.
- Nextcloud-Originale bleiben unverändert.
- Runtime-, Shadowtree-, Source-, Preview- und Statusdaten werden bereinigt.
- `runtime/cleanup-webdav-piwigo.php` erkennt zusätzlich bereits verwaiste WebDAV-Piwigo-Sites, für die keine Connector-Verbindung mehr existiert, und bereinigt sie im gemeinsamen Runner nachträglich.
### Runtime
Aktive Verbindungen werden über einen gemeinsamen systemd-Timer verarbeitet:
Aktive Verbindungen werden über den gemeinsamen Runner verarbeitet:
- `bratonien-nc-connector.timer`
- `bratonien-nc-connector.service`
- `runtime/run-all.sh`
- `runtime/sync.sh`
Verbindungsspezifische Konfigurationen liegen unter `/etc/bratonien-tools/nc-connector/`, State-Daten unter `/var/lib/bratonien-tools/nc-connector/connection-ID`.
Der Shadow-Tree-Austausch besitzt einen Rollback: Scheitert der Wechsel auf den neuen Baum, wird der vorherige Galeriebaum wiederhergestellt.
Die Reihenfolge des gemeinsamen Laufs ist:
### Statusanzeige
Die Administration zeigt letzten und nächsten Lauf. Der Status wird regelmäßig im Hintergrund abgefragt. Dafür wird die Admin-Seite **nicht** neu geladen; offene Dialoge, Assistenten und aufgeklappte Bereiche bleiben erhalten.
### Bestehende Verbindungen
Bestehende Verbindungen können:
- umbenannt;
- geprüft;
- technisch bearbeitet, solange sie nicht aktiv sind;
- deaktiviert und anschließend gelöscht werden.
Das Löschen einer Connector-Verbindung entfernt keine Nextcloud-Originale und keine Piwigo-Bilder. Erreichbare Connector-eigene Status-/State-Daten der Verbindung werden bereinigt.
### Legacy-Migration
Für ältere Installationen mit dem früheren separaten `piwigo-sync` existieren weiterhin Migrations-/Cutover-Helfer. Neue Installationen sollen den nativen Connector-Weg verwenden.
1. lokale Verbindungen reconciliieren;
2. WebDAV-Verbindungen reconciliieren;
3. verwaiste WebDAV-Piwigo-Inhalte bereinigen;
4. allgemeine verwaiste Runtime-Dateien bereinigen;
5. WebDAV-Verbindungen synchronisieren;
6. lokale Verbindungen synchronisieren.
## Bildcache
Bratonien Tools kann:
- Piwigo-Bildderivate gezielt leeren;
- vorhandene Bildgrößen neu erzeugen;
- Cache-Aufbau als Worker-Prozess starten und abbrechen;
- Worker-Zahl automatisch oder manuell konfigurieren;
- Originalbilder bleiben unangetastet.
- den Cache-Aufbau als Worker-Prozess starten und abbrechen;
- die Worker-Zahl automatisch oder manuell konfigurieren.
Originalbilder bleiben unangetastet.
## Wasserzeichenverwaltung
@@ -128,7 +146,7 @@ Für ältere Installationen mit dem früheren separaten `piwigo-sync` existieren
- Album-Ausnahmen und Vererbung;
- Position, Transparenz und Skalierung;
- eigener Runtime-Filter für Piwigo-Derivate;
- bisherige Piwigo-Wasserzeichenkonfiguration wird beim Aktivieren gesichert.
- Sicherung der bisherigen Piwigo-Wasserzeichenkonfiguration beim Aktivieren.
## Bilddateien und Pfade
@@ -171,37 +189,34 @@ Die Piwigo-Bilddetailseite erhält responsive Navigationszonen für vorheriges B
Der integrierte Updater:
- liest den Zielstand aus GitHub;
- bindet ein Update an einen konkreten Commit statt an einen während des Updates beweglichen Branch-Stand;
- bindet ein Update an einen konkreten Commit;
- prüft Version und SHA-256 des Zielstands;
- erstellt vor dem Austausch ein Backup;
- nutzt Post/Redirect/Get, damit ein Browser-Reload keine schreibende Aktion erneut ausführt;
- nutzt Post/Redirect/Get;
- verlangt Webmaster-Rechte, `ZipArchive` und ausreichende Schreibrechte.
## Architektur
Wichtige Dateien und Verzeichnisse:
## Wichtige Dateien und Verzeichnisse
- `main.inc.php` Plugin-Einstieg und Runtime-Hooks
- `admin.php` zentraler Admin-Controller mit Post/Redirect/Get
- `include/tool_registry.inc.php` Registry administrativer Aktionen
- `admin.php` zentraler Admin-Controller
- `include/nc_connector.inc.php` Datenmodell und gemeinsame Connector-Funktionen
- `include/nc_connector_wizard.inc.php` Endnutzer-Assistent und Connection-Bearbeitung
- `include/nc_connector_manage.inc.php` Credential-Format, Storage-Mappings, Verifikation und Löschen
- `include/nc_connector_create_api.inc.php` API-first-Verbindungserstellung
- `include/nc_connector_piwigo_api.inc.php` API-Zugang und Fallback-Verwaltung
- `include/nc_connector_system.inc.php` Timer- und Laufzeitstatus
- `include/nc_connector_wizard.inc.php` Verbindungsassistent
- `include/nc_connector_wizard_webdav_flow.inc.php` WebDAV-first-Wizardablauf
- `include/nc_connector_delete_safe.inc.php` sicheres Löschen einschließlich WebDAV-Piwigo-Inhalten
- `include/nc_productive_ws.inc.php` produktiver Piwigo-Dateisync
- `include/nc_orphan_ws.inc.php` Orphan-Synchronisierung
- `runtime/lib/activity_gate.py` Activity Gate
- `runtime/lib/build_manifest.py` Auflösung der Nextcloud-Freigaben auf Storage-Mounts
- `runtime/lib/shadow_tree.py` atomarer Shadow Tree mit Rollback
- `runtime/lib/piwigo-sync.php` API-first-Piwigo-Sync mit Fallback
- `runtime/run-all.sh` Multi-Connection-Runner
- `runtime/sync.sh` Ablauf einer Verbindung
- `nc-connector-install.php` native Root-Aktivierung
- `nc-connector-disable.php` Deaktivierung
- `nc-connector-normalize.php` Normalisierung älterer aktiver Verbindungen
- `nc-connector-*-cleanup/switch/cutover` Legacy-Migrationshelfer
- `include/webdav_image_runtime.inc.php` WebDAV-Bildzuordnung, Preview-/Derivathilfen und URL-Filter
- `webdav-image.php` berechtigungsgeprüfter WebDAV-Bildstream
- `runtime/reconcile-webdav.php` WebDAV-Runtime-Reconcile
- `runtime/cleanup-webdav-piwigo.php` Bereinigung gelöschter/verwaister WebDAV-Verbindungen in Piwigo
- `runtime/sync-webdav.sh` Ablauf einer WebDAV-Verbindung
- `runtime/run-all.sh` gemeinsamer Multi-Connection-Runner
- `runtime/lib/build_webdav_placeholder_source.py` rekursiver WebDAV-Scan und Platzhalterquelle
- `runtime/lib/precache-webdav-previews.php` vorbereitete JPEG-/PNG-Arbeitsbilder
- `runtime/lib/build-webdav-derivatives.php` CLI-Derivat-Builder
- `runtime/lib/shadow_tree.py` atomarer Shadow Tree
- `runtime/lib/piwigo-sync.php` Piwigo-Sync mit API/Fallback
- `main-cache-build.php` allgemeiner Piwigo-Derivat-/Cache-Builder
- `include/self_update.inc.php` Self-Updater
- `include/album_shares.inc.php` geschützte Albumfreigaben
- `include/public_selection.inc.php` Fotoauswahl
@@ -214,15 +229,18 @@ Wichtige Dateien und Verzeichnisse:
- administrative Schreibaktionen verwenden Piwigos CSRF-Schutz;
- Connector-Zugangsdaten werden verschlüsselt gespeichert;
- Wizard-Geheimnisse werden nicht in Browser-Web-Storage persistiert;
- Storage-Mounts werden explizit zugeordnet und validiert;
- Nextcloud-Zugangsdaten werden verbindungseigen gespeichert und nur serverseitig verwendet;
- Wizard-Geheimnisse werden nicht im Browser-Web-Storage persistiert;
- SQL-View-Namen werden vor Verwendung validiert;
- gefährliche technische Pfade sind nicht Teil des normalen Wizard-Happy-Paths;
- produktive Piwigo-API ist versionsgebunden;
- Originalbilder werden vom Connector nicht gelöscht;
- Update-Pakete werden an Commit und Hash gebunden;
- Root-Helfer prüfen CLI-/Root-Ausführung, bevor sie Systemdienste oder `/etc`-/`/var/lib`-Daten verändern.
- WebDAV-Originalbilder werden nicht dauerhaft lokal gespeichert;
- Update-Pakete werden an Commit und Hash gebunden.
## Entwicklungsstand
`0.9.4.x` ist der aktuelle Entwicklungsblock. Der lokale NC Connector ist End-to-End produktiv im Einsatz. Der Verbindungsassistent und die Verwaltung werden weiter aus Endnutzersicht konsolidiert. Ein vollständig entfernter Nextcloud-Adapter ohne direkten PostgreSQL-/Storage-Zugriff ist noch nicht umgesetzt.
`0.9.6.x` ist die aktuelle Versionslinie.
Mit **0.9.6.1** ist der WebDAV-Pfad einschließlich Piwigo-Registrierung, vorbereiteter Bildquelle, CLI-Derivatstrecke und Lösch-/Cleanup-Lebenszyklus im Repository umgesetzt. Der bestehende lokale Connector bleibt parallel erhalten.
Der jeweils detaillierte Prüf- und Entwicklungsstand steht in [`CURRENT_STATUS.md`](CURRENT_STATUS.md).

View File

@@ -9,6 +9,74 @@ check_status(ACCESS_ADMINISTRATOR);
require_once(BRATONIEN_TOOLS_PATH . 'include/tool_registry.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_system.inc.php');
function bratonien_tools_nc_connector_admin_connections(array $connections)
{
$by_id = array();
foreach ($connections as $connection)
{
$by_id[(int)$connection['id']] = $connection;
}
$hidden_legacy_ids = array();
$logical_remote = array();
foreach ($connections as $connection)
{
if ((string)($connection['adapter'] ?? '') !== 'remote') continue;
$migration = isset($connection['config']['migration']) && is_array($connection['config']['migration'])
? $connection['config']['migration']
: array();
if ((string)($migration['role'] ?? '') !== 'webdav-primary-candidate') continue;
$legacy_id = (int)($migration['legacy_fallback_connection_id'] ?? 0);
if ($legacy_id < 1 || empty($by_id[$legacy_id]) || (string)$by_id[$legacy_id]['adapter'] !== 'local') continue;
$legacy = $by_id[$legacy_id];
$hidden_legacy_ids[$legacy_id] = true;
$connection['logical_connection'] = true;
$connection['legacy_fallback_connection_id'] = $legacy_id;
$connection['enabled'] = !empty($connection['enabled']) || !empty($legacy['enabled']);
if ($connection['enabled']) $connection['takeover_state'] = 'active';
$connection['fallback_stored'] = !empty($connection['fallback_stored']) || !empty($legacy['fallback_stored']);
if (trim((string)$connection['name']) === '') $connection['name'] = (string)$legacy['name'];
$logical_remote[(int)$connection['id']] = $connection;
}
$visible = array();
foreach ($connections as $connection)
{
$id = (int)$connection['id'];
if (isset($hidden_legacy_ids[$id])) continue;
if (isset($logical_remote[$id])) $connection = $logical_remote[$id];
$visible[] = $connection;
}
return $visible;
}
function bratonien_tools_nc_connector_admin_last_status(array $connection)
{
$latest = bratonien_tools_nc_connector_connection_last_status($connection);
$legacy_id = (int)($connection['legacy_fallback_connection_id'] ?? 0);
if ($legacy_id < 1)
{
return $latest;
}
$legacy = bratonien_tools_nc_connector_connection($legacy_id, false);
if (!$legacy)
{
return $latest;
}
$legacy_status = bratonien_tools_nc_connector_connection_last_status($legacy);
if ((int)($legacy_status['timestamp'] ?? 0) > (int)($latest['timestamp'] ?? 0))
{
return $legacy_status;
}
return $latest;
}
$tools = bratonien_tools_get_tools();
$messages = array();
$errors = array();
@@ -94,6 +162,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['bratonien_tool']))
);
$redirect_url = get_root_url().'admin.php?page=plugin-'.BRATONIEN_TOOLS_ID;
$wizard_action = strpos($tool_id, 'nc_connector_wizard_') === 0
|| $tool_id === 'nc_connector_migrate_start'
|| $tool_id === 'nc_connector_edit_start';
if ($wizard_action)
{
$wizard_closed = $tool_id === 'nc_connector_wizard_reset'
|| ($tool_id === 'nc_connector_wizard_finish' && empty($errors));
if (($tool_id === 'nc_connector_migrate_start' || $tool_id === 'nc_connector_edit_start') && !empty($errors))
{
$wizard_closed = true;
}
$redirect_url .= '&nc_wizard='.($wizard_closed ? 'closed' : 'open');
}
if (!headers_sent())
{
header('Location: '.$redirect_url, true, 303);
@@ -115,9 +196,17 @@ $asset_environment = bratonien_tools_get_asset_environment();
$album_shares = bratonien_tools_get_album_shares();
$private_albums = bratonien_tools_get_private_albums();
$nc_connector = bratonien_tools_nc_connector_status();
$nc_connector_runtime_connections = $nc_connector['connections'];
$nc_connector['connections'] = bratonien_tools_nc_connector_admin_connections($nc_connector_runtime_connections);
$nc_connector['connection_count'] = count($nc_connector['connections']);
$nc_connector['active_count'] = 0;
foreach ($nc_connector['connections'] as $visible_connection)
{
if (!empty($visible_connection['enabled'])) $nc_connector['active_count']++;
}
foreach ($nc_connector['connections'] as &$nc_connection)
{
$nc_connection['last_sync'] = bratonien_tools_nc_connector_connection_last_status($nc_connection);
$nc_connection['last_sync'] = bratonien_tools_nc_connector_admin_last_status($nc_connection);
$nc_connection['display_name'] = $nc_connection['name'];
$storage_lines = array();
@@ -166,7 +255,7 @@ $nc_system_defaults = array(
);
$nc_connector['system'] = array_merge(
$nc_system_defaults,
bratonien_tools_nc_connector_system_status($nc_connector['connections'])
bratonien_tools_nc_connector_system_status($nc_connector_runtime_connections)
);
$album_lock_page_number = isset($_GET['br_album_page']) ? max(1, (int)$_GET['br_album_page']) : 1;
$album_lock_search = isset($_GET['br_album_search']) ? trim((string)$_GET['br_album_search']) : '';
@@ -292,4 +381,4 @@ $admin_content .= $template->parse('asset_manager_admin_content', true);
$admin_content .= $template->parse('album_shares_admin_content', true);
$admin_content .= $template->parse('nc_connector_admin_content', true);
$admin_content .= $template->parse('system_admin_content', true);
$template->assign('ADMIN_CONTENT', $admin_content);
$template->assign('ADMIN_CONTENT', $admin_content);

View File

@@ -43,6 +43,8 @@ function bratonien_tools_nc_wizard_finish_connection_scoped()
$_POST['nc_nextcloud_url']=(string)$state['base_url'];
$_POST['nc_showcase_user']=(string)$state['showcase_user'];
$_POST['nc_access_user']=(string)$state['username'];
$_POST['nc_nextcloud_user']=(string)$state['username'];
$_POST['nc_nextcloud_password']=(string)$state['_password'];
$_POST['nc_product']=(string)$state['product'];
$_POST['nc_version']=(string)$state['version'];
$_POST['nc_api_validated']=$state['api_status']==='ok'?'1':'0';
@@ -121,12 +123,14 @@ function bratonien_tools_nc_connector_verify_connection_scoped()
}
$payload = json_encode(array(
'v'=>2,
'v'=>3,
'db_password'=>$db_password,
'piwigo_user'=>$fallback_user,
'piwigo_password'=>$fallback_password,
'api_key_id'=>$api_key_id,
'api_key_secret'=>$api_key_secret,
'nextcloud_user'=>'',
'nextcloud_password'=>'',
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) throw new RuntimeException('Verbindungsauthentifizierung konnte nicht migriert werden.');
@@ -148,33 +152,44 @@ function bratonien_tools_nc_connector_scoped_secret(array $connection)
if (is_array($decoded) && array_key_exists('db_password', $decoded))
{
return array(
'v'=>2,
'v'=>3,
'db_password'=>(string)($decoded['db_password'] ?? ''),
'piwigo_user'=>(string)($decoded['piwigo_user'] ?? ''),
'piwigo_password'=>(string)($decoded['piwigo_password'] ?? ''),
'api_key_id'=>(string)($decoded['api_key_id'] ?? ''),
'api_key_secret'=>(string)($decoded['api_key_secret'] ?? ''),
'nextcloud_user'=>(string)($decoded['nextcloud_user'] ?? ''),
'nextcloud_password'=>(string)($decoded['nextcloud_password'] ?? ''),
);
}
return array(
'v'=>2,
'v'=>3,
'db_password'=>(string)$plain,
'piwigo_user'=>'',
'piwigo_password'=>'',
'api_key_id'=>'',
'api_key_secret'=>'',
'nextcloud_user'=>'',
'nextcloud_password'=>'',
);
}
function bratonien_tools_nc_connector_store_scoped_secret($id, array $connection, array $credentials)
{
if (trim((string)($credentials['db_password'] ?? '')) === '') throw new RuntimeException('Das Datenbankpasswort der Verbindung fehlt.');
$config = is_array($connection['config'] ?? null) ? $connection['config'] : array();
$is_webdav = (string)($config['source_mode'] ?? '') === 'webdav-placeholder';
if (!$is_webdav && trim((string)($credentials['db_password'] ?? '')) === '') throw new RuntimeException('Das Datenbankpasswort der Verbindung fehlt.');
if ($is_webdav && (trim((string)($credentials['nextcloud_user'] ?? '')) === '' || (string)($credentials['nextcloud_password'] ?? '') === ''))
{
throw new RuntimeException('Die Nextcloud-Zugangsdaten der WebDAV-Verbindung fehlen.');
}
$credentials['v'] = 3;
$payload = json_encode($credentials, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) throw new RuntimeException('Verbindungszugangsdaten konnten nicht serialisiert werden.');
$blob = bratonien_tools_nc_connector_encrypt_secret($payload);
$config = is_array($connection['config'] ?? null) ? $connection['config'] : array();
if (array_key_exists('api_enabled', $config))
{
$config['api_enabled'] = trim((string)($credentials['api_key_id'] ?? '')) !== '' && trim((string)($credentials['api_key_secret'] ?? '')) !== '';

View File

@@ -5,6 +5,11 @@ if (!defined('PHPWG_ROOT_PATH'))
}
function bratonien_tools_nc_connector_create_local_api_first()
{
throw new RuntimeException('Neue Verbindungen über die alte lokale PostgreSQL-/Storage-Struktur sind ab 0.9.6.2 gesperrt. Bestehende lokale Verbindungen bleiben für die schrittweise Migration weiterhin aktiv. Neue Verbindungen müssen über den WebDAV-Assistenten angelegt werden.');
}
function bratonien_tools_nc_connector_create_local_api_first_legacy()
{
$required = array(
'nc_name' => 'Name',
@@ -28,6 +33,13 @@ function bratonien_tools_nc_connector_create_local_api_first()
throw new RuntimeException('Fallback-Benutzer und Fallback-Passwort muessen entweder beide angegeben oder beide leer gelassen werden.');
}
$nextcloud_user = trim((string)($_POST['nc_nextcloud_user'] ?? $_POST['nc_access_user'] ?? ''));
$nextcloud_password = (string)($_POST['nc_nextcloud_password'] ?? '');
if (($nextcloud_user === '') !== ($nextcloud_password === ''))
{
throw new RuntimeException('Nextcloud-Benutzer und Nextcloud-Passwort muessen entweder beide vorhanden oder beide leer sein.');
}
$wizard_auth_present = array_key_exists('nc_api_validated', $_POST);
$validated_pending_api = (string)($_POST['nc_api_validated'] ?? '') === '1';
$api_key_id = trim((string)($_POST['nc_connection_api_key_id'] ?? ''));
@@ -84,12 +96,14 @@ function bratonien_tools_nc_connector_create_local_api_first()
bratonien_tools_nc_connector_view_name($config['activity_view']);
$secret_payload = json_encode(array(
'v'=>2,
'v'=>3,
'db_password'=>(string)$_POST['nc_db_password'],
'piwigo_user'=>$fallback_user,
'piwigo_password'=>$fallback_password,
'api_key_id'=>$api_key_id,
'api_key_secret'=>$api_key_secret,
'nextcloud_user'=>$nextcloud_user,
'nextcloud_password'=>$nextcloud_password,
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($secret_payload)) throw new RuntimeException('Connector-Zugangsdaten konnten nicht serialisiert werden.');
$secret_blob = bratonien_tools_nc_connector_encrypt_secret($secret_payload);

View File

@@ -0,0 +1,162 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_nc_connector_db_prefix_from_absolute($path)
{
$path = rtrim(str_replace('\\', '/', (string)$path), '/');
$piwigo_root = rtrim(str_replace('\\', '/', PHPWG_ROOT_PATH), '/');
if ($path === '' || strpos($path, $piwigo_root.'/') !== 0) return '';
$relative = ltrim(substr($path, strlen($piwigo_root)), '/');
return $relative === '' ? '' : './'.rtrim($relative, '/').'/';
}
function bratonien_tools_nc_connector_owned_db_prefixes(array $connection)
{
$id = (int)($connection['id'] ?? 0);
if ($id < 1) return array();
$prefixes = array(
'./_data/bratonien-tools/nc-webdav-gallery/connection-'.$id.'/',
'./_data/bratonien-tools/nc-webdav-source/connection-'.$id.'/',
'./galleries/bratonien-webdav-'.$id.'/',
);
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$configured = bratonien_tools_nc_connector_db_prefix_from_absolute($config['parallel_gallery_root'] ?? '');
if ($configured !== '') $prefixes[] = $configured;
return array_values(array_unique($prefixes));
}
function bratonien_tools_nc_connector_owned_site_urls(array $connection)
{
$id = (int)($connection['id'] ?? 0);
if ($id < 1) return array();
$urls = array(
'./_data/bratonien-tools/nc-webdav-gallery/connection-'.$id.'/',
'./galleries/bratonien-webdav-'.$id.'/',
);
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$configured = bratonien_tools_nc_connector_db_prefix_from_absolute($config['parallel_gallery_root'] ?? '');
if ($configured !== '') $urls[] = $configured;
return array_values(array_unique($urls));
}
function bratonien_tools_nc_connector_remove_webdav_piwigo_content(array $connection)
{
include_once(PHPWG_ROOT_PATH.'admin/include/functions.php');
$prefixes = bratonien_tools_nc_connector_owned_db_prefixes($connection);
if (!$prefixes) return array('sites'=>0, 'images'=>0);
$where = array();
foreach ($prefixes as $prefix)
{
$escaped = pwg_db_real_escape_string(addcslashes($prefix, '_%\\'));
$where[] = "path LIKE '".$escaped."%' ESCAPE '\\\\'";
}
$image_rows = array();
$result = pwg_query(
'SELECT id, path, representative_ext FROM '.IMAGES_TABLE.
' WHERE '.implode(' OR ', $where)
);
while ($row = pwg_db_fetch_assoc($result))
{
$path = (string)$row['path'];
$owned = false;
foreach ($prefixes as $prefix)
{
if (strpos($path, $prefix) === 0)
{
$owned = true;
break;
}
}
if (!$owned) continue;
$row['id'] = (int)$row['id'];
$image_rows[$row['id']] = $row;
}
foreach ($image_rows as $row)
{
try
{
delete_element_derivatives($row);
}
catch (Throwable $e)
{
error_log('Bratonien WebDAV delete derivative cleanup #'.(int)$row['id'].': '.$e->getMessage());
}
}
if ($image_rows)
{
delete_elements(array_map('intval', array_keys($image_rows)), false);
}
$site_count = 0;
foreach (bratonien_tools_nc_connector_owned_site_urls($connection) as $site_url)
{
$result = pwg_query(
"SELECT id FROM ".SITES_TABLE.
" WHERE galleries_url='".pwg_db_real_escape_string($site_url)."'"
);
while ($row = pwg_db_fetch_assoc($result))
{
delete_site((int)$row['id']);
$site_count++;
}
}
update_category('all');
invalidate_user_cache(true);
return array('sites'=>$site_count, 'images'=>count($image_rows));
}
/**
* Delete exactly the connection selected by the user.
* No other connector record is implicitly removed.
*/
function bratonien_tools_nc_connector_delete_safe()
{
$id = (int)($_POST['connection_id'] ?? 0);
$connection = bratonien_tools_nc_connector_connection($id, false);
if (!$connection)
{
throw new RuntimeException('Connector-Verbindung wurde nicht gefunden.');
}
$cleanup = array('sites'=>0, 'images'=>0);
if (
(string)($connection['adapter'] ?? '') === 'remote'
&& (string)($connection['config']['source_mode'] ?? '') === 'webdav-placeholder'
)
{
$cleanup = bratonien_tools_nc_connector_remove_webdav_piwigo_content($connection);
}
$table = bratonien_tools_nc_connector_table();
pwg_query("DELETE FROM `$table` WHERE id=".$id." LIMIT 1");
$status_dir = rtrim(PHPWG_ROOT_PATH, '/').'/_data/bratonien-tools/nc-connector-status';
$public_status = $status_dir.'/connection-'.$id.'.json';
if (is_file($public_status)) @unlink($public_status);
if (is_dir($status_dir) || @mkdir($status_dir, 0755, true))
{
@file_put_contents($status_dir.'/deleted-'.$id, date('c')."\n", LOCK_EX);
}
return array(
'message'=>'Verbindung wurde gelöscht. '.(int)$cleanup['images'].' eindeutig zu dieser Verbindung gehörende Bilder wurden aus Piwigo entfernt. Nextcloud-Dateien blieben unverändert.',
);
}

View File

@@ -0,0 +1,348 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_nc_connector_migration_state(array $connection)
{
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$credentials = bratonien_tools_nc_connector_scoped_secret($connection);
$missing = array();
if (trim((string)($config['nextcloud_url'] ?? '')) === '') $missing[] = 'Nextcloud-Adresse';
if (trim((string)($credentials['nextcloud_user'] ?? '')) === '') $missing[] = 'Nextcloud-Benutzer';
if ((string)($credentials['nextcloud_password'] ?? '') === '') $missing[] = 'Nextcloud-Passwort';
$api_id = trim((string)($credentials['api_key_id'] ?? ''));
$api_secret = trim((string)($credentials['api_key_secret'] ?? ''));
$fallback_user = trim((string)($credentials['piwigo_user'] ?? ''));
$fallback_password = (string)($credentials['piwigo_password'] ?? '');
$api_complete = $api_id !== '' && $api_secret !== '';
$fallback_complete = $fallback_user !== '' && $fallback_password !== '';
if (($api_id === '') !== ($api_secret === '')) $missing[] = 'vollstaendiger Piwigo-API-Zugang';
if (!$api_complete && !$fallback_complete) $missing[] = 'Piwigo-API oder Benutzer/Passwort-Fallback';
return array(
'ready'=>!$missing,
'missing'=>$missing,
'api_available'=>$api_complete,
'fallback_available'=>$fallback_complete,
);
}
function bratonien_tools_nc_connector_validate_nextcloud_access($base_url, $username, $password)
{
$base_url = bratonien_tools_nc_wizard_normalize_url($base_url);
$username = trim((string)$username);
$password = (string)$password;
if ($username === '' || $password === '') throw new RuntimeException('Nextcloud-Benutzer und Passwort werden benoetigt.');
$status_response = bratonien_tools_nc_wizard_http($base_url.'/status.php');
if ($status_response['status'] < 200 || $status_response['status'] >= 300)
{
throw new RuntimeException('Die Nextcloud-Adresse konnte nicht bestaetigt werden.');
}
$status = json_decode((string)$status_response['body'], true);
if (!is_array($status) || empty($status['installed']))
{
throw new RuntimeException('Unter dieser Adresse wurde keine installierte Nextcloud erkannt.');
}
$user_response = bratonien_tools_nc_wizard_http(
$base_url.'/ocs/v2.php/cloud/user?format=json',
$username,
$password,
array('OCS-APIRequest: true')
);
if ($user_response['status'] === 401 || $user_response['status'] === 403)
{
throw new RuntimeException('Nextcloud hat Benutzername oder Passwort abgelehnt.');
}
if ($user_response['status'] < 200 || $user_response['status'] >= 300)
{
throw new RuntimeException('Nextcloud ist erreichbar, aber der Benutzerzugang konnte nicht geprueft werden.');
}
$user_data = bratonien_tools_nc_wizard_ocs_data($user_response['body']);
$resolved = trim((string)($user_data['id'] ?? $username));
return array(
'base_url'=>$base_url,
'username'=>$resolved !== '' ? $resolved : $username,
);
}
function bratonien_tools_nc_connector_validate_scoped_api($api_key_id, $api_key_secret)
{
$api_key_id = trim((string)$api_key_id);
$api_key_secret = trim((string)$api_key_secret);
if ($api_key_id === '' || $api_key_secret === '')
{
throw new RuntimeException('Piwigo-API-Schluessel-ID und API-Geheimnis werden benoetigt.');
}
$status = bratonien_tools_nc_connector_piwigo_api_request($api_key_id, $api_key_secret, 'pwg.session.getStatus');
if (!is_array($status)) throw new RuntimeException('Piwigo hat keinen auswertbaren API-Benutzerstatus geliefert.');
$role = strtolower(trim((string)($status['status'] ?? '')));
if (!in_array($role, array('admin','webmaster'), true))
{
throw new RuntimeException('Der API-Key funktioniert, gehoert aber keinem Piwigo-Administrator/Webmaster.');
}
$method_result = bratonien_tools_nc_connector_piwigo_api_request($api_key_id, $api_key_secret, 'reflection.getMethodList');
$method_map = array();
bratonien_tools_nc_connector_collect_method_names($method_result, $method_map);
$required = array('bratonien.nc.syncProductive', 'bratonien.nc.syncOrphans');
$missing = array_values(array_diff($required, array_keys($method_map)));
if ($missing)
{
throw new RuntimeException('Der API-Key ist gueltig, aber benoetigte Bratonien-Sync-Methoden fehlen: '.implode(', ', $missing).'.');
}
}
function bratonien_tools_nc_connector_prepare_webdav_wizard_from_connection(array $connection, $mode)
{
$id = (int)$connection['id'];
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$credentials = bratonien_tools_nc_connector_scoped_secret($connection);
$base_url = trim((string)($config['nextcloud_url'] ?? ''));
$username = trim((string)($credentials['nextcloud_user'] ?? ''));
if ($username === '')
{
$username = trim((string)($config['nextcloud_access_user'] ?? $config['access_user'] ?? ''));
}
$password = (string)($credentials['nextcloud_password'] ?? '');
$roots = isset($config['roots']) && is_array($config['roots']) ? array_values($config['roots']) : array();
$selected = array();
$selected_ids = array();
foreach ($roots as $root)
{
$path = trim((string)($root['webdav_path'] ?? ''), '/');
$fileid = (int)($root['fileid'] ?? 0);
if ($fileid < 1) continue;
$selected[] = $path;
$selected_ids[$path] = $fileid;
}
$state = bratonien_tools_nc_wizard_state();
$state = array_merge($state, array(
'editing_connection_id'=>$id,
'editing_adapter'=>(string)$connection['adapter'],
'editing_mode'=>(string)$mode,
'connection_name'=>(string)$connection['name'],
'host_input'=>$base_url,
'base_url'=>$base_url,
'username'=>$username,
'_password'=>$password,
'_fallback_user'=>(string)($credentials['piwigo_user'] ?? ''),
'_fallback_password'=>(string)($credentials['piwigo_password'] ?? ''),
'_api_key_id'=>(string)($credentials['api_key_id'] ?? ''),
'_api_key_secret'=>(string)($credentials['api_key_secret'] ?? ''),
'api_status'=>trim((string)($credentials['api_key_id'] ?? '')) !== '' && trim((string)($credentials['api_key_secret'] ?? '')) !== '' ? 'ok' : 'pending',
'roots'=>$roots,
'directory_selected'=>$selected,
'directory_selected_fileids'=>$selected_ids,
'source_mode'=>'webdav-placeholder',
'transport'=>'webdav',
));
if ($base_url !== '' && $username !== '' && $password !== '')
{
$state['step'] = 2;
$state['scan_ok'] = true;
$state['technical_stage'] = 'mounts';
$state['technical_source'] = 'WebDAV';
$state['technical_error'] = '';
$state['technical_complete'] = false;
$state['directory_selection_ready'] = true;
$state['directory_path'] = '';
$state['directory_parent'] = '';
$state['directory_children'] = array();
$state['directory_current_fileid'] = 0;
try
{
bratonien_tools_nc_wizard_refresh_directory_state($state, '');
}
catch (Throwable $e)
{
$state['step'] = 1;
$state['scan_ok'] = false;
$state['technical_error'] = $e->getMessage();
}
}
else
{
$state['step'] = 1;
$state['scan_ok'] = false;
}
bratonien_tools_nc_wizard_store($state);
}
function bratonien_tools_nc_connector_edit_start()
{
$id = (int)($_POST['connection_id'] ?? 0);
$connection = bratonien_tools_nc_connector_connection($id, true);
if (!$connection) throw new RuntimeException('Connector-Verbindung wurde nicht gefunden.');
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$is_webdav = (string)$connection['adapter'] === 'remote'
&& (string)($config['source_mode'] ?? '') === 'webdav-placeholder';
if (!$is_webdav)
{
throw new RuntimeException('Diese Legacy-Verbindung wird direkt bearbeitet. Eine WebDAV-Migration ist eine separate Aktion.');
}
bratonien_tools_nc_connector_prepare_webdav_wizard_from_connection($connection, 'update');
return array('message'=>'Verbindung #'.$id.' wurde zum Bearbeiten geoeffnet.');
}
function bratonien_tools_nc_connector_migrate_start()
{
$id = (int)($_POST['connection_id'] ?? 0);
$connection = bratonien_tools_nc_connector_connection($id, true);
if (!$connection) throw new RuntimeException('Connector-Verbindung wurde nicht gefunden.');
if ((string)$connection['adapter'] !== 'local')
{
throw new RuntimeException('Nur eine Legacy-Verbindung kann auf WebDAV migriert werden.');
}
$migration = bratonien_tools_nc_connector_migration_state($connection);
if (empty($migration['ready']))
{
throw new RuntimeException('Die WebDAV-Migration ist noch nicht bereit. Unter Bearbeiten fehlen: '.implode(', ', $migration['missing']).'.');
}
bratonien_tools_nc_connector_prepare_webdav_wizard_from_connection($connection, 'migrate');
return array('message'=>'Die WebDAV-Migration fuer Verbindung #'.$id.' wurde geoeffnet. Die bestehende Legacy-Verbindung bleibt bis zum erfolgreichen Umstieg unveraendert.');
}
function bratonien_tools_nc_connector_update_local_friendly()
{
$id = (int)($_POST['connection_id'] ?? 0);
$connection = bratonien_tools_nc_connector_connection($id, true);
if (!$connection) throw new RuntimeException('Connector-Verbindung wurde nicht gefunden.');
if ((string)$connection['adapter'] !== 'local') throw new RuntimeException('Diese Bearbeitung ist nur fuer Legacy-Verbindungen vorgesehen.');
$name = trim((string)($_POST['connection_name'] ?? ''));
if ($name === '') throw new RuntimeException('Bitte einen Namen fuer die Verbindung angeben.');
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$host = trim((string)($_POST['nc_host'] ?? ''));
$port = (int)($_POST['nc_port'] ?? 5432);
$database = trim((string)($_POST['nc_database'] ?? ''));
$user = trim((string)($_POST['nc_user'] ?? ''));
$gallery_root = rtrim(trim((string)($_POST['nc_gallery_root'] ?? '')), '/');
$source_view = trim((string)($_POST['nc_source_view'] ?? ''));
$activity_view = trim((string)($_POST['nc_activity_view'] ?? ''));
if ($host === '') throw new RuntimeException('Datenbank-Server fehlt.');
if ($port < 1 || $port > 65535) throw new RuntimeException('Der Datenbank-Port ist ungueltig.');
if ($database === '') throw new RuntimeException('Datenbankname fehlt.');
if ($user === '') throw new RuntimeException('Reader-Benutzer fehlt.');
if ($gallery_root === '' || $gallery_root[0] !== '/') throw new RuntimeException('Der Piwigo-Galerieordner muss ein absoluter Pfad sein.');
if ($source_view === '' || $activity_view === '') throw new RuntimeException('Die gespeicherten Datenbankansichten duerfen nicht leer sein.');
bratonien_tools_nc_connector_view_name($source_view);
bratonien_tools_nc_connector_view_name($activity_view);
$storage_ids = isset($_POST['nc_storage_id']) && is_array($_POST['nc_storage_id']) ? $_POST['nc_storage_id'] : array();
$storage_prefixes = isset($_POST['nc_source_prefix']) && is_array($_POST['nc_source_prefix']) ? $_POST['nc_source_prefix'] : array();
$storage_mounts = isset($_POST['nc_local_mount']) && is_array($_POST['nc_local_mount']) ? $_POST['nc_local_mount'] : array();
$storages = array();
$count = max(count($storage_ids), count($storage_prefixes), count($storage_mounts));
for ($index = 0; $index < $count; $index++)
{
$storage_id = trim((string)($storage_ids[$index] ?? ''));
$prefix = trim((string)($storage_prefixes[$index] ?? ''), '/');
$mount = rtrim(trim((string)($storage_mounts[$index] ?? '')), '/');
if ($storage_id === '' && $prefix === '' && $mount === '') continue;
if ($storage_id === '') throw new RuntimeException('Bei einem Speicherort fehlt die Storage-ID.');
if ($mount === '' || $mount[0] !== '/') throw new RuntimeException('Bei einem Speicherort fehlt ein gueltiger lokaler Pfad.');
$storages[] = array('storage_id'=>$storage_id, 'source_prefix'=>$prefix, 'local_mount'=>$mount);
}
if (!$storages) throw new RuntimeException('Mindestens ein Speicherort muss vorhanden sein.');
$credentials = bratonien_tools_nc_connector_scoped_secret($connection);
$new_db_password = (string)($_POST['nc_db_password'] ?? '');
if ($new_db_password !== '') $credentials['db_password'] = $new_db_password;
if (trim((string)($credentials['db_password'] ?? '')) === '') throw new RuntimeException('Fuer die Legacy-Verbindung ist kein Datenbankpasswort gespeichert.');
$nextcloud_url = trim((string)($_POST['nc_nextcloud_url'] ?? ($config['nextcloud_url'] ?? '')));
$nextcloud_user = trim((string)($_POST['nc_nextcloud_user'] ?? ($credentials['nextcloud_user'] ?? '')));
$new_nextcloud_password = (string)($_POST['nc_nextcloud_password'] ?? '');
if ($new_nextcloud_password !== '') $credentials['nextcloud_password'] = $new_nextcloud_password;
$nextcloud_password = (string)($credentials['nextcloud_password'] ?? '');
$api_key_id = trim((string)($_POST['nc_connection_api_key_id'] ?? ($credentials['api_key_id'] ?? '')));
$new_api_secret = trim((string)($_POST['nc_connection_api_key_secret'] ?? ''));
$old_api_id = trim((string)($credentials['api_key_id'] ?? ''));
if ($api_key_id !== $old_api_id && $new_api_secret === '')
{
throw new RuntimeException('Wenn die API-Schluessel-ID geaendert wird, muss auch das API-Geheimnis neu eingegeben werden.');
}
if ($new_api_secret !== '') $credentials['api_key_secret'] = $new_api_secret;
$api_key_secret = trim((string)($credentials['api_key_secret'] ?? ''));
if (($nextcloud_url === '' || $nextcloud_user === '' || $nextcloud_password === '') && ($nextcloud_url !== '' || $nextcloud_user !== '' || $nextcloud_password !== ''))
{
throw new RuntimeException('Nextcloud-Adresse, Nextcloud-Benutzer und Nextcloud-Passwort muessen fuer WebDAV gemeinsam vollstaendig sein.');
}
if (($api_key_id === '') !== ($api_key_secret === ''))
{
throw new RuntimeException('Piwigo-API-Schluessel-ID und API-Geheimnis muessen gemeinsam vollstaendig sein.');
}
if ($nextcloud_url !== '')
{
$validated_nc = bratonien_tools_nc_connector_validate_nextcloud_access($nextcloud_url, $nextcloud_user, $nextcloud_password);
$nextcloud_url = $validated_nc['base_url'];
$nextcloud_user = $validated_nc['username'];
$credentials['nextcloud_user'] = $nextcloud_user;
}
if ($api_key_id !== '')
{
bratonien_tools_nc_connector_validate_scoped_api($api_key_id, $api_key_secret);
$credentials['api_key_id'] = $api_key_id;
}
$config['host'] = $host;
$config['port'] = (string)$port;
$config['database'] = $database;
$config['user'] = $user;
$config['source_view'] = $source_view;
$config['activity_view'] = $activity_view;
$config['gallery_root'] = $gallery_root;
$config['quiet_seconds'] = max(0, (int)($_POST['nc_quiet_seconds'] ?? ($config['quiet_seconds'] ?? 120)));
$config['max_wait_seconds'] = max(60, (int)($_POST['nc_max_wait_seconds'] ?? ($config['max_wait_seconds'] ?? 900)));
$config['full_sync_seconds'] = max(300, (int)($_POST['nc_full_sync_seconds'] ?? ($config['full_sync_seconds'] ?? 86400)));
$config['storages'] = $storages;
if ($nextcloud_url !== '')
{
$config['nextcloud_url'] = $nextcloud_url;
$config['access_user'] = $nextcloud_user;
$config['nextcloud_access_user'] = $nextcloud_user;
}
$config['piwigo_auth'] = 'connection-scoped';
$config['api_enabled'] = $api_key_id !== '' && $api_key_secret !== '';
unset($config['verification']);
$config_json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($config_json)) throw new RuntimeException('Connector-Konfiguration konnte nicht serialisiert werden.');
$secret_payload = json_encode($credentials, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($secret_payload)) throw new RuntimeException('Connector-Zugangsdaten konnten nicht serialisiert werden.');
$secret_blob = bratonien_tools_nc_connector_encrypt_secret($secret_payload);
$table = bratonien_tools_nc_connector_table();
$now = date('Y-m-d H:i:s');
pwg_query("UPDATE `$table` SET name='".pwg_db_real_escape_string($name)."', config_json='".pwg_db_real_escape_string($config_json)."', secret_blob='".pwg_db_real_escape_string($secret_blob)."', updated='".pwg_db_real_escape_string($now)."' WHERE id=".$id." LIMIT 1");
$updated = bratonien_tools_nc_connector_connection($id, true);
$migration = $updated ? bratonien_tools_nc_connector_migration_state($updated) : array('ready'=>false);
return array(
'message'=>'Verbindung #'.$id.' wurde gespeichert. '.(!empty($migration['ready']) ? 'Die WebDAV-Migration ist jetzt bereit.' : 'Die WebDAV-Migration ist noch nicht vollstaendig vorbereitet.'),
);
}

View File

@@ -0,0 +1,101 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_nc_wizard_finish_generic_scope()
{
$state = bratonien_tools_nc_wizard_state();
if ((int)($state['step'] ?? 0) !== 4 || empty($state['technical_complete']) || trim((string)($state['username'] ?? '')) === '')
{
throw new RuntimeException('Der Assistent ist noch nicht vollständig.');
}
$roots = isset($state['roots']) && is_array($state['roots']) ? $state['roots'] : array();
$storages = isset($state['storages']) && is_array($state['storages']) ? $state['storages'] : array();
if (!$roots) throw new RuntimeException('Es wurden keine Nextcloud-Quellen ausgewählt.');
if (!$storages) throw new RuntimeException('Es wurden keine Storage-Adapter zugeordnet.');
if (array_key_exists('nc_wizard_fallback_user', $_POST)) $state['_fallback_user'] = trim((string)$_POST['nc_wizard_fallback_user']);
if (array_key_exists('nc_wizard_fallback_password', $_POST)) $state['_fallback_password'] = (string)$_POST['nc_wizard_fallback_password'];
bratonien_tools_nc_wizard_store($state);
$fallback_user = trim((string)$state['_fallback_user']);
$fallback_password = (string)$state['_fallback_password'];
if (($fallback_user === '') !== ($fallback_password === '')) throw new RuntimeException('Fallback-Benutzer und Fallback-Passwort müssen entweder beide angegeben oder beide leer gelassen werden.');
if (($state['api_status'] ?? '') !== 'ok' && $fallback_user === '') throw new RuntimeException('Da die Piwigo-API übersprungen wurde, ist für diese Verbindung ein Fallback-Zugang erforderlich.');
$mapping_lines = array();
foreach ($storages as $storage)
{
$storage_id = trim((string)($storage['storage_id'] ?? ''));
$source_prefix = trim((string)($storage['source_prefix'] ?? ''), '/');
$local_mount = rtrim(trim((string)($storage['local_mount'] ?? '')), '/');
if ($storage_id === '' || $local_mount === '') throw new RuntimeException('Eine Storage-Zuordnung ist unvollständig.');
$key = $storage_id.'|'.$source_prefix.'|'.$local_mount;
$mapping_lines[$key] = $storage_id.' | '.$source_prefix.' | '.$local_mount;
}
$_POST['nc_name']=(string)$state['connection_name'];
$_POST['nc_host']=(string)$state['db_host'];
$_POST['nc_port']=(string)$state['db_port'];
$_POST['nc_database']=(string)$state['db_database'];
$_POST['nc_user']=(string)$state['db_user'];
$_POST['nc_db_password']=(string)$state['_db_password'];
$_POST['nc_source_view']='piwigo_connector_files';
$_POST['nc_activity_view']='piwigo_connector_activity';
$_POST['nc_gallery_root']=(string)$state['gallery_root'];
$_POST['nc_storages']=implode("\n", array_values($mapping_lines));
$_POST['nc_quiet_seconds']='120';
$_POST['nc_max_wait_seconds']='900';
$_POST['nc_full_sync_seconds']='86400';
$_POST['nc_piwigo_user']=$fallback_user;
$_POST['nc_piwigo_password']=$fallback_password;
$_POST['nc_nextcloud_url']=(string)$state['base_url'];
$_POST['nc_access_user']=(string)$state['username'];
$_POST['nc_nextcloud_user']=(string)$state['username'];
$_POST['nc_nextcloud_password']=(string)$state['_password'];
$_POST['nc_product']=(string)$state['product'];
$_POST['nc_version']=(string)$state['version'];
$_POST['nc_api_validated']=($state['api_status'] ?? '')==='ok'?'1':'0';
$_POST['nc_connection_api_key_id']=($state['api_status'] ?? '')==='ok'?(string)$state['_api_key_id']:'';
$_POST['nc_connection_api_key_secret']=($state['api_status'] ?? '')==='ok'?(string)$state['_api_key_secret']:'';
$result = bratonien_tools_nc_connector_create_local_api_first();
$connection_id = (int)($result['connection_id'] ?? 0);
if ($connection_id < 1) throw new RuntimeException('Die neue Verbindung konnte nicht gespeichert werden.');
$connection = bratonien_tools_nc_connector_connection($connection_id, false);
if (!$connection) throw new RuntimeException('Die neue Verbindung konnte nach dem Anlegen nicht gelesen werden.');
$config = is_array($connection['config'] ?? null) ? $connection['config'] : array();
$config['source_mode'] = 'selected-fileids';
$config['source_view'] = 'piwigo_connector_files';
$config['activity_view'] = 'piwigo_connector_activity';
$config['access_user'] = (string)$state['username'];
$config['nextcloud_access_user'] = (string)$state['username'];
$config['storages'] = array_values($storages);
$config['roots'] = array_values(array_map(function($root) {
return array(
'fileid'=>(int)($root['fileid'] ?? 0),
'display_name'=>(string)($root['display_name'] ?? ''),
'webdav_path'=>(string)($root['webdav_path'] ?? ''),
);
}, $roots));
unset($config['showcase_user']);
foreach ($config['roots'] as $root)
{
if ((int)$root['fileid'] < 1 || trim((string)$root['display_name']) === '') throw new RuntimeException('Eine ausgewählte Nextcloud-Quelle ist unvollständig.');
}
$config_json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($config_json)) throw new RuntimeException('Die Quellenkonfiguration konnte nicht serialisiert werden.');
$table = bratonien_tools_nc_connector_table();
pwg_query("UPDATE `$table` SET config_json='".pwg_db_real_escape_string($config_json)."' WHERE id=".$connection_id." LIMIT 1");
unset($_SESSION['bratonien_nc_wizard']);
unset($result['connection_id']);
$result['message'] = 'Verbindung wurde mit generischer Nextcloud-Quellenauswahl angelegt. '.$result['message'];
return $result;
}

View File

@@ -179,6 +179,76 @@ function bratonien_tools_nc_connector_piwigo_api_request($api_key_id, $api_key_s
return bratonien_tools_nc_connector_api_payload($decoded);
}
function bratonien_tools_nc_connector_validate_fallback_credentials($username, $password)
{
if (!function_exists('curl_init'))
{
throw new RuntimeException('cURL ist in PHP nicht verfuegbar. Der Piwigo-Fallback kann nicht geprueft werden.');
}
$username = trim((string)$username);
$password = (string)$password;
if ($username === '' || $password === '')
{
throw new RuntimeException('Piwigo-Benutzername und Passwort muessen angegeben werden.');
}
$cookie_file = tempnam(sys_get_temp_dir(), 'br-pwg-auth-');
if ($cookie_file === false)
{
throw new RuntimeException('Temporare Piwigo-Sitzung konnte nicht angelegt werden.');
}
try
{
$url = rtrim(get_absolute_root_url(true), '/').'/ws.php?format=json';
$request = function(array $fields) use ($url, $cookie_file)
{
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER=>true,
CURLOPT_POST=>true,
CURLOPT_POSTFIELDS=>http_build_query($fields),
CURLOPT_COOKIEJAR=>$cookie_file,
CURLOPT_COOKIEFILE=>$cookie_file,
CURLOPT_CONNECTTIMEOUT=>10,
CURLOPT_TIMEOUT=>20,
CURLOPT_FOLLOWLOCATION=>false,
CURLOPT_USERAGENT=>'Bratonien-Tools-NC-Connector/'.(function_exists('bratonien_tools_current_version') ? bratonien_tools_current_version() : 'dev'),
));
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($body === false || $errno !== 0) throw new RuntimeException('Piwigo-Fallback konnte nicht geprueft werden: '.$error);
if ($http < 200 || $http >= 300) throw new RuntimeException('Piwigo-Fallback-Pruefung antwortete mit HTTP '.$http.'.');
$decoded = bratonien_tools_nc_connector_decode_api_response((string)$body, $type);
if (($decoded['stat'] ?? '') !== 'ok')
{
$message = trim((string)($decoded['message'] ?? $decoded['err'] ?? 'Piwigo hat die Anmeldung abgelehnt.'));
throw new RuntimeException($message !== '' ? $message : 'Piwigo hat die Anmeldung abgelehnt.');
}
return bratonien_tools_nc_connector_api_payload($decoded);
};
$request(array('method'=>'pwg.session.login', 'username'=>$username, 'password'=>$password));
$status = $request(array('method'=>'pwg.session.getStatus'));
if (!is_array($status)) throw new RuntimeException('Piwigo hat keinen auswertbaren Benutzerstatus geliefert.');
$role = strtolower(trim((string)($status['status'] ?? '')));
if (!in_array($role, array('admin','webmaster'), true))
{
throw new RuntimeException('Der Piwigo-Fallback funktioniert, gehoert aber keinem Administrator/Webmaster.');
}
return array('username'=>(string)($status['username'] ?? $username), 'status'=>$role);
}
finally
{
@unlink($cookie_file);
}
}
function bratonien_tools_nc_connector_piwigo_api_test()
{
$api_key_id = trim((string)($_POST['nc_piwigo_api_key_id'] ?? ''));

View File

@@ -0,0 +1,210 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_nc_scheduler_paths()
{
$base = rtrim(PHPWG_ROOT_PATH, '/').'/_data/bratonien-tools';
return array(
'base'=>$base,
'runtime'=>$base.'/nc-connector-runtime',
'state_root'=>$base.'/nc-connector-state',
'scheduler'=>$base.'/nc-connector-scheduler',
'state'=>$base.'/nc-connector-scheduler/state.json',
'trigger_lock'=>$base.'/nc-connector-scheduler/trigger.lock',
'worker_lock'=>$base.'/nc-connector-scheduler/worker.lock',
'log'=>$base.'/nc-connector-scheduler/last-worker.log',
'status_root'=>$base.'/nc-connector-status',
);
}
function bratonien_tools_nc_scheduler_interval()
{
global $conf;
$interval = isset($conf['bratonien_nc_scheduler_interval']) ? (int)$conf['bratonien_nc_scheduler_interval'] : 60;
return max(60, $interval);
}
function bratonien_tools_nc_scheduler_ensure_dirs()
{
foreach (bratonien_tools_nc_scheduler_paths() as $key=>$path)
{
if (in_array($key, array('state','trigger_lock','worker_lock','log'), true)) continue;
if (!is_dir($path) && !@mkdir($path, 0750, true) && !is_dir($path))
{
return false;
}
}
return true;
}
function bratonien_tools_nc_scheduler_read_state()
{
$paths = bratonien_tools_nc_scheduler_paths();
if (!is_readable($paths['state'])) return array();
$decoded = json_decode((string)@file_get_contents($paths['state']), true);
return is_array($decoded) ? $decoded : array();
}
function bratonien_tools_nc_scheduler_write_state(array $state)
{
$paths = bratonien_tools_nc_scheduler_paths();
if (!bratonien_tools_nc_scheduler_ensure_dirs()) return false;
$json = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if (!is_string($json)) return false;
$tmp = $paths['state'].'.tmp';
if (@file_put_contents($tmp, $json."\n", LOCK_EX) === false) return false;
@chmod($tmp, 0640);
return @rename($tmp, $paths['state']);
}
function bratonien_tools_nc_scheduler_write_connection_status($connection_id, $state, $message)
{
$connection_id = (int)$connection_id;
if ($connection_id < 1) return false;
$paths = bratonien_tools_nc_scheduler_paths();
if (!bratonien_tools_nc_scheduler_ensure_dirs()) return false;
$target = $paths['status_root'].'/connection-'.$connection_id.'.json';
$existing = array();
if (is_readable($target))
{
$decoded = json_decode((string)@file_get_contents($target), true);
if (is_array($decoded)) $existing = $decoded;
}
$existing['state'] = (string)$state;
$existing['message'] = (string)$message;
$existing['timestamp'] = time();
$existing['connection_id'] = $connection_id;
$json = json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if (!is_string($json)) return false;
$tmp = $target.'.tmp';
if (@file_put_contents($tmp, $json."\n", LOCK_EX) === false) return false;
@chmod($tmp, 0644);
return @rename($tmp, $target);
}
function bratonien_tools_nc_scheduler_install()
{
if (!bratonien_tools_nc_scheduler_ensure_dirs()) return false;
$state = bratonien_tools_nc_scheduler_read_state();
if (empty($state['next_due']))
{
$state['next_due'] = time();
$state['enabled'] = true;
$state['mode'] = 'piwigo-native';
bratonien_tools_nc_scheduler_write_state($state);
}
return true;
}
function bratonien_tools_nc_scheduler_php_binary()
{
foreach (array('/usr/bin/php', '/usr/local/bin/php', PHP_BINARY) as $candidate)
{
if (is_string($candidate) && $candidate !== '' && is_executable($candidate)) return $candidate;
}
return '';
}
function bratonien_tools_nc_scheduler_spawn($force = false, $connection_id = 0)
{
$connection_id = max(0, (int)$connection_id);
$paths = bratonien_tools_nc_scheduler_paths();
if (!bratonien_tools_nc_scheduler_ensure_dirs())
{
throw new RuntimeException('Der native NC-Scheduler kann sein Laufzeitverzeichnis nicht anlegen.');
}
$lock = @fopen($paths['trigger_lock'], 'c+');
if (!is_resource($lock) || !@flock($lock, LOCK_EX | LOCK_NB))
{
if (is_resource($lock)) fclose($lock);
return array('started'=>false, 'message'=>'Ein NC-Abgleich wird bereits vorbereitet.');
}
$state = bratonien_tools_nc_scheduler_read_state();
$now = time();
$next_due = (int)($state['next_due'] ?? 0);
if (!$force && $next_due > $now)
{
@flock($lock, LOCK_UN);
fclose($lock);
return array('started'=>false, 'message'=>'Der nächste NC-Abgleich ist noch nicht fällig.');
}
$php = bratonien_tools_nc_scheduler_php_binary();
if ($php === '')
{
@flock($lock, LOCK_UN);
fclose($lock);
throw new RuntimeException('Kein ausführbares PHP-CLI für den nativen NC-Scheduler gefunden.');
}
$state['enabled'] = true;
$state['mode'] = 'piwigo-native';
$state['state'] = 'queued';
$state['message'] = $connection_id > 0 ? 'NC-Abgleich für Verbindung #'.$connection_id.' wurde angefordert.' : 'NC-Abgleich für alle Verbindungen wurde angefordert.';
$state['queued_at'] = $now;
$state['timestamp'] = $now;
$state['connection_id'] = $connection_id;
$state['next_due'] = $now + bratonien_tools_nc_scheduler_interval();
bratonien_tools_nc_scheduler_write_state($state);
if ($connection_id > 0)
{
bratonien_tools_nc_scheduler_write_connection_status($connection_id, 'queued', 'Abgleich wurde angefordert.');
}
$runner = BRATONIEN_TOOLS_PATH.'runtime/native-runner.php';
$runner_args = $connection_id > 0 ? ' --connection-id='.escapeshellarg((string)$connection_id) : '';
$command = escapeshellarg($php).' '.escapeshellarg($runner).$runner_args.' >> '.escapeshellarg($paths['log']).' 2>&1 &';
$spec = array(
0=>array('file','/dev/null','r'),
1=>array('file','/dev/null','a'),
2=>array('file','/dev/null','a'),
);
$process = function_exists('proc_open') ? @proc_open(array('/bin/sh','-c',$command), $spec, $pipes) : false;
$exit = is_resource($process) ? proc_close($process) : 1;
@flock($lock, LOCK_UN);
fclose($lock);
if ($exit !== 0)
{
$state = bratonien_tools_nc_scheduler_read_state();
$state['state'] = 'error';
$state['message'] = 'Der native NC-Abgleich konnte nicht gestartet werden.';
$state['timestamp'] = time();
bratonien_tools_nc_scheduler_write_state($state);
if ($connection_id > 0)
{
bratonien_tools_nc_scheduler_write_connection_status($connection_id, 'error', 'Abgleich konnte nicht gestartet werden.');
}
throw new RuntimeException('Der native NC-Abgleich konnte nicht gestartet werden.');
}
return array('started'=>true, 'message'=>$connection_id > 0 ? 'Abgleich für Verbindung #'.$connection_id.' wurde angefordert.' : 'Abgleich für alle Verbindungen wurde angefordert.');
}
function bratonien_tools_nc_scheduler_tick()
{
$state = bratonien_tools_nc_scheduler_read_state();
if (isset($state['enabled']) && !$state['enabled']) return;
$next_due = (int)($state['next_due'] ?? 0);
if ($next_due > time()) return;
try
{
bratonien_tools_nc_scheduler_spawn(false, 0);
}
catch (Throwable $e)
{
$state = bratonien_tools_nc_scheduler_read_state();
$state['state'] = 'error';
$state['message'] = $e->getMessage();
$state['timestamp'] = time();
$state['next_due'] = time() + bratonien_tools_nc_scheduler_interval();
bratonien_tools_nc_scheduler_write_state($state);
}
}

View File

@@ -4,109 +4,13 @@ if (!defined('PHPWG_ROOT_PATH'))
die('Hacking attempt!');
}
function bratonien_tools_nc_connector_systemctl_value(array $args)
{
if (!function_exists('proc_open'))
{
return '';
}
$command = array_merge(array('/usr/bin/systemctl'), $args);
$spec = array(
0 => array('file', '/dev/null', 'r'),
1 => array('pipe', 'w'),
2 => array('pipe', 'w'),
);
$environment = array_merge($_ENV, array('LC_ALL'=>'C', 'LANG'=>'C'));
$process = @proc_open($command, $spec, $pipes, null, $environment);
if (!is_resource($process))
{
return '';
}
$stdout = stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exit = proc_close($process);
return $exit === 0 ? trim((string)$stdout) : '';
}
function bratonien_tools_nc_connector_parse_systemd_time($value)
{
$value = trim((string)$value);
if ($value === '' || strtolower($value) === 'n/a')
{
return 0;
}
$parsed = strtotime($value);
return $parsed === false ? 0 : (int)$parsed;
}
function bratonien_tools_nc_connector_monotonic_to_timestamp($value)
{
$value = trim((string)$value);
if ($value === '' || $value === '0' || strtolower($value) === 'n/a')
{
return 0;
}
if (preg_match('/^([0-9]+)(?:us)?$/', $value, $matches))
{
$next_boot_seconds = ((float)$matches[1]) / 1000000;
}
else
{
return 0;
}
$uptime_raw = @file_get_contents('/proc/uptime');
if (!is_string($uptime_raw) || !preg_match('/^([0-9]+(?:\.[0-9]+)?)/', trim($uptime_raw), $uptime_match))
{
return 0;
}
$remaining = $next_boot_seconds - (float)$uptime_match[1];
if ($remaining < -1)
{
return 0;
}
return (int)round(time() + max(0, $remaining));
}
function bratonien_tools_nc_connector_next_from_timer_list($timer)
{
$line = bratonien_tools_nc_connector_systemctl_value(array(
'list-timers', '--all', '--no-pager', '--no-legend', $timer,
));
if ($line === '')
{
return 0;
}
$first_line = trim((string)strtok($line, "\n"));
if (preg_match('/^(\S+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+\S+)/', $first_line, $matches))
{
$parsed = strtotime($matches[1]);
return $parsed === false ? 0 : (int)$parsed;
}
return 0;
}
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_scheduler.inc.php');
function bratonien_tools_nc_connector_connection_last_status(array $connection)
{
$empty = array(
'timestamp'=>0,
'label'=>'Nicht verfügbar',
'state'=>'',
'message'=>'',
'auth_mode'=>'',
'api_state'=>'',
'api_message'=>'',
'fallback_state'=>'',
'fallback_message'=>'',
'error_detail'=>'',
'timestamp'=>0,'label'=>'Nicht verfügbar','state'=>'','message'=>'','auth_mode'=>'',
'api_state'=>'','api_message'=>'','fallback_state'=>'','fallback_message'=>'','error_detail'=>'',
);
$connection_id = (int)($connection['id'] ?? 0);
@@ -114,42 +18,25 @@ function bratonien_tools_nc_connector_connection_last_status(array $connection)
if ($connection_id > 0)
{
$candidates[] = rtrim(PHPWG_ROOT_PATH, '/').'/_data/bratonien-tools/nc-connector-status/connection-'.$connection_id.'.json';
$candidates[] = rtrim(PHPWG_ROOT_PATH, '/').'/_data/bratonien-tools/nc-connector-state/connection-'.$connection_id.'/connector-status.json';
}
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$state_dir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($state_dir === '' && $connection_id > 0)
{
$state_dir = '/var/lib/bratonien-tools/nc-connector/connection-'.$connection_id;
}
if ($state_dir !== '')
{
$candidates[] = $state_dir.'/connector-status.json';
}
if ($state_dir !== '') $candidates[] = $state_dir.'/connector-status.json';
$decoded = null;
foreach ($candidates as $candidate)
foreach (array_unique($candidates) as $candidate)
{
if (!is_readable($candidate))
{
continue;
}
if (!is_readable($candidate)) continue;
$value = json_decode((string)@file_get_contents($candidate), true);
if (is_array($value))
{
$decoded = $value;
break;
}
}
if (!is_array($decoded))
{
return $empty;
if (is_array($value)) { $decoded = $value; break; }
}
if (!is_array($decoded)) return $empty;
$timestamp = (int)($decoded['timestamp'] ?? 0);
$api = isset($decoded['api']) && is_array($decoded['api']) ? $decoded['api'] : array();
$fallback = isset($decoded['fallback']) && is_array($decoded['fallback']) ? $decoded['fallback'] : array();
return array(
'timestamp'=>$timestamp,
'label'=>$timestamp > 0 ? date('d.m.Y H:i:s', $timestamp) : 'Nicht verfügbar',
@@ -166,73 +53,88 @@ function bratonien_tools_nc_connector_connection_last_status(array $connection)
function bratonien_tools_nc_connector_last_status(array $connections)
{
$latest = array('timestamp'=>0, 'state'=>'', 'message'=>'', 'auth_mode'=>'', 'api_state'=>'', 'api_message'=>'', 'fallback_state'=>'', 'fallback_message'=>'', 'error_detail'=>'');
$latest = array('timestamp'=>0,'state'=>'','message'=>'','auth_mode'=>'','api_state'=>'','api_message'=>'','fallback_state'=>'','fallback_message'=>'','error_detail'=>'');
foreach ($connections as $connection)
{
if (empty($connection['enabled']) || (string)($connection['takeover_state'] ?? '') !== 'active')
{
continue;
}
if (empty($connection['enabled'])) continue;
$status = bratonien_tools_nc_connector_connection_last_status($connection);
if ((int)$status['timestamp'] >= (int)$latest['timestamp'])
{
$latest = $status;
}
if ((int)$status['timestamp'] >= (int)$latest['timestamp']) $latest = $status;
}
return $latest;
}
function bratonien_tools_nc_connector_system_status(array $connections = array())
{
$timer = 'bratonien-nc-connector.timer';
$service = 'bratonien-nc-connector.service';
$next_realtime_raw = bratonien_tools_nc_connector_systemctl_value(array('show', $timer, '--property=NextElapseUSecRealtime', '--value'));
$next_timestamp = bratonien_tools_nc_connector_parse_systemd_time($next_realtime_raw);
if ($next_timestamp <= 0)
{
$next_monotonic_raw = bratonien_tools_nc_connector_systemctl_value(array('show', $timer, '--property=NextElapseUSecMonotonic', '--value'));
$next_timestamp = bratonien_tools_nc_connector_monotonic_to_timestamp($next_monotonic_raw);
}
if ($next_timestamp <= 0)
{
$next_timestamp = bratonien_tools_nc_connector_next_from_timer_list($timer);
}
$active = bratonien_tools_nc_connector_systemctl_value(array('is-active', $timer));
$enabled = bratonien_tools_nc_connector_systemctl_value(array('is-enabled', $timer));
$scheduler = bratonien_tools_nc_scheduler_read_state();
$enabled = !isset($scheduler['enabled']) || !empty($scheduler['enabled']);
$scheduler_state = (string)($scheduler['state'] ?? '');
$running = $scheduler_state === 'running';
$queued = $scheduler_state === 'queued';
$started = (int)($scheduler['started_at'] ?? 0);
$next = (int)($scheduler['next_due'] ?? 0);
$last = bratonien_tools_nc_connector_last_status($connections);
if ($last['timestamp'] <= 0)
if ($next > 0)
{
$last_raw = bratonien_tools_nc_connector_systemctl_value(array('show', $service, '--property=ExecMainExitTimestamp', '--value'));
$last['timestamp'] = bratonien_tools_nc_connector_parse_systemd_time($last_raw);
$next_label = date('d.m.Y H:i:s', $next).' (beim nächsten Piwigo-Aufruf)';
}
else
{
$next_label = 'Beim nächsten Piwigo-Aufruf';
}
$scheduler_timestamp = (int)($scheduler['timestamp'] ?? 0);
if ($scheduler_timestamp > (int)$last['timestamp'])
{
$last['timestamp'] = $scheduler_timestamp;
$last['state'] = $scheduler_state;
$last['message'] = (string)($scheduler['message'] ?? '');
if ($scheduler_state === 'error')
{
$detail = trim((string)($scheduler['stderr'] ?? ''));
if ($detail === '') $detail = trim((string)($scheduler['stdout'] ?? ''));
$last['error_detail'] = $detail;
}
}
elseif ((int)$last['timestamp'] <= 0 && !empty($scheduler['finished_at']))
{
$last['timestamp'] = (int)$scheduler['finished_at'];
$last['state'] = $scheduler_state;
$last['message'] = (string)($scheduler['message'] ?? '');
if ((string)$last['state'] === 'error')
{
$detail = trim((string)($scheduler['stderr'] ?? ''));
if ($detail === '') $detail = trim((string)($scheduler['stdout'] ?? ''));
$last['error_detail'] = $detail;
}
}
$current_label = 'Kein Lauf aktiv';
if ($queued) $current_label = 'Abgleich angefordert';
if ($running) $current_label = $started > 0 ? 'Läuft seit '.date('d.m.Y H:i:s', $started) : 'Läuft gerade';
return array(
'timer_name' => $timer,
'timer_active' => $active === 'active',
'timer_enabled' => $enabled === 'enabled',
'last_run_timestamp' => (int)$last['timestamp'],
'last_run_label' => $last['timestamp'] > 0 ? date('d.m.Y H:i:s', (int)$last['timestamp']) : 'Nicht verfügbar',
'last_run_state' => (string)$last['state'],
'last_run_message' => (string)$last['message'],
'last_run_auth_mode' => (string)$last['auth_mode'],
'last_run_api_state' => (string)$last['api_state'],
'last_run_api_message' => (string)$last['api_message'],
'last_run_fallback_state' => (string)$last['fallback_state'],
'last_run_fallback_message' => (string)$last['fallback_message'],
'last_run_error_detail' => (string)$last['error_detail'],
'next_run_timestamp' => $next_timestamp,
'next_run_label' => $next_timestamp > 0 ? date('d.m.Y H:i:s', $next_timestamp) : 'Nicht verfügbar',
'legacy_runtime_exists' => is_dir('/opt/piwigo-sync'),
'legacy_config_exists' => is_dir('/etc/piwigo-sync'),
'legacy_service_exists' => is_file('/etc/systemd/system/piwigo-sync.service'),
'legacy_timer_exists' => is_file('/etc/systemd/system/piwigo-sync.timer'),
'timer_name'=>'Piwigo nativer NC-Scheduler',
'timer_active'=>$enabled,
'timer_enabled'=>$enabled,
'service_active'=>$running || $queued,
'current_run_timestamp'=>$queued ? (int)($scheduler['queued_at'] ?? 0) : $started,
'current_run_label'=>$current_label,
'last_run_timestamp'=>(int)$last['timestamp'],
'last_run_label'=>(int)$last['timestamp'] > 0 ? date('d.m.Y H:i:s', (int)$last['timestamp']) : 'Nicht verfügbar',
'last_run_state'=>(string)$last['state'],
'last_run_message'=>(string)$last['message'],
'last_run_auth_mode'=>(string)$last['auth_mode'],
'last_run_api_state'=>(string)$last['api_state'],
'last_run_api_message'=>(string)$last['api_message'],
'last_run_fallback_state'=>(string)$last['fallback_state'],
'last_run_fallback_message'=>(string)$last['fallback_message'],
'last_run_error_detail'=>(string)$last['error_detail'],
'next_run_timestamp'=>$next,
'next_run_label'=>$next_label,
'legacy_runtime_exists'=>is_dir('/opt/piwigo-sync'),
'legacy_config_exists'=>is_dir('/etc/piwigo-sync'),
'legacy_service_exists'=>is_file('/etc/systemd/system/piwigo-sync.service'),
'legacy_timer_exists'=>is_file('/etc/systemd/system/piwigo-sync.timer'),
);
}

View File

@@ -1,135 +0,0 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
/**
* Controlled handover preparation for verified NC Connector connections.
*
* This phase intentionally does not stop, disable or modify the legacy
* piwigo-sync service. It only marks a verified Connector connection as ready
* for a later controlled takeover. The marker can be rolled back at any time.
*/
function bratonien_tools_nc_connector_ensure_takeover_state()
{
bratonien_tools_nc_connector_ensure_table();
$table = bratonien_tools_nc_connector_table();
pwg_query("ALTER TABLE `$table` MODIFY takeover_state enum('imported','verified','ready','active','disabled') NOT NULL DEFAULT 'imported'");
}
function bratonien_tools_nc_connector_prepare_takeover()
{
$id = isset($_POST['connection_id']) ? (int)$_POST['connection_id'] : 0;
$connection = bratonien_tools_nc_connector_connection($id, false);
if (!$connection)
{
throw new RuntimeException('Connector-Verbindung wurde nicht gefunden.');
}
if ($connection['takeover_state'] !== 'verified')
{
throw new RuntimeException('Nur eine erfolgreich verifizierte Verbindung kann fuer die Uebergabe vorbereitet werden.');
}
$config = $connection['config'];
$verification = isset($config['verification']) && is_array($config['verification'])
? $config['verification']
: array();
if (empty($verification['ok']))
{
throw new RuntimeException('Die gespeicherte Verifikation ist nicht erfolgreich. Bitte die Verbindung erneut pruefen.');
}
$config['takeover'] = array(
'prepared_at' => date('Y-m-d H:i:s'),
'legacy_sync_untouched' => true,
'connector_enabled' => false,
'first_run' => array(
'status' => 'pending',
'finished_at' => null,
'detail' => '',
),
);
$config_json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($config_json))
{
throw new RuntimeException('Uebergabestatus konnte nicht gespeichert werden.');
}
bratonien_tools_nc_connector_ensure_takeover_state();
$table = bratonien_tools_nc_connector_table();
$now = date('Y-m-d H:i:s');
pwg_query("UPDATE `$table` SET
takeover_state = 'ready',
enabled = 0,
config_json = '".pwg_db_real_escape_string($config_json)."',
updated = '".pwg_db_real_escape_string($now)."'
WHERE id = ".(int)$connection['id']);
return array(
'message' => 'Die verifizierte Nextcloud-Verbindung ist jetzt fuer die kontrollierte Uebergabe vorbereitet. Der Connector wurde noch nicht aktiviert und der Legacy-Sync bleibt unveraendert Produktionsverbindung.',
);
}
function bratonien_tools_nc_connector_cancel_takeover()
{
$id = isset($_POST['connection_id']) ? (int)$_POST['connection_id'] : 0;
$connection = bratonien_tools_nc_connector_connection($id, false);
if (!$connection)
{
throw new RuntimeException('Connector-Verbindung wurde nicht gefunden.');
}
if ($connection['takeover_state'] !== 'ready')
{
throw new RuntimeException('Diese Verbindung befindet sich nicht in der Uebergabevorbereitung.');
}
$config = $connection['config'];
if (isset($config['takeover']))
{
unset($config['takeover']);
}
$config_json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($config_json))
{
throw new RuntimeException('Uebergabestatus konnte nicht zurueckgesetzt werden.');
}
bratonien_tools_nc_connector_ensure_takeover_state();
$table = bratonien_tools_nc_connector_table();
$now = date('Y-m-d H:i:s');
pwg_query("UPDATE `$table` SET
takeover_state = 'verified',
enabled = 0,
config_json = '".pwg_db_real_escape_string($config_json)."',
updated = '".pwg_db_real_escape_string($now)."'
WHERE id = ".(int)$connection['id']);
return array(
'message' => 'Die Uebergabevorbereitung wurde zurueckgenommen. Die Verbindung bleibt verifiziert und deaktiviert; der Legacy-Sync bleibt unveraendert aktiv.',
);
}
/**
* Normalize the outcome of a Connector sync run during takeover.
*
* Both "changed" and "no_changes" are successful results. Only "error"
* represents a failed run and may trigger rollback of a controlled handover.
*/
function bratonien_tools_nc_connector_takeover_result($status, $detail = '')
{
$status = trim((string)$status);
if (!in_array($status, array('changed', 'no_changes', 'error'), true))
{
throw new RuntimeException('Unbekannter Connector-Laufstatus: '.$status);
}
return array(
'status' => $status,
'success' => $status !== 'error',
'changed' => $status === 'changed',
'finished_at' => date('Y-m-d H:i:s'),
'detail' => (string)$detail,
);
}

View File

@@ -0,0 +1,179 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
/**
* Create or update one independent WebDAV connector connection.
* No migration pair, successor or implicit legacy fallback is created.
*/
function bratonien_tools_nc_connector_create_webdav_placeholder_from_wizard()
{
$state = bratonien_tools_nc_wizard_state();
if (empty($state['scan_ok']) || empty($state['technical_complete']))
{
throw new RuntimeException('Die WebDAV-Verbindung wurde im Assistenten noch nicht vollstaendig vorbereitet.');
}
$base_url = rtrim(trim((string)($state['base_url'] ?? '')), '/');
$username = trim((string)($state['username'] ?? ''));
$password = (string)($state['_password'] ?? '');
if ($base_url === '' || $username === '' || $password === '')
{
throw new RuntimeException('Fuer den WebDAV-Zugang fehlen Nextcloud-Adresse oder Zugangsdaten.');
}
$selected = isset($state['directory_selected']) && is_array($state['directory_selected'])
? array_values(array_unique(array_map(function($path) { return trim((string)$path, '/'); }, $state['directory_selected'])))
: array();
$selected_ids = isset($state['directory_selected_fileids']) && is_array($state['directory_selected_fileids'])
? $state['directory_selected_fileids']
: array();
if (!$selected)
{
$selected = array('');
}
$roots = array();
foreach ($selected as $path)
{
$fileid = isset($selected_ids[$path]) ? (int)$selected_ids[$path] : 0;
if ($fileid < 1)
{
throw new RuntimeException('Fuer ein ausgewaehltes Nextcloud-Verzeichnis fehlt die eindeutige Datei-ID.');
}
$display_name = $path === ''
? (trim((string)($state['display_name'] ?? '')) !== '' ? trim((string)$state['display_name']) : $username)
: basename($path);
$roots[] = array(
'fileid'=>$fileid,
'display_name'=>$display_name,
'webdav_path'=>$path,
);
}
$name = trim((string)($state['connection_name'] ?? ''));
if ($name === '') $name = 'Nextcloud WebDAV';
$gallery_root = rtrim(trim((string)($state['gallery_root'] ?? '')), '/');
if ($gallery_root === '') $gallery_root = rtrim(PHPWG_ROOT_PATH, '/').'/galleries';
$api_key_id = ($state['api_status'] ?? '') === 'ok' ? trim((string)($state['_api_key_id'] ?? '')) : '';
$api_key_secret = ($state['api_status'] ?? '') === 'ok' ? trim((string)($state['_api_key_secret'] ?? '')) : '';
$fallback_user = trim((string)($state['_fallback_user'] ?? ''));
$fallback_password = (string)($state['_fallback_password'] ?? '');
$api_enabled = $api_key_id !== '' && $api_key_secret !== '';
$editing_id = (int)($state['editing_connection_id'] ?? 0);
$editing_mode = (string)($state['editing_mode'] ?? '');
$editing_connection = $editing_id > 0 ? bratonien_tools_nc_connector_connection($editing_id, false) : null;
$editing_remote = $editing_connection
&& (string)$editing_connection['adapter'] === 'remote'
&& (string)($editing_connection['config']['source_mode'] ?? '') === 'webdav-placeholder'
&& $editing_mode === 'update';
if ($editing_mode === 'migrate')
{
throw new RuntimeException('Die Migrationsfunktion wurde entfernt. Bitte eine normale WebDAV-Verbindung anlegen.');
}
$config = array(
'origin'=>'native',
'source_mode'=>'webdav-placeholder',
'transport'=>'webdav',
'nextcloud_url'=>$base_url,
'access_user'=>$username,
'nextcloud_access_user'=>$username,
'gallery_root'=>$gallery_root,
'roots'=>$roots,
'state_dir'=>'',
'status_file'=>'',
'quiet_seconds'=>120,
'max_wait_seconds'=>900,
'full_sync_seconds'=>86400,
'piwigo_auth'=>'connection-scoped',
'api_enabled'=>$api_enabled,
);
if ($editing_remote)
{
$old_config = is_array($editing_connection['config'] ?? null) ? $editing_connection['config'] : array();
foreach (array('state_dir','status_file','parallel_gallery_root','source_fingerprint','runtime') as $preserve)
{
if (array_key_exists($preserve, $old_config)) $config[$preserve] = $old_config[$preserve];
}
foreach (array('quiet_seconds','max_wait_seconds','full_sync_seconds') as $preserve)
{
if (isset($old_config[$preserve])) $config[$preserve] = (int)$old_config[$preserve];
}
}
foreach (array('product'=>'nextcloud_product', 'version'=>'nextcloud_version') as $state_key=>$config_key)
{
$value = trim((string)($state[$state_key] ?? ''));
if ($value !== '') $config[$config_key] = $value;
}
$secret_payload = json_encode(array(
'v'=>3,
'db_password'=>'',
'piwigo_user'=>$fallback_user,
'piwigo_password'=>$fallback_password,
'api_key_id'=>$api_key_id,
'api_key_secret'=>$api_key_secret,
'nextcloud_user'=>$username,
'nextcloud_password'=>$password,
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($secret_payload)) throw new RuntimeException('WebDAV-Zugangsdaten konnten nicht serialisiert werden.');
bratonien_tools_nc_connector_ensure_table();
$table = bratonien_tools_nc_connector_table();
$now = date('Y-m-d H:i:s');
$secret_blob = bratonien_tools_nc_connector_encrypt_secret($secret_payload);
if ($editing_remote)
{
$config['state_dir'] = trim((string)($config['state_dir'] ?? '')) !== '' ? $config['state_dir'] : '/var/lib/bratonien-tools/nc-connector/connection-'.$editing_id;
$config['status_file'] = trim((string)($config['status_file'] ?? '')) !== '' ? $config['status_file'] : $config['state_dir'].'/connector-status.json';
$config_json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($config_json)) throw new RuntimeException('WebDAV-Konfiguration konnte nicht serialisiert werden.');
pwg_query("UPDATE `$table` SET name='".pwg_db_real_escape_string($name)."', config_json='".pwg_db_real_escape_string($config_json)."', secret_blob='".pwg_db_real_escape_string($secret_blob)."', updated='".pwg_db_real_escape_string($now)."' WHERE id=".$editing_id." AND adapter='remote' LIMIT 1");
unset($_SESSION['bratonien_nc_wizard']);
return array(
'connection_id'=>$editing_id,
'message'=>'WebDAV-Verbindung wurde gespeichert.',
);
}
$config_json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($config_json)) throw new RuntimeException('WebDAV-Konfiguration konnte nicht serialisiert werden.');
$connection_key = 'webdav-'.bin2hex(random_bytes(12));
pwg_query("INSERT INTO `$table` (connection_key,name,adapter,enabled,takeover_state,config_json,secret_blob,created,updated) VALUES ('"
.pwg_db_real_escape_string($connection_key)."','"
.pwg_db_real_escape_string($name)."','remote',0,'disabled','"
.pwg_db_real_escape_string($config_json)."','"
.pwg_db_real_escape_string($secret_blob)."','"
.pwg_db_real_escape_string($now)."','"
.pwg_db_real_escape_string($now)."')");
$id = (int)pwg_db_insert_id();
if ($id < 1) throw new RuntimeException('Die WebDAV-Verbindung konnte nicht eindeutig angelegt werden.');
$config['state_dir'] = '/var/lib/bratonien-tools/nc-connector/connection-'.$id;
$config['status_file'] = $config['state_dir'].'/connector-status.json';
$config_json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($config_json)) throw new RuntimeException('WebDAV-Konfiguration konnte nach dem Anlegen nicht serialisiert werden.');
pwg_query("UPDATE `$table` SET config_json='".pwg_db_real_escape_string($config_json)."' WHERE id=".$id." AND adapter='remote' LIMIT 1");
unset($_SESSION['bratonien_nc_wizard']);
return array(
'connection_id'=>$id,
'message'=>'WebDAV-Verbindung wurde angelegt.',
);
}

View File

@@ -6,21 +6,5 @@ if (!defined('PHPWG_ROOT_PATH'))
function bratonien_tools_nc_wizard_save_technical_flow()
{
$result = bratonien_tools_nc_wizard_save_technical_with_known_database();
$state = bratonien_tools_nc_wizard_state();
if ((string)($state['technical_stage'] ?? '') === 'mounts')
{
if (!empty($state['directory_selection_ready']))
{
bratonien_tools_nc_wizard_refresh_directory_state($state, '');
}
else
{
$state['mount_prompted'] = true;
}
bratonien_tools_nc_wizard_store($state);
}
return $result;
return bratonien_tools_nc_wizard_save_technical_generic();
}

View File

@@ -20,7 +20,7 @@ function bratonien_tools_nc_wizard_webdav_list(array $state, $path = '')
$url = rtrim((string)$state['base_url'], '/').'/remote.php/dav/files/'.$user.'/'.implode('/', $segments);
if (substr($url, -1) !== '/') $url .= '/';
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:displayname/></d:prop></d:propfind>';
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><d:resourcetype/><d:displayname/><oc:fileid/></d:prop></d:propfind>';
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER=>true,
@@ -45,27 +45,43 @@ function bratonien_tools_nc_wizard_webdav_list(array $state, $path = '')
$xml = simplexml_load_string((string)$response);
if ($xml === false) throw new RuntimeException('Nextcloud hat eine ungültige WebDAV-Antwort geliefert.');
$xml->registerXPathNamespace('d', 'DAV:');
$xml->registerXPathNamespace('oc', 'http://owncloud.org/ns');
$children = array();
$fileids = array();
$current_fileid = 0;
$base_path = (string)parse_url($url, PHP_URL_PATH);
foreach ($xml->xpath('//d:response') as $item)
{
$item->registerXPathNamespace('d', 'DAV:');
$item->registerXPathNamespace('oc', 'http://owncloud.org/ns');
$hrefs = $item->xpath('d:href');
$collections = $item->xpath('d:propstat/d:prop/d:resourcetype/d:collection');
if (!$hrefs || !$collections) continue;
$ids = $item->xpath('d:propstat/d:prop/oc:fileid');
if (!$hrefs || !$collections || !$ids) continue;
$fileid = (int)trim((string)$ids[0]);
if ($fileid < 1) continue;
$href = rawurldecode((string)$hrefs[0]);
$href_path = (string)parse_url($href, PHP_URL_PATH);
$base_path = (string)parse_url($url, PHP_URL_PATH);
if (rtrim($href_path, '/') === rtrim($base_path, '/')) continue;
if (rtrim($href_path, '/') === rtrim($base_path, '/'))
{
$current_fileid = $fileid;
continue;
}
$name = basename(rtrim($href_path, '/'));
if ($name === '') continue;
$child_path = $path === '' ? $name : $path.'/'.$name;
$children[$child_path] = $name;
$fileids[$child_path] = $fileid;
}
natcasesort($children);
if ($current_fileid < 1) throw new RuntimeException('Nextcloud hat für das aktuelle Verzeichnis keine eindeutige Datei-ID geliefert.');
$parent = '';
if ($path !== '')
{
@@ -74,7 +90,13 @@ function bratonien_tools_nc_wizard_webdav_list(array $state, $path = '')
$parent = implode('/', $parts);
}
return array('current'=>$path, 'parent'=>$parent, 'children'=>$children);
return array(
'current'=>$path,
'parent'=>$parent,
'children'=>$children,
'current_fileid'=>$current_fileid,
'fileids'=>$fileids,
);
}
function bratonien_tools_nc_wizard_refresh_directory_state(array &$state, $path = null)
@@ -84,7 +106,149 @@ function bratonien_tools_nc_wizard_refresh_directory_state(array &$state, $path
$state['directory_path'] = (string)$listing['current'];
$state['directory_parent'] = (string)$listing['parent'];
$state['directory_children'] = (array)$listing['children'];
$state['directory_current_fileid'] = (int)$listing['current_fileid'];
$state['directory_fileids'] = (array)$listing['fileids'];
if (!isset($state['directory_selected']) || !is_array($state['directory_selected'])) $state['directory_selected'] = array();
if (!isset($state['directory_selected_fileids']) || !is_array($state['directory_selected_fileids'])) $state['directory_selected_fileids'] = array();
}
function bratonien_tools_nc_wizard_generic_db_config(array $state)
{
return array(
'host'=>(string)$state['db_host'],
'port'=>(string)$state['db_port'],
'database'=>(string)$state['db_database'],
'user'=>(string)$state['db_user'],
);
}
function bratonien_tools_nc_wizard_verify_generic_data_access(array &$state)
{
$config = bratonien_tools_nc_wizard_generic_db_config($state);
$password = (string)$state['_db_password'];
if (trim((string)$config['user']) === '' || $password === '') throw new RuntimeException('Die gespeicherte Reader-Verbindung ist unvollständig.');
bratonien_tools_nc_connector_psql($config, $password, 'SELECT 1');
bratonien_tools_nc_connector_psql($config, $password, 'SELECT 1 FROM piwigo_connector_files LIMIT 1');
bratonien_tools_nc_connector_psql($config, $password, 'SELECT 1 FROM piwigo_connector_activity LIMIT 1');
$state['source_view'] = 'piwigo_connector_files';
$state['activity_view'] = 'piwigo_connector_activity';
$state['source_mode'] = 'selected-fileids';
$state['storages'] = array();
$state['storage_candidates'] = array();
$state['roots'] = array();
$state['technical_complete'] = false;
$state['technical_stage'] = 'mounts';
$state['technical_source'] = 'Generische Reader-Schnittstelle geprüft';
$state['technical_error'] = '';
$state['directory_selection_ready'] = true;
}
function bratonien_tools_nc_wizard_known_adapter($storage_id, $source_path)
{
$storage_id = trim((string)$storage_id);
$source_path = trim((string)$source_path, '/');
$matches = array();
foreach (bratonien_tools_nc_connector_connections() as $connection)
{
foreach ((array)($connection['config']['storages'] ?? array()) as $storage)
{
if ((string)($storage['storage_id'] ?? '') !== $storage_id) continue;
$prefix = trim((string)($storage['source_prefix'] ?? ''), '/');
if ($prefix !== '' && $source_path !== $prefix && strpos($source_path, $prefix.'/') !== 0) continue;
$mount = rtrim(trim((string)($storage['local_mount'] ?? '')), '/');
if ($mount === '' || !is_dir($mount) || !is_readable($mount)) continue;
$key = $prefix.'|'.$mount;
$matches[$key] = array('storage_id'=>$storage_id,'source_prefix'=>$prefix,'local_mount'=>$mount);
}
}
if (!$matches) return null;
$max = -1;
foreach ($matches as $match) $max = max($max, strlen((string)$match['source_prefix']));
$best = array_values(array_filter($matches, function($match) use ($max) { return strlen((string)$match['source_prefix']) === $max; }));
return count($best) === 1 ? $best[0] : null;
}
function bratonien_tools_nc_wizard_resolve_selected_roots(array &$state)
{
$selected = isset($state['directory_selected']) && is_array($state['directory_selected']) ? $state['directory_selected'] : array();
$selected_fileids = isset($state['directory_selected_fileids']) && is_array($state['directory_selected_fileids']) ? $state['directory_selected_fileids'] : array();
if (!$selected) throw new RuntimeException('Bitte mindestens ein Nextcloud-Verzeichnis auswählen.');
$ids = array();
foreach ($selected as $path)
{
if (!isset($selected_fileids[$path]) || (int)$selected_fileids[$path] < 1) throw new RuntimeException('Für eine Auswahl fehlt die eindeutige Nextcloud-Datei-ID. Bitte das Verzeichnis erneut auswählen.');
$ids[(int)$selected_fileids[$path]] = (string)$path;
}
$config = bratonien_tools_nc_wizard_generic_db_config($state);
$id_list = implode(',', array_map('intval', array_keys($ids)));
$rows = bratonien_tools_nc_connector_psql(
$config,
(string)$state['_db_password'],
"SELECT fileid::text || E'\\t' || storage_id || E'\\t' || COALESCE(source_path, '') FROM piwigo_connector_files WHERE fileid IN (".$id_list.") ORDER BY fileid"
);
$resolved = array();
foreach (preg_split('/\r\n|\r|\n/', trim($rows)) as $line)
{
if ($line === '') continue;
$parts = explode("\t", $line, 3);
if (count($parts) !== 3 || !ctype_digit($parts[0])) continue;
$resolved[(int)$parts[0]] = array('storage_id'=>(string)$parts[1], 'source_path'=>trim((string)$parts[2], '/'));
}
$roots = array();
$candidates = array();
foreach ($ids as $fileid=>$path)
{
if (!isset($resolved[$fileid])) throw new RuntimeException('Das ausgewählte Nextcloud-Verzeichnis mit Datei-ID '.$fileid.' konnte nicht mehr aufgelöst werden.');
$storage_id = trim((string)$resolved[$fileid]['storage_id']);
$source_path = trim((string)$resolved[$fileid]['source_path'], '/');
if ($storage_id === '') throw new RuntimeException('Nextcloud hat für Datei-ID '.$fileid.' keine Storage-ID geliefert.');
$display_name = $path === '' ? ((string)($state['display_name'] ?? '') !== '' ? (string)$state['display_name'] : 'Nextcloud') : basename($path);
$roots[] = array(
'fileid'=>(int)$fileid,
'display_name'=>$display_name,
'webdav_path'=>$path,
'storage_id'=>$storage_id,
'source_path'=>$source_path,
);
$adapter = bratonien_tools_nc_wizard_known_adapter($storage_id, $source_path);
if (!$adapter) $adapter = array('storage_id'=>$storage_id,'source_prefix'=>'','local_mount'=>'');
$key = $adapter['storage_id'].'|'.$adapter['source_prefix'].'|'.$adapter['local_mount'];
$candidates[$key] = $adapter;
}
$state['roots'] = array_values($roots);
$state['storage_candidates'] = array_values($candidates);
$state['storages'] = $state['storage_candidates'];
$all_mapped = !empty($state['storage_candidates']);
foreach ($state['storage_candidates'] as $candidate)
{
$mount = rtrim(trim((string)($candidate['local_mount'] ?? '')), '/');
if ($mount === '' || !is_dir($mount) || !is_readable($mount)) $all_mapped = false;
}
if ($all_mapped)
{
$state['technical_complete'] = true;
$state['technical_stage'] = 'ready';
$state['directory_selection_ready'] = false;
return;
}
$state['technical_complete'] = false;
$state['technical_stage'] = 'mounts';
$state['directory_selection_ready'] = false;
$state['mount_prompted'] = true;
}
function bratonien_tools_nc_wizard_scan_user_scoped()
@@ -135,9 +299,9 @@ function bratonien_tools_nc_wizard_scan_user_scoped()
'users'=>array(),'can_list_users'=>false,'showcase_user'=>$resolved_username,'connection_name'=>$url_host !== '' ? $url_host : 'Nextcloud',
'scan_message'=>'Nextcloud wurde erkannt und der Benutzerzugriff wurde bestätigt.','_password'=>$password,
'api_status'=>'pending','api_username'=>'','api_error'=>'','db_host'=>strtolower($url_host),'db_port'=>'5432','db_database'=>'nextcloud','db_user'=>'','db_password_set'=>false,'_db_password'=>'',
'source_view'=>'piwigo_showcase_sources','activity_view'=>'piwigo_showcase_activity','gallery_root'=>rtrim(PHPWG_ROOT_PATH, '/').'/galleries/nextcloud',
'storages'=>array(),'storage_candidates'=>array(),'technical_stage'=>'auto_check','technical_source'=>'Automatische Prüfung','technical_error'=>'','technical_complete'=>false,
'directory_selection_ready'=>false,'directory_path'=>'','directory_parent'=>'','directory_children'=>array(),'directory_selected'=>array(),
'source_view'=>'piwigo_connector_files','activity_view'=>'piwigo_connector_activity','source_mode'=>'selected-fileids','gallery_root'=>rtrim(PHPWG_ROOT_PATH, '/').'/galleries/nextcloud',
'storages'=>array(),'storage_candidates'=>array(),'roots'=>array(),'technical_stage'=>'auto_check','technical_source'=>'Automatische Prüfung','technical_error'=>'','technical_complete'=>false,
'directory_selection_ready'=>false,'directory_path'=>'','directory_parent'=>'','directory_children'=>array(),'directory_current_fileid'=>0,'directory_fileids'=>array(),'directory_selected'=>array(),'directory_selected_fileids'=>array(),
'database_prompted'=>false,'mount_prompted'=>false,
));
@@ -151,31 +315,51 @@ function bratonien_tools_nc_wizard_scan_user_scoped()
return array('message'=>'Nextcloud wurde gefunden. Für den Datenzugriff wird eine separate Reader-Verbindung benötigt.');
}
$known_storages = isset($state['storages']) && is_array($state['storages']) ? $state['storages'] : array();
try
{
bratonien_tools_nc_wizard_finish_data_access_for_selection($state, $known_storages);
if (!empty($state['directory_selection_ready']))
{
bratonien_tools_nc_wizard_refresh_directory_state($state, '');
}
else
{
$state['mount_prompted'] = true;
}
bratonien_tools_nc_wizard_verify_generic_data_access($state);
bratonien_tools_nc_wizard_refresh_directory_state($state, '');
}
catch (Throwable $e)
{
$state['technical_complete'] = false;
$state['technical_stage'] = 'database_details';
$state['database_prompted'] = true;
$state['technical_error'] = $e->getMessage();
$state['technical_error'] = $e->getMessage().' Die generischen Connector-Views müssen in Nextcloud eingerichtet sein.';
}
bratonien_tools_nc_wizard_store($state);
if ($state['technical_stage'] === 'database_details') return array('message'=>'Nextcloud wurde gefunden. Die bekannte Reader-Verbindung konnte noch nicht vollständig bestätigt werden.');
if (empty($state['directory_selection_ready'])) return array('message'=>'Nextcloud und Datenzugriff wurden bestätigt. Der technische Speicherort muss einmal bestätigt werden.');
return array('message'=>'Nextcloud und Datenzugriff wurden bestätigt. Jetzt können die Verzeichnisse des angemeldeten Benutzers gewählt werden.');
if ($state['technical_stage'] === 'database_details') return array('message'=>'Nextcloud wurde gefunden. Die generische Reader-Schnittstelle konnte noch nicht bestätigt werden.');
return array('message'=>'Nextcloud und generischer Datenzugriff wurden bestätigt. Jetzt können die Verzeichnisse des angemeldeten Benutzers gewählt werden.');
}
function bratonien_tools_nc_wizard_save_technical_generic()
{
$state = bratonien_tools_nc_wizard_state();
if (empty($state['scan_ok'])) throw new RuntimeException('Bitte zuerst Nextcloud erfolgreich scannen.');
if (trim((string)($state['db_user'] ?? '')) === '' || (string)($state['_db_password'] ?? '') === '') bratonien_tools_nc_wizard_apply_known_database_profile($state);
if (trim((string)($state['db_user'] ?? '')) === '' || (string)($state['_db_password'] ?? '') === '') throw new RuntimeException('Für diese Nextcloud sind noch keine Datenbank-Reader-Zugangsdaten bekannt.');
$state['db_host'] = trim((string)($_POST['nc_wizard_db_host'] ?? $state['db_host']));
$state['db_port'] = (string)max(1, min(65535, (int)($_POST['nc_wizard_db_port'] ?? $state['db_port'])));
$state['db_database'] = trim((string)($_POST['nc_wizard_db_database'] ?? $state['db_database']));
if ($state['db_host'] === '' || $state['db_database'] === '') throw new RuntimeException('Die Adresse der Datenbank ist noch unvollständig.');
try
{
bratonien_tools_nc_wizard_verify_generic_data_access($state);
bratonien_tools_nc_wizard_refresh_directory_state($state, '');
bratonien_tools_nc_wizard_store($state);
return array('message'=>'Generischer Datenzugriff wurde erfolgreich geprüft. Jetzt können Verzeichnisse ausgewählt werden.');
}
catch (Throwable $e)
{
$state['technical_error'] = $e->getMessage();
$state['technical_stage'] = 'database_details';
$state['database_prompted'] = true;
bratonien_tools_nc_wizard_store($state);
throw $e;
}
}
function bratonien_tools_nc_wizard_directory_browse()
@@ -193,9 +377,13 @@ function bratonien_tools_nc_wizard_directory_add()
$state = bratonien_tools_nc_wizard_state();
if ((int)$state['step'] !== 2 || (string)$state['technical_stage'] !== 'mounts' || empty($state['directory_selection_ready'])) throw new RuntimeException('Die Verzeichnisauswahl ist in diesem Fenster nicht verfügbar.');
$path = trim((string)($state['directory_path'] ?? ''), '/');
$fileid = (int)($state['directory_current_fileid'] ?? 0);
if ($fileid < 1) throw new RuntimeException('Für dieses Verzeichnis fehlt die eindeutige Nextcloud-Datei-ID.');
$selected = isset($state['directory_selected']) && is_array($state['directory_selected']) ? $state['directory_selected'] : array();
if (!in_array($path, $selected, true)) $selected[] = $path;
$state['directory_selected'] = array_values($selected);
if (!isset($state['directory_selected_fileids']) || !is_array($state['directory_selected_fileids'])) $state['directory_selected_fileids'] = array();
$state['directory_selected_fileids'][$path] = $fileid;
bratonien_tools_nc_wizard_store($state);
return array('message'=>$path === '' ? 'Stammverzeichnis ausgewählt.' : 'Verzeichnis hinzugefügt.');
}
@@ -207,6 +395,7 @@ function bratonien_tools_nc_wizard_directory_remove()
$path = trim((string)($_POST['nc_wizard_directory_remove'] ?? ''), '/');
$selected = isset($state['directory_selected']) && is_array($state['directory_selected']) ? $state['directory_selected'] : array();
$state['directory_selected'] = array_values(array_filter($selected, function($value) use ($path) { return (string)$value !== $path; }));
if (isset($state['directory_selected_fileids'][$path])) unset($state['directory_selected_fileids'][$path]);
bratonien_tools_nc_wizard_store($state);
return array('message'=>'Verzeichnis entfernt.');
}
@@ -216,69 +405,35 @@ function bratonien_tools_nc_wizard_save_mounts_server_side()
$state = bratonien_tools_nc_wizard_state();
if ((int)$state['step'] !== 2 || (string)$state['technical_stage'] !== 'mounts') throw new RuntimeException('Die Speicher-/Verzeichnisauswahl ist in diesem Fenster nicht verfügbar.');
$candidates = isset($state['storage_candidates']) && is_array($state['storage_candidates']) ? $state['storage_candidates'] : array();
if (!$candidates) throw new RuntimeException('Es wurden noch keine Speicherorte erkannt.');
if (!empty($state['directory_selection_ready']))
{
bratonien_tools_nc_wizard_resolve_selected_roots($state);
bratonien_tools_nc_wizard_store($state);
return array('message'=>!empty($state['technical_complete']) ? 'Verzeichnisse und vorhandene Storage-Adapter wurden übernommen.' : 'Verzeichnisse wurden aufgelöst. Ein neuer Storage-Adapter muss noch zugeordnet werden.');
}
$candidates = isset($state['storage_candidates']) && is_array($state['storage_candidates']) ? $state['storage_candidates'] : array();
if (!$candidates) throw new RuntimeException('Es wurden noch keine Speicherorte aus der Verzeichnisauswahl aufgelöst.');
$mounts = isset($_POST['nc_wizard_storage_mount']) && is_array($_POST['nc_wizard_storage_mount']) ? $_POST['nc_wizard_storage_mount'] : array();
// Erstes sichtbares Fenster: technische Mount-Zuordnung bestätigen.
if (empty($state['directory_selection_ready']))
{
foreach ($candidates as $index=>&$candidate)
{
$mount = rtrim(trim((string)($candidate['local_mount'] ?? '')), '/');
if ($mount === '') $mount = rtrim(trim((string)($mounts[$index] ?? '')), '/');
if ($mount === '' || $mount[0] !== '/') throw new RuntimeException('Für einen Speicherort fehlt ein gültiger lokaler Pfad.');
if (!is_dir($mount) || !is_readable($mount)) throw new RuntimeException('Der angegebene Speicherort ist nicht vorhanden oder nicht lesbar: '.$mount);
$candidate['local_mount'] = $mount;
}
unset($candidate);
$state['storage_candidates'] = array_values($candidates);
$state['mount_prompted'] = true;
$state['directory_selection_ready'] = true;
$state['technical_complete'] = false;
$state['technical_stage'] = 'mounts';
$state['directory_path'] = '';
$state['directory_parent'] = '';
$state['directory_children'] = array();
$state['directory_selected'] = array();
bratonien_tools_nc_wizard_refresh_directory_state($state, '');
bratonien_tools_nc_wizard_store($state);
return array('message'=>'Speicherzuordnung wurde bestätigt. Jetzt können die Verzeichnisse ausgewählt werden.');
}
// Zweites sichtbares Fenster: Verzeichnisauswahl übernehmen.
$selected = isset($state['directory_selected']) && is_array($state['directory_selected']) ? $state['directory_selected'] : array();
if (!$selected) $selected = array('');
$storages = array();
foreach ($candidates as $candidate)
foreach ($candidates as $index=>&$candidate)
{
$mount = rtrim(trim((string)($candidate['local_mount'] ?? '')), '/');
if ($mount === '') $mount = rtrim(trim((string)($mounts[$index] ?? '')), '/');
if ($mount === '' || $mount[0] !== '/') throw new RuntimeException('Für einen Speicherort fehlt ein gültiger lokaler Pfad.');
if (!is_dir($mount) || !is_readable($mount)) throw new RuntimeException('Der angegebene Speicherort ist nicht vorhanden oder nicht lesbar: '.$mount);
foreach ($selected as $directory)
{
$directory = trim((string)$directory, '/');
if ($directory !== '' && preg_match('#(^|/)\.\.(/|$)#', $directory)) throw new RuntimeException('Ungültige Verzeichnisauswahl.');
$storages[] = array(
'storage_id'=>(string)($candidate['storage_id'] ?? ''),
'source_prefix'=>trim((string)($candidate['source_prefix'] ?? ''), '/'),
'local_mount'=>$mount,
'include_prefix'=>$directory,
);
}
$candidate['local_mount'] = $mount;
}
unset($candidate);
$state['storages'] = $storages;
$state['storage_candidates'] = array_values($candidates);
$state['storages'] = $state['storage_candidates'];
$state['technical_complete'] = true;
$state['technical_stage'] = 'ready';
$state['technical_error'] = '';
$state['directory_selection_ready'] = false;
bratonien_tools_nc_wizard_store($state);
return array('message'=>'Verzeichnisauswahl wurde übernommen.');
return array('message'=>'Storage-Adapter wurde geprüft. Die Verbindung ist technisch vollständig.');
}
function bratonien_tools_nc_wizard_select_current_user()
@@ -304,12 +459,10 @@ function bratonien_tools_nc_wizard_back()
if ($step === 4)
{
// Abschluss -> API
$state['step'] = 3;
}
elseif ($step === 3)
{
// API -> Verbindungsname
$state['step'] = 2;
$state['technical_complete'] = true;
$state['technical_stage'] = 'ready';
@@ -317,7 +470,6 @@ function bratonien_tools_nc_wizard_back()
}
elseif (!empty($state['technical_complete']))
{
// Verbindungsname -> Verzeichnisauswahl
$state['technical_complete'] = false;
$state['technical_stage'] = 'mounts';
$state['directory_selection_ready'] = true;
@@ -325,36 +477,16 @@ function bratonien_tools_nc_wizard_back()
}
elseif ((string)($state['technical_stage'] ?? '') === 'mounts' && !empty($state['directory_selection_ready']))
{
// Verzeichnisauswahl -> vorher tatsächlich sichtbares Fenster.
if (!empty($state['mount_prompted']))
{
$state['directory_selection_ready'] = false;
}
elseif (!empty($state['database_prompted']))
{
$state['technical_stage'] = 'database_details';
$state['directory_selection_ready'] = false;
}
else
{
$state['step'] = 1;
}
if (!empty($state['database_prompted'])) $state['technical_stage'] = 'database_details'; else $state['step'] = 1;
$state['directory_selection_ready'] = false;
}
elseif ((string)($state['technical_stage'] ?? '') === 'mounts')
{
// Mount-Zuordnung -> Datenbankfenster, falls es sichtbar war, sonst Anmeldung.
if (!empty($state['database_prompted']))
{
$state['technical_stage'] = 'database_details';
}
else
{
$state['step'] = 1;
}
$state['directory_selection_ready'] = true;
bratonien_tools_nc_wizard_refresh_directory_state($state);
}
elseif ((string)($state['technical_stage'] ?? '') === 'database_details')
{
// Datenbankfenster -> Anmeldung.
$state['step'] = 1;
}
else

View File

@@ -0,0 +1,235 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_nc_wizard_scan_webdav_first()
{
$state = bratonien_tools_nc_wizard_state();
$host_input = trim((string)($_POST['nc_wizard_host'] ?? $state['host_input']));
$username = trim((string)($_POST['nc_wizard_user'] ?? $state['username']));
$password = array_key_exists('nc_wizard_password', $_POST) ? (string)$_POST['nc_wizard_password'] : (string)$state['_password'];
$state['step'] = 1;
$state['host_input'] = $host_input;
$state['username'] = $username;
$state['_password'] = $password;
bratonien_tools_nc_wizard_store($state);
if ($username === '' || $password === '')
{
throw new RuntimeException('Nextcloud-Benutzer und Passwort werden für den Scan benötigt.');
}
$base_url = '';
$status_data = null;
foreach (bratonien_tools_nc_wizard_candidate_urls($host_input) as $candidate_url)
{
try
{
$response = bratonien_tools_nc_transport_http($candidate_url.'/status.php');
if ($response['status'] < 200 || $response['status'] >= 300) continue;
$candidate_status = json_decode($response['body'], true);
if (!is_array($candidate_status) || empty($candidate_status['installed'])) continue;
$base_url = $candidate_url;
$status_data = $candidate_status;
break;
}
catch (Throwable $ignored) {}
}
if ($base_url === '' || !is_array($status_data))
{
throw new RuntimeException('Unter dieser Adresse konnte keine Nextcloud erreicht werden. HTTP und HTTPS wurden automatisch geprüft.');
}
$user_response = bratonien_tools_nc_transport_http(
$base_url.'/ocs/v2.php/cloud/user?format=json',
$username,
$password,
array('OCS-APIRequest: true')
);
if ($user_response['status'] === 401 || $user_response['status'] === 403)
{
throw new RuntimeException('Nextcloud hat Benutzername oder Passwort abgelehnt.');
}
if ($user_response['status'] < 200 || $user_response['status'] >= 300)
{
throw new RuntimeException('Nextcloud ist erreichbar, aber die Anmeldung konnte nicht geprüft werden.');
}
$user_data = bratonien_tools_nc_wizard_ocs_data($user_response['body']);
$resolved_username = trim((string)($user_data['id'] ?? $username));
if ($resolved_username === '') $resolved_username = $username;
$url_host = (string)parse_url($base_url, PHP_URL_HOST);
$state = array_merge($state, array(
'step'=>2,
'scan_ok'=>true,
'base_url'=>$base_url,
'host_input'=>$host_input,
'username'=>$resolved_username,
'display_name'=>(string)($user_data['display-name'] ?? $user_data['displayname'] ?? ''),
'version'=>(string)($status_data['versionstring'] ?? $status_data['version'] ?? ''),
'product'=>(string)($status_data['productname'] ?? 'Nextcloud'),
'users'=>array(),
'can_list_users'=>false,
'showcase_user'=>$resolved_username,
'connection_name'=>$url_host !== '' ? $url_host : 'Nextcloud WebDAV',
'scan_message'=>'Nextcloud und WebDAV-Zugriff wurden bestätigt.',
'_password'=>$password,
'source_mode'=>'webdav-placeholder',
'transport'=>'webdav',
'gallery_root'=>rtrim(PHPWG_ROOT_PATH, '/').'/galleries',
'storages'=>array(),
'storage_candidates'=>array(),
'roots'=>array(),
'technical_stage'=>'mounts',
'technical_source'=>'WebDAV',
'technical_error'=>'',
'technical_complete'=>false,
'directory_selection_ready'=>true,
'directory_path'=>'',
'directory_parent'=>'',
'directory_children'=>array(),
'directory_current_fileid'=>0,
'directory_fileids'=>array(),
'directory_selected'=>array(),
'directory_selected_fileids'=>array(),
'database_prompted'=>false,
'mount_prompted'=>false,
'api_status'=>'pending',
'api_username'=>'',
'api_error'=>'',
));
bratonien_tools_nc_transport_refresh_directory_state($state, '');
bratonien_tools_nc_wizard_store($state);
return array('message'=>'Nextcloud und WebDAV wurden bestätigt. Jetzt können die Verzeichnisse des angemeldeten Benutzers ausgewählt werden.');
}
function bratonien_tools_nc_wizard_save_sources_dispatch()
{
$state = bratonien_tools_nc_wizard_state();
if ((string)($state['source_mode'] ?? '') !== 'webdav-placeholder')
{
return bratonien_tools_nc_wizard_save_mounts_server_side();
}
if ((int)($state['step'] ?? 0) !== 2 || empty($state['directory_selection_ready']))
{
throw new RuntimeException('Die Verzeichnisauswahl ist in diesem Fenster nicht verfügbar.');
}
$selected = isset($state['directory_selected']) && is_array($state['directory_selected'])
? array_values(array_unique(array_map(function($path) { return trim((string)$path, '/'); }, $state['directory_selected'])))
: array();
if (!$selected)
{
throw new RuntimeException('Bitte mindestens ein Nextcloud-Verzeichnis auswählen.');
}
$selected_ids = isset($state['directory_selected_fileids']) && is_array($state['directory_selected_fileids'])
? $state['directory_selected_fileids']
: array();
$roots = array();
foreach ($selected as $path)
{
$fileid = (int)($selected_ids[$path] ?? 0);
if ($fileid < 1)
{
throw new RuntimeException('Für eine Auswahl fehlt die eindeutige Nextcloud-Datei-ID. Bitte das Verzeichnis erneut auswählen.');
}
$display_name = $path === ''
? (trim((string)($state['display_name'] ?? '')) !== '' ? trim((string)$state['display_name']) : (string)$state['username'])
: basename($path);
$roots[] = array(
'fileid'=>$fileid,
'display_name'=>$display_name,
'webdav_path'=>$path,
);
}
$state['roots'] = $roots;
$state['technical_complete'] = true;
$state['technical_stage'] = 'ready';
$state['technical_source'] = 'WebDAV-Verzeichnisse ausgewählt';
$state['technical_error'] = '';
$state['directory_selection_ready'] = false;
if ((string)($state['editing_mode'] ?? '') === 'migrate')
{
$editing_id = (int)($state['editing_connection_id'] ?? 0);
$connection = $editing_id > 0 ? bratonien_tools_nc_connector_connection($editing_id, true) : null;
if (!$connection || (string)$connection['adapter'] !== 'local')
{
throw new RuntimeException('Die zu migrierende Legacy-Verbindung ist nicht mehr verfügbar.');
}
$migration = bratonien_tools_nc_connector_migration_state($connection);
if (empty($migration['ready']))
{
throw new RuntimeException('Die WebDAV-Migration kann noch nicht abgeschlossen werden. Es fehlen: '.implode(', ', $migration['missing']).'.');
}
$credentials = bratonien_tools_nc_connector_scoped_secret($connection);
$state['_api_key_id'] = (string)($credentials['api_key_id'] ?? '');
$state['_api_key_secret'] = (string)($credentials['api_key_secret'] ?? '');
$state['api_status'] = !empty($migration['api_available']) ? 'ok' : 'skipped';
$state['api_error'] = '';
$state['step'] = 4;
bratonien_tools_nc_wizard_store($state);
return array('message'=>'WebDAV-Quelle wurde übernommen. Mit „Migration starten“ wird jetzt der WebDAV-Nachfolger angelegt; die Legacy-Verbindung bleibt als Fallback erhalten.');
}
bratonien_tools_nc_wizard_store($state);
return array('message'=>'WebDAV-Verzeichnisse wurden übernommen. Die Verbindung ist für den nächsten Einrichtungsschritt vorbereitet.');
}
function bratonien_tools_nc_wizard_finish_dispatch()
{
$state = bratonien_tools_nc_wizard_state();
if ((string)($state['source_mode'] ?? '') !== 'webdav-placeholder')
{
return bratonien_tools_nc_wizard_finish_generic_scope();
}
if ((int)($state['step'] ?? 0) !== 4 || empty($state['technical_complete']))
{
throw new RuntimeException('Der Assistent ist noch nicht vollständig.');
}
if (array_key_exists('nc_wizard_fallback_user', $_POST)) $state['_fallback_user'] = trim((string)$_POST['nc_wizard_fallback_user']);
if (array_key_exists('nc_wizard_fallback_password', $_POST)) $state['_fallback_password'] = (string)$_POST['nc_wizard_fallback_password'];
bratonien_tools_nc_wizard_store($state);
$fallback_user = trim((string)($state['_fallback_user'] ?? ''));
$fallback_password = (string)($state['_fallback_password'] ?? '');
if (($fallback_user === '') !== ($fallback_password === ''))
{
throw new RuntimeException('Fallback-Benutzer und Fallback-Passwort müssen entweder beide angegeben oder beide leer gelassen werden.');
}
if (($state['api_status'] ?? '') !== 'ok' && $fallback_user === '')
{
throw new RuntimeException('Da die Piwigo-API übersprungen wurde, ist für diese Verbindung ein Fallback-Zugang erforderlich.');
}
if ($fallback_user !== '')
{
try
{
bratonien_tools_nc_connector_validate_fallback_credentials($fallback_user, $fallback_password);
}
catch (Throwable $e)
{
throw new RuntimeException('Der Piwigo-Fallback wurde nicht gespeichert: '.$e->getMessage());
}
}
$result = bratonien_tools_nc_connector_create_webdav_placeholder_from_wizard();
unset($_SESSION['bratonien_nc_wizard']);
return $result;
}

View File

@@ -0,0 +1,340 @@
<?php
function bratonien_tools_nc_transport_host($url)
{
$host = trim((string)parse_url((string)$url, PHP_URL_HOST));
if ($host === '') throw new RuntimeException('Die Nextcloud-Adresse enthält keinen gültigen Hostnamen oder keine IP-Adresse.');
return trim($host, '[]');
}
function bratonien_tools_nc_transport_scheme($url)
{
$scheme = strtolower(trim((string)parse_url((string)$url, PHP_URL_SCHEME)));
if (!in_array($scheme, array('http','https'), true)) throw new RuntimeException('Nextcloud muss per HTTP oder HTTPS angesprochen werden.');
return $scheme;
}
function bratonien_tools_nc_transport_is_ip($host)
{
return filter_var(trim((string)$host, '[]'), FILTER_VALIDATE_IP) !== false;
}
function bratonien_tools_nc_transport_public_ip($host)
{
static $cache = array();
$host = strtolower(trim((string)$host, '[]'));
if ($host === '') throw new RuntimeException('Für die Nextcloud-Verbindung fehlt der Hostname.');
if (bratonien_tools_nc_transport_is_ip($host)) return $host;
if (isset($cache[$host])) return $cache[$host];
if (!function_exists('curl_init')) throw new RuntimeException('Der öffentliche DNS-Abgleich benötigt PHP-cURL.');
$providers = array(
array('host'=>'dns.google', 'ips'=>array('8.8.8.8','8.8.4.4'), 'url'=>'https://dns.google/resolve?name='.rawurlencode($host).'&type=A'),
array('host'=>'cloudflare-dns.com', 'ips'=>array('1.1.1.1','1.0.0.1'), 'url'=>'https://cloudflare-dns.com/dns-query?name='.rawurlencode($host).'&type=A'),
);
foreach ($providers as $provider)
{
foreach ($provider['ips'] as $resolver_ip)
{
$ch = curl_init($provider['url']);
$options = array(
CURLOPT_RETURNTRANSFER=>true,
CURLOPT_FOLLOWLOCATION=>false,
CURLOPT_CONNECTTIMEOUT=>5,
CURLOPT_TIMEOUT=>10,
CURLOPT_HTTPHEADER=>array('Accept: application/dns-json'),
CURLOPT_USERAGENT=>'Bratonien-Tools-DNS/0.9.7.1',
);
if (defined('CURLOPT_RESOLVE'))
{
$options[CURLOPT_RESOLVE] = array($provider['host'].':443:'.$resolver_ip);
}
curl_setopt_array($ch, $options);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $errno !== 0 || $status < 200 || $status >= 300) continue;
$decoded = json_decode((string)$body, true);
if (!is_array($decoded) || !isset($decoded['Answer']) || !is_array($decoded['Answer'])) continue;
foreach ($decoded['Answer'] as $answer)
{
if ((int)($answer['type'] ?? 0) !== 1) continue;
$ip = trim((string)($answer['data'] ?? ''));
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) continue;
return $cache[$host] = $ip;
}
}
}
throw new RuntimeException('Für '.$host.' konnte keine öffentliche IPv4-Adresse ermittelt werden.');
}
function bratonien_tools_nc_transport_resolve_entry($url)
{
$scheme = bratonien_tools_nc_transport_scheme($url);
$host = bratonien_tools_nc_transport_host($url);
if (bratonien_tools_nc_transport_is_ip($host)) return null;
$port = (int)parse_url((string)$url, PHP_URL_PORT);
if ($port < 1) $port = $scheme === 'https' ? 443 : 80;
$ip = bratonien_tools_nc_transport_public_ip($host);
return $host.':'.$port.':'.$ip;
}
function bratonien_tools_nc_transport_apply_curl(array &$options, $url)
{
$entry = bratonien_tools_nc_transport_resolve_entry($url);
if ($entry !== null)
{
if (!defined('CURLOPT_RESOLVE')) throw new RuntimeException('Diese cURL-Version unterstützt keine direkte Host-zu-IP-Zuordnung.');
$options[CURLOPT_RESOLVE] = array($entry);
}
}
function bratonien_tools_nc_transport_http($url, $username = '', $password = '', array $headers = array())
{
if (!function_exists('curl_init')) throw new RuntimeException('Der Server kann Nextcloud derzeit nicht per HTTP prüfen.');
$ch = curl_init($url);
$options = array(
CURLOPT_RETURNTRANSFER=>true,
CURLOPT_FOLLOWLOCATION=>true,
CURLOPT_MAXREDIRS=>3,
CURLOPT_CONNECTTIMEOUT=>8,
CURLOPT_TIMEOUT=>15,
CURLOPT_HTTPHEADER=>array_merge(array('Accept: application/json'), $headers),
CURLOPT_USERAGENT=>'Bratonien-Tools-NC-Wizard/0.9.7.1',
);
bratonien_tools_nc_transport_apply_curl($options, $url);
if ($username !== '')
{
$options[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
$options[CURLOPT_USERPWD] = $username.':'.$password;
}
curl_setopt_array($ch, $options);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $errno !== 0)
{
throw new RuntimeException('Verbindung fehlgeschlagen'.($error !== '' ? ': '.$error : '.'));
}
return array('status'=>$status, 'body'=>(string)$body);
}
function bratonien_tools_nc_transport_webdav_list(array $state, $path = '')
{
if (empty($state['scan_ok']) || trim((string)$state['base_url']) === '' || trim((string)$state['username']) === '' || (string)$state['_password'] === '')
{
throw new RuntimeException('Die Nextcloud-Sitzung des Assistenten ist nicht vollständig.');
}
if (!function_exists('curl_init')) throw new RuntimeException('cURL ist für die Verzeichnisauswahl nicht verfügbar.');
$path = trim((string)$path, '/');
if ($path !== '' && preg_match('#(^|/)\.\.(/|$)#', $path)) throw new RuntimeException('Ungültiger Verzeichnispfad.');
$segments = $path === '' ? array() : array_map('rawurlencode', explode('/', $path));
$user = rawurlencode((string)$state['username']);
$url = rtrim((string)$state['base_url'], '/').'/remote.php/dav/files/'.$user.'/'.implode('/', $segments);
if (substr($url, -1) !== '/') $url .= '/';
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><d:resourcetype/><d:displayname/><oc:fileid/></d:prop></d:propfind>';
$ch = curl_init($url);
$options = array(
CURLOPT_RETURNTRANSFER=>true,
CURLOPT_CUSTOMREQUEST=>'PROPFIND',
CURLOPT_POSTFIELDS=>$body,
CURLOPT_HTTPHEADER=>array('Depth: 1','Content-Type: application/xml; charset=utf-8'),
CURLOPT_HTTPAUTH=>CURLAUTH_BASIC,
CURLOPT_USERPWD=>(string)$state['username'].':'.(string)$state['_password'],
CURLOPT_CONNECTTIMEOUT=>8,
CURLOPT_TIMEOUT=>20,
);
bratonien_tools_nc_transport_apply_curl($options, $url);
curl_setopt_array($ch, $options);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $errno !== 0) throw new RuntimeException('Nextcloud-Verzeichnisse konnten nicht geladen werden'.($error !== '' ? ': '.$error : '.'));
if ($status === 401 || $status === 403) throw new RuntimeException('Nextcloud hat den Zugriff auf dieses Verzeichnis abgelehnt.');
if ($status !== 207) throw new RuntimeException('Nextcloud-Verzeichnisabfrage antwortete mit HTTP '.$status.'.');
libxml_use_internal_errors(true);
$xml = simplexml_load_string((string)$response);
if ($xml === false) throw new RuntimeException('Nextcloud hat eine ungültige WebDAV-Antwort geliefert.');
$xml->registerXPathNamespace('d', 'DAV:');
$xml->registerXPathNamespace('oc', 'http://owncloud.org/ns');
$children = array();
$fileids = array();
$current_fileid = 0;
$base_path = (string)parse_url($url, PHP_URL_PATH);
foreach ($xml->xpath('//d:response') as $item)
{
$item->registerXPathNamespace('d', 'DAV:');
$item->registerXPathNamespace('oc', 'http://owncloud.org/ns');
$hrefs = $item->xpath('d:href');
$collections = $item->xpath('d:propstat/d:prop/d:resourcetype/d:collection');
$ids = $item->xpath('d:propstat/d:prop/oc:fileid');
if (!$hrefs || !$collections || !$ids) continue;
$fileid = (int)trim((string)$ids[0]);
if ($fileid < 1) continue;
$href = rawurldecode((string)$hrefs[0]);
$href_path = (string)parse_url($href, PHP_URL_PATH);
if (rtrim($href_path, '/') === rtrim($base_path, '/'))
{
$current_fileid = $fileid;
continue;
}
$name = basename(rtrim($href_path, '/'));
if ($name === '') continue;
$child_path = $path === '' ? $name : $path.'/'.$name;
$children[$child_path] = $name;
$fileids[$child_path] = $fileid;
}
natcasesort($children);
if ($current_fileid < 1) throw new RuntimeException('Nextcloud hat für das aktuelle Verzeichnis keine eindeutige Datei-ID geliefert.');
$parent = '';
if ($path !== '')
{
$parts = explode('/', $path);
array_pop($parts);
$parent = implode('/', $parts);
}
return array(
'current'=>$path,
'parent'=>$parent,
'children'=>$children,
'current_fileid'=>$current_fileid,
'fileids'=>$fileids,
);
}
function bratonien_tools_nc_transport_refresh_directory_state(array &$state, $path = null)
{
if ($path === null) $path = (string)($state['directory_path'] ?? '');
$listing = bratonien_tools_nc_transport_webdav_list($state, $path);
$state['directory_path'] = (string)$listing['current'];
$state['directory_parent'] = (string)$listing['parent'];
$state['directory_children'] = (array)$listing['children'];
$state['directory_current_fileid'] = (int)$listing['current_fileid'];
$state['directory_fileids'] = (array)$listing['fileids'];
if (!isset($state['directory_selected']) || !is_array($state['directory_selected'])) $state['directory_selected'] = array();
if (!isset($state['directory_selected_fileids']) || !is_array($state['directory_selected_fileids'])) $state['directory_selected_fileids'] = array();
}
function bratonien_tools_nc_transport_wizard_directory_browse()
{
$state = bratonien_tools_nc_wizard_state();
if ((int)$state['step'] !== 2 || (string)$state['technical_stage'] !== 'mounts' || empty($state['directory_selection_ready'])) throw new RuntimeException('Die Verzeichnisauswahl ist in diesem Fenster nicht verfügbar.');
$path = trim((string)($_POST['nc_wizard_directory_path'] ?? ''), '/');
bratonien_tools_nc_transport_refresh_directory_state($state, $path);
bratonien_tools_nc_wizard_store($state);
return array('message'=>'Verzeichnis geöffnet.');
}
function bratonien_tools_nc_transport_edit_start()
{
$id = (int)($_POST['connection_id'] ?? 0);
$connection = bratonien_tools_nc_connector_connection($id, true);
if (!$connection) throw new RuntimeException('Connector-Verbindung wurde nicht gefunden.');
$config = isset($connection['config']) && is_array($connection['config']) ? $connection['config'] : array();
$is_webdav = (string)$connection['adapter'] === 'remote'
&& (string)($config['source_mode'] ?? '') === 'webdav-placeholder';
if (!$is_webdav) return bratonien_tools_nc_connector_edit_start();
$credentials = bratonien_tools_nc_connector_scoped_secret($connection);
$base_url = trim((string)($config['nextcloud_url'] ?? ''));
$username = trim((string)($credentials['nextcloud_user'] ?? ''));
if ($username === '') $username = trim((string)($config['nextcloud_access_user'] ?? $config['access_user'] ?? ''));
$password = (string)($credentials['nextcloud_password'] ?? '');
$roots = isset($config['roots']) && is_array($config['roots']) ? array_values($config['roots']) : array();
$selected = array();
$selected_ids = array();
foreach ($roots as $root)
{
$path = trim((string)($root['webdav_path'] ?? ''), '/');
$fileid = (int)($root['fileid'] ?? 0);
if ($fileid < 1) continue;
$selected[] = $path;
$selected_ids[$path] = $fileid;
}
$state = bratonien_tools_nc_wizard_state();
$state = array_merge($state, array(
'editing_connection_id'=>$id,
'editing_adapter'=>(string)$connection['adapter'],
'editing_mode'=>'update',
'connection_name'=>(string)$connection['name'],
'host_input'=>$base_url,
'base_url'=>$base_url,
'username'=>$username,
'_password'=>$password,
'_fallback_user'=>(string)($credentials['piwigo_user'] ?? ''),
'_fallback_password'=>(string)($credentials['piwigo_password'] ?? ''),
'_api_key_id'=>(string)($credentials['api_key_id'] ?? ''),
'_api_key_secret'=>(string)($credentials['api_key_secret'] ?? ''),
'api_status'=>trim((string)($credentials['api_key_id'] ?? '')) !== '' && trim((string)($credentials['api_key_secret'] ?? '')) !== '' ? 'ok' : 'pending',
'roots'=>$roots,
'directory_selected'=>$selected,
'directory_selected_fileids'=>$selected_ids,
'source_mode'=>'webdav-placeholder',
'transport'=>'webdav',
));
if ($base_url !== '' && $username !== '' && $password !== '')
{
$state['step'] = 2;
$state['scan_ok'] = true;
$state['technical_stage'] = 'mounts';
$state['technical_source'] = 'WebDAV';
$state['technical_error'] = '';
$state['technical_complete'] = false;
$state['directory_selection_ready'] = true;
$state['directory_path'] = '';
$state['directory_parent'] = '';
$state['directory_children'] = array();
$state['directory_current_fileid'] = 0;
try
{
bratonien_tools_nc_transport_refresh_directory_state($state, '');
}
catch (Throwable $e)
{
$state['step'] = 1;
$state['scan_ok'] = false;
$state['technical_error'] = $e->getMessage();
}
}
else
{
$state['step'] = 1;
$state['scan_ok'] = false;
}
bratonien_tools_nc_wizard_store($state);
return array('message'=>'Verbindung #'.$id.' wurde zum Bearbeiten geöffnet.');
}

View File

@@ -4,6 +4,17 @@ if (!defined('PHPWG_ROOT_PATH'))
die('Hacking attempt!');
}
if (isset($GLOBALS['template']) && is_object($GLOBALS['template']) && method_exists($GLOBALS['template'], 'func_combine_script'))
{
$script_version = function_exists('bratonien_tools_current_version') ? bratonien_tools_current_version() : '0.9.7.1';
$GLOBALS['template']->func_combine_script(array(
'id'=>'bratonien_nc_connector_edit_v2',
'path'=>BRATONIEN_TOOLS_PATH.'js/nc_connector_edit_v2.js',
'load'=>'footer',
'version'=>$script_version,
));
}
require_once(BRATONIEN_TOOLS_PATH . 'tools/image_cache.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'tools/watermark.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'tools/watermark_profiles.inc.php');
@@ -17,7 +28,6 @@ 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/nc_connector.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_manage.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_takeover.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_auth.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_create_api.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_piwigo_api.inc.php');
@@ -26,6 +36,29 @@ require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_wizard_db_bridge.inc.p
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_wizard_user_scope.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_wizard_flow.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_connection_scope.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_generic_scope.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_wizard_webdav_flow.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_edit.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_transport.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_scheduler.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_system.inc.php');
function bratonien_tools_nc_connector_run_now()
{
$connection_id = isset($_POST['connection_id']) ? max(0, (int)$_POST['connection_id']) : 0;
if ($connection_id > 0)
{
$connection = bratonien_tools_nc_connector_connection($connection_id, false);
if (!$connection)
{
throw new RuntimeException('Die ausgewählte Verbindung existiert nicht.');
}
}
$result = bratonien_tools_nc_scheduler_spawn(true, $connection_id);
return array('message'=>(string)($result['message'] ?? ($connection_id > 0 ? 'Abgleich wurde gestartet.' : 'Abgleich für alle Verbindungen wurde gestartet.')));
}
function bratonien_tools_get_tools()
{
@@ -50,26 +83,26 @@ function bratonien_tools_get_tools()
'album_share_create' => array('handler' => 'bratonien_tools_create_album_share'),
'album_share_regenerate_link' => array('handler' => 'bratonien_tools_regenerate_album_share_link'),
'album_share_revoke' => array('handler' => 'bratonien_tools_revoke_album_share'),
'nc_connector_create_local' => array('handler' => 'bratonien_tools_nc_connector_create_local_api_first'),
'nc_connector_delete' => array('handler' => 'bratonien_tools_nc_connector_delete_any'),
'nc_connector_create_webdav_parallel' => array('handler' => 'bratonien_tools_nc_connector_create_webdav_placeholder_from_wizard'),
'nc_connector_edit_start' => array('handler' => 'bratonien_tools_nc_transport_edit_start'),
'nc_connector_update_local' => array('handler' => 'bratonien_tools_nc_connector_update_local_friendly'),
'nc_connector_delete' => array('handler' => 'bratonien_tools_nc_connector_delete_safe'),
'nc_connector_update_name' => array('handler' => 'bratonien_tools_nc_connector_update_name'),
'nc_connector_update_technical' => array('handler' => 'bratonien_tools_nc_connector_update_technical'),
'nc_connector_wizard_scan' => array('handler' => 'bratonien_tools_nc_wizard_scan_user_scoped'),
'nc_connector_run_now' => array('handler' => 'bratonien_tools_nc_connector_run_now'),
'nc_connector_wizard_scan' => array('handler' => 'bratonien_tools_nc_wizard_scan_webdav_first'),
'nc_connector_wizard_save_technical' => array('handler' => 'bratonien_tools_nc_wizard_save_technical_flow'),
'nc_connector_wizard_directory_browse' => array('handler' => 'bratonien_tools_nc_wizard_directory_browse'),
'nc_connector_wizard_directory_browse' => array('handler' => 'bratonien_tools_nc_transport_wizard_directory_browse'),
'nc_connector_wizard_directory_add' => array('handler' => 'bratonien_tools_nc_wizard_directory_add'),
'nc_connector_wizard_directory_remove' => array('handler' => 'bratonien_tools_nc_wizard_directory_remove'),
'nc_connector_wizard_save_mounts' => array('handler' => 'bratonien_tools_nc_wizard_save_mounts_server_side'),
'nc_connector_wizard_save_mounts' => array('handler' => 'bratonien_tools_nc_wizard_save_sources_dispatch'),
'nc_connector_wizard_select_user' => array('handler' => 'bratonien_tools_nc_wizard_select_current_user'),
'nc_connector_wizard_api_test' => array('handler' => 'bratonien_tools_nc_wizard_api_test'),
'nc_connector_wizard_api_skip' => array('handler' => 'bratonien_tools_nc_wizard_api_skip'),
'nc_connector_wizard_finish' => array('handler' => 'bratonien_tools_nc_wizard_finish_connection_scoped'),
'nc_connector_wizard_finish' => array('handler' => 'bratonien_tools_nc_wizard_finish_dispatch'),
'nc_connector_wizard_back' => array('handler' => 'bratonien_tools_nc_wizard_back'),
'nc_connector_wizard_reset' => array('handler' => 'bratonien_tools_nc_wizard_reset'),
'nc_connector_import_legacy' => array('handler' => 'bratonien_tools_nc_connector_import_legacy'),
'nc_connector_verify' => array('handler' => 'bratonien_tools_nc_connector_verify_connection_scoped'),
'nc_connector_prepare_takeover' => array('handler' => 'bratonien_tools_nc_connector_prepare_takeover'),
'nc_connector_cancel_takeover' => array('handler' => 'bratonien_tools_nc_connector_cancel_takeover'),
'nc_connector_piwigo_api_test' => array('handler' => 'bratonien_tools_nc_connector_piwigo_api_test'),
'nc_connector_piwigo_api_delete' => array('handler' => 'bratonien_tools_nc_connector_api_delete'),
'nc_connector_fallback_save' => array('handler' => 'bratonien_tools_nc_connector_fallback_save_scoped'),

View File

@@ -8,8 +8,17 @@ require_once(BRATONIEN_TOOLS_PATH . 'include/watermark_base.inc.php');
function bratonien_tools_watermark_engine_enabled()
{
static $initialized = false;
static $enabled = false;
if ($initialized)
{
return $enabled;
}
$config = bratonien_tools_get_watermark_engine_config();
$enabled = !empty($config['enabled']);
$initialized = true;
// Piwigo recalculates use_watermark for custom derivatives from the native
// watermark file itself. Therefore an active Bratonien engine must keep the

View File

@@ -0,0 +1,434 @@
<?php
if (!defined('PHPWG_ROOT_PATH'))
{
die('Hacking attempt!');
}
function bratonien_tools_webdav_image_source_info($image_id)
{
static $cache = array();
$image_id = (int)$image_id;
if ($image_id < 1) return null;
if (array_key_exists($image_id, $cache)) return $cache[$image_id];
$result = pwg_query('SELECT path, coi FROM '.IMAGES_TABLE.' WHERE id='.$image_id.' LIMIT 1');
if (!pwg_db_num_rows($result)) return $cache[$image_id] = null;
$row = pwg_db_fetch_assoc($result);
$path = (string)($row['path'] ?? '');
if ($path === '') return $cache[$image_id] = null;
$absolute = $path;
if (strpos($absolute, '/') !== 0)
{
$absolute = PHPWG_ROOT_PATH.ltrim(preg_replace('#^\./#', '', $absolute), '/');
}
$resolved = realpath($absolute);
if ($resolved === false) return $cache[$image_id] = null;
$normalized = str_replace('\\', '/', $resolved);
if (!preg_match('#/nc-webdav-source/connection-([0-9]+)/root-([0-9]+)/(.*)$#', $normalized, $match))
{
return $cache[$image_id] = null;
}
$connection_id = (int)$match[1];
$root_fileid = (int)$match[2];
$relative_path = trim((string)$match[3], '/');
if ($relative_path === '') return $cache[$image_id] = null;
$table = defined('BRATONIEN_TOOLS_NC_CONNECTIONS_TABLE')
? BRATONIEN_TOOLS_NC_CONNECTIONS_TABLE
: $GLOBALS['prefixeTable'].'bratonien_tools_nc_connections';
$connection_result = pwg_query('SELECT config_json FROM `'.$table.'` WHERE id='.$connection_id.' LIMIT 1');
if (!pwg_db_num_rows($connection_result)) return $cache[$image_id] = null;
$connection_row = pwg_db_fetch_assoc($connection_result);
$config = json_decode((string)$connection_row['config_json'], true);
if (!is_array($config) || (string)($config['source_mode'] ?? '') !== 'webdav-placeholder')
{
return $cache[$image_id] = null;
}
$root_path = '';
$roots = isset($config['roots']) && is_array($config['roots']) ? $config['roots'] : array();
foreach ($roots as $root)
{
if ((int)($root['fileid'] ?? 0) === $root_fileid)
{
$root_path = trim((string)($root['webdav_path'] ?? ''), '/');
break;
}
}
if ($root_path === '') return $cache[$image_id] = null;
$webdav_path = $root_path.'/'.$relative_path;
$content_type = '';
$size = 0;
$etag = '';
$state_dir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($state_dir !== '')
{
$mapping_file = $state_dir.'/webdav-map.json';
if (is_readable($mapping_file))
{
$mapping = json_decode((string)file_get_contents($mapping_file), true);
if (is_array($mapping) && isset($mapping['files']) && is_array($mapping['files']))
{
$entry = $mapping['files'][$resolved] ?? $mapping['files'][$normalized] ?? null;
if (is_array($entry) && (string)($entry['kind'] ?? '') === 'file')
{
$webdav_path = trim((string)($entry['webdav_path'] ?? $webdav_path), '/');
$content_type = (string)($entry['content_type'] ?? '');
$size = (int)($entry['size'] ?? 0);
$etag = (string)($entry['etag'] ?? '');
}
}
}
}
return $cache[$image_id] = array(
'image_id'=>$image_id,
'connection_id'=>$connection_id,
'webdav_path'=>$webdav_path,
'content_type'=>$content_type,
'size'=>$size,
'etag'=>$etag,
'coi'=>$row['coi'] ?? null,
);
}
function bratonien_tools_webdav_image_url($image_id, $preview=false)
{
$info = bratonien_tools_webdav_image_source_info($image_id);
if (!$info) return null;
$url = get_root_url().'plugins/'.BRATONIEN_TOOLS_ID.'/webdav-image.php?id='.(int)$image_id;
if ($preview) $url .= '&preview=1';
if ($info['etag'] !== '') $url .= '&v='.rawurlencode(substr(sha1($info['etag']), 0, 12));
return $url;
}
function bratonien_tools_webdav_preview_path(array $info)
{
$connection_id = (int)($info['connection_id'] ?? 0);
$webdav_path = trim((string)($info['webdav_path'] ?? ''), '/');
if ($connection_id < 1 || $webdav_path === '') return null;
$base = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-webdav-preview/connection-'.$connection_id.'/'.sha1($webdav_path);
foreach (array('jpg', 'png', 'webp') as $ext)
{
$path = $base.'.'.$ext;
if (is_file($path) && is_readable($path)) return $path;
}
return null;
}
function bratonien_tools_webdav_preview_content_type($path)
{
$ext = strtolower(pathinfo((string)$path, PATHINFO_EXTENSION));
if ($ext === 'png') return 'image/png';
if ($ext === 'webp') return 'image/webp';
return 'image/jpeg';
}
function bratonien_tools_webdav_custom_derivative_params($key)
{
if (!class_exists('DerivativeParams') || !class_exists('SizingParams')) return null;
$tokens = explode('_', (string)$key);
if (!$tokens) return null;
$token = array_shift($tokens);
$crop = 0;
$min_size = null;
$parse_size = function($value)
{
$parts = explode('x', (string)$value, 2);
if (count($parts) === 1)
{
$size = max(1, (int)$parts[0]);
return array($size, $size);
}
return array(max(1, (int)$parts[0]), max(1, (int)$parts[1]));
};
if (isset($token[0]) && $token[0] === 's')
{
$size = $parse_size(substr($token, 1));
}
elseif (isset($token[0]) && $token[0] === 'e')
{
$crop = 1;
$size = $min_size = $parse_size(substr($token, 1));
}
else
{
if (count($tokens) < 2) return null;
$size = $parse_size($token);
$crop_token = array_shift($tokens);
$min_size = $parse_size(array_shift($tokens));
$crop = function_exists('char_to_fraction') ? char_to_fraction($crop_token) : 0;
}
$params = new DerivativeParams(new SizingParams($size, $crop, $min_size));
if (class_exists('ImageStdParams')) ImageStdParams::apply_global($params);
return $params;
}
function bratonien_tools_webdav_derivative_variants()
{
$variants = array();
if (!class_exists('ImageStdParams')) return $variants;
foreach (ImageStdParams::get_defined_type_map() as $type => $params)
{
$variants['standard:'.$type] = $params;
}
foreach (ImageStdParams::$custom as $custom_key => $last_used)
{
$params = bratonien_tools_webdav_custom_derivative_params($custom_key);
if ($params) $variants['custom:'.$custom_key] = $params;
}
return $variants;
}
function bratonien_tools_webdav_generate_derivative($params, $src_image, &$detail=null)
{
global $conf;
$detail = '';
if (!is_object($src_image) || empty($src_image->id))
{
$detail = 'SrcImage fehlt.';
return false;
}
$info = bratonien_tools_webdav_image_source_info((int)$src_image->id);
if (!$info)
{
$detail = 'WebDAV-Quellzuordnung fehlt.';
return false;
}
$preview = bratonien_tools_webdav_preview_path($info);
if (!$preview)
{
$detail = 'Vorbereitetes WebDAV-Preview fehlt.';
return false;
}
$preview_ext = strtolower(pathinfo($preview, PATHINFO_EXTENSION));
if (!in_array($preview_ext, array('jpg', 'jpeg', 'png', 'gif'), true))
{
$detail = 'Preview-Format '.$preview_ext.' ist für die Piwigo-Bildbibliothek nicht sicher nutzbar.';
return false;
}
if (!class_exists('DerivativeImage')) require_once(PHPWG_ROOT_PATH.'include/derivative.inc.php');
if (!class_exists('pwg_image')) require_once(PHPWG_ROOT_PATH.'admin/include/image.class.php');
$derivative = new DerivativeImage($params, $src_image);
if ($derivative->same_as_source())
{
$detail = 'Identisch mit Quelle; kein eigenes Derivat erforderlich.';
return true;
}
$target = $derivative->get_path();
$derivative_root = PHPWG_ROOT_PATH.PWG_DERIVATIVE_DIR;
if ($target === '' || strpos($target, $derivative_root) !== 0)
{
$detail = 'Ungültiger Derivat-Zielpfad: '.$target;
return false;
}
$preview_mtime = @filemtime($preview) ?: 0;
if (is_file($target) && is_readable($target) && (@filemtime($target) ?: 0) >= max($preview_mtime, (int)($params->last_mod_time ?? 0)))
{
$detail = 'Bereits vorhanden.';
return true;
}
$directory = dirname($target);
$dir_ok = function_exists('mkgetdir') ? mkgetdir($directory) : (is_dir($directory) || @mkdir($directory, 0755, true));
if (!$dir_ok || !is_dir($directory))
{
$detail = 'Derivat-Verzeichnis konnte nicht angelegt werden: '.$directory;
return false;
}
$image = null;
try
{
$image = new pwg_image($preview);
$original_size = array((int)$image->get_width(), (int)$image->get_height());
if ($original_size[0] < 1 || $original_size[1] < 1)
{
$detail = 'Preview-Abmessungen sind ungültig.';
return false;
}
$crop_rect = null;
$scaled_size = null;
$params->sizing->compute($original_size, $info['coi'] ?? null, $crop_rect, $scaled_size);
if ($crop_rect)
{
if (!$image->crop($crop_rect->width(), $crop_rect->height(), $crop_rect->l, $crop_rect->t))
{
$detail = 'Crop fehlgeschlagen.';
return false;
}
}
$final_size = $original_size;
if ($crop_rect)
{
$final_size = array($crop_rect->width(), $crop_rect->height());
}
if ($scaled_size)
{
if (!$image->resize($scaled_size[0], $scaled_size[1]))
{
$detail = 'Resize fehlgeschlagen.';
return false;
}
$final_size = array((int)$scaled_size[0], (int)$scaled_size[1]);
}
if (!empty($params->sharpen) && !$image->sharpen($params->sharpen))
{
$detail = 'Sharpen fehlgeschlagen.';
return false;
}
if ($params->will_watermark($final_size))
{
$wm = ImageStdParams::get_watermark();
if (!empty($wm->file))
{
$wm_path = PHPWG_ROOT_PATH.$wm->file;
if (!is_file($wm_path) || !is_readable($wm_path))
{
$detail = 'Wasserzeichen-Datei fehlt: '.$wm_path;
return false;
}
$wm_image = new pwg_image($wm_path);
try
{
$wm_size = array((int)$wm_image->get_width(), (int)$wm_image->get_height());
if ($final_size[0] < $wm_size[0] || $final_size[1] < $wm_size[1])
{
$wm_scaling = SizingParams::classic($final_size[0], $final_size[1]);
$tmp = null;
$wm_scaled = null;
$wm_scaling->compute($wm_size, null, $tmp, $wm_scaled);
if ($wm_scaled)
{
$wm_image->resize($wm_scaled[0], $wm_scaled[1]);
$wm_size = array((int)$wm_scaled[0], (int)$wm_scaled[1]);
}
}
$x = (int)round(($wm->xpos / 100) * ($final_size[0] - $wm_size[0]));
$y = (int)round(($wm->ypos / 100) * ($final_size[1] - $wm_size[1]));
$image->compose($wm_image, $x, $y, $wm->opacity);
if ($wm->xrepeat || $wm->yrepeat)
{
$xpad = $wm_size[0] + max(30, (int)round($wm_size[0] / 4));
$ypad = $wm_size[1] + max(30, (int)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] < $final_size[0] && $y2 >= 0 && $y2 + $wm_size[1] < $final_size[1])
{
$image->compose($wm_image, $x2, $y2, $wm->opacity);
}
}
}
}
}
finally
{
$wm_image->destroy();
}
}
}
if (isset($conf['derivatives_strip_metadata_threshold']) && $final_size[0] * $final_size[1] < (int)$conf['derivatives_strip_metadata_threshold'])
{
$image->strip();
}
$quality = (int)ImageStdParams::$quality;
if (isset($params->type) && in_array($params->type, array(IMG_3XLARGE, IMG_4XLARGE), true))
{
$quality = min($quality, 75);
}
$image->set_compression_quality($quality);
$written = $image->write($target);
if ($written === false)
{
$detail = 'Bildbibliothek konnte das Derivat nicht schreiben.';
return false;
}
}
catch (Throwable $e)
{
$detail = get_class($e).': '.$e->getMessage();
return false;
}
finally
{
if (is_object($image)) $image->destroy();
}
@chmod($target, 0644);
clearstatcache(true, $target);
$size = @getimagesize($target);
if (!is_file($target) || !is_readable($target) || !is_array($size) || empty($size[0]) || empty($size[1]))
{
$detail = 'Derivatdatei wurde nicht gültig erzeugt: '.$target;
return false;
}
$detail = 'Erzeugt: '.$target.' ('.$size[0].'x'.$size[1].').';
return true;
}
function bratonien_tools_filter_webdav_src_url($url, $src_image)
{
if (!is_object($src_image) || empty($src_image->id)) return $url;
$webdav_url = bratonien_tools_webdav_image_url((int)$src_image->id, false);
return $webdav_url ?: $url;
}
function bratonien_tools_filter_webdav_derivative_url($url, $params, $src_image, $rel_url)
{
if (!is_object($src_image) || empty($src_image->id)) return $url;
$info = bratonien_tools_webdav_image_source_info((int)$src_image->id);
if (!$info) return $url;
try
{
$derivative = new DerivativeImage($params, $src_image);
if (!$derivative->same_as_source())
{
$path = $derivative->get_path();
if ($path !== '' && is_file($path) && is_readable($path)) return $url;
}
}
catch (Throwable $e)
{
error_log('Bratonien WebDAV derivative lookup #'.(int)$src_image->id.': '.$e->getMessage());
}
$preview_url = bratonien_tools_webdav_image_url((int)$src_image->id, true);
return $preview_url ?: $url;
}

193
js/nc_connector_edit_v2.js Normal file
View File

@@ -0,0 +1,193 @@
(function () {
'use strict';
var statusEndpoint = 'plugins/bratonien_tools/nc-connector-status.php';
function ensureLiveStatus(detail, actions) {
var node = detail.querySelector('[data-nc-run-live]');
if (node) return node;
node = document.createElement('div');
node.setAttribute('data-nc-run-live', '1');
node.className = 'bratonien-base-note';
node.style.marginTop = '.6rem';
node.hidden = true;
actions.parentNode.insertBefore(node, actions.nextSibling);
return node;
}
function renderLiveStatus(node, data) {
var state = String(data && data.state || '');
var message = String(data && data.message || '');
var detail = String(data && data.error_detail || '');
node.hidden = false;
node.innerHTML = '';
var strong = document.createElement('strong');
if (state === 'queued') strong.textContent = 'Angefordert: ';
else if (state === 'running') strong.textContent = 'Läuft: ';
else if (state === 'ok' || state === 'success') strong.textContent = 'Erfolgreich: ';
else if (state === 'error') strong.textContent = 'Fehler: ';
else strong.textContent = 'Status: ';
node.appendChild(strong);
node.appendChild(document.createTextNode(message || state || 'Status wird ermittelt …'));
if (detail) {
var details = document.createElement('details');
details.style.marginTop = '.35rem';
var summary = document.createElement('summary');
summary.textContent = 'Technische Laufzeitdetails';
var pre = document.createElement('pre');
pre.style.whiteSpace = 'pre-wrap';
pre.style.wordBreak = 'break-word';
pre.textContent = detail;
details.appendChild(summary);
details.appendChild(pre);
node.appendChild(details);
}
}
function pollConnection(connectionId, node, button) {
var attempts = 0;
var maxAttempts = 180;
var timer = null;
function stop() {
if (timer) window.clearInterval(timer);
timer = null;
if (button) {
button.disabled = false;
button.textContent = 'Jetzt abgleichen';
}
}
function poll() {
attempts += 1;
fetch(statusEndpoint + '?connection_id=' + encodeURIComponent(connectionId) + '&_=' + Date.now(), {
credentials: 'same-origin',
cache: 'no-store',
headers: {'Accept': 'application/json'}
})
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function (data) {
renderLiveStatus(node, data);
var state = String(data && data.state || '');
if (state === 'ok' || state === 'success' || state === 'error' || attempts >= maxAttempts) stop();
})
.catch(function (error) {
if (attempts >= maxAttempts) {
renderLiveStatus(node, {state: 'error', message: 'Status konnte nicht gelesen werden.', error_detail: error.message});
stop();
}
});
}
poll();
timer = window.setInterval(poll, 1000);
}
function bindRunNow(form, detail, liveNode) {
if (form.dataset.ncRunBound === '1') return;
form.dataset.ncRunBound = '1';
form.addEventListener('submit', function (event) {
event.preventDefault();
detail.open = true;
var button = form.querySelector('button[value="nc_connector_run_now"]');
var connectionInput = form.querySelector('input[name="connection_id"]');
if (!connectionInput) return;
if (button) {
button.disabled = true;
button.textContent = 'Abgleich wird gestartet …';
}
renderLiveStatus(liveNode, {state: 'queued', message: 'Abgleich wird angefordert …'});
fetch(form.action || window.location.href, {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
body: new FormData(form)
})
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
renderLiveStatus(liveNode, {state: 'queued', message: 'Abgleich wurde angefordert.'});
pollConnection(connectionInput.value, liveNode, button);
})
.catch(function (error) {
renderLiveStatus(liveNode, {state: 'error', message: 'Abgleich konnte nicht angefordert werden.', error_detail: error.message});
if (button) {
button.disabled = false;
button.textContent = 'Jetzt abgleichen';
}
});
});
}
function restoreRunNowButtons() {
var section = document.getElementById('nc-connector');
if (!section) return;
var connectionCard = null;
var headings = section.querySelectorAll('h4');
for (var i = 0; i < headings.length; i++) {
if ((headings[i].textContent || '').trim() === 'Bestehende Verbindungen') {
connectionCard = headings[i].closest('.bratonien-card');
break;
}
}
if (!connectionCard) return;
var details = connectionCard.querySelectorAll(':scope > details');
details.forEach(function (detail) {
var actions = detail.querySelector('.bratonien-actions');
if (!actions) return;
var connectionInput = detail.querySelector('input[name="connection_id"]');
var tokenInput = detail.querySelector('input[name="pwg_token"]');
if (!connectionInput || !tokenInput) return;
var form = actions.querySelector('form[data-nc-run-now]');
if (!form) {
form = document.createElement('form');
form.method = 'post';
form.setAttribute('data-nc-run-now', '1');
var token = document.createElement('input');
token.type = 'hidden';
token.name = 'pwg_token';
token.value = tokenInput.value;
form.appendChild(token);
var connection = document.createElement('input');
connection.type = 'hidden';
connection.name = 'connection_id';
connection.value = connectionInput.value;
form.appendChild(connection);
var button = document.createElement('button');
button.className = 'buttonLike';
button.type = 'submit';
button.name = 'bratonien_tool';
button.value = 'nc_connector_run_now';
button.textContent = 'Jetzt abgleichen';
form.appendChild(button);
actions.insertBefore(form, actions.firstChild);
}
bindRunNow(form, detail, ensureLiveStatus(detail, actions));
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', restoreRunNowButtons);
} else {
restoreRunNowButtons();
}
})();

View File

@@ -1,7 +1,7 @@
<?php
/*
Plugin Name: Bratonien Tools
Version: 0.9.5.2
Version: 0.9.7.24
Description: Erweiterbare Administrationswerkzeuge fuer die Bratonien-Piwigo-Installation.
Plugin URI: https://github.com/Terranom674/Piwigo_Bratonien_Tools
Author: Bratonien
@@ -16,6 +16,7 @@ define('BRATONIEN_TOOLS_ID', basename(dirname(__FILE__)));
define('BRATONIEN_TOOLS_PATH', PHPWG_PLUGINS_PATH . BRATONIEN_TOOLS_ID . '/');
require_once(BRATONIEN_TOOLS_PATH . 'include/watermark_runtime.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/webdav_image_runtime.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/public_selection.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/picture_navigation.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/batch_titles.inc.php');
@@ -23,9 +24,12 @@ require_once(BRATONIEN_TOOLS_PATH . 'include/album_shares.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_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_connector_scheduler.inc.php');
add_event_handler('get_admin_plugin_menu_links', 'bratonien_tools_admin_menu');
add_event_handler('get_derivative_url', 'bratonien_tools_filter_derivative_url', EVENT_HANDLER_PRIORITY_NEUTRAL, 4);
add_event_handler('get_src_image_url', 'bratonien_tools_filter_webdav_src_url', EVENT_HANDLER_PRIORITY_NEUTRAL + 50, 2);
add_event_handler('get_derivative_url', 'bratonien_tools_filter_webdav_derivative_url', EVENT_HANDLER_PRIORITY_NEUTRAL + 50, 4);
add_event_handler('loc_end_element_set_global', 'bratonien_tools_batch_titles_register_action');
add_event_handler('element_set_global_action', 'bratonien_tools_batch_titles_apply', EVENT_HANDLER_PRIORITY_NEUTRAL, 2);
add_event_handler('init', 'bratonien_tools_prepare_connector_private_import', EVENT_HANDLER_PRIORITY_NEUTRAL - 30);

View File

@@ -8,6 +8,7 @@ require_once(dirname(__FILE__) . '/include/album_shares.inc.php');
require_once(dirname(__FILE__) . '/include/database.class.php');
require_once(dirname(__FILE__) . '/tools/watermark_profiles.inc.php');
require_once(dirname(__FILE__) . '/include/dependencies.inc.php');
require_once(dirname(__FILE__) . '/include/nc_connector_scheduler.inc.php');
class bratonien_tools_maintain extends PluginMaintain
{
@@ -18,9 +19,14 @@ class bratonien_tools_maintain extends PluginMaintain
$dependency_messages = array();
bratonien_tools_ensure_dependencies($dependency_messages);
if (!bratonien_tools_nc_scheduler_install())
{
$dependency_messages[] = 'Der native NC-Scheduler konnte sein Piwigo-Laufzeitverzeichnis nicht anlegen.';
}
if (function_exists('conf_update_param'))
{
conf_update_param('bratonien_nc_scheduler_interval', 60);
conf_update_param('bratonien_dependency_status', json_encode(array(
'checked_at' => time(),
'messages' => $dependency_messages,

View File

@@ -24,6 +24,7 @@ if (!function_exists('is_admin') || !is_admin())
exit;
}
$requested_connection_id = isset($_GET['connection_id']) ? max(0, (int)$_GET['connection_id']) : 0;
$dir = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-connector-status';
$latest = array(
'state'=>'idle',
@@ -36,11 +37,21 @@ $latest = array(
'api'=>array('state'=>'not_run','message'=>''),
'fallback'=>array('state'=>'not_run','message'=>''),
'error_detail'=>'',
'route'=>'',
'route_label'=>'Noch kein Datenweg erfasst',
'route_timestamp'=>0,
'route_time_label'=>'Nicht verfügbar',
'route_detail'=>'',
'connection_id'=>$requested_connection_id,
);
if (is_dir($dir))
{
foreach (glob($dir.'/connection-*.json') ?: array() as $file)
$files = $requested_connection_id > 0
? array($dir.'/connection-'.$requested_connection_id.'.json')
: (glob($dir.'/connection-*.json') ?: array());
foreach ($files as $file)
{
if (!is_readable($file))
{
@@ -57,11 +68,57 @@ if (is_dir($dir))
$latest['connection_file'] = basename($file);
}
}
}
if ((int)$latest['timestamp'] > 0)
{
$latest['last_run_label'] = date('d.m.Y H:i:s', (int)$latest['timestamp']);
if ($requested_connection_id === 0)
{
$route_file = $dir.'/route-status.json';
if (is_readable($route_file))
{
$route = json_decode((string)@file_get_contents($route_file), true);
if (is_array($route))
{
$route_name = (string)($route['route'] ?? '');
$route_timestamp = (int)($route['timestamp'] ?? 0);
$route_label = (string)($route['label'] ?? '');
if ($route_name === 'webdav')
{
$route_label = 'WEBDAV PRIMÄR';
}
elseif ($route_name === 'legacy_fallback')
{
$route_label = 'LEGACY-FALLBACK AKTIV';
}
elseif ($route_name === 'failed')
{
$route_label = 'FEHLER - KEIN ERFOLGREICHER DATENWEG';
}
elseif ($route_label === '')
{
$route_label = 'UNBEKANNTER DATENWEG';
}
$route_detail = trim((string)($route['detail'] ?? ''));
$latest['route'] = $route_name;
$latest['route_label'] = $route_label;
$latest['route_timestamp'] = $route_timestamp;
$latest['route_time_label'] = $route_timestamp > 0 ? date('d.m.Y H:i:s', $route_timestamp) : 'Nicht verfügbar';
$latest['route_detail'] = $route_detail;
$base_message = trim((string)($latest['message'] ?? ''));
$message_parts = array($route_label);
if ($route_name !== 'webdav' && $route_detail !== '')
{
$message_parts[] = $route_detail;
}
if ($base_message !== '')
{
$message_parts[] = $base_message;
}
$latest['message'] = implode(' · ', $message_parts);
}
}
}
}
$system_file = BRATONIEN_TOOLS_PATH.'include/nc_connector_system.inc.php';
@@ -76,4 +133,27 @@ if (is_readable($system_file))
}
}
$scheduler_file = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-connector-scheduler/state.json';
if ($requested_connection_id > 0 && is_readable($scheduler_file))
{
$scheduler = json_decode((string)@file_get_contents($scheduler_file), true);
if (
is_array($scheduler)
&& (int)($scheduler['connection_id'] ?? 0) === $requested_connection_id
&& in_array((string)($scheduler['state'] ?? ''), array('queued','running'), true)
&& (int)($scheduler['timestamp'] ?? 0) >= (int)($latest['timestamp'] ?? 0)
)
{
$latest['state'] = (string)$scheduler['state'];
$latest['message'] = (string)($scheduler['message'] ?? 'NC-Abgleich läuft.');
$latest['timestamp'] = (int)($scheduler['timestamp'] ?? time());
$latest['error_detail'] = '';
}
}
if ((int)$latest['timestamp'] > 0)
{
$latest['last_run_label'] = date('d.m.Y H:i:s', (int)$latest['timestamp']);
}
echo json_encode($latest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Seit 0.9.7.20 greift der NC Connector nicht mehr in Piwigos Bild-/Derivatlogik ein.
# Der Shadowtree dient nur der Piwigo-Synchronisation; die Bild-URL wird zur Laufzeit
# ueber den bestehenden WebDAV-URL-Hook auf Nextcloud aufgeloest.
exit 0

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
$options = getopt('', array('piwigo-root:', 'connection-id:'));
$piwigo_root = rtrim((string)($options['piwigo-root'] ?? ''), '/');
$connection_id = (int)($options['connection-id'] ?? 0);
if ($piwigo_root === '' || $connection_id < 1)
{
fwrite(STDERR, "Parameter --piwigo-root und --connection-id werden benoetigt.\n");
exit(1);
}
$db_config = $piwigo_root.'/local/config/database.inc.php';
if (!is_readable($db_config))
{
fwrite(STDERR, "Piwigo-Datenbankkonfiguration ist nicht lesbar.\n");
exit(1);
}
$conf = array();
$prefixeTable = 'piwigo_';
require $db_config;
foreach (array('db_host','db_user','db_password','db_base') as $key)
{
if (!isset($conf[$key]))
{
fwrite(STDERR, "Piwigo-Datenbankkonfiguration ist unvollstaendig: $key\n");
exit(1);
}
}
$db = new mysqli($conf['db_host'], $conf['db_user'], $conf['db_password'], $conf['db_base']);
if ($db->connect_errno)
{
fwrite(STDERR, "Piwigo-Datenbank ist nicht erreichbar: ".$db->connect_error."\n");
exit(1);
}
$db->set_charset('utf8mb4');
$table = $prefixeTable.'bratonien_tools_nc_connections';
$result = $db->query('SELECT config_json FROM `'.$table.'` WHERE id='.$connection_id.' LIMIT 1');
if (!$result || !$result->num_rows)
{
fwrite(STDERR, "Connector-Verbindung #$connection_id wurde nicht gefunden.\n");
exit(1);
}
$config = json_decode((string)$result->fetch_assoc()['config_json'], true);
if (!is_array($config) || (string)($config['source_mode'] ?? '') !== 'webdav-placeholder')
{
echo "Keine WebDAV-Bereinigung erforderlich.\n";
exit(0);
}
$prefixes = array(
'./_data/bratonien-tools/nc-webdav-gallery/connection-'.$connection_id.'/',
'./_data/bratonien-tools/nc-webdav-source/connection-'.$connection_id.'/',
'./galleries/bratonien-webdav-'.$connection_id.'/',
);
$gallery_root = rtrim(str_replace('\\', '/', (string)($config['parallel_gallery_root'] ?? '')), '/');
$normalized_piwigo_root = rtrim(str_replace('\\', '/', $piwigo_root), '/');
if ($gallery_root !== '' && strpos($gallery_root, $normalized_piwigo_root.'/') === 0)
{
$relative = ltrim(substr($gallery_root, strlen($normalized_piwigo_root)), '/');
if ($relative !== '') $prefixes[] = './'.rtrim($relative, '/').'/';
}
$prefixes = array_values(array_unique($prefixes));
$where = array();
foreach ($prefixes as $prefix)
{
$escaped = $db->real_escape_string(addcslashes($prefix, "_%\\"));
$where[] = "path LIKE '{$escaped}%' ESCAPE '\\\\'";
}
$images_table = $prefixeTable.'images';
$rows = $db->query("SELECT id, path FROM `{$images_table}` WHERE ".implode(' OR ', $where));
if (!$rows)
{
fwrite(STDERR, "Connector-Bilder konnten nicht gelesen werden: ".$db->error."\n");
exit(1);
}
$missing_ids = array();
while ($row = $rows->fetch_assoc())
{
$path = (string)$row['path'];
$owned = false;
foreach ($prefixes as $prefix)
{
if (strpos($path, $prefix) === 0)
{
$owned = true;
break;
}
}
if (!$owned) continue;
$absolute = $piwigo_root.'/'.ltrim(preg_replace('#^\./#', '', $path), '/');
if (!is_file($absolute))
{
$missing_ids[] = (int)$row['id'];
}
}
$db->close();
if (!$missing_ids)
{
echo "WebDAV-Bereinigung: keine fehlenden Piwigo-Bilder.\n";
exit(0);
}
// Dieser Prozess wird ausschliesslich nach einem erfolgreich abgeschlossenen
// WebDAV-Aufbau und erfolgreicher Piwigo-Synchronisierung gestartet. Eine
// nicht erreichbare Verbindung kann deshalb niemals ueber diesen Pfad Bilder
// entfernen.
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/cleanup-missing-webdav-images.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
include_once(PHPWG_ROOT_PATH.'admin/include/functions.php');
$deleted = delete_elements(array_values(array_unique(array_map('intval', $missing_ids))), false);
update_category('all');
invalidate_user_cache(true);
echo 'WebDAV-Bereinigung: '.(int)$deleted." nicht mehr freigegebene/vorhandene Bilder aus Piwigo entfernt.\n";

86
runtime/cleanup-stale.php Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
$pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2);
$dbConfig = $piwigoRoot.'/local/config/database.inc.php';
$configDir = '/etc/bratonien-tools/nc-connector';
$stateRoot = '/var/lib/bratonien-tools/nc-connector';
function cleanup_fail($message)
{
fwrite(STDERR, $message."\n");
exit(1);
}
function cleanup_tree($path)
{
$path = rtrim((string)$path, '/');
if ($path === '' || $path === '/' || !file_exists($path)) return;
if (is_link($path) || is_file($path))
{
@unlink($path);
return;
}
$items = @scandir($path);
if (!is_array($items)) return;
foreach ($items as $item)
{
if ($item === '.' || $item === '..') continue;
cleanup_tree($path.'/'.$item);
}
@rmdir($path);
}
if (!is_readable($dbConfig)) cleanup_fail('Piwigo-Datenbankkonfiguration ist nicht lesbar.');
$conf = array();
$prefixeTable = 'piwigo_';
require $dbConfig;
foreach (array('db_host','db_user','db_password','db_base') as $key)
{
if (!isset($conf[$key])) cleanup_fail('Piwigo-Datenbankkonfiguration ist unvollstaendig: '.$key);
}
$db = new mysqli($conf['db_host'], $conf['db_user'], $conf['db_password'], $conf['db_base']);
if ($db->connect_errno) cleanup_fail('Piwigo-Datenbank ist nicht erreichbar: '.$db->connect_error);
$db->set_charset('utf8mb4');
$table = $prefixeTable.'bratonien_tools_nc_connections';
$escapedTable = $db->real_escape_string($table);
$exists = $db->query("SHOW TABLES LIKE '{$escapedTable}'");
if (!$exists) cleanup_fail('Connector-Tabelle konnte nicht geprüft werden: '.$db->error);
if ($exists->num_rows === 0) exit(0);
$activeIds = array();
$result = $db->query("SELECT id FROM `{$table}`");
if (!$result) cleanup_fail('Connector-Verbindungen konnten nicht gelesen werden: '.$db->error);
while ($row = $result->fetch_assoc())
{
$activeIds[(int)$row['id']] = true;
}
if (!is_dir($configDir)) exit(0);
foreach (glob($configDir.'/connection-*.conf') ?: array() as $configPath)
{
if (!preg_match('/connection-([0-9]+)\.conf$/', $configPath, $matches)) continue;
$id = (int)$matches[1];
if (isset($activeIds[$id])) continue;
echo "NC Connector: entferne verwaiste Laufzeitdateien fuer Verbindung {$id}.\n";
foreach (array('.conf','.db-password','.piwigo-password','.storages.tsv','.roots.tsv') as $suffix)
{
@unlink($configDir.'/connection-'.$id.$suffix);
}
cleanup_tree($stateRoot.'/connection-'.$id);
$statusDir = rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-connector-status';
@unlink($statusDir.'/connection-'.$id.'.json');
@unlink($statusDir.'/deleted-'.$id);
}
exit(0);

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
$pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2);
define('PHPWG_ROOT_PATH', rtrim($piwigoRoot, '/').'/');
$_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/cleanup-webdav-piwigo.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTPS'] = 'off';
require_once(PHPWG_ROOT_PATH.'include/common.inc.php');
require_once(PHPWG_ROOT_PATH.'admin/include/functions.php');
function bratonien_cleanup_tree($path, $allowed_root)
{
$path = rtrim((string)$path, '/');
$allowed_root = rtrim((string)$allowed_root, '/');
if ($path === '' || $allowed_root === '' || strpos($path, $allowed_root.'/') !== 0 || !file_exists($path)) return;
if (is_link($path) || is_file($path))
{
@unlink($path);
return;
}
$items = @scandir($path);
if (!is_array($items)) return;
foreach ($items as $item)
{
if ($item === '.' || $item === '..') continue;
bratonien_cleanup_tree($path.'/'.$item, $allowed_root);
}
@rmdir($path);
}
function bratonien_connection_id_from_owned_path($path)
{
$path = (string)$path;
foreach (array(
'#^\./_data/bratonien-tools/nc-webdav-gallery/connection-([0-9]+)/#',
'#^\./_data/bratonien-tools/nc-webdav-source/connection-([0-9]+)/#',
'#^\./galleries/bratonien-webdav-([0-9]+)/#',
) as $pattern)
{
if (preg_match($pattern, $path, $match)) return (int)$match[1];
}
return 0;
}
function bratonien_connection_id_from_site_url($url)
{
$url = (string)$url;
foreach (array(
'#^\./_data/bratonien-tools/nc-webdav-gallery/connection-([0-9]+)/$#',
'#^\./galleries/bratonien-webdav-([0-9]+)/$#',
) as $pattern)
{
if (preg_match($pattern, $url, $match)) return (int)$match[1];
}
return 0;
}
try
{
$connection_table = function_exists('bratonien_tools_nc_connector_table')
? bratonien_tools_nc_connector_table()
: $GLOBALS['prefixeTable'].'bratonien_tools_nc_connections';
$active = array();
$exists = pwg_query("SHOW TABLES LIKE '".pwg_db_real_escape_string($connection_table)."'");
if (pwg_db_num_rows($exists))
{
$result = pwg_query("SELECT id FROM `".$connection_table."`");
while ($row = pwg_db_fetch_assoc($result))
{
$active[(int)$row['id']] = true;
}
}
$removed_images = 0;
$stale_by_connection = array();
$result = pwg_query(
"SELECT id, path, representative_ext FROM ".IMAGES_TABLE.
" WHERE path LIKE './_data/bratonien-tools/nc-webdav-gallery/connection-%'".
" OR path LIKE './_data/bratonien-tools/nc-webdav-source/connection-%'".
" OR path LIKE './galleries/bratonien-webdav-%'"
);
while ($row = pwg_db_fetch_assoc($result))
{
$connection_id = bratonien_connection_id_from_owned_path($row['path'] ?? '');
if ($connection_id < 1 || isset($active[$connection_id])) continue;
$row['id'] = (int)$row['id'];
$stale_by_connection[$connection_id][$row['id']] = $row;
}
foreach ($stale_by_connection as $connection_id=>$images)
{
foreach ($images as $row)
{
try
{
delete_element_derivatives($row);
}
catch (Throwable $e)
{
fwrite(STDERR, 'WebDAV-Cleanup: Derivate von Bild #'.(int)$row['id'].' konnten nicht vollständig entfernt werden: '.$e->getMessage()."\n");
}
}
delete_elements(array_map('intval', array_keys($images)), false);
$removed_images += count($images);
echo 'NC WebDAV: entferne '.count($images).' verwaiste Bilder der gelöschten Verbindung '.$connection_id.".\n";
}
$removed_sites = 0;
$result = pwg_query('SELECT id, galleries_url FROM '.SITES_TABLE);
$stale_sites = array();
while ($site = pwg_db_fetch_assoc($result))
{
$connection_id = bratonien_connection_id_from_site_url($site['galleries_url'] ?? '');
if ($connection_id < 1 || isset($active[$connection_id])) continue;
$stale_sites[] = array('id'=>(int)$site['id'], 'connection_id'=>$connection_id);
}
foreach ($stale_sites as $site)
{
delete_site($site['id']);
$removed_sites++;
echo 'NC WebDAV: entferne verwaiste Piwigo-Site '.$site['id'].' der gelöschten Verbindung '.$site['connection_id'].".\n";
}
$stale_connection_ids = array_unique(array_merge(
array_map('intval', array_keys($stale_by_connection)),
array_map(function($site) { return (int)$site['connection_id']; }, $stale_sites)
));
$gallery_root = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-webdav-gallery';
$source_root = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-webdav-source';
$preview_root = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-webdav-preview';
$legacy_root = PHPWG_ROOT_PATH.'galleries';
$state_root = '/var/lib/bratonien-tools/nc-connector';
$status_root = PHPWG_ROOT_PATH.'_data/bratonien-tools/nc-connector-status';
foreach ($stale_connection_ids as $connection_id)
{
if ($connection_id < 1) continue;
bratonien_cleanup_tree($gallery_root.'/connection-'.$connection_id, $gallery_root);
bratonien_cleanup_tree($source_root.'/connection-'.$connection_id, $source_root);
bratonien_cleanup_tree($preview_root.'/connection-'.$connection_id, $preview_root);
bratonien_cleanup_tree($legacy_root.'/bratonien-webdav-'.$connection_id, $legacy_root);
bratonien_cleanup_tree($state_root.'/connection-'.$connection_id, $state_root);
foreach (glob('/etc/bratonien-tools/nc-connector/webdav-connection-'.$connection_id.'.*') ?: array() as $file)
{
@unlink($file);
}
@unlink($status_root.'/connection-'.$connection_id.'.json');
@unlink($status_root.'/deleted-'.$connection_id);
}
if ($removed_sites > 0 || $removed_images > 0)
{
update_category('all');
invalidate_user_cache(true);
}
echo 'NC WebDAV Piwigo-Cleanup: sites='.$removed_sites.' bilder='.$removed_images."\n";
exit(0);
}
catch (Throwable $e)
{
fwrite(STDERR, 'NC WebDAV Piwigo-Cleanup: '.$e->getMessage()."\n");
exit(1);
}

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Debounce Nextcloud activity and request a periodic safety reconciliation."""
"""Debounce Nextcloud activity for one connector scope."""
from __future__ import annotations
@@ -24,54 +24,117 @@ def validate_view(name: str) -> str:
return value
def sql_literal(value: str) -> str:
return "'" + str(value).replace("'", "''") + "'"
def load(path: Path) -> dict[str, object]:
defaults: dict[str, object] = {"processed":0,"observed":0,"pending_since":0,"last_change":0,"last_full":0,"source_signature":""}
defaults: dict[str, object] = {
"processed": 0, "observed": 0, "pending_since": 0,
"last_change": 0, "last_full": 0, "source_signature": "",
}
if not path.exists():
return defaults
data=json.loads(path.read_text(encoding="utf-8"))
return {"processed":int(data.get("processed",0)),"observed":int(data.get("observed",0)),"pending_since":int(data.get("pending_since",0)),"last_change":int(data.get("last_change",0)),"last_full":int(data.get("last_full",0)),"source_signature":str(data.get("source_signature",""))}
data = json.loads(path.read_text(encoding="utf-8"))
return {
"processed": int(data.get("processed", 0)),
"observed": int(data.get("observed", 0)),
"pending_since": int(data.get("pending_since", 0)),
"last_change": int(data.get("last_change", 0)),
"last_full": int(data.get("last_full", 0)),
"source_signature": str(data.get("source_signature", "")),
}
def save(path: Path, state: dict[str, object]) -> None:
path.parent.mkdir(parents=True,exist_ok=True)
fd,name=tempfile.mkstemp(dir=path.parent)
with os.fdopen(fd,"w",encoding="utf-8") as handle:
json.dump(state,handle,sort_keys=True);handle.write("\n")
path.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=path.parent)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(state, handle, sort_keys=True)
handle.write("\n")
Path(name).replace(path)
def query(args: argparse.Namespace, sql: str) -> str:
env=os.environ.copy();env["PGPASSWORD"]=args.password_file.read_text(encoding="utf-8").strip()
command=["psql","-XAt","-h",args.host,"-p",str(args.port),"-U",args.user,"-d",args.database,"-v","ON_ERROR_STOP=1","-c",sql]
return subprocess.run(command,env=env,check=True,text=True,capture_output=True).stdout
env = os.environ.copy()
env["PGPASSWORD"] = args.password_file.read_text(encoding="utf-8").strip()
command = [
"psql", "-XAt", "-h", args.host, "-p", str(args.port),
"-U", args.user, "-d", args.database, "-v", "ON_ERROR_STOP=1", "-c", sql,
]
return subprocess.run(command, env=env, check=True, text=True, capture_output=True).stdout
def user_where(args: argparse.Namespace) -> str:
if not args.access_user:
return ""
return " WHERE lower(access_user) = lower(" + sql_literal(args.access_user) + ")"
def latest(args: argparse.Namespace) -> int:
return int(query(args,f"SELECT COALESCE(MAX(activity_id), 0) FROM {validate_view(args.view)}").strip())
view = validate_view(args.view)
return int(query(args, f"SELECT COALESCE(MAX(activity_id), 0) FROM {view}{user_where(args)}").strip())
def source_signature(args: argparse.Namespace) -> str:
if not args.source_view:return ""
payload=query(args,f"SELECT share_id, display_name, storage_id, source_path FROM {validate_view(args.source_view)} ORDER BY share_id")
if args.roots_config:
return hashlib.sha256(args.roots_config.read_bytes()).hexdigest()
if not args.source_view:
return ""
view = validate_view(args.source_view)
payload = query(args, f"SELECT share_id, display_name, storage_id, source_path FROM {view}{user_where(args)} ORDER BY share_id")
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def main() -> int:
parser=argparse.ArgumentParser();parser.add_argument("action",choices=("check","commit"));parser.add_argument("--state",required=True,type=Path);parser.add_argument("--host",required=True);parser.add_argument("--port",type=int,default=5432);parser.add_argument("--database",required=True);parser.add_argument("--user",required=True);parser.add_argument("--password-file",required=True,type=Path);parser.add_argument("--view",default="piwigo_showcase_activity");parser.add_argument("--source-view",default="");parser.add_argument("--quiet",type=int,default=120);parser.add_argument("--max-wait",type=int,default=900);parser.add_argument("--full-after",type=int,default=86400);args=parser.parse_args()
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("check", "commit"))
parser.add_argument("--state", required=True, type=Path)
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=5432)
parser.add_argument("--database", required=True)
parser.add_argument("--user", required=True)
parser.add_argument("--password-file", required=True, type=Path)
parser.add_argument("--view", required=True)
parser.add_argument("--source-view", default="")
parser.add_argument("--roots-config", type=Path)
parser.add_argument("--access-user", default="")
parser.add_argument("--quiet", type=int, default=120)
parser.add_argument("--max-wait", type=int, default=900)
parser.add_argument("--full-after", type=int, default=86400)
args = parser.parse_args()
try:
validate_view(args.view)
if args.source_view:validate_view(args.source_view)
state=load(args.state);now=int(time.time());current=latest(args);current_source=source_signature(args)
if args.action=="commit":state.update(processed=current,observed=current,pending_since=0,last_change=0,last_full=now,source_signature=current_source);save(args.state,state);return 0
if not int(state["last_full"]):return 0
if current_source and current_source!=str(state["source_signature"]):return 0
if current>int(state["observed"]):state["observed"]=current;state["last_change"]=now;state["pending_since"]=int(state["pending_since"]) or now;save(args.state,state)
if now-int(state["last_full"])>=args.full_after:return 0
if int(state["observed"])<=int(state["processed"]):return 3
if now-int(state["last_change"])>=args.quiet or now-int(state["pending_since"])>=args.max_wait:return 0
if args.source_view:
validate_view(args.source_view)
state = load(args.state)
now = int(time.time())
current = latest(args)
current_source = source_signature(args)
if args.action == "commit":
state.update(processed=current, observed=current, pending_since=0, last_change=0, last_full=now, source_signature=current_source)
save(args.state, state)
return 0
if not int(state["last_full"]):
return 0
if current_source and current_source != str(state["source_signature"]):
return 0
if current > int(state["observed"]):
state["observed"] = current
state["last_change"] = now
state["pending_since"] = int(state["pending_since"]) or now
save(args.state, state)
if now - int(state["last_full"]) >= args.full_after:
return 0
if int(state["observed"]) <= int(state["processed"]):
return 3
if now - int(state["last_change"]) >= args.quiet or now - int(state["pending_since"]) >= args.max_wait:
return 0
return 3
except Exception as error:
print(f"activity-gate: {error}",file=sys.stderr);return 1
print(f"activity-gate: {error}", file=sys.stderr)
return 1
if __name__=="__main__":raise SystemExit(main())
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
const BRATONIEN_WEBDAV_DERIVATIVE_BUILDER_VERSION = '0.9.6.1';
$options = getopt('', array('piwigo-root:', 'connection-id:'));
$piwigo_root = rtrim((string)($options['piwigo-root'] ?? ''), '/');
$connection_id = (int)($options['connection-id'] ?? 0);
if ($piwigo_root === '' || $connection_id < 1)
{
fwrite(STDERR, "Parameter --piwigo-root und --connection-id werden benoetigt.\n");
exit(1);
}
if (!is_dir($piwigo_root))
{
fwrite(STDERR, "Piwigo-Root wurde nicht gefunden.\n");
exit(1);
}
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/build-webdav-derivatives.php';
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['QUERY_STRING'] = '';
$_SERVER['HTTP_USER_AGENT'] = 'Bratonien-WebDAV-Derivative-Builder/'.BRATONIEN_WEBDAV_DERIVATIVE_BUILDER_VERSION;
$_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 (!function_exists('bratonien_tools_webdav_image_source_info') || !function_exists('bratonien_tools_webdav_generate_derivative'))
{
fwrite(STDERR, "Bratonien WebDAV-Bildruntime ist nicht aktiv.\n");
exit(1);
}
try
{
$variants = bratonien_tools_webdav_derivative_variants();
if (!$variants)
{
throw new RuntimeException('Keine Piwigo-Derivate konfiguriert.');
}
$images = 0;
$generated_or_ready = 0;
$identity = 0;
$metadata_repaired = 0;
$errors = 0;
$error_lines = array();
$result = pwg_query('SELECT * FROM '.IMAGES_TABLE.' ORDER BY id');
while ($row = pwg_db_fetch_assoc($result))
{
$image_id = (int)$row['id'];
$info = bratonien_tools_webdav_image_source_info($image_id);
if (!$info || (int)$info['connection_id'] !== $connection_id) continue;
$images++;
$preview = bratonien_tools_webdav_preview_path($info);
if (!$preview)
{
$errors++;
$error_lines[] = 'Bild #'.$image_id.': vorbereitetes WebDAV-Preview fehlt.';
continue;
}
$preview_ext = strtolower(pathinfo($preview, PATHINFO_EXTENSION));
if (!in_array($preview_ext, array('jpg', 'jpeg', 'png', 'gif'), true))
{
$errors++;
$error_lines[] = 'Bild #'.$image_id.': Preview-Format '.$preview_ext.' ist nicht Piwigo-kompatibel; Precache muss neu erzeugt werden.';
continue;
}
$size = @getimagesize($preview);
if (!is_array($size) || empty($size[0]) || empty($size[1]))
{
$errors++;
$error_lines[] = 'Bild #'.$image_id.': Preview ist kein lesbares Bild: '.$preview;
continue;
}
$preview_width = (int)$size[0];
$preview_height = (int)$size[1];
if ((int)($row['width'] ?? 0) !== $preview_width || (int)($row['height'] ?? 0) !== $preview_height || (int)($row['rotation'] ?? 0) !== 0)
{
pwg_query(
'UPDATE '.IMAGES_TABLE.
' SET width='.$preview_width.', height='.$preview_height.', rotation=0'.
' WHERE id='.$image_id
);
$row['width'] = $preview_width;
$row['height'] = $preview_height;
$row['rotation'] = 0;
$metadata_repaired++;
}
$src = new SrcImage($row);
foreach ($variants as $variant_name => $params)
{
try
{
$probe = new DerivativeImage($params, $src);
if ($probe->same_as_source())
{
$identity++;
continue;
}
$detail = '';
if (bratonien_tools_webdav_generate_derivative($params, $src, $detail))
{
$generated_or_ready++;
}
else
{
$errors++;
$error_lines[] = 'Bild #'.$image_id.' '.$variant_name.': '.($detail !== '' ? $detail : 'Derivat konnte nicht erzeugt werden.');
}
}
catch (Throwable $e)
{
$errors++;
$error_lines[] = 'Bild #'.$image_id.' '.$variant_name.': '.get_class($e).': '.$e->getMessage();
}
}
}
if ($metadata_repaired > 0)
{
update_category('all');
invalidate_user_cache(true);
}
if ($errors > 0)
{
foreach (array_slice($error_lines, 0, 30) as $line)
{
fwrite(STDERR, $line."\n");
}
if (count($error_lines) > 30)
{
fwrite(STDERR, 'Weitere Fehler: '.(count($error_lines) - 30)."\n");
}
}
echo 'WebDAV-Derivative-Builder: version='.BRATONIEN_WEBDAV_DERIVATIVE_BUILDER_VERSION.
' bilder='.$images.
' varianten='.count($variants).
' bereit='.$generated_or_ready.
' identisch='.$identity.
' metadaten_repariert='.$metadata_repaired.
' fehler='.$errors."\n";
exit($errors > 0 ? 1 : 0);
}
catch (Throwable $e)
{
fwrite(STDERR, 'WebDAV-Derivate: '.get_class($e).': '.$e->getMessage()."\n");
exit(1);
}

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Resolve Nextcloud view rows through explicitly configured storage mounts."""
"""Resolve Nextcloud share rows through configured storage adapters."""
from __future__ import annotations
@@ -23,111 +23,194 @@ def validate_view(name: str) -> str:
return value
def sql_literal(value: str) -> str:
return "'" + str(value).replace("'", "''") + "'"
def read_config(path: Path) -> dict[str, list[tuple[str, Path, str]]]:
result: dict[str, list[tuple[str, Path, str]]] = {}
with path.open(encoding="utf-8") as handle:
for number, line in enumerate(handle, 1):
line=line.rstrip("\n")
if not line or line.startswith("#"):continue
fields=line.split("\t")
if len(fields) not in {3,4}:raise ValueError(f"{path}:{number}: expected storage_id, source_prefix, local_mount and optional include_prefix")
storage_id,prefix,mount=fields[:3]
include_prefix=fields[3] if len(fields)==4 else ""
storage_id=storage_id.strip();prefix=prefix.strip("/");include_prefix=include_prefix.strip("/")
if not storage_id:raise ValueError(f"{path}:{number}: storage_id is empty")
if ".." in PurePosixPath(prefix).parts or ".." in PurePosixPath(include_prefix).parts:raise ValueError(f"{path}:{number}: unsafe prefix")
result.setdefault(storage_id,[]).append((prefix,Path(mount),include_prefix))
line = line.rstrip("\n")
if not line or line.startswith("#"):
continue
fields = line.split("\t")
if len(fields) not in {3, 4}:
raise ValueError(f"{path}:{number}: expected storage_id, source_prefix, local_mount and optional include_prefix")
storage_id, prefix, mount = fields[:3]
include_prefix = fields[3] if len(fields) == 4 else ""
storage_id = storage_id.strip()
prefix = prefix.strip("/")
include_prefix = include_prefix.strip("/")
if not storage_id:
raise ValueError(f"{path}:{number}: storage_id is empty")
if ".." in PurePosixPath(prefix).parts or ".." in PurePosixPath(include_prefix).parts:
raise ValueError(f"{path}:{number}: unsafe prefix")
result.setdefault(storage_id, []).append((prefix, Path(mount), include_prefix))
return result
def run_query(args: argparse.Namespace, env: dict[str,str], sql: str) -> subprocess.CompletedProcess[str]:
command=["psql","-X","-A","-F","\t","-t","-v","ON_ERROR_STOP=1","-h",args.host,"-p",str(args.port),"-U",args.user,"-d",args.database,"-c",sql]
return subprocess.run(command,env=env,check=False,text=True,capture_output=True)
def run_query(args: argparse.Namespace, env: dict[str, str], sql: str) -> subprocess.CompletedProcess[str]:
command = [
"psql", "-X", "-A", "-F", "\t", "-t", "-v", "ON_ERROR_STOP=1",
"-h", args.host, "-p", str(args.port), "-U", args.user, "-d", args.database, "-c", sql,
]
return subprocess.run(command, env=env, check=False, text=True, capture_output=True)
def query_rows(args: argparse.Namespace) -> list[list[str]]:
password=args.password_file.read_text(encoding="utf-8").strip();env=os.environ.copy();env["PGPASSWORD"]=password;view=validate_view(args.view)
modern_sql=f"SELECT share_id, item_type, display_name, storage_id, source_path FROM {view} ORDER BY share_id"
completed=run_query(args,env,modern_sql)
if completed.returncode==0:return list(csv.reader(completed.stdout.splitlines(),delimiter="\t"))
if "item_type" not in completed.stderr or "does not exist" not in completed.stderr:raise RuntimeError(completed.stderr.strip() or "Nextcloud source view query failed")
legacy_sql=f"SELECT share_id, display_name, storage_id, source_path FROM {view} ORDER BY share_id"
completed=run_query(args,env,legacy_sql)
if completed.returncode!=0:raise RuntimeError(completed.stderr.strip() or "legacy Nextcloud source view query failed")
rows=[]
for row in csv.reader(completed.stdout.splitlines(),delimiter="\t"):
if len(row)==4:
share_id,display_name,storage_id,source_path=row;rows.append([share_id,"",display_name,storage_id,source_path])
else:rows.append(row)
password = args.password_file.read_text(encoding="utf-8").strip()
env = os.environ.copy()
env["PGPASSWORD"] = password
view = validate_view(args.view)
where = ""
if args.access_user:
where = " WHERE lower(access_user) = lower(" + sql_literal(args.access_user) + ")"
modern_sql = f"SELECT share_id, item_type, display_name, storage_id, source_path FROM {view}{where} ORDER BY share_id"
completed = run_query(args, env, modern_sql)
if completed.returncode == 0:
return list(csv.reader(completed.stdout.splitlines(), delimiter="\t"))
if args.access_user:
raise RuntimeError(completed.stderr.strip() or "Nextcloud user-filtered source query failed")
if "item_type" not in completed.stderr or "does not exist" not in completed.stderr:
raise RuntimeError(completed.stderr.strip() or "Nextcloud source view query failed")
legacy_sql = f"SELECT share_id, display_name, storage_id, source_path FROM {view} ORDER BY share_id"
completed = run_query(args, env, legacy_sql)
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or "legacy Nextcloud source view query failed")
rows: list[list[str]] = []
for row in csv.reader(completed.stdout.splitlines(), delimiter="\t"):
if len(row) == 4:
share_id, display_name, storage_id, source_path = row
rows.append([share_id, "", display_name, storage_id, source_path])
else:
rows.append(row)
return rows
def contained_join(root: Path, relative: str) -> Path:
parts=PurePosixPath(relative).parts
if relative.startswith("/") or ".." in parts:raise ValueError(f"unsafe source path: {relative}")
parts = PurePosixPath(relative).parts
if relative.startswith("/") or ".." in parts:
raise ValueError(f"unsafe source path: {relative}")
return root.joinpath(*parts)
def matches_prefix(path: str, prefix: str) -> bool:
if not prefix:return True
return path==prefix or path.startswith(prefix+"/")
return not prefix or path == prefix or path.startswith(prefix + "/")
def resolve_adapter(adapters: dict[str,list[tuple[str,Path,str]]],storage_id: str,source_path: str) -> tuple[str,Path,str] | None:
relative=source_path.strip("/")
matches=[]
for prefix,mount,include_prefix in adapters.get(storage_id,[]):
if not matches_prefix(relative,prefix):continue
mapped_relative=relative[len(prefix):].lstrip("/") if prefix else relative
if not matches_prefix(mapped_relative,include_prefix):continue
matches.append((prefix,mount,include_prefix))
if not matches:return None
matches.sort(key=lambda item:(len(item[0]),len(item[2])),reverse=True)
return matches[0]
def resolve_adapter(adapters: dict[str, list[tuple[str, Path, str]]], storage_id: str, source_path: str) -> tuple[str, Path, str] | None:
relative = source_path.strip("/")
matches: list[tuple[str, Path, str]] = []
for prefix, mount, include_prefix in adapters.get(storage_id, []):
if not matches_prefix(relative, prefix):
continue
mapped_relative = relative[len(prefix):].lstrip("/") if prefix else relative
if not matches_prefix(mapped_relative, include_prefix):
continue
matches.append((prefix, mount, include_prefix))
if not matches:
return None
matches.sort(key=lambda item: (len(item[0]), len(item[2])), reverse=True)
best_score = (len(matches[0][0]), len(matches[0][2]))
best = {(item[0], str(item[1]), item[2]): item for item in matches if (len(item[0]), len(item[2])) == best_score}
if len(best) != 1:
raise RuntimeError(f"storage adapter is ambiguous for {storage_id}")
return next(iter(best.values()))
def build(args: argparse.Namespace) -> dict[str,object]:
validate_view(args.view);adapters=read_config(args.storage_config);rows=query_rows(args)
if not rows and not args.allow_empty:raise RuntimeError("Nextcloud returned no Showcase shares; refusing an empty manifest")
manifest=[];errors=[];folder_count=0;file_count=0
def build(args: argparse.Namespace) -> dict[str, object]:
validate_view(args.view)
adapters = read_config(args.storage_config)
rows = query_rows(args)
if not rows and not args.allow_empty:
raise RuntimeError("Nextcloud returned no matching sources; refusing an empty manifest")
manifest: list[str] = []
errors: list[str] = []
folder_count = 0
file_count = 0
for row in rows:
if len(row)!=5:errors.append(f"invalid database row with {len(row)} columns");continue
share_id,item_type,display_name,storage_id,source_path=row;item_type=item_type.strip().lower();relative=source_path.strip("/")
if item_type and item_type not in {"folder","file"}:errors.append(f"share {share_id}: unsupported item_type {item_type!r}");continue
adapter=resolve_adapter(adapters,storage_id,relative)
if not adapter:continue
prefix,mount,_include_prefix=adapter
if prefix:relative=relative[len(prefix):].lstrip("/")
source=contained_join(mount,relative)
if not mount.is_mount():errors.append(f"share {share_id}: storage mount unavailable: {mount}");continue
if len(row) != 5:
errors.append(f"invalid database row with {len(row)} columns")
continue
share_id, item_type, display_name, storage_id, source_path = row
item_type = item_type.strip().lower()
relative = source_path.strip("/")
if item_type and item_type not in {"folder", "file"}:
errors.append(f"source {share_id}: unsupported item_type {item_type!r}")
continue
try:
adapter = resolve_adapter(adapters, storage_id, relative)
except RuntimeError as error:
errors.append(f"source {share_id}: {error}")
continue
if not adapter:
continue
prefix, mount, _include_prefix = adapter
if prefix:
relative = relative[len(prefix):].lstrip("/")
source = contained_join(mount, relative)
if not mount.is_mount():
errors.append(f"source {share_id}: storage mount unavailable: {mount}")
continue
if not item_type:
if source.is_dir():item_type="folder"
elif source.is_file():item_type="file"
else:errors.append(f"share {share_id}: source unavailable: {source}");continue
if item_type=="folder":
if not source.is_dir():errors.append(f"share {share_id}: source directory unavailable: {source}");continue
folder_count+=1
if source.is_dir():
item_type = "folder"
elif source.is_file():
item_type = "file"
else:
errors.append(f"source {share_id}: source unavailable: {source}")
continue
if item_type == "folder":
if not source.is_dir():
errors.append(f"source {share_id}: source directory unavailable: {source}")
continue
folder_count += 1
else:
if not source.is_file():errors.append(f"share {share_id}: source file unavailable: {source}");continue
file_count+=1
if "\t" in display_name or "\n" in display_name or "\r" in display_name:errors.append(f"share {share_id}: display name contains unsupported control characters");continue
source_text=str(source)
if "\t" in source_text or "\n" in source_text or "\r" in source_text:errors.append(f"share {share_id}: source path contains unsupported control characters");continue
if not source.is_file():
errors.append(f"source {share_id}: source file unavailable: {source}")
continue
file_count += 1
if any(char in display_name for char in ("\t", "\n", "\r")):
errors.append(f"source {share_id}: display name contains unsupported control characters")
continue
source_text = str(source)
if any(char in source_text for char in ("\t", "\n", "\r")):
errors.append(f"source {share_id}: source path contains unsupported control characters")
continue
manifest.append(f"{share_id}\t{item_type}\t{display_name.lstrip('/')}\t{source}")
if errors:raise RuntimeError("; ".join(errors))
if not manifest and rows and not args.allow_empty:raise RuntimeError("no Showcase shares match the selected directories")
args.output.parent.mkdir(parents=True,exist_ok=True)
with tempfile.NamedTemporaryFile("w",encoding="utf-8",dir=args.output.parent,delete=False) as handle:
handle.write("\n".join(manifest)+("\n" if manifest else ""));temporary=Path(handle.name)
if errors:
raise RuntimeError("; ".join(errors))
if not manifest and rows and not args.allow_empty:
raise RuntimeError("no Nextcloud sources match the configured storage adapters")
args.output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=args.output.parent, delete=False) as handle:
handle.write("\n".join(manifest) + ("\n" if manifest else ""))
temporary = Path(handle.name)
temporary.replace(args.output)
return {"shares":len(manifest),"folders":folder_count,"files":file_count,"manifest":str(args.output)}
return {"sources": len(manifest), "folders": folder_count, "files": file_count, "manifest": str(args.output)}
def main() -> int:
parser=argparse.ArgumentParser();parser.add_argument("--host",required=True);parser.add_argument("--port",type=int,default=5432);parser.add_argument("--database",required=True);parser.add_argument("--user",required=True);parser.add_argument("--password-file",required=True,type=Path);parser.add_argument("--view",default="piwigo_showcase_sources");parser.add_argument("--storage-config",required=True,type=Path);parser.add_argument("--output",required=True,type=Path);parser.add_argument("--allow-empty",action="store_true");args=parser.parse_args()
try:print(json.dumps(build(args),ensure_ascii=False))
except Exception as error:print(f"manifest: {error}",file=sys.stderr);return 1
parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=5432)
parser.add_argument("--database", required=True)
parser.add_argument("--user", required=True)
parser.add_argument("--password-file", required=True, type=Path)
parser.add_argument("--view", required=True)
parser.add_argument("--access-user", default="")
parser.add_argument("--storage-config", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--allow-empty", action="store_true")
args = parser.parse_args()
try:
print(json.dumps(build(args), ensure_ascii=False))
except Exception as error:
print(f"manifest: {error}", file=sys.stderr)
return 1
return 0
if __name__=="__main__":raise SystemExit(main())
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Build a manifest from WebDAV-authorized Nextcloud file IDs.
The selected roots are resolved through a generic Nextcloud file view and then
mapped through configured storage adapters. No user name, path layout or storage
protocol is assumed here.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath
IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$")
def validate_view(name: str) -> str:
value = str(name).strip()
if not IDENTIFIER.fullmatch(value):
raise ValueError(f"invalid SQL view name: {value!r}")
return value
def safe_relative(value: str) -> str:
value = str(value).strip("/")
if ".." in PurePosixPath(value).parts:
raise ValueError(f"unsafe relative path: {value!r}")
return value
def read_roots(path: Path) -> dict[int, str]:
roots: dict[int, str] = {}
with path.open(encoding="utf-8") as handle:
for number, raw in enumerate(handle, 1):
line = raw.rstrip("\n")
if not line or line.startswith("#"):
continue
fields = line.split("\t")
if len(fields) != 2:
raise ValueError(f"{path}:{number}: expected fileid and display_name")
fileid_text, display_name = fields
if not fileid_text.isdigit() or int(fileid_text) < 1:
raise ValueError(f"{path}:{number}: invalid fileid")
if any(char in display_name for char in ("\t", "\n", "\r")):
raise ValueError(f"{path}:{number}: invalid display name")
roots[int(fileid_text)] = display_name or f"Element_{fileid_text}"
if not roots:
raise ValueError("no selected Nextcloud roots configured")
return roots
def read_adapters(path: Path) -> dict[str, list[tuple[str, Path]]]:
result: dict[str, list[tuple[str, Path]]] = {}
with path.open(encoding="utf-8") as handle:
for number, raw in enumerate(handle, 1):
line = raw.rstrip("\n")
if not line or line.startswith("#"):
continue
fields = line.split("\t")
if len(fields) not in {3, 4}:
raise ValueError(f"{path}:{number}: expected storage_id, source_prefix, local_mount and optional include_prefix")
storage_id, source_prefix, local_mount = fields[:3]
storage_id = storage_id.strip()
source_prefix = safe_relative(source_prefix)
mount = Path(local_mount)
if not storage_id:
raise ValueError(f"{path}:{number}: empty storage_id")
if not mount.is_absolute():
raise ValueError(f"{path}:{number}: local_mount must be absolute")
result.setdefault(storage_id, []).append((source_prefix, mount))
if not result:
raise ValueError("no storage adapters configured")
return result
def matches_prefix(path: str, prefix: str) -> bool:
return not prefix or path == prefix or path.startswith(prefix + "/")
def resolve_adapter(adapters: dict[str, list[tuple[str, Path]]], storage_id: str, source_path: str) -> tuple[str, Path]:
relative = safe_relative(source_path)
matches = [(prefix, mount) for prefix, mount in adapters.get(storage_id, []) if matches_prefix(relative, prefix)]
if not matches:
raise RuntimeError(f"no storage adapter configured for {storage_id}")
matches.sort(key=lambda item: len(item[0]), reverse=True)
best_length = len(matches[0][0])
best = {(prefix, str(mount)): (prefix, mount) for prefix, mount in matches if len(prefix) == best_length}
if len(best) != 1:
raise RuntimeError(f"storage adapter is ambiguous for {storage_id}")
return next(iter(best.values()))
def query_rows(args: argparse.Namespace, roots: dict[int, str]) -> dict[int, tuple[str, str]]:
password = args.password_file.read_text(encoding="utf-8").strip()
env = os.environ.copy()
env["PGPASSWORD"] = password
ids = ",".join(str(fileid) for fileid in sorted(roots))
view = validate_view(args.view)
sql = f"SELECT fileid, storage_id, source_path FROM {view} WHERE fileid IN ({ids}) ORDER BY fileid"
command = [
"psql", "-X", "-A", "-F", "\t", "-t", "-v", "ON_ERROR_STOP=1",
"-h", args.host, "-p", str(args.port), "-U", args.user, "-d", args.database, "-c", sql,
]
completed = subprocess.run(command, env=env, check=False, text=True, capture_output=True)
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or "Nextcloud file view query failed")
result: dict[int, tuple[str, str]] = {}
for row in csv.reader(completed.stdout.splitlines(), delimiter="\t"):
if len(row) != 3 or not row[0].isdigit():
raise RuntimeError("invalid row returned by Nextcloud file view")
result[int(row[0])] = (row[1], row[2])
missing = sorted(set(roots) - set(result))
if missing:
raise RuntimeError("selected Nextcloud file IDs are no longer resolvable: " + ", ".join(map(str, missing)))
return result
def contained_join(root: Path, relative: str) -> Path:
relative = safe_relative(relative)
candidate = root.joinpath(*PurePosixPath(relative).parts) if relative else root
root_real = root.resolve()
candidate_real = candidate.resolve()
try:
candidate_real.relative_to(root_real)
except ValueError as error:
raise ValueError(f"resolved source escapes configured mount: {candidate}") from error
return candidate
def build(args: argparse.Namespace) -> dict[str, object]:
roots = read_roots(args.roots_config)
adapters = read_adapters(args.storage_config)
rows = query_rows(args, roots)
manifest: list[str] = []
for fileid, display_name in roots.items():
storage_id, source_path = rows[fileid]
prefix, mount = resolve_adapter(adapters, storage_id, source_path)
if not mount.is_mount():
raise RuntimeError(f"storage mount unavailable: {mount}")
relative = safe_relative(source_path)
if prefix:
relative = relative[len(prefix):].lstrip("/")
source = contained_join(mount, relative)
if source.is_dir():
item_type = "folder"
elif source.is_file():
item_type = "file"
else:
raise RuntimeError(f"selected source unavailable: {source}")
if any(char in str(source) for char in ("\t", "\n", "\r")):
raise ValueError("resolved source path contains unsupported control characters")
manifest.append(f"fileid:{fileid}\t{item_type}\t{display_name}\t{source}")
args.output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=args.output.parent, delete=False) as handle:
handle.write("\n".join(manifest) + "\n")
temporary = Path(handle.name)
temporary.replace(args.output)
return {"roots": len(manifest), "manifest": str(args.output)}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=5432)
parser.add_argument("--database", required=True)
parser.add_argument("--user", required=True)
parser.add_argument("--password-file", required=True, type=Path)
parser.add_argument("--view", required=True)
parser.add_argument("--storage-config", required=True, type=Path)
parser.add_argument("--roots-config", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
try:
print(json.dumps(build(args), ensure_ascii=False))
except Exception as error:
print(f"selected-manifest: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,142 +0,0 @@
#!/usr/bin/env python3
"""Build a manifest from one connection's explicitly selected user filesystem roots.
This mode deliberately does not read the legacy Showcase source view. The storage
configuration must already resolve to the authenticated Nextcloud user's local
home/files tree. Only the selected include prefixes are exposed to Piwigo.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import tempfile
from pathlib import Path, PurePosixPath
def safe_relative(value: str) -> str:
value = str(value).strip("/")
parts = PurePosixPath(value).parts
if ".." in parts:
raise ValueError(f"unsafe relative path: {value!r}")
return value
def contained(root: Path, relative: str) -> Path:
relative = safe_relative(relative)
candidate = root.joinpath(*PurePosixPath(relative).parts) if relative else root
root_real = root.resolve()
candidate_real = candidate.resolve()
try:
candidate_real.relative_to(root_real)
except ValueError as error:
raise ValueError(f"path escapes configured user root: {relative!r}") from error
return candidate
def read_config(path: Path) -> list[tuple[str, str, Path, str]]:
rows: list[tuple[str, str, Path, str]] = []
with path.open(encoding="utf-8") as handle:
for number, raw in enumerate(handle, 1):
line = raw.rstrip("\n")
if not line or line.startswith("#"):
continue
fields = line.split("\t")
if len(fields) not in {3, 4}:
raise ValueError(
f"{path}:{number}: expected storage_id, source_prefix, local_mount and optional include_prefix"
)
storage_id, source_prefix, local_mount = fields[:3]
include_prefix = fields[3] if len(fields) == 4 else ""
source_prefix = safe_relative(source_prefix)
include_prefix = safe_relative(include_prefix)
mount = Path(local_mount)
if not storage_id.strip():
raise ValueError(f"{path}:{number}: storage_id is empty")
if not mount.is_absolute():
raise ValueError(f"{path}:{number}: local_mount must be absolute")
rows.append((storage_id.strip(), source_prefix, mount, include_prefix))
if not rows:
raise ValueError("no user storage mappings configured")
return rows
def stable_id(source: Path) -> str:
digest = hashlib.sha256(str(source.resolve()).encode("utf-8")).hexdigest()[:24]
return f"user-{digest}"
def manifest_entry(source: Path) -> str:
if source.is_symlink():
raise ValueError(f"selected source must not be a symlink: {source}")
if source.is_dir():
item_type = "folder"
elif source.is_file():
item_type = "file"
else:
raise FileNotFoundError(f"selected source is unavailable: {source}")
name = source.name
if not name:
raise ValueError(f"selected source has no display name: {source}")
for value in (name, str(source)):
if any(char in value for char in ("\t", "\n", "\r")):
raise ValueError(f"selected source contains unsupported control characters: {source}")
return f"{stable_id(source)}\t{item_type}\t{name}\t{source}"
def build(storage_config: Path, output: Path) -> dict[str, object]:
mappings = read_config(storage_config)
entries: dict[str, str] = {}
for _storage_id, source_prefix, mount, include_prefix in mappings:
if not mount.is_dir():
raise FileNotFoundError(f"user storage mount unavailable: {mount}")
user_root = contained(mount, source_prefix)
if not user_root.is_dir():
raise FileNotFoundError(f"user files root unavailable: {user_root}")
if include_prefix:
selected = contained(user_root, include_prefix)
line = manifest_entry(selected)
entries[str(selected.resolve())] = line
continue
# Empty selection means the user's root. Expose its direct children as
# Piwigo roots instead of creating an artificial "files" album.
for child in sorted(user_root.iterdir(), key=lambda item: (item.name.casefold(), item.name)):
if child.is_symlink():
continue
if not (child.is_dir() or child.is_file()):
continue
line = manifest_entry(child)
entries[str(child.resolve())] = line
if not entries:
raise RuntimeError("the selected Nextcloud directories contain no readable files or folders")
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=output.parent, delete=False) as handle:
for line in entries.values():
handle.write(line + "\n")
temporary = Path(handle.name)
temporary.replace(output)
return {"roots": len(entries), "manifest": str(output)}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--storage-config", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
try:
print(json.dumps(build(args.storage_config, args.output), ensure_ascii=False))
except Exception as error:
print(f"user-manifest: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""Build a placeholder-backed local source tree from Nextcloud WebDAV.
This creates only tiny placeholder files plus a metadata mapping; no Nextcloud
original media is stored locally. The authenticated Nextcloud user is never used
as an album name.
Each placeholder carries the original image dimensions. Dimensions are read
from Nextcloud metadata first. If those metadata are unavailable, only the
beginning of the original file is read through WebDAV and parsed locally.
"""
from __future__ import annotations
import argparse
import base64
import getpass
import json
import os
import shutil
import socket
import ssl
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from pathlib import Path, PurePosixPath
DAV = "DAV:"
OC = "http://owncloud.org/ns"
NC = "http://nextcloud.org/ns"
SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
PLACEHOLDER = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==")
DIMENSION_PROBE_BYTES = 4 * 1024 * 1024
def fail(message: str) -> None:
raise RuntimeError(message)
def validate_relative(value: str) -> str:
value = str(value).strip("/")
parts = PurePosixPath(value).parts
if ".." in parts:
raise ValueError(f"unsafe WebDAV path: {value!r}")
return value
def quote_path(value: str) -> str:
value = validate_relative(value)
return "/".join(urllib.parse.quote(part, safe="") for part in PurePosixPath(value).parts)
def safe_local_name(name: str) -> str:
if not name or name in {".", ".."} or "/" in name or "\x00" in name:
raise ValueError(f"unsafe remote name: {name!r}")
return name
def parse_dimensions(prop: ET.Element) -> tuple[int, int]:
for property_name in ("file-metadata-size", "metadata-photos-size"):
raw = prop.findtext(f"{{{NC}}}{property_name}", default="").strip()
if not raw:
continue
try:
decoded = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if isinstance(decoded, dict) and isinstance(decoded.get("value"), dict):
decoded = decoded["value"]
if not isinstance(decoded, dict):
continue
try:
width = int(decoded.get("width", 0) or 0)
height = int(decoded.get("height", 0) or 0)
except (TypeError, ValueError):
continue
if width > 0 and height > 0:
return width, height
return 0, 0
def parse_image_header_dimensions(data: bytes) -> tuple[int, int]:
if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n") and data[12:16] == b"IHDR":
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
if len(data) >= 10 and data[:6] in (b"GIF87a", b"GIF89a"):
return int.from_bytes(data[6:8], "little"), int.from_bytes(data[8:10], "little")
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
chunk = data[12:16]
if chunk == b"VP8X" and len(data) >= 30:
width = 1 + int.from_bytes(data[24:27], "little")
height = 1 + int.from_bytes(data[27:30], "little")
return width, height
if chunk == b"VP8 " and len(data) >= 30:
payload = 20
if data[payload + 3:payload + 6] == b"\x9d\x01\x2a":
width = int.from_bytes(data[payload + 6:payload + 8], "little") & 0x3FFF
height = int.from_bytes(data[payload + 8:payload + 10], "little") & 0x3FFF
return width, height
if chunk == b"VP8L" and len(data) >= 25 and data[20] == 0x2F:
b1, b2, b3, b4 = data[21:25]
width = 1 + b1 + ((b2 & 0x3F) << 8)
height = 1 + ((b2 & 0xC0) >> 6) + (b3 << 2) + ((b4 & 0x0F) << 10)
return width, height
if len(data) >= 4 and data[:2] == b"\xff\xd8":
sof_markers = {
0xC0, 0xC1, 0xC2, 0xC3,
0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB,
0xCD, 0xCE, 0xCF,
}
pos = 2
while pos + 4 <= len(data):
if data[pos] != 0xFF:
pos += 1
continue
while pos < len(data) and data[pos] == 0xFF:
pos += 1
if pos >= len(data):
break
marker = data[pos]
pos += 1
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
continue
if marker == 0xDA:
break
if pos + 2 > len(data):
break
segment_length = int.from_bytes(data[pos:pos + 2], "big")
if segment_length < 2:
break
if marker in sof_markers:
if pos + 7 > len(data):
break
height = int.from_bytes(data[pos + 3:pos + 5], "big")
width = int.from_bytes(data[pos + 5:pos + 7], "big")
return width, height
pos += segment_length
return 0, 0
def placeholder_bytes(width: int, height: int) -> bytes:
if not (1 <= width <= 65535 and 1 <= height <= 65535):
fail(f"unsupported image dimensions for placeholder: {width}x{height}")
data = bytearray(PLACEHOLDER)
data[6:8] = width.to_bytes(2, "little")
data[8:10] = height.to_bytes(2, "little")
return bytes(data)
@contextmanager
def pinned_resolution(host: str, ip: str):
host = host.strip("[]").lower()
ip = ip.strip("[]")
if not host or not ip or host == ip:
yield
return
original = socket.getaddrinfo
def resolve(name, port, family=0, type=0, proto=0, flags=0):
normalized = str(name).strip("[]").lower()
if normalized == host:
return original(ip, port, family, type, proto, flags)
return original(name, port, family, type, proto, flags)
socket.getaddrinfo = resolve
try:
yield
finally:
socket.getaddrinfo = original
class WebDavClient:
def __init__(self, base_url: str, user: str, password: str, timeout: int = 30, connect_ip: str = "") -> None:
self.base_url = base_url.rstrip("/")
parsed = urllib.parse.urlparse(self.base_url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
fail("Nextcloud base URL must use HTTP or HTTPS and contain a host")
self.host = parsed.hostname.strip("[]")
self.connect_ip = connect_ip.strip("[]") or self.host
self.user = user
self.password = password
self.timeout = timeout
token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
self.auth_header = f"Basic {token}"
self.context = ssl.create_default_context()
def file_url(self, relative: str) -> str:
user = urllib.parse.quote(self.user, safe="")
suffix = quote_path(relative)
return f"{self.base_url}/remote.php/dav/files/{user}/{suffix}"
def collection_url(self, relative: str) -> str:
url = self.file_url(relative)
if not url.endswith("/"):
url += "/"
return url
def probe_dimensions(self, relative: str) -> tuple[int, int]:
request = urllib.request.Request(self.file_url(relative), method="GET")
request.add_header("Authorization", self.auth_header)
request.add_header("Range", f"bytes=0-{DIMENSION_PROBE_BYTES - 1}")
try:
with pinned_resolution(self.host, self.connect_ip):
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
if response.status not in (200, 206):
fail(f"Nextcloud image header returned HTTP {response.status}")
data = response.read(DIMENSION_PROBE_BYTES)
except urllib.error.HTTPError as error:
if error.code in {401, 403}:
fail("Nextcloud rejected the WebDAV credentials or file access")
fail(f"Nextcloud image header request failed with HTTP {error.code}")
except urllib.error.URLError as error:
fail(f"Nextcloud WebDAV is unreachable while reading image dimensions: {error.reason}")
width, height = parse_image_header_dimensions(data)
if width < 1 or height < 1:
fail(f"original image dimensions could not be read from Nextcloud file header: {relative}")
return width, height
def list_collection(self, relative: str) -> tuple[dict[str, object], list[dict[str, object]]]:
relative = validate_relative(relative)
url = self.collection_url(relative)
body = (
'<?xml version="1.0"?>'
'<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:getcontentlength/><d:getetag/><oc:fileid/>'
'<nc:file-metadata-size/><nc:metadata-photos-size/>'
'</d:prop></d:propfind>'
).encode("utf-8")
request = urllib.request.Request(url, data=body, method="PROPFIND")
request.add_header("Authorization", self.auth_header)
request.add_header("Depth", "1")
request.add_header("Content-Type", "application/xml; charset=utf-8")
try:
with pinned_resolution(self.host, self.connect_ip):
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
status = response.status
payload = response.read()
except urllib.error.HTTPError as error:
if error.code in {401, 403}:
fail("Nextcloud rejected the WebDAV credentials or directory access")
fail(f"Nextcloud PROPFIND failed with HTTP {error.code}")
except urllib.error.URLError as error:
fail(f"Nextcloud WebDAV is unreachable: {error.reason}")
if status != 207:
fail(f"Nextcloud PROPFIND returned HTTP {status}")
base_path = urllib.parse.unquote(urllib.parse.urlparse(url).path).rstrip("/")
current: dict[str, object] | None = None
children: list[dict[str, object]] = []
try:
root = ET.fromstring(payload)
except ET.ParseError as error:
raise RuntimeError("Nextcloud returned invalid WebDAV XML") from error
for response in root.findall(f"{{{DAV}}}response"):
href = response.findtext(f"{{{DAV}}}href", default="")
href_path = urllib.parse.unquote(urllib.parse.urlparse(href).path).rstrip("/")
prop = None
for propstat in response.findall(f"{{{DAV}}}propstat"):
status_text = propstat.findtext(f"{{{DAV}}}status", default="")
if " 200 " in status_text:
prop = propstat.find(f"{{{DAV}}}prop")
if prop is not None:
break
if prop is None:
continue
display = prop.findtext(f"{{{DAV}}}displayname", default="")
fileid_text = prop.findtext(f"{{{OC}}}fileid", default="").strip()
resource_type = prop.find(f"{{{DAV}}}resourcetype")
is_dir = resource_type is not None and resource_type.find(f"{{{DAV}}}collection") is not None
width, height = parse_dimensions(prop)
item = {
"display_name": display,
"fileid": int(fileid_text) if fileid_text.isdigit() else 0,
"is_dir": is_dir,
"content_type": prop.findtext(f"{{{DAV}}}getcontenttype", default=""),
"size": int(prop.findtext(f"{{{DAV}}}getcontentlength", default="0") or 0),
"etag": prop.findtext(f"{{{DAV}}}getetag", default="").strip('"'),
"width": width,
"height": height,
}
if href_path == base_path:
current = item
continue
if display:
children.append(item)
if current is None or int(current.get("fileid", 0)) < 1:
fail(f"Nextcloud returned no stable fileid for {relative or '/'}")
return current, children
def write_placeholder(target: Path, width: int, height: int) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(placeholder_bytes(width, height))
os.chmod(target, 0o644)
def build_root(client: WebDavClient, remote_root: str, local_root: Path, mapping: dict[str, dict[str, object]]) -> tuple[int, int, int, int]:
files = 0
folders = 0
skipped = 0
probed = 0
stack: list[tuple[str, Path]] = [(validate_relative(remote_root), local_root)]
visited: set[str] = set()
while stack:
remote_dir, local_dir = stack.pop()
if remote_dir in visited:
continue
visited.add(remote_dir)
current, children = client.list_collection(remote_dir)
local_dir.mkdir(parents=True, exist_ok=True)
folders += 1
mapping[str(local_dir)] = {
"kind": "folder",
"fileid": int(current["fileid"]),
"webdav_path": remote_dir,
"display_name": str(current.get("display_name", "")),
}
for child in children:
name = safe_local_name(str(child["display_name"]))
child_remote = name if remote_dir == "" else f"{remote_dir}/{name}"
child_local = local_dir / name
if bool(child["is_dir"]):
stack.append((child_remote, child_local))
continue
extension = Path(name).suffix.lower()
if extension not in SUPPORTED_IMAGE_EXTENSIONS:
skipped += 1
continue
width = int(child.get("width", 0) or 0)
height = int(child.get("height", 0) or 0)
dimension_source = "metadata"
if width < 1 or height < 1:
width, height = client.probe_dimensions(child_remote)
dimension_source = "header"
probed += 1
write_placeholder(child_local, width, height)
files += 1
mapping[str(child_local)] = {
"kind": "file",
"fileid": int(child.get("fileid", 0)),
"webdav_path": child_remote,
"display_name": name,
"content_type": str(child.get("content_type", "")),
"size": int(child.get("size", 0)),
"etag": str(child.get("etag", "")),
"width": width,
"height": height,
"dimension_source": dimension_source,
}
return files, folders, skipped, probed
def atomic_json(path: Path, payload: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write("\n")
temporary = Path(handle.name)
temporary.replace(path)
def atomic_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
handle.write(text)
temporary = Path(handle.name)
temporary.replace(path)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", required=True)
parser.add_argument("--connect-ip", default="", help="IP address used for the TCP connection while preserving the URL host for HTTP Host and TLS SNI")
parser.add_argument("--user", required=True)
parser.add_argument("--password-file", type=Path)
parser.add_argument("--root", action="append", required=True, help="WebDAV path relative to the authenticated user's files root")
parser.add_argument("--source-dir", required=True, type=Path)
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument("--mapping", required=True, type=Path)
parser.add_argument("--timeout", type=int, default=30)
args = parser.parse_args()
try:
if args.password_file:
password = args.password_file.read_text(encoding="utf-8").rstrip("\r\n")
else:
password = getpass.getpass("Nextcloud password: ")
if not password:
fail("Nextcloud password is empty")
source_dir = args.source_dir.resolve()
staging = source_dir.with_name(f".{source_dir.name}.next")
previous = source_dir.with_name(f".{source_dir.name}.previous")
source_dir.parent.mkdir(parents=True, exist_ok=True)
if staging.exists():
shutil.rmtree(staging)
staging.mkdir(parents=True)
client = WebDavClient(args.base_url, args.user, password, max(1, args.timeout), args.connect_ip)
mapping: dict[str, dict[str, object]] = {}
manifest: list[str] = []
total_files = total_folders = total_skipped = total_probed = 0
used_fileids: set[int] = set()
for remote_root_raw in args.root:
remote_root = validate_relative(remote_root_raw)
current, root_children = client.list_collection(remote_root)
fileid = int(current["fileid"])
if fileid in used_fileids:
fail(f"duplicate selected Nextcloud root fileid: {fileid}")
used_fileids.add(fileid)
local_name = f"root-{fileid}"
local_root = staging / local_name
files, folders, skipped, probed = build_root(client, remote_root, local_root, mapping)
total_files += files
total_folders += folders
total_skipped += skipped
total_probed += probed
if remote_root == "":
for child in sorted(root_children, key=lambda item: str(item.get("display_name", "")).casefold()):
name = safe_local_name(str(child.get("display_name", "")))
child_fileid = int(child.get("fileid", 0))
if child_fileid < 1:
fail(f"Nextcloud returned no stable fileid for root child {name!r}")
child_path = source_dir / local_name / name
if bool(child.get("is_dir")):
manifest.append(f"webdav:{child_fileid}\tfolder\t{name}\t{child_path}")
elif Path(name).suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS:
manifest.append(f"webdav:{child_fileid}\tfile\t{name}\t{child_path}")
continue
display = str(current.get("display_name", "")).strip() or PurePosixPath(remote_root).name
manifest.append(f"webdav:{fileid}\tfolder\t{display}\t{source_dir / local_name}")
if previous.exists():
shutil.rmtree(previous)
if source_dir.exists():
source_dir.rename(previous)
try:
staging.rename(source_dir)
except Exception:
if source_dir.exists():
shutil.rmtree(source_dir)
if previous.exists():
previous.rename(source_dir)
raise
if previous.exists():
shutil.rmtree(previous)
final_mapping: dict[str, dict[str, object]] = {}
staging_text = str(staging)
source_text = str(source_dir)
for path, data in mapping.items():
final_path = source_text + path[len(staging_text):] if path.startswith(staging_text) else path
final_mapping[final_path] = data
atomic_text(args.manifest, "\n".join(manifest) + "\n")
atomic_json(args.mapping, {
"version": 3,
"base_url": args.base_url.rstrip("/"),
"connect_ip": client.connect_ip,
"user": args.user,
"files": final_mapping,
})
print(json.dumps({
"roots": len(args.root),
"files": total_files,
"folders": total_folders,
"skipped": total_skipped,
"dimension_header_probes": total_probed,
"connect_ip": client.connect_ip,
"source_dir": str(source_dir),
"manifest": str(args.manifest),
"mapping": str(args.mapping),
}, ensure_ascii=False))
return 0
except Exception as error:
print(f"webdav-placeholder: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

58
runtime/lib/piwigo-sync.php Normal file → Executable file
View File

@@ -139,6 +139,39 @@ function decrypt_blob($blob, $hex_key)
return (string)$plain;
}
function ensure_webdav_site(mysqli $db, $prefixeTable, $piwigo_root, array $connection_config, $connection_id)
{
if ((string)($connection_config['source_mode'] ?? '') !== 'webdav-placeholder') return 1;
$gallery_root = rtrim((string)($connection_config['parallel_gallery_root'] ?? ''), '/');
if ($gallery_root === '') fail_sync('WebDAV-Galeriewurzel fehlt in der Verbindungskonfiguration.');
$piwigo_root = rtrim((string)$piwigo_root, '/');
if (strpos($gallery_root, $piwigo_root.'/') !== 0)
{
fail_sync('WebDAV-Galeriewurzel liegt ausserhalb der Piwigo-Installation.');
}
if (!is_dir($gallery_root)) fail_sync('WebDAV-Galeriewurzel existiert nicht: '.$gallery_root);
$relative = ltrim(substr($gallery_root, strlen($piwigo_root)), '/');
if ($relative === '') fail_sync('WebDAV-Galeriewurzel darf nicht dem Piwigo-Hauptverzeichnis entsprechen.');
$site_url = './'.rtrim($relative, '/').'/';
$escaped = $db->real_escape_string($site_url);
$result = $db->query("SELECT id FROM `{$prefixeTable}sites` WHERE galleries_url='{$escaped}' LIMIT 1");
if (!$result) fail_sync('Piwigo-Site konnte nicht gelesen werden: '.$db->error);
if ($result->num_rows)
{
return (int)$result->fetch_assoc()['id'];
}
if (!$db->query("INSERT INTO `{$prefixeTable}sites` (galleries_url) VALUES ('{$escaped}')"))
{
fail_sync('Piwigo-Site fuer WebDAV-Verbindung #'.$connection_id.' konnte nicht angelegt werden: '.$db->error);
}
$site_id = (int)$db->insert_id;
if ($site_id < 1) fail_sync('Piwigo-Site fuer WebDAV-Verbindung #'.$connection_id.' erhielt keine gueltige ID.');
return $site_id;
}
try
{
$options = getopt('', array('piwigo-root:', 'connection-id:', 'base-url:'));
@@ -173,6 +206,8 @@ try
$connection_credentials = json_decode($connection_plain, true);
if (!is_array($connection_credentials)) $connection_credentials = array();
$site_id = ensure_webdav_site($db, $prefixeTable, $piwigo_root, $connection_config, $connection_id);
$api = array('key_id'=>'', 'key_secret'=>'');
$connection_scoped = (string)($connection_config['piwigo_auth'] ?? '') === 'connection-scoped' || array_key_exists('api_enabled', $connection_config);
if ($connection_scoped)
@@ -185,7 +220,6 @@ try
}
else
{
// Nur fuer bereits aktive Altverbindungen: bisheriger globaler API-Zugang.
$api_result = $db->query("SELECT value FROM `{$prefixeTable}config` WHERE param='bratonien_nc_piwigo_api' LIMIT 1");
if ($api_result && $api_result->num_rows)
{
@@ -216,11 +250,17 @@ try
'Accept: application/json, text/xml;q=0.9',
'Content-Type: application/x-www-form-urlencoded',
);
decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'bratonien.nc.syncProductive', 'site_id'=>1), $headers));
$orphan = decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'bratonien.nc.syncOrphans', 'site_id'=>1, 'simulate'=>0), $headers));
decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'bratonien.nc.syncProductive', 'site_id'=>$site_id), $headers));
$orphan = decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'bratonien.nc.syncOrphans', 'site_id'=>$site_id, 'simulate'=>0), $headers));
// Entfernt alte technische bratonien-webdav-N Wrapper aus Site 1,
// nachdem deren generierte Verzeichnisse beim Reconcile entfernt wurden.
if ($site_id !== 1)
{
decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'bratonien.nc.syncOrphans', 'site_id'=>1, 'simulate'=>0), $headers));
}
$added = (int)($orphan['added_orphans'] ?? 0);
$deleted = (int)($orphan['deleted_orphans'] ?? 0);
echo "Piwigo-Synchronisierung per API erfolgreich\n";
echo "Piwigo-Synchronisierung per API erfolgreich (Site $site_id)\n";
echo "Piwigo-Orphans synchronisiert: +$added / -$deleted\n";
exit(0);
}
@@ -245,15 +285,19 @@ try
{
decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'pwg.session.login', 'username'=>$fallback_user, 'password'=>$fallback_password), array(), $cookie_file));
http_request(
$base_url.'/admin.php?page=site_update&site=1',
$base_url.'/admin.php?page=site_update&site='.$site_id,
array('sync'=>'files','display_info'=>1,'privacy_level'=>0,'sync_meta'=>1,'simulate'=>0,'subcats-included'=>1,'bratonien_connector'=>1,'submit'=>1),
array(),
$cookie_file
);
$orphan = decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'bratonien.nc.syncOrphans', 'site_id'=>1, 'simulate'=>0), array(), $cookie_file));
$orphan = decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'bratonien.nc.syncOrphans', 'site_id'=>$site_id, 'simulate'=>0), array(), $cookie_file));
if ($site_id !== 1)
{
decode_ws(http_request($base_url.'/ws.php?format=json', array('method'=>'bratonien.nc.syncOrphans', 'site_id'=>1, 'simulate'=>0), array(), $cookie_file));
}
$added = (int)($orphan['added_orphans'] ?? 0);
$deleted = (int)($orphan['deleted_orphans'] ?? 0);
echo "Piwigo-Datenbanksynchronisierung per Benutzername/Passwort-Fallback erfolgreich\n";
echo "Piwigo-Datenbanksynchronisierung per Benutzername/Passwort-Fallback erfolgreich (Site $site_id)\n";
echo "Piwigo-Orphans synchronisiert: +$added / -$deleted\n";
}
finally

View File

@@ -0,0 +1,346 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
require_once(dirname(__DIR__, 2).'/include/nc_transport.inc.php');
const BRATONIEN_WEBDAV_PREVIEW_VERSION = 2;
const BRATONIEN_WEBDAV_PREVIEW_MAX_EDGE = 4096;
const BRATONIEN_WEBDAV_PREVIEW_JPEG_QUALITY = 88;
function fail_preview($message)
{
throw new RuntimeException($message);
}
function quote_webdav_path($path)
{
$parts = array_values(array_filter(explode('/', trim((string)$path, '/')), 'strlen'));
return implode('/', array_map('rawurlencode', $parts));
}
function fetch_remote_blob($url, $user, $password)
{
$ch = curl_init($url);
$options = array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $user.':'.$password,
CURLOPT_FAILONERROR => false,
CURLOPT_USERAGENT => 'Bratonien-Tools-WebDAV-Precache/0.9.7.16',
);
bratonien_tools_nc_transport_apply_curl($options, $url);
curl_setopt_array($ch, $options);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $errno !== 0) fail_preview('WebDAV-Bild konnte nicht geladen werden: '.$error);
if ($http < 200 || $http >= 300) fail_preview('WebDAV-Bild antwortete mit HTTP '.$http.'.');
if ($body === '') fail_preview('WebDAV-Bild ist leer.');
return (string)$body;
}
function preview_extension_for_entry(array $entry)
{
$path = strtolower((string)($entry['webdav_path'] ?? ''));
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($extension === 'png') return 'png';
if ($extension === 'gif') return 'gif';
if ($extension === 'webp') return 'webp';
return 'jpg';
}
function preview_target_for_entry($cache_dir, $key, array $entry)
{
return $cache_dir.'/'.$key.'.'.preview_extension_for_entry($entry);
}
function remove_other_preview_format($cache_dir, $key, $keep_target)
{
foreach (array('jpg', 'png', 'gif', 'webp') as $ext)
{
$candidate = $cache_dir.'/'.$key.'.'.$ext;
if ($candidate !== $keep_target && is_file($candidate)) @unlink($candidate);
}
}
function write_preview_imagick($blob, $target, $extension)
{
$image = new Imagick();
try
{
$image->readImageBlob($blob);
if (method_exists($image, 'autoOrientImage')) @$image->autoOrientImage();
$image->setIteratorIndex(0);
$image->thumbnailImage(BRATONIEN_WEBDAV_PREVIEW_MAX_EDGE, BRATONIEN_WEBDAV_PREVIEW_MAX_EDGE, true, true);
$image->stripImage();
if ($extension === 'png')
{
$image->setImageFormat('png');
}
elseif ($extension === 'gif')
{
$image->setImageFormat('gif');
}
elseif ($extension === 'webp')
{
$image->setImageFormat('webp');
$image->setImageCompressionQuality(BRATONIEN_WEBDAV_PREVIEW_JPEG_QUALITY);
}
else
{
$image->setImageBackgroundColor('white');
if (method_exists($image, 'mergeImageLayers')) $image = $image->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
$image->setImageFormat('jpeg');
$image->setImageCompression(Imagick::COMPRESSION_JPEG);
$image->setImageCompressionQuality(BRATONIEN_WEBDAV_PREVIEW_JPEG_QUALITY);
$image->setInterlaceScheme(Imagick::INTERLACE_PLANE);
}
if (!$image->writeImage($target)) fail_preview('Imagick konnte das Preview nicht schreiben.');
}
finally
{
$image->clear();
$image->destroy();
}
}
function write_preview_gd($blob, $target, $extension)
{
$source = @imagecreatefromstring($blob);
if (!$source) fail_preview('GD konnte das Bild nicht dekodieren.');
try
{
$width = imagesx($source);
$height = imagesy($source);
if ($width < 1 || $height < 1) fail_preview('Bildabmessungen sind ungültig.');
$scale = min(1, BRATONIEN_WEBDAV_PREVIEW_MAX_EDGE / $width, BRATONIEN_WEBDAV_PREVIEW_MAX_EDGE / $height);
$target_width = max(1, (int)round($width * $scale));
$target_height = max(1, (int)round($height * $scale));
$preview = imagecreatetruecolor($target_width, $target_height);
if (!$preview) fail_preview('GD konnte die Preview-Arbeitsfläche nicht anlegen.');
try
{
if (in_array($extension, array('png', 'gif', 'webp'), true))
{
imagealphablending($preview, false);
imagesavealpha($preview, true);
$transparent = imagecolorallocatealpha($preview, 0, 0, 0, 127);
imagefilledrectangle($preview, 0, 0, $target_width, $target_height, $transparent);
}
else
{
$white = imagecolorallocate($preview, 255, 255, 255);
imagefilledrectangle($preview, 0, 0, $target_width, $target_height, $white);
}
if (!imagecopyresampled($preview, $source, 0, 0, 0, 0, $target_width, $target_height, $width, $height))
{
fail_preview('GD konnte das Preview nicht skalieren.');
}
if ($extension === 'png') $written = imagepng($preview, $target, 6);
elseif ($extension === 'gif') $written = function_exists('imagegif') ? imagegif($preview, $target) : false;
elseif ($extension === 'webp') $written = function_exists('imagewebp') ? imagewebp($preview, $target, BRATONIEN_WEBDAV_PREVIEW_JPEG_QUALITY) : false;
else $written = imagejpeg($preview, $target, BRATONIEN_WEBDAV_PREVIEW_JPEG_QUALITY);
if (!$written) fail_preview('GD konnte das Preview im Format '.$extension.' nicht schreiben.');
}
finally
{
imagedestroy($preview);
}
}
finally
{
imagedestroy($source);
}
}
function write_preview($blob, $target, $extension)
{
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fail_preview('Preview-Verzeichnis konnte nicht angelegt werden.');
if (class_exists('Imagick'))
{
write_preview_imagick($blob, $target, $extension);
}
elseif (function_exists('imagecreatefromstring') && function_exists('imagejpeg') && function_exists('imagepng'))
{
write_preview_gd($blob, $target, $extension);
}
else
{
fail_preview('Weder Imagick noch GD ist für die Preview-Erzeugung verfügbar.');
}
clearstatcache(true, $target);
$size = @getimagesize($target);
if (!is_array($size) || empty($size[0]) || empty($size[1]) || !is_file($target) || filesize($target) < 64)
{
@unlink($target);
fail_preview('Das erzeugte Preview ist ungültig.');
}
@chmod($target, 0664);
}
function local_source_is_hydrated($source_path, $preview)
{
if (!is_file($source_path) || !is_readable($source_path)) return false;
$source_size = @getimagesize($source_path);
$preview_size = @getimagesize($preview);
if (!is_array($source_size) || !is_array($preview_size)) return false;
if ((int)$source_size[0] !== (int)$preview_size[0] || (int)$source_size[1] !== (int)$preview_size[1]) return false;
$source_bytes = @filesize($source_path);
$preview_bytes = @filesize($preview);
return $source_bytes !== false && $preview_bytes !== false && (int)$source_bytes === (int)$preview_bytes;
}
function hydrate_local_source($source_path, $preview)
{
$normalized = str_replace('\\', '/', (string)$source_path);
if (!preg_match('#/nc-webdav-source/connection-[0-9]+/root-[0-9]+/#', $normalized))
{
fail_preview('Unsicherer lokaler Connector-Quellpfad: '.$source_path);
}
if (!is_file($preview) || !is_readable($preview)) fail_preview('Preview für lokale Quelle fehlt: '.$preview);
if (local_source_is_hydrated($source_path, $preview)) return false;
$dir = dirname($source_path);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fail_preview('Lokales Connector-Verzeichnis konnte nicht angelegt werden: '.$dir);
$tmp = $source_path.'.bratonien-next-'.getmypid();
if (!@copy($preview, $tmp)) fail_preview('Preview konnte nicht in lokale Piwigo-Quelle kopiert werden: '.$source_path);
@chmod($tmp, 0664);
$mtime = @filemtime($preview);
if ($mtime) @touch($tmp, $mtime);
if (!@rename($tmp, $source_path))
{
@unlink($tmp);
fail_preview('Lokale Piwigo-Quelle konnte nicht ersetzt werden: '.$source_path);
}
clearstatcache(true, $source_path);
$size = @getimagesize($source_path);
if (!is_array($size) || empty($size[0]) || empty($size[1])) fail_preview('Lokale Piwigo-Quelle ist nach dem Ersetzen ungültig: '.$source_path);
return true;
}
try
{
$options = getopt('', array('mapping:', 'base-url:', 'user:', 'password-file:', 'cache-dir:'));
$mapping_file = (string)($options['mapping'] ?? '');
$base_url = rtrim((string)($options['base-url'] ?? ''), '/');
$user = trim((string)($options['user'] ?? ''));
$password_file = (string)($options['password-file'] ?? '');
$cache_dir = rtrim((string)($options['cache-dir'] ?? ''), '/');
if ($mapping_file === '' || !is_readable($mapping_file)) fail_preview('WebDAV-Mapping ist nicht lesbar.');
if ($base_url === '' || $user === '' || $password_file === '' || !is_readable($password_file) || $cache_dir === '') fail_preview('Preview-Parameter sind unvollständig.');
if (!function_exists('curl_init')) fail_preview('PHP-cURL ist nicht verfügbar.');
$password = rtrim((string)file_get_contents($password_file), "\r\n");
if ($password === '') fail_preview('Nextcloud-Passwort fehlt.');
$mapping = json_decode((string)file_get_contents($mapping_file), true);
if (!is_array($mapping) || !isset($mapping['files']) || !is_array($mapping['files'])) fail_preview('WebDAV-Mapping ist ungültig.');
if (!is_dir($cache_dir) && !mkdir($cache_dir, 0775, true) && !is_dir($cache_dir)) fail_preview('Preview-Cache konnte nicht angelegt werden.');
@chmod($cache_dir, 2775);
$state_file = $cache_dir.'/state.json';
$old_state = array();
if (is_readable($state_file))
{
$decoded = json_decode((string)file_get_contents($state_file), true);
if (is_array($decoded)) $old_state = $decoded;
}
$new_state = array();
$generated = 0;
$cached = 0;
$hydrated = 0;
$errors = 0;
foreach ($mapping['files'] as $local_path=>$entry)
{
if (!is_array($entry) || (string)($entry['kind'] ?? '') !== 'file') continue;
$webdav_path = trim((string)($entry['webdav_path'] ?? ''), '/');
if ($webdav_path === '') continue;
$etag = (string)($entry['etag'] ?? '');
$key = sha1($webdav_path);
$target = preview_target_for_entry($cache_dir, $key, $entry);
$extension = pathinfo($target, PATHINFO_EXTENSION);
$new_state[$key] = array(
'path' => $webdav_path,
'etag' => $etag,
'format' => $extension,
'version' => BRATONIEN_WEBDAV_PREVIEW_VERSION,
);
try
{
$old = $old_state[$key] ?? null;
$valid_cached = is_file($target)
&& is_array($old)
&& (string)($old['etag'] ?? '') === $etag
&& (string)($old['format'] ?? '') === $extension
&& (int)($old['version'] ?? 0) === BRATONIEN_WEBDAV_PREVIEW_VERSION;
if (!$valid_cached)
{
$url = $base_url.'/remote.php/dav/files/'.rawurlencode($user).'/'.quote_webdav_path($webdav_path);
$blob = fetch_remote_blob($url, $user, $password);
write_preview($blob, $target, $extension);
remove_other_preview_format($cache_dir, $key, $target);
$generated++;
}
else
{
$cached++;
}
if (hydrate_local_source((string)$local_path, $target)) $hydrated++;
}
catch (Throwable $e)
{
$errors++;
fwrite(STDERR, $webdav_path.': '.$e->getMessage()."\n");
}
}
foreach (glob($cache_dir.'/*') ?: array() as $file)
{
if (!is_file($file) || basename($file) === 'state.json') continue;
$name = basename($file);
if (!preg_match('/^([a-f0-9]{40})\.(jpg|png|gif|webp)$/', $name, $m)) continue;
if (!isset($new_state[$m[1]])) @unlink($file);
}
file_put_contents($state_file, json_encode($new_state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)."\n", LOCK_EX);
@chmod($state_file, 0664);
echo 'WebDAV-Previews: erzeugt='.$generated.' vorhanden='.$cached.' lokale_quellen='.$hydrated.' fehler='.$errors."\n";
exit($errors > 0 ? 1 : 0);
}
catch (Throwable $e)
{
fwrite(STDERR, 'WebDAV-Preview-Cache: '.$e->getMessage()."\n");
exit(1);
}

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
require_once(dirname(__DIR__, 2).'/include/nc_transport.inc.php');
try
{
if ($argc !== 2) throw new RuntimeException('Aufruf: resolve-nextcloud-target.php <nextcloud-url>');
$url = rtrim(trim((string)$argv[1]), '/');
bratonien_tools_nc_transport_scheme($url);
$host = bratonien_tools_nc_transport_host($url);
echo bratonien_tools_nc_transport_public_ip($host)."\n";
exit(0);
}
catch (Throwable $e)
{
fwrite(STDERR, $e->getMessage()."\n");
exit(1);
}

0
runtime/lib/shadow_tree.py Normal file → Executable file
View File

108
runtime/native-runner.php Normal file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
$options = getopt('', array('connection-id::'));
$connectionId = isset($options['connection-id']) ? (int)$options['connection-id'] : 0;
$pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2);
$base = rtrim($piwigoRoot, '/').'/_data/bratonien-tools';
$schedulerDir = $base.'/nc-connector-scheduler';
$stateFile = $schedulerDir.'/state.json';
$runtimeDir = $base.'/nc-connector-runtime';
$stateRoot = $base.'/nc-connector-state';
function native_scheduler_state($path)
{
if (!is_readable($path)) return array();
$decoded = json_decode((string)@file_get_contents($path), true);
return is_array($decoded) ? $decoded : array();
}
function native_scheduler_write($path, array $state)
{
$json = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if (!is_string($json)) return false;
$tmp = $path.'.tmp';
if (@file_put_contents($tmp, $json."\n", LOCK_EX) === false) return false;
@chmod($tmp, 0640);
return @rename($tmp, $path);
}
foreach (array($schedulerDir, $runtimeDir, $stateRoot) as $dir)
{
if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir))
{
fwrite(STDERR, "Runtime-Verzeichnis konnte nicht angelegt werden: {$dir}\n");
exit(1);
}
}
$state = native_scheduler_state($stateFile);
$state['enabled'] = true;
$state['mode'] = 'piwigo-native';
$state['state'] = 'running';
$state['message'] = $connectionId > 0 ? 'NC-Abgleich für Verbindung #'.$connectionId.' läuft.' : 'NC-Abgleich läuft.';
$state['started_at'] = time();
$state['timestamp'] = time();
$state['connection_id'] = $connectionId;
native_scheduler_write($stateFile, $state);
$env = $_ENV;
$env['PATH'] = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
$env['BRATONIEN_NC_NATIVE'] = '1';
$env['BRATONIEN_NC_PIWIGO_ROOT'] = $piwigoRoot;
$env['BRATONIEN_NC_CONFIG_DIR'] = $runtimeDir;
$env['BRATONIEN_NC_STATE_ROOT'] = $stateRoot;
$env['BRATONIEN_NC_CONNECTION_ID'] = (string)$connectionId;
$env['LC_ALL'] = 'C';
$env['LANG'] = 'C';
$bash = is_executable('/usr/bin/bash') ? '/usr/bin/bash' : '/bin/bash';
$command = array($bash, $pluginRoot.'/runtime/run-all.sh');
$spec = array(
0=>array('file','/dev/null','r'),
1=>array('pipe','w'),
2=>array('pipe','w'),
);
$process = @proc_open($command, $spec, $pipes, null, $env);
$stdout = '';
$stderr = '';
$exit = 1;
if (is_resource($process))
{
$stdout = (string)stream_get_contents($pipes[1]);
$stderr = (string)stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exit = proc_close($process);
}
else
{
$stderr = 'run-all.sh konnte nicht gestartet werden.';
}
$state = native_scheduler_state($stateFile);
$state['enabled'] = true;
$state['mode'] = 'piwigo-native';
$state['state'] = $exit === 0 ? 'success' : 'error';
$state['message'] = $exit === 0
? ($connectionId > 0 ? 'NC-Abgleich für Verbindung #'.$connectionId.' erfolgreich abgeschlossen.' : 'NC-Abgleich erfolgreich abgeschlossen.')
: ($connectionId > 0 ? 'NC-Abgleich für Verbindung #'.$connectionId.' fehlgeschlagen.' : 'NC-Abgleich fehlgeschlagen.');
$state['timestamp'] = time();
$state['finished_at'] = time();
$state['exit_code'] = $exit;
$state['connection_id'] = $connectionId;
$state['stdout'] = trim($stdout);
$state['stderr'] = trim($stderr);
if (empty($state['next_due']) || (int)$state['next_due'] < time())
{
$state['next_due'] = time() + 60;
}
native_scheduler_write($stateFile, $state);
exit($exit === 0 ? 0 : 1);

View File

@@ -1,219 +0,0 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
function user_scope_fail($message)
{
throw new RuntimeException($message);
}
function user_scope_path_has_segment($path, $segment)
{
$parts = preg_split('#[/\\\\]+#', trim((string)$path, '/\\'));
foreach ($parts as $part)
{
if ((string)$part === (string)$segment) return true;
}
return false;
}
function user_scope_candidate($root, $accessUser, $sourcePrefix = '')
{
$root = rtrim((string)$root, '/');
$sourcePrefix = trim((string)$sourcePrefix, '/');
if ($root === '' || $root[0] !== '/') return null;
if (!is_dir($root) || !is_readable($root)) return null;
$real = realpath($root);
if ($real === false || !user_scope_path_has_segment($real, $accessUser)) return null;
return array('local_mount'=>$real, 'source_prefix'=>$sourcePrefix, 'root'=>$real);
}
function user_scope_storage(array $storage, $accessUser)
{
$mount = rtrim(trim((string)($storage['local_mount'] ?? '')), '/');
$prefix = trim((string)($storage['source_prefix'] ?? ''), '/');
$include = trim((string)($storage['include_prefix'] ?? ''), '/');
$candidates = array();
$add = function($candidate) use (&$candidates) {
if (!$candidate) return;
$candidates[$candidate['root']] = $candidate;
};
// Bereits benutzerspezifisch gespeicherter Mount.
$add(user_scope_candidate($mount, $accessUser, $prefix));
// Nextcloud Home-Storage liegt normalerweise unter <data>/<uid>/files.
// Wir akzeptieren ausschließlich Pfade, die die konkrete UID als eigenes
// Pfadsegment enthalten. Ein generischer Daten-Mount wird nie freigegeben.
if ($mount !== '')
{
$bases = array(
$mount,
dirname($mount),
dirname(dirname($mount)),
);
foreach (array_unique($bases) as $base)
{
$add(user_scope_candidate(rtrim($base, '/').'/'.$accessUser.'/files', $accessUser, ''));
$add(user_scope_candidate(rtrim($base, '/').'/'.$accessUser, $accessUser, 'files'));
}
}
// Falls der alte source_prefix bereits die UID enthaelt, kann daraus ein
// eindeutiger benutzerspezifischer Root abgeleitet werden.
if ($prefix !== '')
{
$parts = explode('/', $prefix);
$userPos = array_search($accessUser, $parts, true);
if ($userPos !== false)
{
$before = array_slice($parts, 0, $userPos);
$after = array_slice($parts, $userPos + 1);
$base = $mount;
if ($before) $base .= '/'.implode('/', $before);
$root = $base.'/'.$accessUser;
if (isset($after[0]) && $after[0] === 'files')
{
$root .= '/files';
array_shift($after);
}
$add(user_scope_candidate($root, $accessUser, implode('/', $after)));
}
}
if (count($candidates) !== 1)
{
$count = count($candidates);
user_scope_fail('Der lokale Dateistamm für Nextcloud-Benutzer '.$accessUser.' konnte nicht eindeutig bestimmt werden ('.$count.' passende Pfade). Die Verbindung wird aus Sicherheitsgründen nicht gestartet.');
}
$resolved = reset($candidates);
return array(
'storage_id'=>'user:'.$accessUser,
'source_prefix'=>$resolved['source_prefix'],
'local_mount'=>$resolved['local_mount'],
'include_prefix'=>$include,
);
}
function user_scope_write_status($piwigoRoot, $id, $message)
{
$dir = rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-connector-status';
if (!is_dir($dir)) @mkdir($dir, 0755, true);
$payload = array(
'state'=>'error',
'message'=>'Benutzerbezogene Datenquelle konnte nicht vorbereitet werden',
'timestamp'=>time(),
'auth_mode'=>'failed',
'api'=>array('state'=>'not_run','message'=>''),
'fallback'=>array('state'=>'not_run','message'=>''),
'error_detail'=>(string)$message,
);
@file_put_contents($dir.'/connection-'.$id.'.json', json_encode($payload, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)."\n", LOCK_EX);
}
function user_scope_shell_value($value)
{
return escapeshellarg((string)$value);
}
$pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2);
$dbConfig = $piwigoRoot.'/local/config/database.inc.php';
$configDir = '/etc/bratonien-tools/nc-connector';
try
{
if (!is_readable($dbConfig)) user_scope_fail('Piwigo-Datenbankkonfiguration ist nicht lesbar.');
$conf = array();
$prefixeTable = 'piwigo_';
require $dbConfig;
foreach (array('db_host','db_user','db_password','db_base') as $key)
{
if (!isset($conf[$key])) user_scope_fail('Piwigo-Datenbankkonfiguration ist unvollständig: '.$key);
}
$db = new mysqli($conf['db_host'], $conf['db_user'], $conf['db_password'], $conf['db_base']);
if ($db->connect_errno) user_scope_fail('Piwigo-Datenbank ist nicht erreichbar: '.$db->connect_error);
$db->set_charset('utf8mb4');
$table = $prefixeTable.'bratonien_tools_nc_connections';
$rows = $db->query("SELECT id,adapter,config_json FROM `{$table}` ORDER BY id");
if (!$rows) user_scope_fail('Connector-Verbindungen konnten nicht gelesen werden: '.$db->error);
while ($row = $rows->fetch_assoc())
{
$id = (int)$row['id'];
if ((string)$row['adapter'] !== 'local') continue;
$config = json_decode((string)$row['config_json'], true);
if (!is_array($config)) continue;
if ((string)($config['origin'] ?? '') !== 'native') continue;
$accessUser = trim((string)($config['nextcloud_access_user'] ?? $config['access_user'] ?? ''));
if ($accessUser === '') continue;
try
{
$storages = isset($config['storages']) && is_array($config['storages']) ? $config['storages'] : array();
if (!$storages) user_scope_fail('Keine Speicherzuordnung für Benutzer '.$accessUser.' vorhanden.');
$resolved = array();
foreach ($storages as $storage)
{
$item = user_scope_storage((array)$storage, $accessUser);
$key = $item['local_mount'].'|'.$item['source_prefix'].'|'.$item['include_prefix'];
$resolved[$key] = $item;
}
$config['storages'] = array_values($resolved);
$config['source_mode'] = 'user-filesystem';
$config['access_user'] = $accessUser;
$json = json_encode($config, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
if (!is_string($json)) user_scope_fail('Benutzerbezogene Connector-Konfiguration konnte nicht serialisiert werden.');
$escaped = $db->real_escape_string($json);
if (!$db->query("UPDATE `{$table}` SET config_json='{$escaped}' WHERE id={$id} LIMIT 1"))
{
user_scope_fail('Benutzerbezogene Connector-Konfiguration konnte nicht gespeichert werden: '.$db->error);
}
$base = $configDir.'/connection-'.$id;
$storagePath = $base.'.storages.tsv';
$configPath = $base.'.conf';
if (!is_file($configPath)) continue;
$lines = array('# storage_id<TAB>source_prefix<TAB>local_mount<TAB>include_prefix');
foreach ($config['storages'] as $storage)
{
$lines[] = (string)$storage['storage_id']."\t".(string)$storage['source_prefix']."\t".(string)$storage['local_mount']."\t".(string)$storage['include_prefix'];
}
file_put_contents($storagePath, implode("\n", $lines)."\n", LOCK_EX);
chmod($storagePath, 0600);
$existing = file($configPath, FILE_IGNORE_NEW_LINES);
if (!is_array($existing)) user_scope_fail('Runtime-Konfiguration konnte nicht gelesen werden.');
$filtered = array_values(array_filter($existing, function($line) {
return strpos($line, 'SOURCE_MODE=') !== 0 && strpos($line, 'ACCESS_USER=') !== 0;
}));
$filtered[] = 'SOURCE_MODE=user-filesystem';
$filtered[] = 'ACCESS_USER='.user_scope_shell_value($accessUser);
file_put_contents($configPath, implode("\n", $filtered)."\n", LOCK_EX);
chmod($configPath, 0600);
}
catch (Throwable $e)
{
@unlink($configDir.'/connection-'.$id.'.conf');
user_scope_write_status($piwigoRoot, $id, $e->getMessage());
fwrite(STDERR, 'NC Connector #'.$id.': '.$e->getMessage()."\n");
}
}
exit(0);
}
catch (Throwable $e)
{
fwrite(STDERR, 'NC Connector User Scope: '.$e->getMessage()."\n");
exit(1);
}

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
function fail_webdav_reconcile($message)
{
throw new RuntimeException($message);
}
function decrypt_webdav_credentials($blob, $hexKey)
{
if (!preg_match('/^[a-f0-9]{64}$/', (string)$hexKey)) fail_webdav_reconcile('Connector-Schluessel ist ungueltig.');
$outer = base64_decode(trim((string)$blob), true);
$payload = is_string($outer) ? json_decode($outer, true) : null;
if (!is_array($payload) || (int)($payload['v'] ?? 0) !== 1) fail_webdav_reconcile('Connector-Zugangsdaten haben ein unbekanntes Format.');
$iv = base64_decode((string)($payload['iv'] ?? ''), true);
$tag = base64_decode((string)($payload['tag'] ?? ''), true);
$cipher = base64_decode((string)($payload['data'] ?? ''), true);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', hex2bin($hexKey), OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false || $plain === '') fail_webdav_reconcile('Connector-Zugangsdaten konnten nicht entschluesselt werden.');
$decoded = json_decode($plain, true);
if (!is_array($decoded)) fail_webdav_reconcile('WebDAV-Verbindung besitzt kein kompatibles Secret-Format.');
return array(
'nextcloud_user'=>(string)($decoded['nextcloud_user'] ?? ''),
'nextcloud_password'=>(string)($decoded['nextcloud_password'] ?? ''),
);
}
function webdav_shell_value($value)
{
return escapeshellarg((string)$value);
}
function webdav_remove_generated_tree($path, $allowedRoot)
{
$path = rtrim((string)$path, '/');
$allowedRoot = rtrim((string)$allowedRoot, '/');
if ($path === '' || $allowedRoot === '' || strpos($path, $allowedRoot.'/') !== 0 || !file_exists($path)) return;
if (is_link($path) || is_file($path))
{
@unlink($path);
return;
}
$items = scandir($path);
if (is_array($items))
{
foreach ($items as $item)
{
if ($item === '.' || $item === '..') continue;
webdav_remove_generated_tree($path.'/'.$item, $allowedRoot);
}
}
@rmdir($path);
}
function webdav_source_fingerprint($baseUrl, $user, array $roots)
{
$normalized = array();
foreach ($roots as $root)
{
$normalized[] = array(
'fileid'=>(int)($root['fileid'] ?? 0),
'path'=>trim((string)($root['webdav_path'] ?? ''), '/'),
);
}
usort($normalized, function($a, $b)
{
$cmp = $a['fileid'] <=> $b['fileid'];
return $cmp !== 0 ? $cmp : strcmp($a['path'], $b['path']);
});
return hash('sha256', strtolower(rtrim((string)$baseUrl, '/'))."\n".strtolower(trim((string)$user))."\n".json_encode($normalized));
}
$pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2);
$dbConfig = $piwigoRoot.'/local/config/database.inc.php';
$nativeMode = getenv('BRATONIEN_NC_NATIVE') === '1';
$configDir = trim((string)getenv('BRATONIEN_NC_CONFIG_DIR'));
if ($configDir === '') $configDir = $nativeMode ? rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-connector-runtime' : '/etc/bratonien-tools/nc-connector';
$stateRoot = trim((string)getenv('BRATONIEN_NC_STATE_ROOT'));
if ($stateRoot === '') $stateRoot = $nativeMode ? rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-connector-state' : '/var/lib/bratonien-tools/nc-connector';
$publicSourceRoot = rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-webdav-source';
$publicGalleryRoot = rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-webdav-gallery';
$legacyGalleryRoot = rtrim($piwigoRoot, '/').'/galleries';
try
{
if (!is_readable($dbConfig)) fail_webdav_reconcile('Piwigo-Datenbankkonfiguration ist nicht lesbar: '.$dbConfig);
$conf = array();
$prefixeTable = 'piwigo_';
require $dbConfig;
foreach (array('db_host','db_user','db_password','db_base') as $key)
{
if (!isset($conf[$key])) fail_webdav_reconcile('Piwigo-Datenbankkonfiguration ist unvollstaendig: '.$key);
}
$db = new mysqli($conf['db_host'], $conf['db_user'], $conf['db_password'], $conf['db_base']);
if ($db->connect_errno) fail_webdav_reconcile('Piwigo-Datenbank ist nicht erreichbar: '.$db->connect_error);
$db->set_charset('utf8mb4');
$keyResult = $db->query("SELECT value FROM `{$prefixeTable}config` WHERE param='bratonien_nc_connector_secret' LIMIT 1");
if (!$keyResult || !$keyResult->num_rows) fail_webdav_reconcile('Connector-Schluessel wurde nicht gefunden.');
$hexKey = trim((string)$keyResult->fetch_assoc()['value']);
$table = $prefixeTable.'bratonien_tools_nc_connections';
$rows = $db->query("SELECT id,name,adapter,config_json,secret_blob FROM `{$table}` ORDER BY id DESC");
if (!$rows) fail_webdav_reconcile('Connector-Verbindungen konnten nicht gelesen werden: '.$db->error);
foreach (array($configDir, $stateRoot, $publicSourceRoot, $publicGalleryRoot) as $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($stateRoot, 0750);
@chmod($publicSourceRoot, 0755);
@chmod($publicGalleryRoot, 0755);
$known = array();
$seenFingerprints = array();
while ($row = $rows->fetch_assoc())
{
$id = (int)$row['id'];
$config = json_decode((string)$row['config_json'], true);
if (!is_array($config)) $config = array();
if ((string)$row['adapter'] !== 'remote') continue;
if ((string)($config['source_mode'] ?? '') !== 'webdav-placeholder') continue;
try
{
$baseUrl = rtrim(trim((string)($config['nextcloud_url'] ?? '')), '/');
$roots = isset($config['roots']) && is_array($config['roots']) ? $config['roots'] : array();
if ($baseUrl === '') fail_webdav_reconcile('Nextcloud-Adresse fehlt.');
if (!$roots) fail_webdav_reconcile('Keine WebDAV-Wurzeln gespeichert.');
$credentials = decrypt_webdav_credentials((string)$row['secret_blob'], $hexKey);
$user = trim($credentials['nextcloud_user']);
$password = $credentials['nextcloud_password'];
if ($user === '' || $password === '') fail_webdav_reconcile('Nextcloud-Zugangsdaten fehlen.');
$fingerprint = webdav_source_fingerprint($baseUrl, $user, $roots);
if (isset($seenFingerprints[$fingerprint]))
{
fwrite(STDERR, "NC WebDAV #{$id}: identische Quelle bereits durch Verbindung #".$seenFingerprints[$fingerprint]." abgedeckt; doppelte Runtime wird unterdrueckt.\n");
foreach (glob($configDir.'/webdav-connection-'.$id.'.*') ?: array() as $stale) @unlink($stale);
webdav_remove_generated_tree($legacyGalleryRoot.'/bratonien-webdav-'.$id, $legacyGalleryRoot);
webdav_remove_generated_tree($publicGalleryRoot.'/connection-'.$id, $publicGalleryRoot);
continue;
}
$seenFingerprints[$fingerprint] = $id;
$known[$id] = true;
$stateDir = $nativeMode ? $stateRoot.'/connection-'.$id : rtrim((string)($config['state_dir'] ?? ''), '/');
if ($stateDir === '') $stateDir = $stateRoot.'/connection-'.$id;
if (!is_dir($stateDir) && !mkdir($stateDir, 0750, true)) fail_webdav_reconcile('State-Verzeichnis konnte nicht angelegt werden.');
@chmod($stateDir, 0750);
$legacyDefault = $legacyGalleryRoot.'/bratonien-webdav-'.$id;
$galleryRoot = rtrim((string)($config['parallel_gallery_root'] ?? ''), '/');
if ($galleryRoot === '' || $galleryRoot === $legacyDefault || strpos($galleryRoot, $legacyGalleryRoot.'/bratonien-webdav-') === 0)
{
$galleryRoot = $publicGalleryRoot.'/connection-'.$id;
}
if (!is_dir($galleryRoot) && !mkdir($galleryRoot, 0755, true)) fail_webdav_reconcile('WebDAV-Galeriebereich konnte nicht angelegt werden.');
@chmod($galleryRoot, 0755);
webdav_remove_generated_tree($legacyDefault, $legacyGalleryRoot);
$sourceDir = $publicSourceRoot.'/connection-'.$id;
if (!is_dir($sourceDir) && !mkdir($sourceDir, 0755, true)) fail_webdav_reconcile('WebDAV-Platzhalterquelle konnte nicht angelegt werden.');
@chmod($sourceDir, 0755);
$base = $configDir.'/webdav-connection-'.$id;
$passwordPath = $base.'.nextcloud-password';
$rootsPath = $base.'.roots.tsv';
$configPath = $base.'.conf';
$statusFile = $stateDir.'/connector-status.json';
$mappingFile = $stateDir.'/webdav-map.json';
$manifestFile = $stateDir.'/webdav-manifest.tsv';
$shadowMapFile = $stateDir.'/webdav-shadow-map.json';
file_put_contents($passwordPath, $password."\n", LOCK_EX);
@chmod($passwordPath, 0600);
$rootLines = array('# webdav_path<TAB>display_name');
foreach ($roots as $root)
{
$path = trim((string)($root['webdav_path'] ?? ''), '/');
$display = trim((string)($root['display_name'] ?? ''));
$runtimePath = $path === '' ? '/' : $path;
if ($display === '' || preg_match('/[\t\r\n]/', $runtimePath.$display)) fail_webdav_reconcile('Eine gespeicherte WebDAV-Wurzel ist ungueltig.');
$rootLines[] = $runtimePath."\t".$display;
}
file_put_contents($rootsPath, implode("\n", $rootLines)."\n", LOCK_EX);
@chmod($rootsPath, 0600);
$lines = array(
'PIWIGO_ROOT='.webdav_shell_value($piwigoRoot),
'CONNECTION_ID='.$id,
'SOURCE_MODE=webdav-placeholder',
'WEBDAV_BASE_URL='.webdav_shell_value($baseUrl),
'WEBDAV_USER='.webdav_shell_value($user),
'WEBDAV_PASSWORD_FILE='.webdav_shell_value($passwordPath),
'WEBDAV_ROOTS_FILE='.webdav_shell_value($rootsPath),
'WEBDAV_SOURCE_DIR='.webdav_shell_value($sourceDir),
'WEBDAV_MAPPING_FILE='.webdav_shell_value($mappingFile),
'MANIFEST='.webdav_shell_value($manifestFile),
'SHADOW_MAP_FILE='.webdav_shell_value($shadowMapFile),
'GALLERY_ROOT='.webdav_shell_value($galleryRoot),
'STATE_DIR='.webdav_shell_value($stateDir),
'STATUS_FILE='.webdav_shell_value($statusFile),
'PIWIGO_SYNC_ENABLED=1',
);
file_put_contents($configPath, implode("\n", $lines)."\n", LOCK_EX);
@chmod($configPath, 0600);
unset($config['migration'], $config['parallel_test']);
$config['state_dir'] = $stateDir;
$config['status_file'] = $statusFile;
$config['parallel_gallery_root'] = $galleryRoot;
$config['source_fingerprint'] = $fingerprint;
$config['runtime'] = array(
'mode'=>$nativeMode ? 'piwigo-native-webdav' : 'webdav',
'config'=>$configPath,
'piwigo_sync_enabled'=>true,
'reconciled_at'=>date('Y-m-d H:i:s'),
);
$json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json)) fail_webdav_reconcile('WebDAV-Runtime-Konfiguration konnte nicht serialisiert werden.');
$escaped = $db->real_escape_string($json);
if (!$db->query("UPDATE `{$table}` SET config_json='{$escaped}' WHERE id={$id} AND adapter='remote' LIMIT 1"))
{
fail_webdav_reconcile('WebDAV-Runtime-Status konnte nicht gespeichert werden: '.$db->error);
}
}
catch (Throwable $e)
{
fwrite(STDERR, "NC WebDAV #{$id}: ".$e->getMessage()."\n");
}
}
foreach (glob($configDir.'/webdav-connection-*.conf') ?: array() as $path)
{
if (!preg_match('/webdav-connection-([0-9]+)\.conf$/', $path, $match)) continue;
$id = (int)$match[1];
if (isset($known[$id])) continue;
foreach (glob($configDir.'/webdav-connection-'.$id.'.*') ?: array() as $stale) @unlink($stale);
}
exit(0);
}
catch (Throwable $e)
{
fwrite(STDERR, "NC WebDAV Reconcile: ".$e->getMessage()."\n");
exit(1);
}

View File

@@ -87,6 +87,35 @@ function sql_reconcile(mysqli $db, $value)
return $db->real_escape_string((string)$value);
}
function nextcloud_view_available(array $config, $password, $view)
{
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', (string)$view)) return false;
$spec = array(0=>array('file','/dev/null','r'),1=>array('pipe','w'),2=>array('pipe','w'));
$command = array(
'psql','-XAt','-v','ON_ERROR_STOP=1',
'-h',(string)$config['host'],'-p',(string)$config['port'],'-U',(string)$config['user'],'-d',(string)$config['database'],
'-c','SELECT 1 FROM '.$view.' LIMIT 1'
);
$env = $_ENV;
$env['PGPASSWORD'] = (string)$password;
$process = @proc_open($command, $spec, $pipes, null, $env);
if (!is_resource($process)) return false;
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
return proc_close($process) === 0;
}
function configured_access_user(array $config)
{
foreach (array('access_user','nextcloud_access_user','showcase_user') as $key)
{
$value = trim((string)($config[$key] ?? ''));
if ($value !== '') return $value;
}
return '';
}
$pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2);
$dbConfig = $piwigoRoot.'/local/config/database.inc.php';
@@ -130,14 +159,13 @@ try
try
{
if ((string)$row['adapter'] !== 'local') continue;
$isActive = (int)$row['enabled'] === 1 && (string)$row['takeover_state'] === 'active';
$accessUser = configured_access_user($config);
$wizardConnection = (string)($config['origin'] ?? '') === 'native'
&& trim((string)($config['nextcloud_url'] ?? '')) !== ''
&& trim((string)($config['showcase_user'] ?? '')) !== '';
&& $accessUser !== '';
$verification = isset($config['verification']) && is_array($config['verification']) ? $config['verification'] : null;
$verificationFailed = is_array($verification) && empty($verification['ok']);
if (!$isActive && (!$wizardConnection || $verificationFailed)) continue;
foreach (array('host','port','database','user','source_view','activity_view','gallery_root') as $key)
@@ -150,10 +178,44 @@ try
$credentials = decrypt_reconcile((string)$row['secret_blob'], $hexKey);
if ($credentials['db_password'] === '') fail_reconcile('Datenbankpasswort fehlt.');
$sourceMode = trim((string)($config['source_mode'] ?? ''));
if ($sourceMode === 'user-filesystem')
{
@unlink($configDir.'/connection-'.$id.'.conf');
fail_reconcile('Diese Verbindung verwendet den verworfenen experimentellen Benutzerpfad-Modus. Bitte die Verbindung mit dem aktuellen Assistenten neu anlegen.');
}
if ($sourceMode === '' || $sourceMode === 'legacy-view')
{
$canMigrate = $accessUser !== ''
&& nextcloud_view_available($config, $credentials['db_password'], 'piwigo_connector_shares')
&& nextcloud_view_available($config, $credentials['db_password'], 'piwigo_connector_activity');
if ($canMigrate)
{
$sourceMode = 'user-shares';
$config['source_mode'] = $sourceMode;
$config['source_view'] = 'piwigo_connector_shares';
$config['activity_view'] = 'piwigo_connector_activity';
$config['access_user'] = $accessUser;
$config['nextcloud_access_user'] = $accessUser;
unset($config['showcase_user']);
echo "NC Connector: Verbindung #{$id} auf generische benutzergefilterte Quellen migriert.\n";
}
else
{
$sourceMode = 'legacy-view';
$config['source_mode'] = $sourceMode;
}
}
if (!in_array($sourceMode, array('legacy-view','user-shares','selected-fileids'), true)) fail_reconcile('Unbekannter Quellenmodus: '.$sourceMode);
if ($sourceMode !== 'legacy-view' && $accessUser === '') fail_reconcile('Für die verbindungsbezogene Quelle fehlt der Nextcloud-Benutzer.');
$roots = isset($config['roots']) && is_array($config['roots']) ? $config['roots'] : array();
if ($sourceMode === 'selected-fileids' && !$roots) fail_reconcile('Für die Verbindung sind keine ausgewählten Nextcloud-Datei-IDs gespeichert.');
if (!$isActive && !array_key_exists('api_enabled', $config))
{
// Alte Wizard-Verbindungen ohne API, aber mit gespeichertem Fallback,
// duerfen niemals den globalen API-Key einer anderen Verbindung erben.
$hasFallback = $credentials['piwigo_user'] !== '' && $credentials['piwigo_password'] !== '';
if (!$hasFallback) fail_reconcile('Die Verbindung besitzt weder einen eigenen API-Zugang noch einen eigenen Fallback.');
$credentials['api_key_id'] = '';
@@ -178,6 +240,7 @@ try
$dbPasswordPath = $base.'.db-password';
$piwigoPasswordPath = $base.'.piwigo-password';
$storagePath = $base.'.storages.tsv';
$rootsPath = $base.'.roots.tsv';
$configPath = $base.'.conf';
$statusFile = $stateDir.'/connector-status.json';
@@ -190,10 +253,7 @@ try
file_put_contents($piwigoPasswordPath, $credentials['piwigo_password']."\n", LOCK_EX);
chmod($piwigoPasswordPath, 0600);
}
else
{
@unlink($piwigoPasswordPath);
}
else @unlink($piwigoPasswordPath);
$storageLines = array('# storage_id<TAB>source_prefix<TAB>local_mount<TAB>include_prefix');
foreach ($storages as $storage)
@@ -206,6 +266,21 @@ try
file_put_contents($storagePath, implode("\n", $storageLines)."\n", LOCK_EX);
chmod($storagePath, 0600);
if ($sourceMode === 'selected-fileids')
{
$rootLines = array('# fileid<TAB>display_name');
foreach ($roots as $root)
{
$fileid = (int)($root['fileid'] ?? 0);
$display = trim((string)($root['display_name'] ?? ''));
if ($fileid < 1 || $display === '' || preg_match('/[\t\r\n]/', $display)) fail_reconcile('Eine gespeicherte Nextcloud-Quelle ist ungueltig.');
$rootLines[] = $fileid."\t".$display;
}
file_put_contents($rootsPath, implode("\n", $rootLines)."\n", LOCK_EX);
chmod($rootsPath, 0600);
}
else @unlink($rootsPath);
$lines = array(
'PIWIGO_ROOT='.$piwigoRoot,
'GALLERY_ROOT='.(string)$config['gallery_root'],
@@ -219,11 +294,14 @@ try
'NC_ACTIVITY_VIEW='.(string)$config['activity_view'],
'NC_DB_PASSWORD_FILE='.$dbPasswordPath,
'STORAGE_CONFIG='.$storagePath,
'SOURCE_MODE='.$sourceMode,
'QUIET_SECONDS='.(int)($config['quiet_seconds'] ?? 120),
'MAX_WAIT_SECONDS='.(int)($config['max_wait_seconds'] ?? 900),
'FULL_SYNC_SECONDS='.(int)($config['full_sync_seconds'] ?? 86400),
'PIWIGO_SYNC_ENABLED=1',
);
if ($sourceMode !== 'legacy-view') $lines[] = 'ACCESS_USER='.escapeshellarg($accessUser);
if ($sourceMode === 'selected-fileids') $lines[] = 'ROOTS_CONFIG='.$rootsPath;
if ($fallbackAvailable)
{
$lines[] = 'PIWIGO_SYNC_USER='.$credentials['piwigo_user'];
@@ -240,7 +318,6 @@ try
$now = date('Y-m-d H:i:s');
$sql = "UPDATE `{$table}` SET enabled=1,takeover_state='active',config_json='".sql_reconcile($db,$json)."',secret_blob='".sql_reconcile($db,$row['secret_blob'])."',updated='".sql_reconcile($db,$now)."' WHERE id=".$id;
if (!$db->query($sql)) fail_reconcile('Runtime-Status konnte nicht gespeichert werden: '.$db->error);
if (!$isActive) echo "NC Connector: Verbindung #{$id} ({$row['name']}) automatisch in die gemeinsame Runtime uebernommen.\n";
}
catch (Throwable $e)

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -Eeuo pipefail
PIWIGO_ROOT="${1:-/var/www/piwigo}"
PIWIGO_ROOT="${PIWIGO_ROOT%/}"
DATA_DIR="$PIWIGO_ROOT/_data"
[[ -d "$DATA_DIR" ]] || { echo "Piwigo-_data wurde nicht gefunden: $DATA_DIR" >&2; exit 1; }
[[ "$(id -u)" -eq 0 ]] || { echo "Die Reparatur muss als root ausgeführt werden." >&2; exit 1; }
DATA_UID="$(stat -c '%u' "$DATA_DIR")"
DATA_GID="$(stat -c '%g' "$DATA_DIR")"
PATHS=(
"$DATA_DIR/bratonien-tools/nc-webdav-gallery"
"$DATA_DIR/bratonien-tools/nc-webdav-preview"
"$DATA_DIR/i/_data/bratonien-tools/nc-webdav-gallery"
"$DATA_DIR/i/bratonien-watermark"
)
found=0
for path in "${PATHS[@]}"; do
[[ -e "$path" ]] || continue
found=1
echo "Repariere: $path"
chown -R "$DATA_UID:$DATA_GID" -- "$path"
find "$path" -type d -exec chmod 2775 {} +
find "$path" -type f -exec chmod 0664 {} +
done
if [[ "$found" -eq 0 ]]; then
echo "Keine Bratonien-Cache-Verzeichnisse gefunden."
exit 0
fi
echo "Bratonien-Cache-Rechte wurden an $DATA_DIR angeglichen (UID $DATA_UID, GID $DATA_GID)."

View File

@@ -1,57 +1,194 @@
#!/usr/bin/env bash
set -Eeuo pipefail
CONFIG_DIR="/etc/bratonien-tools/nc-connector"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PIWIGO_ROOT_DEFAULT="${BRATONIEN_NC_PIWIGO_ROOT:-$(cd -- "$SCRIPT_DIR/../../.." && pwd)}"
CONFIG_DIR="${BRATONIEN_NC_CONFIG_DIR:-/etc/bratonien-tools/nc-connector}"
NATIVE_MODE="${BRATONIEN_NC_NATIVE:-0}"
TARGET_CONNECTION_ID="${BRATONIEN_NC_CONNECTION_ID:-0}"
GLOBAL_LOCK_DIR="${PIWIGO_ROOT_DEFAULT%/}/_data/bratonien-tools/nc-connector-scheduler"
GLOBAL_LOCK_FILE="$GLOBAL_LOCK_DIR/worker.lock"
mkdir -p -- "$GLOBAL_LOCK_DIR"
exec 8>"$GLOBAL_LOCK_FILE"
if ! flock -n 8; then
echo "NC Connector: ein Lauf ist bereits aktiv."
exit 0
fi
shopt -s nullglob
if ! php "$SCRIPT_DIR/reconcile.php"; then
echo "NC Connector: gespeicherte Verbindungen konnten nicht mit der Runtime abgeglichen werden." >&2
if [[ ! "$TARGET_CONNECTION_ID" =~ ^[0-9]+$ ]]; then
echo "NC Connector: ungültige Ziel-Verbindungs-ID: $TARGET_CONNECTION_ID" >&2
exit 1
fi
if ! php "$SCRIPT_DIR/reconcile-user-scope.php"; then
echo "NC Connector: benutzerbezogene Verbindungen konnten nicht sicher vorbereitet werden." >&2
exit 1
read_config_value() {
local key="$1"
local file="$2"
local value
value="$(sed -n "s/^${key}=//p" "$file" | tail -n 1)"
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
printf '%s' "$value"
}
write_route_status() {
local route="$1"
local label="$2"
local detail="$3"
local success="$4"
[[ -n "${ROUTE_STATUS_FILE:-}" ]] || return 0
mkdir -p -- "$(dirname -- "$ROUTE_STATUS_FILE")"
php -r '
$payload = array(
"timestamp" => time(),
"route" => (string)$argv[2],
"label" => (string)$argv[3],
"detail" => (string)$argv[4],
"fallback_used" => false,
"success" => $argv[5] === "1"
);
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if (!is_string($json) || file_put_contents($argv[1], $json.PHP_EOL, LOCK_EX) === false) exit(1);
@chmod($argv[1], 0644);
' "$ROUTE_STATUS_FILE" "$route" "$label" "$detail" "$success"
}
if [[ "$NATIVE_MODE" != "1" ]]; then
php "$SCRIPT_DIR/reconcile.php"
fi
php "$SCRIPT_DIR/reconcile-webdav.php"
php "$SCRIPT_DIR/cleanup-webdav-piwigo.php"
if [[ "$NATIVE_MODE" != "1" ]]; then
php "$SCRIPT_DIR/cleanup-stale.php"
fi
configs=("$CONFIG_DIR"/connection-*.conf)
webdav_configs=("$CONFIG_DIR"/webdav-connection-*.conf)
if [[ ${#configs[@]} -eq 0 ]]; then
echo "Keine aktiven NC-Connector-Verbindungen konfiguriert."
if [[ ${#configs[@]} -eq 0 && ${#webdav_configs[@]} -eq 0 ]]; then
echo "Keine NC-Connector-Verbindungen konfiguriert."
exit 0
fi
result=0
for config in "${configs[@]}"; do
name="$(basename "$config")"
connection_id=""
if [[ "$name" =~ ^connection-([0-9]+)\.conf$ ]]; then
connection_id="${BASH_REMATCH[1]}"
route_piwigo_root=""
for candidate in "${webdav_configs[@]}" "${configs[@]}"; do
[[ -f "$candidate" ]] || continue
candidate_id="$(read_config_value CONNECTION_ID "$candidate")"
if [[ "$TARGET_CONNECTION_ID" -gt 0 && "$candidate_id" != "$TARGET_CONNECTION_ID" ]]; then
continue
fi
route_piwigo_root="$(read_config_value PIWIGO_ROOT "$candidate")"
[[ -n "$route_piwigo_root" ]] && break
done
[[ -n "$route_piwigo_root" ]] || route_piwigo_root="$PIWIGO_ROOT_DEFAULT"
ROUTE_STATUS_FILE="${route_piwigo_root%/}/_data/bratonien-tools/nc-connector-status/route-status.json"
piwigo_root="$(sed -n 's/^PIWIGO_ROOT=//p' "$config" | tail -n 1)"
piwigo_root="${piwigo_root%\"}"
piwigo_root="${piwigo_root#\"}"
piwigo_root="${piwigo_root%\'}"
piwigo_root="${piwigo_root#\'}"
[[ -n "$piwigo_root" ]] || piwigo_root="/var/www/piwigo"
tombstone_dir="${piwigo_root%/}/_data/bratonien-tools/nc-connector-status"
failure_count=0
webdav_count=0
local_count=0
summary_parts=()
matched_count=0
if [[ -n "$connection_id" && -f "$tombstone_dir/deleted-$connection_id" ]]; then
echo "NC Connector: Verbindung $connection_id wurde geloescht; Laufzeitdateien werden entfernt."
rm -f -- "$CONFIG_DIR/connection-$connection_id.conf" \
"$CONFIG_DIR/connection-$connection_id.db-password" \
"$CONFIG_DIR/connection-$connection_id.piwigo-password" \
"$CONFIG_DIR/connection-$connection_id.storages.tsv"
rm -f -- "$tombstone_dir/deleted-$connection_id"
for config in "${webdav_configs[@]}"; do
[[ -f "$config" ]] || continue
name="$(basename "$config")"
connection_id="$(read_config_value CONNECTION_ID "$config")"
if [[ ! "$connection_id" =~ ^[0-9]+$ ]] || [[ "$connection_id" -lt 1 ]]; then
echo "NC Connector: $name besitzt keine gueltige Verbindungs-ID." >&2
failure_count=$((failure_count + 1))
summary_parts+=("$name: ungueltige Verbindungs-ID")
continue
fi
if [[ "$TARGET_CONNECTION_ID" -gt 0 && "$connection_id" != "$TARGET_CONNECTION_ID" ]]; then
continue
fi
echo "NC Connector: $name"
if ! env PIWIGO_CONFIG="$config" bash "$SCRIPT_DIR/sync.sh"; then
result=1
matched_count=$((matched_count + 1))
webdav_count=$((webdav_count + 1))
echo "NC Connector WebDAV #$connection_id: $name"
output=""
if output="$(env PIWIGO_CONFIG="$config" bash "$SCRIPT_DIR/sync-webdav.sh" 2>&1)"; then
[[ -z "$output" ]] || printf '%s\n' "$output"
summary_parts+=("WebDAV #$connection_id erfolgreich")
else
code=$?
[[ -z "$output" ]] || printf '%s\n' "$output" >&2
failure_count=$((failure_count + 1))
summary_parts+=("WebDAV #$connection_id fehlgeschlagen (Exit $code)")
fi
done
exit "$result"
if [[ "$NATIVE_MODE" != "1" ]]; then
for config in "${configs[@]}"; do
[[ -f "$config" ]] || continue
name="$(basename "$config")"
connection_id="0"
if [[ "$name" =~ ^connection-([0-9]+)\.conf$ ]]; then
connection_id="${BASH_REMATCH[1]}"
fi
if [[ "$connection_id" -lt 1 ]]; then
echo "NC Connector: $name besitzt keine gueltige Verbindungs-ID." >&2
failure_count=$((failure_count + 1))
summary_parts+=("$name: ungueltige Verbindungs-ID")
continue
fi
if [[ "$TARGET_CONNECTION_ID" -gt 0 && "$connection_id" != "$TARGET_CONNECTION_ID" ]]; then
continue
fi
matched_count=$((matched_count + 1))
piwigo_root="$(read_config_value PIWIGO_ROOT "$config")"
[[ -n "$piwigo_root" ]] || piwigo_root="$PIWIGO_ROOT_DEFAULT"
tombstone_dir="${piwigo_root%/}/_data/bratonien-tools/nc-connector-status"
if [[ -f "$tombstone_dir/deleted-$connection_id" ]]; then
echo "NC Connector: Verbindung $connection_id wurde geloescht; Laufzeitdateien werden entfernt."
rm -f -- "$CONFIG_DIR/connection-$connection_id.conf" \
"$CONFIG_DIR/connection-$connection_id.db-password" \
"$CONFIG_DIR/connection-$connection_id.piwigo-password" \
"$CONFIG_DIR/connection-$connection_id.storages.tsv" \
"$CONFIG_DIR/connection-$connection_id.roots.tsv"
rm -f -- "$tombstone_dir/deleted-$connection_id"
continue
fi
local_count=$((local_count + 1))
echo "NC Connector Local #$connection_id: $name"
if env PIWIGO_CONFIG="$config" bash "$SCRIPT_DIR/sync.sh"; then
summary_parts+=("Local #$connection_id erfolgreich")
else
failure_count=$((failure_count + 1))
summary_parts+=("Local #$connection_id fehlgeschlagen")
fi
done
fi
if [[ "$TARGET_CONNECTION_ID" -gt 0 && "$matched_count" -eq 0 ]]; then
write_route_status "failed" "FEHLER - Verbindung #$TARGET_CONNECTION_ID" "Keine Laufzeitkonfiguration für Verbindung #$TARGET_CONNECTION_ID gefunden." "0"
echo "NC Connector: keine Laufzeitkonfiguration für Verbindung #$TARGET_CONNECTION_ID gefunden." >&2
exit 1
fi
summary_detail="$(IFS='; '; printf '%s' "${summary_parts[*]}")"
[[ -n "$summary_detail" ]] || summary_detail="Keine Verbindung wurde ausgefuehrt."
if [[ "$failure_count" -eq 0 ]]; then
if [[ "$webdav_count" -gt 0 && "$local_count" -gt 0 ]]; then
route="mixed"
label="WebDAV + Local"
elif [[ "$webdav_count" -gt 0 ]]; then
route="webdav"
label="WebDAV"
else
route="local"
label="Local"
fi
write_route_status "$route" "$label" "$summary_detail" "1"
echo "NC Connector: angeforderte Verbindung wurde erfolgreich verarbeitet."
exit 0
fi
write_route_status "failed" "FEHLER - angeforderte Verbindung" "$summary_detail" "0"
echo "NC Connector: die angeforderte Verbindung ist fehlgeschlagen." >&2
exit 1

196
runtime/sync-webdav.sh Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env bash
set -Eeuo pipefail
CONFIG_FILE="${PIWIGO_CONFIG:-}"
[[ -n "$CONFIG_FILE" && -r "$CONFIG_FILE" ]] || { echo "WebDAV-Konfiguration fehlt: ${CONFIG_FILE:-<leer>}" >&2; exit 1; }
# shellcheck source=/dev/null
source "$CONFIG_FILE"
: "${PIWIGO_ROOT:?PIWIGO_ROOT fehlt}"
: "${CONNECTION_ID:?CONNECTION_ID fehlt}"
: "${WEBDAV_BASE_URL:?WEBDAV_BASE_URL fehlt}"
: "${WEBDAV_USER:?WEBDAV_USER fehlt}"
: "${WEBDAV_PASSWORD_FILE:?WEBDAV_PASSWORD_FILE fehlt}"
: "${WEBDAV_ROOTS_FILE:?WEBDAV_ROOTS_FILE fehlt}"
: "${WEBDAV_SOURCE_DIR:?WEBDAV_SOURCE_DIR fehlt}"
: "${WEBDAV_MAPPING_FILE:?WEBDAV_MAPPING_FILE fehlt}"
: "${MANIFEST:?MANIFEST fehlt}"
: "${SHADOW_MAP_FILE:?SHADOW_MAP_FILE fehlt}"
: "${GALLERY_ROOT:?GALLERY_ROOT fehlt}"
: "${STATE_DIR:?STATE_DIR fehlt}"
: "${STATUS_FILE:?STATUS_FILE fehlt}"
[[ "${SOURCE_MODE:-}" == "webdav-placeholder" ]] || { echo "Falscher SOURCE_MODE: ${SOURCE_MODE:-<leer>}" >&2; exit 1; }
[[ -r "$WEBDAV_PASSWORD_FILE" ]] || { echo "Nextcloud-Passwortdatei fehlt." >&2; exit 1; }
[[ -r "$WEBDAV_ROOTS_FILE" ]] || { echo "WebDAV-Wurzeln fehlen." >&2; exit 1; }
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
LOCK_FILE="$STATE_DIR/webdav-sync.lock"
mkdir -p -- "$STATE_DIR"
exec 9>"$LOCK_FILE"
flock -n 9 || exit 0
write_status() {
local state="$1" message="$2" detail="${3:-}" auth_mode="${4:-webdav}" api_state="${5:-not_run}" api_message="${6:-}" fallback_state="${7:-not_run}" fallback_message="${8:-}"
python3 - "$STATUS_FILE" "$PIWIGO_ROOT" "$CONNECTION_ID" "$state" "$message" "$detail" "$auth_mode" "$api_state" "$api_message" "$fallback_state" "$fallback_message" <<'PY'
import json, os, sys, tempfile, time
(status_file, piwigo_root, connection_id, state, message, detail, auth_mode,
api_state, api_message, fallback_state, fallback_message) = sys.argv[1:]
payload = {
"state": state,
"message": message,
"timestamp": int(time.time()),
"auth_mode": auth_mode,
"api": {"state": api_state, "message": api_message},
"fallback": {"state": fallback_state, "message": fallback_message},
"error_detail": detail if state == "error" else "",
}
public_file = os.path.join(piwigo_root.rstrip('/'), '_data', 'bratonien-tools', 'nc-connector-status', f'connection-{connection_id}.json')
for target in (status_file, public_file):
directory = os.path.dirname(target)
os.makedirs(directory, exist_ok=True)
fd, temporary = tempfile.mkstemp(dir=directory)
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
json.dump(payload, handle, ensure_ascii=False)
handle.write('\n')
os.chmod(temporary, 0o644)
os.replace(temporary, target)
PY
}
failure() {
local code="$1" command="$2" line="$3"
trap - ERR
write_status error "WebDAV-Shadow-Tree fehlgeschlagen" "Exit-Code: $code; Zeile: $line; Befehl: $command"
exit "$code"
}
trap 'failure $? "$BASH_COMMAND" "$LINENO"' ERR
ROOT_ARGS=()
while IFS=$'\t' read -r path display_name; do
[[ -z "$path" || "$path" == \#* ]] && continue
ROOT_ARGS+=(--root "$path")
done < "$WEBDAV_ROOTS_FILE"
[[ ${#ROOT_ARGS[@]} -gt 0 ]] || { write_status error "Keine WebDAV-Wurzeln konfiguriert"; exit 1; }
WEBDAV_CONNECT_IP="$(php "$SCRIPT_DIR/lib/resolve-nextcloud-target.php" "$WEBDAV_BASE_URL")"
[[ -n "$WEBDAV_CONNECT_IP" ]] || { write_status error "Nextcloud-Zieladresse konnte nicht ermittelt werden"; exit 1; }
PLACEHOLDER_OUTPUT=""
PLACEHOLDER_EXIT=0
if PLACEHOLDER_OUTPUT="$(python3 "$SCRIPT_DIR/lib/build_webdav_placeholder_source.py" \
--base-url "$WEBDAV_BASE_URL" \
--connect-ip "$WEBDAV_CONNECT_IP" \
--user "$WEBDAV_USER" \
--password-file "$WEBDAV_PASSWORD_FILE" \
"${ROOT_ARGS[@]}" \
--source-dir "$WEBDAV_SOURCE_DIR" \
--manifest "$MANIFEST" \
--mapping "$WEBDAV_MAPPING_FILE" 2>&1)"; then
PLACEHOLDER_EXIT=0
else
PLACEHOLDER_EXIT=$?
fi
[[ -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" | tail -n 20 | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^[[:space:]]//; s/[[:space:]]$//')"
fi
trap - ERR
write_status error "WebDAV-Shadow-Tree fehlgeschlagen" "$DETAIL"
exit "$PLACEHOLDER_EXIT"
fi
SHADOW_OUTPUT=""
SHADOW_EXIT=0
if SHADOW_OUTPUT="$(python3 "$SCRIPT_DIR/lib/shadow_tree.py" \
--manifest "$MANIFEST" \
--destination "$GALLERY_ROOT" \
--state "$SHADOW_MAP_FILE" 2>&1)"; then
SHADOW_EXIT=0
else
SHADOW_EXIT=$?
fi
[[ -z "$SHADOW_OUTPUT" ]] || printf '%s\n' "$SHADOW_OUTPUT"
if [[ "$SHADOW_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $SHADOW_EXIT"
if [[ -n "$SHADOW_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$SHADOW_OUTPUT" | tail -n 20 | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^[[:space:]]//; s/[[:space:]]$//')"
fi
trap - ERR
write_status error "WebDAV-Shadow-Tree fehlgeschlagen" "$DETAIL"
exit "$SHADOW_EXIT"
fi
trap - ERR
if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
PIWIGO_OUTPUT=""
PIWIGO_EXIT=0
if PIWIGO_OUTPUT="$(php "$SCRIPT_DIR/lib/piwigo-sync.php" \
--piwigo-root="$PIWIGO_ROOT" \
--connection-id="$CONNECTION_ID" \
--base-url="http://127.0.0.1" 2>&1)"; then
PIWIGO_EXIT=0
else
PIWIGO_EXIT=$?
fi
[[ -z "$PIWIGO_OUTPUT" ]] || printf '%s\n' "$PIWIGO_OUTPUT"
if [[ "$PIWIGO_EXIT" -ne 0 ]]; then
DETAIL="Exit-Code: $PIWIGO_EXIT"
if [[ -n "$PIWIGO_OUTPUT" ]]; then
DETAIL+="; Ausgabe: $(printf '%s\n' "$PIWIGO_OUTPUT" | tail -n 12 | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^[[:space:]]//; s/[[:space:]]$//')"
fi
if grep -qi 'Invalid username/password' <<<"$PIWIGO_OUTPUT"; then
write_status error \
"Piwigo-Fallback fehlgeschlagen: Benutzername oder Passwort ist ungültig" \
"$DETAIL" \
"fallback" \
"not_configured" \
"Für diese Verbindung ist keine nutzbare API konfiguriert" \
"error" \
"Piwigo hat Benutzername oder Passwort des Fallback-Zugangs abgelehnt"
elif grep -qi 'Kein gespeicherter Benutzername/Passwort-Fallback\|Kein verbindungseigener Piwigo-Zugang' <<<"$PIWIGO_OUTPUT"; then
write_status error \
"Piwigo-Zugang fehlt: API und Fallback sind nicht nutzbar" \
"$DETAIL" \
"failed" \
"not_configured" \
"Für diese Verbindung ist keine nutzbare API konfiguriert" \
"not_configured" \
"Kein vollständiger Fallback-Zugang gespeichert"
else
write_status error "Piwigo-Synchronisierung des WebDAV-Shadow-Trees fehlgeschlagen" "$DETAIL"
fi
exit "$PIWIGO_EXIT"
fi
if grep -q 'Piwigo-Synchronisierung per API erfolgreich' <<<"$PIWIGO_OUTPUT"; then
write_status ok \
"WebDAV eingelesen und Piwigo synchronisiert" \
"" \
"api" \
"ok" \
"Piwigo-API erfolgreich" \
"not_needed" \
"Fallback wurde nicht benötigt"
elif grep -q 'Piwigo-Datenbanksynchronisierung per Benutzername/Passwort-Fallback erfolgreich' <<<"$PIWIGO_OUTPUT"; then
write_status ok \
"WebDAV eingelesen und Piwigo über Fallback synchronisiert" \
"" \
"fallback" \
"not_used" \
"API war nicht nutzbar" \
"ok" \
"Benutzername/Passwort-Fallback erfolgreich"
else
write_status ok "WebDAV eingelesen und Piwigo synchronisiert"
fi
else
write_status ok "WebDAV eingelesen; Piwigo-Synchronisierung ist für diese Verbindung deaktiviert"
fi

View File

@@ -5,21 +5,25 @@ CONFIG_FILE="${PIWIGO_CONFIG:-/etc/bratonien-tools/nc-connector/connection-1.con
[[ -r "$CONFIG_FILE" ]] || { echo "Konfiguration fehlt: $CONFIG_FILE" >&2; exit 1; }
PIWIGO_SYNC_OVERRIDE_VALUE="${PIWIGO_SYNC_OVERRIDE-}"
# shellcheck source=/dev/null
source "$CONFIG_FILE"
NC_ACTIVITY_VIEW="${NC_ACTIVITY_VIEW:-piwigo_showcase_activity}"
NC_DB_VIEW="${NC_DB_VIEW:-piwigo_showcase_sources}"
SOURCE_MODE="${SOURCE_MODE:-showcase-view}"
: "${NC_ACTIVITY_VIEW:?NC_ACTIVITY_VIEW fehlt}"
: "${NC_DB_VIEW:?NC_DB_VIEW fehlt}"
SOURCE_MODE="${SOURCE_MODE:-legacy-view}"
ACCESS_USER="${ACCESS_USER:-}"
ROOTS_CONFIG="${ROOTS_CONFIG:-}"
case "$SOURCE_MODE" in
showcase-view|user-filesystem) ;;
legacy-view|user-shares|selected-fileids) ;;
*) echo "Unbekannter SOURCE_MODE: $SOURCE_MODE" >&2; exit 1 ;;
esac
if [[ "$SOURCE_MODE" == "user-filesystem" && -z "$ACCESS_USER" ]]; then
echo "ACCESS_USER fehlt fuer die benutzerbezogene Verbindung." >&2
if [[ "$SOURCE_MODE" != "legacy-view" && -z "$ACCESS_USER" ]]; then
echo "ACCESS_USER fehlt fuer die verbindungsbezogene Datenquelle." >&2
exit 1
fi
if [[ "$SOURCE_MODE" == "selected-fileids" && ( -z "$ROOTS_CONFIG" || ! -r "$ROOTS_CONFIG" ) ]]; then
echo "ROOTS_CONFIG fehlt fuer die ausgewaehlten Nextcloud-Quellen." >&2
exit 1
fi
@@ -71,9 +75,7 @@ compact_output() {
write_status() {
local state="$1" message="$2"
local error_detail="$ERROR_DETAIL"
if [[ "$state" != "error" ]]; then
error_detail=""
fi
[[ "$state" == "error" ]] || error_detail=""
python3 - "$STATUS_FILE" "$PUBLIC_STATUS_FILE" "$state" "$message" "$AUTH_MODE" "$API_STATE" "$API_MESSAGE" "$FALLBACK_STATE" "$FALLBACK_MESSAGE" "$error_detail" <<'PY'
import json, os, sys, tempfile, time
(path, public_path, state, message, auth_mode, api_state, api_message,
@@ -116,27 +118,16 @@ failure() {
trap 'failure $? "$BASH_COMMAND" "$LINENO"' ERR
run_stage() {
local stage="$1"
local message="$2"
local stage="$1" message="$2"
shift 2
local output=""
local exit_code=0
local output="" exit_code=0
ERROR_STAGE="$stage"
ERROR_MESSAGE="$message"
if output="$("$@" 2>&1)"; then
exit_code=0
else
exit_code=$?
fi
if [[ -n "$output" ]]; then
printf '%s\n' "$output"
fi
if output="$("$@" 2>&1)"; then exit_code=0; else exit_code=$?; fi
[[ -z "$output" ]] || printf '%s\n' "$output"
if [[ "$exit_code" -ne 0 ]]; then
ERROR_DETAIL="Schritt: $stage. Exit-Code: $exit_code."
if [[ -n "$output" ]]; then
ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$output" | compact_output)"
fi
[[ -z "$output" ]] || ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$output" | compact_output)"
trap - ERR
write_status error "$message"
exit "$exit_code"
@@ -151,16 +142,11 @@ if [[ ! -s "$MAP_FILE" ]]; then
else
set +e
python3 - "$MAP_FILE" "$GALLERY_ROOT" <<'PY'
import json
import sys
import json, sys
from pathlib import Path
mapping = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
gallery = Path(sys.argv[2])
roots = [
target for source, target in mapping.items()
if source.startswith("share:") and "/" not in source
]
roots = [target for source, target in mapping.items() if source.startswith("share:") and "/" not in source]
raise SystemExit(0 if roots and all((gallery / target).is_dir() for target in roots) else 1)
PY
ROOTS_INTACT=$?
@@ -176,108 +162,76 @@ if [[ "$NEEDS_LOCAL_REPAIR" == "0" && "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; the
[[ "$PIWIGO_ALBUMS_INTACT" == "0" ]] || NEEDS_LOCAL_REPAIR=1
fi
if [[ "$SOURCE_MODE" == "user-filesystem" ]]; then
# Benutzerbezogene Verbindungen werden pro Timerlauf aus ihrem eigenen
# lokalen Home-Dateibaum aufgebaut. Die alte globale Showcase-Aktivitaets-
# View darf hier weder Daten anderer Benutzer steuern noch Aenderungen
# dieses Benutzers verschlucken.
GATE_RESULT=0
elif [[ "$NEEDS_LOCAL_REPAIR" == "1" ]]; then
GATE_ARGS=(
--state "$ACTIVITY_STATE" --host "$NC_DB_HOST" --port "$NC_DB_PORT"
--database "$NC_DB_NAME" --user "$NC_DB_USER" --password-file "$NC_DB_PASSWORD_FILE"
--view "$NC_ACTIVITY_VIEW" --source-view "$NC_DB_VIEW"
--quiet "$QUIET_SECONDS" --max-wait "$MAX_WAIT_SECONDS" --full-after "$FULL_SYNC_SECONDS"
)
if [[ "$SOURCE_MODE" != "legacy-view" ]]; then GATE_ARGS+=(--access-user "$ACCESS_USER"); fi
if [[ "$SOURCE_MODE" == "selected-fileids" ]]; then GATE_ARGS+=(--roots-config "$ROOTS_CONFIG"); fi
if [[ "$NEEDS_LOCAL_REPAIR" == "1" ]]; then
GATE_RESULT=0
else
ERROR_STAGE="Nextcloud-Aktivität prüfen"
ERROR_MESSAGE="Nextcloud-Aktivität konnte nicht geprüft werden"
if GATE_OUTPUT="$(python3 "$SCRIPT_DIR/lib/activity_gate.py" check \
--state "$ACTIVITY_STATE" --host "$NC_DB_HOST" --port "$NC_DB_PORT" \
--database "$NC_DB_NAME" --user "$NC_DB_USER" --password-file "$NC_DB_PASSWORD_FILE" \
--view "$NC_ACTIVITY_VIEW" --source-view "$NC_DB_VIEW" \
--quiet "$QUIET_SECONDS" --max-wait "$MAX_WAIT_SECONDS" --full-after "$FULL_SYNC_SECONDS" 2>&1)"; then
GATE_RESULT=0
else
GATE_RESULT=$?
fi
if [[ -n "$GATE_OUTPUT" ]]; then
printf '%s\n' "$GATE_OUTPUT"
fi
ERROR_STAGE="Nextcloud-Aktivitaet pruefen"
ERROR_MESSAGE="Nextcloud-Aktivitaet konnte nicht geprueft werden"
if GATE_OUTPUT="$(python3 "$SCRIPT_DIR/lib/activity_gate.py" check "${GATE_ARGS[@]}" 2>&1)"; then GATE_RESULT=0; else GATE_RESULT=$?; fi
[[ -z "$GATE_OUTPUT" ]] || printf '%s\n' "$GATE_OUTPUT"
fi
if [[ "$GATE_RESULT" == "3" ]]; then
trap - ERR
write_status ok "Keine Änderungen gefunden"
write_status ok "Keine Aenderungen gefunden"
exit 0
fi
if [[ "$GATE_RESULT" != "0" ]]; then
ERROR_DETAIL="Schritt: Nextcloud-Aktivität prüfen. Exit-Code: $GATE_RESULT."
if [[ -n "${GATE_OUTPUT:-}" ]]; then
ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$GATE_OUTPUT" | compact_output)"
fi
ERROR_DETAIL="Schritt: Nextcloud-Aktivitaet pruefen. Exit-Code: $GATE_RESULT."
[[ -z "${GATE_OUTPUT:-}" ]] || ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$GATE_OUTPUT" | compact_output)"
trap - ERR
write_status error "Nextcloud-Aktivität konnte nicht geprüft werden"
write_status error "Nextcloud-Aktivitaet konnte nicht geprueft werden"
exit "$GATE_RESULT"
fi
if [[ "$SOURCE_MODE" == "user-filesystem" ]]; then
run_stage \
"Benutzer-Dateiliste lesen" \
"Dateiliste des Nextcloud-Benutzers konnte nicht erstellt werden" \
python3 "$SCRIPT_DIR/lib/build_user_manifest.py" \
--storage-config "$STORAGE_CONFIG" --output "$MANIFEST"
else
run_stage \
"Nextcloud-Dateiliste lesen" \
"Dateiliste aus Nextcloud konnte nicht erstellt werden" \
python3 "$SCRIPT_DIR/lib/build_manifest.py" \
if [[ "$SOURCE_MODE" == "selected-fileids" ]]; then
run_stage "Ausgewaehlte Nextcloud-Quellen aufloesen" "Ausgewaehlte Nextcloud-Quellen konnten nicht aufgeloest werden" \
python3 "$SCRIPT_DIR/lib/build_selected_manifest.py" \
--host "$NC_DB_HOST" --port "$NC_DB_PORT" --database "$NC_DB_NAME" --user "$NC_DB_USER" \
--password-file "$NC_DB_PASSWORD_FILE" --view "$NC_DB_VIEW" \
--storage-config "$STORAGE_CONFIG" --roots-config "$ROOTS_CONFIG" --output "$MANIFEST"
else
MANIFEST_ARGS=(
--host "$NC_DB_HOST" --port "$NC_DB_PORT" --database "$NC_DB_NAME" --user "$NC_DB_USER"
--password-file "$NC_DB_PASSWORD_FILE" --view "$NC_DB_VIEW"
--storage-config "$STORAGE_CONFIG" --output "$MANIFEST"
)
if [[ "$SOURCE_MODE" == "user-shares" ]]; then MANIFEST_ARGS+=(--access-user "$ACCESS_USER"); fi
run_stage "Nextcloud-Dateiliste lesen" "Dateiliste aus Nextcloud konnte nicht erstellt werden" \
python3 "$SCRIPT_DIR/lib/build_manifest.py" "${MANIFEST_ARGS[@]}"
fi
run_stage \
"Lokalen Galeriebaum aktualisieren" \
"Lokaler Galeriebaum konnte nicht aktualisiert werden" \
python3 "$SCRIPT_DIR/lib/shadow_tree.py" \
--manifest "$MANIFEST" --destination "$GALLERY_ROOT" --state "$MAP_FILE"
run_stage "Lokalen Galeriebaum aktualisieren" "Lokaler Galeriebaum konnte nicht aktualisiert werden" \
python3 "$SCRIPT_DIR/lib/shadow_tree.py" --manifest "$MANIFEST" --destination "$GALLERY_ROOT" --state "$MAP_FILE"
if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
ERROR_STAGE="Piwigo synchronisieren"
ERROR_MESSAGE="Piwigo-Synchronisierung fehlgeschlagen"
if PIWIGO_OUTPUT="$(php "$SCRIPT_DIR/lib/piwigo-sync.php" \
--piwigo-root="$PIWIGO_ROOT" \
--connection-id="$CONNECTION_ID" \
--base-url="http://127.0.0.1" 2>&1)"; then
PIWIGO_EXIT=0
else
PIWIGO_EXIT=$?
fi
if PIWIGO_OUTPUT="$(php "$SCRIPT_DIR/lib/piwigo-sync.php" --piwigo-root="$PIWIGO_ROOT" --connection-id="$CONNECTION_ID" --base-url="http://127.0.0.1" 2>&1)"; then PIWIGO_EXIT=0; else PIWIGO_EXIT=$?; fi
printf '%s\n' "$PIWIGO_OUTPUT"
if grep -q 'Piwigo-Synchronisierung per API erfolgreich' <<<"$PIWIGO_OUTPUT"; then
AUTH_MODE="api"
API_STATE="ok"
API_MESSAGE="API-Synchronisierung erfolgreich"
FALLBACK_STATE="not_needed"
FALLBACK_MESSAGE="Fallback wurde nicht benötigt"
AUTH_MODE="api"; API_STATE="ok"; API_MESSAGE="API-Synchronisierung erfolgreich"
FALLBACK_STATE="not_needed"; FALLBACK_MESSAGE="Fallback wurde nicht benoetigt"
else
API_LINE="$(grep -m1 '^Piwigo-API nicht nutzbar:' <<<"$PIWIGO_OUTPUT" || true)"
if [[ -n "$API_LINE" ]]; then
API_STATE="error"
API_MESSAGE="${API_LINE#Piwigo-API nicht nutzbar: }"
else
API_STATE="error"
API_MESSAGE="API-Synchronisierung war nicht erfolgreich"
fi
API_STATE="error"
if [[ -n "$API_LINE" ]]; then API_MESSAGE="${API_LINE#Piwigo-API nicht nutzbar: }"; else API_MESSAGE="API-Synchronisierung war nicht erfolgreich"; fi
if grep -q 'Piwigo-Datenbanksynchronisierung per Benutzername/Passwort-Fallback erfolgreich' <<<"$PIWIGO_OUTPUT"; then
AUTH_MODE="fallback"
FALLBACK_STATE="ok"
FALLBACK_MESSAGE="Benutzername/Passwort-Fallback erfolgreich"
AUTH_MODE="fallback"; FALLBACK_STATE="ok"; FALLBACK_MESSAGE="Benutzername/Passwort-Fallback erfolgreich"
elif [[ "$PIWIGO_EXIT" -ne 0 ]]; then
AUTH_MODE="failed"
FALLBACK_STATE="error"
FALLBACK_MESSAGE="$(tail -n 1 <<<"$PIWIGO_OUTPUT")"
AUTH_MODE="failed"; FALLBACK_STATE="error"; FALLBACK_MESSAGE="$(tail -n 1 <<<"$PIWIGO_OUTPUT")"
fi
fi
if [[ "$PIWIGO_EXIT" -ne 0 ]]; then
ERROR_DETAIL="Schritt: Piwigo synchronisieren. Exit-Code: $PIWIGO_EXIT. Ausgabe: $(printf '%s\n' "$PIWIGO_OUTPUT" | compact_output)"
trap - ERR
@@ -286,38 +240,31 @@ if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
fi
fi
if [[ "$SOURCE_MODE" != "user-filesystem" ]]; then
ERROR_STAGE="Aktivitätsstand speichern"
ERROR_MESSAGE="Aktivitätsstand konnte nicht gespeichert werden"
if COMMIT_OUTPUT="$(python3 "$SCRIPT_DIR/lib/activity_gate.py" commit \
--state "$ACTIVITY_STATE" --host "$NC_DB_HOST" --port "$NC_DB_PORT" \
--database "$NC_DB_NAME" --user "$NC_DB_USER" --password-file "$NC_DB_PASSWORD_FILE" \
--view "$NC_ACTIVITY_VIEW" --source-view "$NC_DB_VIEW" 2>&1)"; then
COMMIT_EXIT=0
else
COMMIT_EXIT=$?
fi
if [[ -n "$COMMIT_OUTPUT" ]]; then
printf '%s\n' "$COMMIT_OUTPUT"
fi
if [[ "$COMMIT_EXIT" -ne 0 ]]; then
ERROR_DETAIL="Schritt: Aktivitätsstand speichern. Exit-Code: $COMMIT_EXIT."
if [[ -n "$COMMIT_OUTPUT" ]]; then
ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$COMMIT_OUTPUT" | compact_output)"
fi
trap - ERR
write_status error "Aktivitätsstand konnte nicht gespeichert werden"
exit "$COMMIT_EXIT"
fi
COMMIT_ARGS=(
--state "$ACTIVITY_STATE" --host "$NC_DB_HOST" --port "$NC_DB_PORT"
--database "$NC_DB_NAME" --user "$NC_DB_USER" --password-file "$NC_DB_PASSWORD_FILE"
--view "$NC_ACTIVITY_VIEW" --source-view "$NC_DB_VIEW"
)
if [[ "$SOURCE_MODE" != "legacy-view" ]]; then COMMIT_ARGS+=(--access-user "$ACCESS_USER"); fi
if [[ "$SOURCE_MODE" == "selected-fileids" ]]; then COMMIT_ARGS+=(--roots-config "$ROOTS_CONFIG"); fi
ERROR_STAGE="Aktivitaetsstand speichern"
ERROR_MESSAGE="Aktivitaetsstand konnte nicht gespeichert werden"
if COMMIT_OUTPUT="$(python3 "$SCRIPT_DIR/lib/activity_gate.py" commit "${COMMIT_ARGS[@]}" 2>&1)"; then COMMIT_EXIT=0; else COMMIT_EXIT=$?; fi
[[ -z "$COMMIT_OUTPUT" ]] || printf '%s\n' "$COMMIT_OUTPUT"
if [[ "$COMMIT_EXIT" -ne 0 ]]; then
ERROR_DETAIL="Schritt: Aktivitaetsstand speichern. Exit-Code: $COMMIT_EXIT."
[[ -z "$COMMIT_OUTPUT" ]] || ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$COMMIT_OUTPUT" | compact_output)"
trap - ERR
write_status error "Aktivitaetsstand konnte nicht gespeichert werden"
exit "$COMMIT_EXIT"
fi
trap - ERR
if [[ "$AUTH_MODE" == "fallback" ]]; then
write_status warning "Synchronisierung erfolgreich über Fallback; API war nicht nutzbar"
write_status warning "Synchronisierung erfolgreich ueber Fallback; API war nicht nutzbar"
elif [[ "$AUTH_MODE" == "api" ]]; then
write_status ok "Synchronisierung erfolgreich über API"
elif [[ "$SOURCE_MODE" == "user-filesystem" ]]; then
write_status ok "Benutzerbezogene Synchronisierung erfolgreich"
write_status ok "Synchronisierung erfolgreich ueber API"
else
write_status ok "Synchronisierung erfolgreich"
fi

View File

@@ -48,10 +48,31 @@
var openButton=document.getElementById('bratonien-nc-wizard-open');
var closeButton=document.getElementById('bratonien-nc-wizard-close');
var storageKey='bratonienNcWizardOpen';
var modeKey='bratonienNcWizardMode';
var resetBusy=false;
function token(){var input=section.querySelector('input[name="pwg_token"]');return input?input.value:'';}
function setOpen(value){try{if(value)sessionStorage.setItem(storageKey,'1');else sessionStorage.removeItem(storageKey);}catch(e){}}
function setMode(value){try{if(value)sessionStorage.setItem(modeKey,value);else sessionStorage.removeItem(modeKey);}catch(e){}}
function mode(){try{return sessionStorage.getItem(modeKey)||'';}catch(e){return '';}}
function applyMode(){
if(!dialog)return;
var heading=dialog.querySelector('h4');
var finish=dialog.querySelector('button[value="nc_connector_wizard_finish"]');
if(mode()==='edit'){
if(heading)heading.textContent='Verbindung bearbeiten';
if(finish)finish.textContent='Änderungen speichern';
}else{
if(heading)heading.textContent='Neue Verbindung';
if(finish)finish.textContent='Verbindung anlegen';
}
}
function showWizard(){
setOpen(true);applyMode();
if(!dialog)return;
if(typeof dialog.showModal==='function'&&!dialog.open)dialog.showModal();
else dialog.setAttribute('open','open');
}
function resetServer(){
var pwgToken=token();
if(!pwgToken)return Promise.reject(new Error('CSRF token missing'));
@@ -59,14 +80,13 @@
return fetch(window.location.href,{method:'POST',credentials:'same-origin',cache:'no-store',headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:body.toString()});
}
function closeAfterReset(){
if(resetBusy)return;resetBusy=true;setOpen(false);
if(resetBusy)return;resetBusy=true;setOpen(false);setMode('');
resetServer().catch(function(){}).finally(function(){resetBusy=false;if(dialog){if(typeof dialog.close==='function'&&dialog.open)dialog.close();else dialog.removeAttribute('open');}});
}
if(openButton){
openButton.addEventListener('click',function(event){
if(resetBusy)return;event.preventDefault();event.stopImmediatePropagation();resetBusy=true;setOpen(true);
resetServer().then(function(){window.location.reload();}).catch(function(){setOpen(false);resetBusy=false;});
event.preventDefault();event.stopImmediatePropagation();setMode('');showWizard();
},true);
}
if(closeButton){
@@ -75,12 +95,37 @@
if(dialog){
dialog.addEventListener('cancel',function(event){event.preventDefault();event.stopImmediatePropagation();closeAfterReset();},true);
dialog.addEventListener('click',function(event){if(event.target===dialog){event.preventDefault();event.stopImmediatePropagation();closeAfterReset();}},true);
[].slice.call(dialog.querySelectorAll('form[data-bratonien-wizard-form]')).forEach(function(form){
form.addEventListener('submit',function(event){
var submitter=event.submitter;
setOpen(true);
if(submitter&&submitter.hasAttribute('data-bratonien-wizard-end')){
setOpen(false);
if(submitter.value==='nc_connector_wizard_reset'||submitter.value==='nc_connector_wizard_finish')setMode('');
}
},true);
});
[].slice.call(section.querySelectorAll('form')).forEach(function(form){
var editButton=form.querySelector('button[value="nc_connector_edit_start"]');
if(!editButton)return;
form.addEventListener('submit',function(event){
if(event.submitter&&event.submitter.value==='nc_connector_edit_start'){
setMode('edit');setOpen(true);
}
},true);
});
try{if(sessionStorage.getItem(storageKey)==='1')showWizard();}catch(e){}
applyMode();
}
}
function initNCConnectorPolling(){
var section=document.getElementById('nc-connector');if(!section)return;
var endpoint='plugins/bratonien_tools/nc-connector-status.php';
var pollTimer=null;
function valueNodeForLabel(labelText){
var labels=[].slice.call(section.querySelectorAll('.bratonien-label'));
@@ -108,18 +153,25 @@
var nextRunNode=valueNodeForLabel('Nächster Lauf');
var lastResultNode=lastResultValueNode();
function render(data){
if(lastRunNode&&typeof data.last_run_label==='string'&&data.last_run_label!=='')lastRunNode.textContent=data.last_run_label;
if(nextRunNode&&typeof data.next_run_label==='string'&&data.next_run_label!=='')nextRunNode.textContent=data.next_run_label;
if(lastResultNode&&typeof data.message==='string'&&data.message!=='')lastResultNode.textContent=data.message;
}
function poll(){
fetch(endpoint+'?_='+Date.now(),{credentials:'same-origin',cache:'no-store'})
fetch(endpoint+'?_='+Date.now(),{credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}})
.then(function(response){if(!response.ok)throw new Error('HTTP '+response.status);return response.json();})
.then(function(data){
if(lastRunNode&&data.last_run_label)lastRunNode.textContent=data.last_run_label;
if(nextRunNode&&data.next_run_label)nextRunNode.textContent=data.next_run_label;
if(lastResultNode&&typeof data.message==='string'&&data.message!=='')lastResultNode.textContent=data.message;
})
.then(render)
.catch(function(){});
}
poll();window.setInterval(poll,5000);
function schedule(){
if(pollTimer)window.clearInterval(pollTimer);
pollTimer=window.setInterval(poll,5000);
}
poll();schedule();
}
function initAll(){initBratonienTabs();initNCWizardLifecycle();initNCConnectorPolling();}

187
webdav-image.php Normal file
View File

@@ -0,0 +1,187 @@
<?php
define('PHPWG_ROOT_PATH', '../../');
include_once(PHPWG_ROOT_PATH.'include/common.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_image_runtime.inc.php');
require_once(BRATONIEN_TOOLS_PATH.'include/nc_transport.inc.php');
function bratonien_tools_webdav_image_abort($status, $message)
{
http_response_code((int)$status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
function bratonien_tools_webdav_image_decrypt_secret($blob, $hex_key)
{
$hex_key = trim((string)$hex_key);
if (!preg_match('/^[a-f0-9]{64}$/', $hex_key)) return null;
$outer = base64_decode(trim((string)$blob), true);
$payload = is_string($outer) ? json_decode($outer, true) : null;
if (!is_array($payload) || (int)($payload['v'] ?? 0) !== 1) return null;
$iv = base64_decode((string)($payload['iv'] ?? ''), true);
$tag = base64_decode((string)($payload['tag'] ?? ''), true);
$cipher = base64_decode((string)($payload['data'] ?? ''), true);
if (!is_string($iv) || !is_string($tag) || !is_string($cipher)) return null;
$plain = openssl_decrypt($cipher, 'aes-256-gcm', hex2bin($hex_key), OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false) return null;
$decoded = json_decode((string)$plain, true);
return is_array($decoded) ? $decoded : null;
}
function bratonien_tools_webdav_image_quote_path($path)
{
$parts = array_values(array_filter(explode('/', trim((string)$path, '/')), 'strlen'));
return implode('/', array_map('rawurlencode', $parts));
}
$image_id = (int)($_GET['id'] ?? 0);
if ($image_id < 1) bratonien_tools_webdav_image_abort(400, 'Bild-ID fehlt.');
$permission_condition = get_sql_condition_FandF(array('forbidden_categories'=>'category_id'), null, true);
$access_result = pwg_query('SELECT 1 FROM '.IMAGE_CATEGORY_TABLE.' WHERE image_id='.$image_id.' AND '.$permission_condition.' LIMIT 1');
if (!pwg_db_num_rows($access_result)) bratonien_tools_webdav_image_abort(403, 'Kein Zugriff auf dieses Bild.');
$source = bratonien_tools_webdav_image_source_info($image_id);
if (!$source) bratonien_tools_webdav_image_abort(404, 'Keine WebDAV-Quelle für dieses Bild gefunden.');
if (!empty($_GET['ajaxload']))
{
$preview = !empty($_GET['preview']);
$final_url = bratonien_tools_webdav_image_url($image_id, $preview);
if (!$final_url) bratonien_tools_webdav_image_abort(404, 'Keine WebDAV-Bild-URL verfügbar.');
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
echo json_encode(array('url'=>$final_url), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
if (!empty($_GET['preview']))
{
$preview = bratonien_tools_webdav_preview_path($source);
if ($preview)
{
$mtime = @filemtime($preview) ?: time();
$etag = sha1($preview.'|'.$mtime.'|'.(@filesize($preview) ?: 0));
header('Content-Type: '.bratonien_tools_webdav_preview_content_type($preview));
header('Content-Length: '.(string)filesize($preview));
header('ETag: "'.$etag.'"');
header('Cache-Control: private, max-age=86400, must-revalidate');
header('X-Content-Type-Options: nosniff');
$client_etag = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), " \t\r\n\"");
if ($client_etag !== '' && hash_equals($etag, $client_etag))
{
http_response_code(304);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'HEAD') readfile($preview);
exit;
}
}
$table = $GLOBALS['prefixeTable'].'bratonien_tools_nc_connections';
$result = pwg_query('SELECT config_json, secret_blob FROM `'.$table.'` WHERE id='.(int)$source['connection_id'].' LIMIT 1');
if (!pwg_db_num_rows($result)) bratonien_tools_webdav_image_abort(404, 'WebDAV-Verbindung nicht gefunden.');
$row = pwg_db_fetch_assoc($result);
$config = json_decode((string)$row['config_json'], true);
if (!is_array($config)) bratonien_tools_webdav_image_abort(500, 'WebDAV-Konfiguration ist ungültig.');
$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)) bratonien_tools_webdav_image_abort(500, 'Connector-Schlüssel fehlt.');
$key_row = pwg_db_fetch_assoc($key_result);
$credentials = bratonien_tools_webdav_image_decrypt_secret((string)$row['secret_blob'], (string)$key_row['value']);
if (!is_array($credentials)) bratonien_tools_webdav_image_abort(500, 'WebDAV-Zugangsdaten konnten nicht gelesen werden.');
$base_url = rtrim((string)($config['nextcloud_url'] ?? ''), '/');
$user = trim((string)($credentials['nextcloud_user'] ?? ''));
$password = (string)($credentials['nextcloud_password'] ?? '');
$webdav_path = trim((string)$source['webdav_path'], '/');
if ($base_url === '' || $user === '' || $password === '' || $webdav_path === '')
{
bratonien_tools_webdav_image_abort(500, 'WebDAV-Bildquelle ist unvollständig.');
}
$etag = trim((string)($source['etag'] ?? ''));
if ($etag !== '')
{
header('ETag: "'.str_replace('"', '', $etag).'"');
$client_etag = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''), " \t\r\n\"");
if ($client_etag !== '' && hash_equals($etag, $client_etag))
{
http_response_code(304);
exit;
}
}
header('Cache-Control: private, max-age=300, must-revalidate');
header('X-Content-Type-Options: nosniff');
$url = $base_url.'/remote.php/dav/files/'.rawurlencode($user).'/'.bratonien_tools_webdav_image_quote_path($webdav_path);
$ch = curl_init($url);
$options = array(
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $user.':'.$password,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_FAILONERROR => false,
CURLOPT_USERAGENT => 'Bratonien-Tools-WebDAV-Image/0.9.7.24',
CURLOPT_HEADERFUNCTION => function($ch, $line)
{
$length = strlen($line);
$trimmed = trim($line);
if ($trimmed === '') return $length;
if (preg_match('#^HTTP/\S+\s+([0-9]{3})#i', $trimmed, $m))
{
http_response_code((int)$m[1]);
return $length;
}
$colon = strpos($line, ':');
if ($colon === false) return $length;
$name = strtolower(trim(substr($line, 0, $colon)));
$value = trim(substr($line, $colon + 1));
if (in_array($name, array('content-type','content-length','content-range','accept-ranges','last-modified'), true))
{
header($name.': '.$value, true);
}
return $length;
},
CURLOPT_WRITEFUNCTION => function($ch, $data)
{
echo $data;
return strlen($data);
},
);
bratonien_tools_nc_transport_apply_curl($options, $url);
if (!empty($_SERVER['HTTP_RANGE']))
{
$range = trim((string)$_SERVER['HTTP_RANGE']);
if (preg_match('/^bytes=(.+)$/i', $range, $m)) $options[CURLOPT_RANGE] = $m[1];
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD')
{
$options[CURLOPT_NOBODY] = true;
}
curl_setopt_array($ch, $options);
$ok = curl_exec($ch);
$errno = curl_errno($ch);
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($ok === false || $errno !== 0)
{
if (!headers_sent()) bratonien_tools_webdav_image_abort(502, 'Nextcloud-Bild konnte nicht geladen werden.');
exit;
}
if ($http < 200 || $http >= 400)
{
exit;
}