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() function bratonien_tools_nc_wizard_save_technical_flow()
{ {
$result = bratonien_tools_nc_wizard_save_technical_with_known_database(); return bratonien_tools_nc_wizard_save_technical_generic();
$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;
} }

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); $url = rtrim((string)$state['base_url'], '/').'/remote.php/dav/files/'.$user.'/'.implode('/', $segments);
if (substr($url, -1) !== '/') $url .= '/'; 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); $ch = curl_init($url);
curl_setopt_array($ch, array( curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER=>true, CURLOPT_RETURNTRANSFER=>true,
@@ -45,27 +45,43 @@ function bratonien_tools_nc_wizard_webdav_list(array $state, $path = '')
$xml = simplexml_load_string((string)$response); $xml = simplexml_load_string((string)$response);
if ($xml === false) throw new RuntimeException('Nextcloud hat eine ungültige WebDAV-Antwort geliefert.'); if ($xml === false) throw new RuntimeException('Nextcloud hat eine ungültige WebDAV-Antwort geliefert.');
$xml->registerXPathNamespace('d', 'DAV:'); $xml->registerXPathNamespace('d', 'DAV:');
$xml->registerXPathNamespace('oc', 'http://owncloud.org/ns');
$children = array(); $children = array();
$fileids = array();
$current_fileid = 0;
$base_path = (string)parse_url($url, PHP_URL_PATH);
foreach ($xml->xpath('//d:response') as $item) foreach ($xml->xpath('//d:response') as $item)
{ {
$item->registerXPathNamespace('d', 'DAV:'); $item->registerXPathNamespace('d', 'DAV:');
$item->registerXPathNamespace('oc', 'http://owncloud.org/ns');
$hrefs = $item->xpath('d:href'); $hrefs = $item->xpath('d:href');
$collections = $item->xpath('d:propstat/d:prop/d:resourcetype/d:collection'); $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 = rawurldecode((string)$hrefs[0]);
$href_path = (string)parse_url($href, PHP_URL_PATH); $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, '/')); $name = basename(rtrim($href_path, '/'));
if ($name === '') continue; if ($name === '') continue;
$child_path = $path === '' ? $name : $path.'/'.$name; $child_path = $path === '' ? $name : $path.'/'.$name;
$children[$child_path] = $name; $children[$child_path] = $name;
$fileids[$child_path] = $fileid;
} }
natcasesort($children); natcasesort($children);
if ($current_fileid < 1) throw new RuntimeException('Nextcloud hat für das aktuelle Verzeichnis keine eindeutige Datei-ID geliefert.');
$parent = ''; $parent = '';
if ($path !== '') if ($path !== '')
{ {
@@ -74,7 +90,13 @@ function bratonien_tools_nc_wizard_webdav_list(array $state, $path = '')
$parent = implode('/', $parts); $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) 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_path'] = (string)$listing['current'];
$state['directory_parent'] = (string)$listing['parent']; $state['directory_parent'] = (string)$listing['parent'];
$state['directory_children'] = (array)$listing['children']; $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']) || !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() 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', '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, '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'=>'', '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', '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(),'technical_stage'=>'auto_check','technical_source'=>'Automatische Prüfung','technical_error'=>'','technical_complete'=>false, '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_selected'=>array(), '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, '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.'); 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 try
{ {
bratonien_tools_nc_wizard_finish_data_access_for_selection($state, $known_storages); bratonien_tools_nc_wizard_verify_generic_data_access($state);
if (!empty($state['directory_selection_ready']))
{
bratonien_tools_nc_wizard_refresh_directory_state($state, ''); bratonien_tools_nc_wizard_refresh_directory_state($state, '');
} }
else
{
$state['mount_prompted'] = true;
}
}
catch (Throwable $e) catch (Throwable $e)
{ {
$state['technical_complete'] = false; $state['technical_complete'] = false;
$state['technical_stage'] = 'database_details'; $state['technical_stage'] = 'database_details';
$state['database_prompted'] = true; $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); 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 ($state['technical_stage'] === 'database_details') return array('message'=>'Nextcloud wurde gefunden. Die generische Reader-Schnittstelle konnte noch nicht 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 generischer Datenzugriff wurden bestätigt. Jetzt können die Verzeichnisse des angemeldeten Benutzers gewählt werden.');
return array('message'=>'Nextcloud und 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() function bratonien_tools_nc_wizard_directory_browse()
@@ -193,9 +377,13 @@ function bratonien_tools_nc_wizard_directory_add()
$state = bratonien_tools_nc_wizard_state(); $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.'); 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'] ?? ''), '/'); $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(); $selected = isset($state['directory_selected']) && is_array($state['directory_selected']) ? $state['directory_selected'] : array();
if (!in_array($path, $selected, true)) $selected[] = $path; if (!in_array($path, $selected, true)) $selected[] = $path;
$state['directory_selected'] = array_values($selected); $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); bratonien_tools_nc_wizard_store($state);
return array('message'=>$path === '' ? 'Stammverzeichnis ausgewählt.' : 'Verzeichnis hinzugefügt.'); 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'] ?? ''), '/'); $path = trim((string)($_POST['nc_wizard_directory_remove'] ?? ''), '/');
$selected = isset($state['directory_selected']) && is_array($state['directory_selected']) ? $state['directory_selected'] : array(); $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; })); $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); bratonien_tools_nc_wizard_store($state);
return array('message'=>'Verzeichnis entfernt.'); return array('message'=>'Verzeichnis entfernt.');
} }
@@ -216,14 +405,17 @@ function bratonien_tools_nc_wizard_save_mounts_server_side()
$state = bratonien_tools_nc_wizard_state(); $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.'); 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 (!empty($state['directory_selection_ready']))
if (!$candidates) throw new RuntimeException('Es wurden noch keine Speicherorte erkannt.'); {
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(); $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) foreach ($candidates as $index=>&$candidate)
{ {
$mount = rtrim(trim((string)($candidate['local_mount'] ?? '')), '/'); $mount = rtrim(trim((string)($candidate['local_mount'] ?? '')), '/');
@@ -235,50 +427,13 @@ function bratonien_tools_nc_wizard_save_mounts_server_side()
unset($candidate); unset($candidate);
$state['storage_candidates'] = array_values($candidates); $state['storage_candidates'] = array_values($candidates);
$state['mount_prompted'] = true; $state['storages'] = $state['storage_candidates'];
$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)
{
$mount = rtrim(trim((string)($candidate['local_mount'] ?? '')), '/');
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,
);
}
}
$state['storages'] = $storages;
$state['technical_complete'] = true; $state['technical_complete'] = true;
$state['technical_stage'] = 'ready'; $state['technical_stage'] = 'ready';
$state['technical_error'] = ''; $state['technical_error'] = '';
$state['directory_selection_ready'] = false; $state['directory_selection_ready'] = false;
bratonien_tools_nc_wizard_store($state); 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() function bratonien_tools_nc_wizard_select_current_user()
@@ -304,12 +459,10 @@ function bratonien_tools_nc_wizard_back()
if ($step === 4) if ($step === 4)
{ {
// Abschluss -> API
$state['step'] = 3; $state['step'] = 3;
} }
elseif ($step === 3) elseif ($step === 3)
{ {
// API -> Verbindungsname
$state['step'] = 2; $state['step'] = 2;
$state['technical_complete'] = true; $state['technical_complete'] = true;
$state['technical_stage'] = 'ready'; $state['technical_stage'] = 'ready';
@@ -317,7 +470,6 @@ function bratonien_tools_nc_wizard_back()
} }
elseif (!empty($state['technical_complete'])) elseif (!empty($state['technical_complete']))
{ {
// Verbindungsname -> Verzeichnisauswahl
$state['technical_complete'] = false; $state['technical_complete'] = false;
$state['technical_stage'] = 'mounts'; $state['technical_stage'] = 'mounts';
$state['directory_selection_ready'] = true; $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'])) elseif ((string)($state['technical_stage'] ?? '') === 'mounts' && !empty($state['directory_selection_ready']))
{ {
// Verzeichnisauswahl -> vorher tatsächlich sichtbares Fenster. if (!empty($state['database_prompted'])) $state['technical_stage'] = 'database_details'; else $state['step'] = 1;
if (!empty($state['mount_prompted']))
{
$state['directory_selection_ready'] = false; $state['directory_selection_ready'] = false;
} }
elseif (!empty($state['database_prompted']))
{
$state['technical_stage'] = 'database_details';
$state['directory_selection_ready'] = false;
}
else
{
$state['step'] = 1;
}
}
elseif ((string)($state['technical_stage'] ?? '') === 'mounts') elseif ((string)($state['technical_stage'] ?? '') === 'mounts')
{ {
// Mount-Zuordnung -> Datenbankfenster, falls es sichtbar war, sonst Anmeldung. $state['directory_selection_ready'] = true;
if (!empty($state['database_prompted'])) bratonien_tools_nc_wizard_refresh_directory_state($state);
{
$state['technical_stage'] = 'database_details';
}
else
{
$state['step'] = 1;
}
} }
elseif ((string)($state['technical_stage'] ?? '') === 'database_details') elseif ((string)($state['technical_stage'] ?? '') === 'database_details')
{ {
// Datenbankfenster -> Anmeldung.
$state['step'] = 1; $state['step'] = 1;
} }
else 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_user_scope.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_wizard_flow.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_connection_scope.inc.php');
require_once(BRATONIEN_TOOLS_PATH . 'include/nc_connector_generic_scope.inc.php');
function bratonien_tools_get_tools() 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_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_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_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_back' => array('handler' => 'bratonien_tools_nc_wizard_back'),
'nc_connector_wizard_reset' => array('handler' => 'bratonien_tools_nc_wizard_reset'), '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_import_legacy' => array('handler' => 'bratonien_tools_nc_connector_import_legacy'),

View File

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

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Debounce Nextcloud activity and request a periodic safety reconciliation.""" """Debounce Nextcloud activity for one connector scope."""
from __future__ import annotations from __future__ import annotations
@@ -24,54 +24,117 @@ def validate_view(name: str) -> str:
return value return value
def sql_literal(value: str) -> str:
return "'" + str(value).replace("'", "''") + "'"
def load(path: Path) -> dict[str, object]: 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(): if not path.exists():
return defaults return defaults
data=json.loads(path.read_text(encoding="utf-8")) 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",""))} 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: def save(path: Path, state: dict[str, object]) -> None:
path.parent.mkdir(parents=True,exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
fd,name=tempfile.mkstemp(dir=path.parent) fd, name = tempfile.mkstemp(dir=path.parent)
with os.fdopen(fd,"w",encoding="utf-8") as handle: with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(state,handle,sort_keys=True);handle.write("\n") json.dump(state, handle, sort_keys=True)
handle.write("\n")
Path(name).replace(path) Path(name).replace(path)
def query(args: argparse.Namespace, sql: str) -> str: def query(args: argparse.Namespace, sql: str) -> str:
env=os.environ.copy();env["PGPASSWORD"]=args.password_file.read_text(encoding="utf-8").strip() env = os.environ.copy()
command=["psql","-XAt","-h",args.host,"-p",str(args.port),"-U",args.user,"-d",args.database,"-v","ON_ERROR_STOP=1","-c",sql] env["PGPASSWORD"] = args.password_file.read_text(encoding="utf-8").strip()
return subprocess.run(command,env=env,check=True,text=True,capture_output=True).stdout 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: 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: def source_signature(args: argparse.Namespace) -> str:
if not args.source_view:return "" if args.roots_config:
payload=query(args,f"SELECT share_id, display_name, storage_id, source_path FROM {validate_view(args.source_view)} ORDER BY share_id") 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() return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def main() -> int: 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: try:
validate_view(args.view) validate_view(args.view)
if args.source_view:validate_view(args.source_view) if args.source_view:
state=load(args.state);now=int(time.time());current=latest(args);current_source=source_signature(args) validate_view(args.source_view)
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 state = load(args.state)
if not int(state["last_full"]):return 0 now = int(time.time())
if current_source and current_source!=str(state["source_signature"]):return 0 current = latest(args)
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) current_source = source_signature(args)
if now-int(state["last_full"])>=args.full_after:return 0 if args.action == "commit":
if int(state["observed"])<=int(state["processed"]):return 3 state.update(processed=current, observed=current, pending_since=0, last_change=0, last_full=now, source_signature=current_source)
if now-int(state["last_change"])>=args.quiet or now-int(state["pending_since"])>=args.max_wait:return 0 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 return 3
except Exception as error: 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 #!/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 from __future__ import annotations
@@ -23,111 +23,194 @@ def validate_view(name: str) -> str:
return value return value
def sql_literal(value: str) -> str:
return "'" + str(value).replace("'", "''") + "'"
def read_config(path: Path) -> dict[str, list[tuple[str, Path, str]]]: def read_config(path: Path) -> dict[str, list[tuple[str, Path, str]]]:
result: dict[str, list[tuple[str, Path, str]]] = {} result: dict[str, list[tuple[str, Path, str]]] = {}
with path.open(encoding="utf-8") as handle: with path.open(encoding="utf-8") as handle:
for number, line in enumerate(handle, 1): for number, line in enumerate(handle, 1):
line=line.rstrip("\n") line = line.rstrip("\n")
if not line or line.startswith("#"):continue if not line or line.startswith("#"):
fields=line.split("\t") continue
if len(fields) not in {3,4}:raise ValueError(f"{path}:{number}: expected storage_id, source_prefix, local_mount and optional include_prefix") fields = line.split("\t")
storage_id,prefix,mount=fields[:3] if len(fields) not in {3, 4}:
include_prefix=fields[3] if len(fields)==4 else "" raise ValueError(f"{path}:{number}: expected storage_id, source_prefix, local_mount and optional include_prefix")
storage_id=storage_id.strip();prefix=prefix.strip("/");include_prefix=include_prefix.strip("/") storage_id, prefix, mount = fields[:3]
if not storage_id:raise ValueError(f"{path}:{number}: storage_id is empty") include_prefix = fields[3] if len(fields) == 4 else ""
if ".." in PurePosixPath(prefix).parts or ".." in PurePosixPath(include_prefix).parts:raise ValueError(f"{path}:{number}: unsafe prefix") storage_id = storage_id.strip()
result.setdefault(storage_id,[]).append((prefix,Path(mount),include_prefix)) 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 return result
def run_query(args: argparse.Namespace, env: dict[str,str], sql: str) -> subprocess.CompletedProcess[str]: 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] command = [
return subprocess.run(command,env=env,check=False,text=True,capture_output=True) "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]]: 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) password = args.password_file.read_text(encoding="utf-8").strip()
modern_sql=f"SELECT share_id, item_type, display_name, storage_id, source_path FROM {view} ORDER BY share_id" env = os.environ.copy()
completed=run_query(args,env,modern_sql) env["PGPASSWORD"] = password
if completed.returncode==0:return list(csv.reader(completed.stdout.splitlines(),delimiter="\t")) view = validate_view(args.view)
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") where = ""
legacy_sql=f"SELECT share_id, display_name, storage_id, source_path FROM {view} ORDER BY share_id" if args.access_user:
completed=run_query(args,env,legacy_sql) where = " WHERE lower(access_user) = lower(" + sql_literal(args.access_user) + ")"
if completed.returncode!=0:raise RuntimeError(completed.stderr.strip() or "legacy Nextcloud source view query failed") modern_sql = f"SELECT share_id, item_type, display_name, storage_id, source_path FROM {view}{where} ORDER BY share_id"
rows=[] completed = run_query(args, env, modern_sql)
for row in csv.reader(completed.stdout.splitlines(),delimiter="\t"): if completed.returncode == 0:
if len(row)==4: return list(csv.reader(completed.stdout.splitlines(), delimiter="\t"))
share_id,display_name,storage_id,source_path=row;rows.append([share_id,"",display_name,storage_id,source_path]) if args.access_user:
else:rows.append(row) 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 return rows
def contained_join(root: Path, relative: str) -> Path: def contained_join(root: Path, relative: str) -> Path:
parts=PurePosixPath(relative).parts parts = PurePosixPath(relative).parts
if relative.startswith("/") or ".." in parts:raise ValueError(f"unsafe source path: {relative}") if relative.startswith("/") or ".." in parts:
raise ValueError(f"unsafe source path: {relative}")
return root.joinpath(*parts) return root.joinpath(*parts)
def matches_prefix(path: str, prefix: str) -> bool: def matches_prefix(path: str, prefix: str) -> bool:
if not prefix:return True return not prefix or path == prefix or path.startswith(prefix + "/")
return 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: 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("/") relative = source_path.strip("/")
matches=[] matches: list[tuple[str, Path, str]] = []
for prefix,mount,include_prefix in adapters.get(storage_id,[]): for prefix, mount, include_prefix in adapters.get(storage_id, []):
if not matches_prefix(relative,prefix):continue if not matches_prefix(relative, prefix):
mapped_relative=relative[len(prefix):].lstrip("/") if prefix else relative continue
if not matches_prefix(mapped_relative,include_prefix):continue mapped_relative = relative[len(prefix):].lstrip("/") if prefix else relative
matches.append((prefix,mount,include_prefix)) if not matches_prefix(mapped_relative, include_prefix):
if not matches:return None continue
matches.sort(key=lambda item:(len(item[0]),len(item[2])),reverse=True) matches.append((prefix, mount, include_prefix))
return matches[0] 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]: def build(args: argparse.Namespace) -> dict[str, object]:
validate_view(args.view);adapters=read_config(args.storage_config);rows=query_rows(args) validate_view(args.view)
if not rows and not args.allow_empty:raise RuntimeError("Nextcloud returned no Showcase shares; refusing an empty manifest") adapters = read_config(args.storage_config)
manifest=[];errors=[];folder_count=0;file_count=0 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: for row in rows:
if len(row)!=5:errors.append(f"invalid database row with {len(row)} columns");continue if len(row) != 5:
share_id,item_type,display_name,storage_id,source_path=row;item_type=item_type.strip().lower();relative=source_path.strip("/") errors.append(f"invalid database row with {len(row)} columns")
if item_type and item_type not in {"folder","file"}:errors.append(f"share {share_id}: unsupported item_type {item_type!r}");continue continue
adapter=resolve_adapter(adapters,storage_id,relative) share_id, item_type, display_name, storage_id, source_path = row
if not adapter:continue item_type = item_type.strip().lower()
prefix,mount,_include_prefix=adapter relative = source_path.strip("/")
if prefix:relative=relative[len(prefix):].lstrip("/") if item_type and item_type not in {"folder", "file"}:
source=contained_join(mount,relative) errors.append(f"source {share_id}: unsupported item_type {item_type!r}")
if not mount.is_mount():errors.append(f"share {share_id}: storage mount unavailable: {mount}");continue 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 not item_type:
if source.is_dir():item_type="folder" if source.is_dir():
elif source.is_file():item_type="file" item_type = "folder"
else:errors.append(f"share {share_id}: source unavailable: {source}");continue elif source.is_file():
if item_type=="folder": item_type = "file"
if not source.is_dir():errors.append(f"share {share_id}: source directory unavailable: {source}");continue
folder_count+=1
else: else:
if not source.is_file():errors.append(f"share {share_id}: source file unavailable: {source}");continue errors.append(f"source {share_id}: source unavailable: {source}")
file_count+=1 continue
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 if item_type == "folder":
source_text=str(source) if not source.is_dir():
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 errors.append(f"source {share_id}: source directory unavailable: {source}")
continue
folder_count += 1
else:
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}") manifest.append(f"{share_id}\t{item_type}\t{display_name.lstrip('/')}\t{source}")
if errors:raise RuntimeError("; ".join(errors)) if errors:
if not manifest and rows and not args.allow_empty:raise RuntimeError("no Showcase shares match the selected directories") raise RuntimeError("; ".join(errors))
args.output.parent.mkdir(parents=True,exist_ok=True) if not manifest and rows and not args.allow_empty:
with tempfile.NamedTemporaryFile("w",encoding="utf-8",dir=args.output.parent,delete=False) as handle: raise RuntimeError("no Nextcloud sources match the configured storage adapters")
handle.write("\n".join(manifest)+("\n" if manifest else ""));temporary=Path(handle.name) 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) 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: 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() parser = argparse.ArgumentParser()
try:print(json.dumps(build(args),ensure_ascii=False)) parser.add_argument("--host", required=True)
except Exception as error:print(f"manifest: {error}",file=sys.stderr);return 1 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 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); 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__); $pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2); $piwigoRoot = dirname($pluginRoot, 2);
$dbConfig = $piwigoRoot.'/local/config/database.inc.php'; $dbConfig = $piwigoRoot.'/local/config/database.inc.php';
@@ -130,14 +159,13 @@ try
try try
{ {
if ((string)$row['adapter'] !== 'local') continue; if ((string)$row['adapter'] !== 'local') continue;
$isActive = (int)$row['enabled'] === 1 && (string)$row['takeover_state'] === 'active'; $isActive = (int)$row['enabled'] === 1 && (string)$row['takeover_state'] === 'active';
$accessUser = configured_access_user($config);
$wizardConnection = (string)($config['origin'] ?? '') === 'native' $wizardConnection = (string)($config['origin'] ?? '') === 'native'
&& trim((string)($config['nextcloud_url'] ?? '')) !== '' && trim((string)($config['nextcloud_url'] ?? '')) !== ''
&& trim((string)($config['showcase_user'] ?? '')) !== ''; && $accessUser !== '';
$verification = isset($config['verification']) && is_array($config['verification']) ? $config['verification'] : null; $verification = isset($config['verification']) && is_array($config['verification']) ? $config['verification'] : null;
$verificationFailed = is_array($verification) && empty($verification['ok']); $verificationFailed = is_array($verification) && empty($verification['ok']);
if (!$isActive && (!$wizardConnection || $verificationFailed)) continue; if (!$isActive && (!$wizardConnection || $verificationFailed)) continue;
foreach (array('host','port','database','user','source_view','activity_view','gallery_root') as $key) 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); $credentials = decrypt_reconcile((string)$row['secret_blob'], $hexKey);
if ($credentials['db_password'] === '') fail_reconcile('Datenbankpasswort fehlt.'); 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)) 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'] !== ''; $hasFallback = $credentials['piwigo_user'] !== '' && $credentials['piwigo_password'] !== '';
if (!$hasFallback) fail_reconcile('Die Verbindung besitzt weder einen eigenen API-Zugang noch einen eigenen Fallback.'); if (!$hasFallback) fail_reconcile('Die Verbindung besitzt weder einen eigenen API-Zugang noch einen eigenen Fallback.');
$credentials['api_key_id'] = ''; $credentials['api_key_id'] = '';
@@ -178,6 +240,7 @@ try
$dbPasswordPath = $base.'.db-password'; $dbPasswordPath = $base.'.db-password';
$piwigoPasswordPath = $base.'.piwigo-password'; $piwigoPasswordPath = $base.'.piwigo-password';
$storagePath = $base.'.storages.tsv'; $storagePath = $base.'.storages.tsv';
$rootsPath = $base.'.roots.tsv';
$configPath = $base.'.conf'; $configPath = $base.'.conf';
$statusFile = $stateDir.'/connector-status.json'; $statusFile = $stateDir.'/connector-status.json';
@@ -190,10 +253,7 @@ try
file_put_contents($piwigoPasswordPath, $credentials['piwigo_password']."\n", LOCK_EX); file_put_contents($piwigoPasswordPath, $credentials['piwigo_password']."\n", LOCK_EX);
chmod($piwigoPasswordPath, 0600); chmod($piwigoPasswordPath, 0600);
} }
else else @unlink($piwigoPasswordPath);
{
@unlink($piwigoPasswordPath);
}
$storageLines = array('# storage_id<TAB>source_prefix<TAB>local_mount<TAB>include_prefix'); $storageLines = array('# storage_id<TAB>source_prefix<TAB>local_mount<TAB>include_prefix');
foreach ($storages as $storage) foreach ($storages as $storage)
@@ -206,6 +266,21 @@ try
file_put_contents($storagePath, implode("\n", $storageLines)."\n", LOCK_EX); file_put_contents($storagePath, implode("\n", $storageLines)."\n", LOCK_EX);
chmod($storagePath, 0600); 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( $lines = array(
'PIWIGO_ROOT='.$piwigoRoot, 'PIWIGO_ROOT='.$piwigoRoot,
'GALLERY_ROOT='.(string)$config['gallery_root'], 'GALLERY_ROOT='.(string)$config['gallery_root'],
@@ -219,11 +294,14 @@ try
'NC_ACTIVITY_VIEW='.(string)$config['activity_view'], 'NC_ACTIVITY_VIEW='.(string)$config['activity_view'],
'NC_DB_PASSWORD_FILE='.$dbPasswordPath, 'NC_DB_PASSWORD_FILE='.$dbPasswordPath,
'STORAGE_CONFIG='.$storagePath, 'STORAGE_CONFIG='.$storagePath,
'SOURCE_MODE='.$sourceMode,
'QUIET_SECONDS='.(int)($config['quiet_seconds'] ?? 120), 'QUIET_SECONDS='.(int)($config['quiet_seconds'] ?? 120),
'MAX_WAIT_SECONDS='.(int)($config['max_wait_seconds'] ?? 900), 'MAX_WAIT_SECONDS='.(int)($config['max_wait_seconds'] ?? 900),
'FULL_SYNC_SECONDS='.(int)($config['full_sync_seconds'] ?? 86400), 'FULL_SYNC_SECONDS='.(int)($config['full_sync_seconds'] ?? 86400),
'PIWIGO_SYNC_ENABLED=1', 'PIWIGO_SYNC_ENABLED=1',
); );
if ($sourceMode !== 'legacy-view') $lines[] = 'ACCESS_USER='.escapeshellarg($accessUser);
if ($sourceMode === 'selected-fileids') $lines[] = 'ROOTS_CONFIG='.$rootsPath;
if ($fallbackAvailable) if ($fallbackAvailable)
{ {
$lines[] = 'PIWIGO_SYNC_USER='.$credentials['piwigo_user']; $lines[] = 'PIWIGO_SYNC_USER='.$credentials['piwigo_user'];
@@ -240,7 +318,6 @@ try
$now = date('Y-m-d H:i:s'); $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; $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 (!$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"; if (!$isActive) echo "NC Connector: Verbindung #{$id} ({$row['name']}) automatisch in die gemeinsame Runtime uebernommen.\n";
} }
catch (Throwable $e) catch (Throwable $e)

View File

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