Compare commits

..

13 Commits

Author SHA1 Message Date
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
13 changed files with 939 additions and 727 deletions

View File

@@ -0,0 +1,99 @@
<?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_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

@@ -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

@@ -26,6 +26,7 @@ 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');
function bratonien_tools_get_tools()
{
@@ -63,7 +64,7 @@ function bratonien_tools_get_tools()
'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_generic_scope'),
'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'),

View File

@@ -1,7 +1,7 @@
<?php
/*
Plugin Name: Bratonien Tools
Version: 0.9.5.2
Version: 0.9.5.3
Description: Erweiterbare Administrationswerkzeuge fuer die Bratonien-Piwigo-Installation.
Plugin URI: https://github.com/Terranom674/Piwigo_Bratonien_Tools
Author: Bratonien

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

@@ -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

@@ -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

@@ -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

@@ -10,11 +10,6 @@ if ! php "$SCRIPT_DIR/reconcile.php"; then
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
fi
configs=("$CONFIG_DIR"/connection-*.conf)
if [[ ${#configs[@]} -eq 0 ]]; then
@@ -43,7 +38,8 @@ for config in "${configs[@]}"; do
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.storages.tsv" \
"$CONFIG_DIR/connection-$connection_id.roots.tsv"
rm -f -- "$tombstone_dir/deleted-$connection_id"
continue
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