Compare commits

...

16 Commits

Author SHA1 Message Date
Terranom674
774c82ab68 Release 0.9.5.1 2026-08-18 17:07:36 +02:00
Terranom674
7024d3cd3d 0.9.5.1: Connector-Ergebnis beim Polling aktualisieren 2026-08-18 17:07:06 +02:00
Terranom674
042a090db4 0.9.5.1: gespeicherten Nextcloud-Zugriffsbenutzer verwenden 2026-08-18 17:06:39 +02:00
Terranom674
2edacf5e0b 0.9.5.1: Benutzerisolation vor jedem Lauf anwenden 2026-08-18 17:06:07 +02:00
Terranom674
e53041f1c1 0.9.5.1: native Verbindungen auf Benutzer-Stamm isolieren 2026-08-18 17:05:53 +02:00
Terranom674
3512f60136 0.9.5.1: Runtime nach Verbindungsquelle trennen 2026-08-18 17:05:04 +02:00
Terranom674
73e4b08dbd 0.9.5.1: benutzerbezogenes Manifest ohne Showcase-View 2026-08-18 17:04:20 +02:00
Terranom674
e06539c125 Bratonien Tools 0.9.4.13 2026-08-18 16:50:13 +02:00
Terranom674
e0cd91a0b7 NC-Connector echte Runtime-Fehlerausgabe erhalten 2026-08-18 16:49:54 +02:00
Terranom674
c28a8c6f80 Version 0.9.4.12 2026-08-18 16:46:58 +02:00
Terranom674
032d9a151c Shell-Syntax im CI mitpruefen 2026-08-18 16:46:34 +02:00
Terranom674
86fad7aa1f NC Connector Laufzeitfehler aussagekraeftig erfassen 2026-08-18 16:46:08 +02:00
Terranom674
62b17fcd09 Bump version for automatic multi-connection runtime reconciliation 2026-08-18 16:22:58 +02:00
Terranom674
1e77988831 Reconcile stored connections before shared runtime 2026-08-18 16:22:31 +02:00
Terranom674
eb3bc48ceb Reconcile wizard connections into shared runtime 2026-08-18 16:22:19 +02:00
Terranom674
b8d31e37ca Version 0.9.4.10 2026-08-18 16:13:59 +02:00
8 changed files with 753 additions and 24 deletions

View File

@@ -30,3 +30,11 @@ jobs:
run: |
set -euo pipefail
python3 -m compileall -q runtime
- name: Shell Syntax pruefen
shell: bash
run: |
set -euo pipefail
while IFS= read -r -d '' file; do
bash -n "$file"
done < <(find . -type f -name '*.sh' -print0)

View File

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

View File

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

View File

@@ -0,0 +1,195 @@
#!/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($mount, $prefix, $accessUser)
{
$mount = rtrim((string)$mount, '/');
$prefix = trim((string)$prefix, '/');
if ($mount === '' || $mount[0] !== '/') return null;
$root = $mount.($prefix !== '' ? '/'.$prefix : '');
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'=>$mount, 'source_prefix'=>$prefix, '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;
};
$add(user_scope_candidate($mount, $prefix, $accessUser));
if ($mount !== '')
{
$add(user_scope_candidate(dirname($mount).'/'.$accessUser, $prefix, $accessUser));
$add(user_scope_candidate($mount.'/'.$accessUser, $prefix, $accessUser));
}
if ($prefix !== '')
{
$parts = explode('/', $prefix);
if ($parts && (string)$parts[0] !== (string)$accessUser)
{
array_shift($parts);
$rest = implode('/', $parts);
$add(user_scope_candidate($mount.'/'.$accessUser, $rest, $accessUser));
}
}
if (count($candidates) !== 1)
{
user_scope_fail('Der lokale Dateistamm für Nextcloud-Benutzer '.$accessUser.' konnte nicht eindeutig bestimmt werden. 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);
}

259
runtime/reconcile.php Normal file
View File

@@ -0,0 +1,259 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli')
{
fwrite(STDERR, "CLI only\n");
exit(1);
}
function fail_reconcile($message)
{
throw new RuntimeException($message);
}
function decrypt_reconcile($blob, $hexKey)
{
if (!preg_match('/^[a-f0-9]{64}$/', (string)$hexKey)) fail_reconcile('Connector-Schluessel ist ungueltig.');
$outer = base64_decode(trim((string)$blob), true);
$payload = is_string($outer) ? json_decode($outer, true) : null;
if (!is_array($payload) || (int)($payload['v'] ?? 0) !== 1) fail_reconcile('Connector-Zugangsdaten haben ein unbekanntes Format.');
$iv = base64_decode((string)($payload['iv'] ?? ''), true);
$tag = base64_decode((string)($payload['tag'] ?? ''), true);
$cipher = base64_decode((string)($payload['data'] ?? ''), true);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', hex2bin($hexKey), OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false || $plain === '') fail_reconcile('Connector-Zugangsdaten konnten nicht entschluesselt werden.');
$decoded = json_decode($plain, true);
if (!is_array($decoded))
{
return array('db_password'=>$plain,'piwigo_user'=>'','piwigo_password'=>'','api_key_id'=>'','api_key_secret'=>'');
}
return array(
'db_password'=>(string)($decoded['db_password'] ?? ''),
'piwigo_user'=>(string)($decoded['piwigo_user'] ?? ''),
'piwigo_password'=>(string)($decoded['piwigo_password'] ?? ''),
'api_key_id'=>(string)($decoded['api_key_id'] ?? ''),
'api_key_secret'=>(string)($decoded['api_key_secret'] ?? ''),
);
}
function encrypt_reconcile(array $credentials, $hexKey)
{
$plain = json_encode(array(
'v'=>2,
'db_password'=>(string)$credentials['db_password'],
'piwigo_user'=>(string)$credentials['piwigo_user'],
'piwigo_password'=>(string)$credentials['piwigo_password'],
'api_key_id'=>(string)$credentials['api_key_id'],
'api_key_secret'=>(string)$credentials['api_key_secret'],
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($plain)) fail_reconcile('Connector-Zugangsdaten konnten nicht serialisiert werden.');
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt($plain, 'aes-256-gcm', hex2bin($hexKey), OPENSSL_RAW_DATA, $iv, $tag);
if ($cipher === false) fail_reconcile('Connector-Zugangsdaten konnten nicht verschluesselt werden.');
return base64_encode(json_encode(array(
'v'=>1,
'iv'=>base64_encode($iv),
'tag'=>base64_encode($tag),
'data'=>base64_encode($cipher),
)));
}
function write_runtime_status($piwigoRoot, $id, $stateDir, $message)
{
$payload = json_encode(array(
'state'=>'error',
'message'=>'Runtime-Vorbereitung fehlgeschlagen',
'timestamp'=>time(),
'auth_mode'=>'failed',
'api'=>array('state'=>'not_run','message'=>''),
'fallback'=>array('state'=>'not_run','message'=>''),
'error_detail'=>(string)$message,
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) return;
$targets = array(rtrim($piwigoRoot, '/').'/_data/bratonien-tools/nc-connector-status/connection-'.$id.'.json');
if ($stateDir !== '') $targets[] = rtrim($stateDir, '/').'/connector-status.json';
foreach ($targets as $target)
{
$dir = dirname($target);
if (!is_dir($dir)) @mkdir($dir, 0755, true);
@file_put_contents($target, $payload."\n", LOCK_EX);
@chmod($target, 0644);
}
}
function sql_reconcile(mysqli $db, $value)
{
return $db->real_escape_string((string)$value);
}
$pluginRoot = dirname(__DIR__);
$piwigoRoot = dirname($pluginRoot, 2);
$dbConfig = $piwigoRoot.'/local/config/database.inc.php';
$configDir = '/etc/bratonien-tools/nc-connector';
$stateRoot = '/var/lib/bratonien-tools/nc-connector';
try
{
if (!is_readable($dbConfig)) fail_reconcile('Piwigo-Datenbankkonfiguration ist nicht lesbar: '.$dbConfig);
$conf = array();
$prefixeTable = 'piwigo_';
require $dbConfig;
foreach (array('db_host','db_user','db_password','db_base') as $key)
{
if (!isset($conf[$key])) fail_reconcile('Piwigo-Datenbankkonfiguration ist unvollstaendig: '.$key);
}
$db = new mysqli($conf['db_host'], $conf['db_user'], $conf['db_password'], $conf['db_base']);
if ($db->connect_errno) fail_reconcile('Piwigo-Datenbank ist nicht erreichbar: '.$db->connect_error);
$db->set_charset('utf8mb4');
$keyResult = $db->query("SELECT value FROM `{$prefixeTable}config` WHERE param='bratonien_nc_connector_secret' LIMIT 1");
if (!$keyResult || !$keyResult->num_rows) fail_reconcile('Connector-Schluessel wurde nicht gefunden.');
$hexKey = trim((string)$keyResult->fetch_assoc()['value']);
$table = $prefixeTable.'bratonien_tools_nc_connections';
$rows = $db->query("SELECT id,name,adapter,enabled,takeover_state,config_json,secret_blob FROM `{$table}` ORDER BY id");
if (!$rows) fail_reconcile('Connector-Verbindungen konnten nicht gelesen werden: '.$db->error);
if (!is_dir($configDir) && !mkdir($configDir, 0700, true)) fail_reconcile('Runtime-Konfigurationsverzeichnis konnte nicht angelegt werden.');
chmod($configDir, 0700);
while ($row = $rows->fetch_assoc())
{
$id = (int)$row['id'];
$config = json_decode((string)$row['config_json'], true);
if (!is_array($config)) $config = array();
$stateDir = rtrim((string)($config['state_dir'] ?? ''), '/');
if ($stateDir === '') $stateDir = $stateRoot.'/connection-'.$id;
try
{
if ((string)$row['adapter'] !== 'local') continue;
$isActive = (int)$row['enabled'] === 1 && (string)$row['takeover_state'] === 'active';
$wizardConnection = (string)($config['origin'] ?? '') === 'native'
&& trim((string)($config['nextcloud_url'] ?? '')) !== ''
&& trim((string)($config['showcase_user'] ?? '')) !== '';
$verification = isset($config['verification']) && is_array($config['verification']) ? $config['verification'] : null;
$verificationFailed = is_array($verification) && empty($verification['ok']);
if (!$isActive && (!$wizardConnection || $verificationFailed)) continue;
foreach (array('host','port','database','user','source_view','activity_view','gallery_root') as $key)
{
if (trim((string)($config[$key] ?? '')) === '') fail_reconcile('Konfiguration unvollstaendig: '.$key.' fehlt.');
}
$storages = isset($config['storages']) && is_array($config['storages']) ? $config['storages'] : array();
if (!$storages) fail_reconcile('Keine Storage-Zuordnungen gespeichert.');
$credentials = decrypt_reconcile((string)$row['secret_blob'], $hexKey);
if ($credentials['db_password'] === '') fail_reconcile('Datenbankpasswort fehlt.');
if (!$isActive && !array_key_exists('api_enabled', $config))
{
// Alte Wizard-Verbindungen ohne API, aber mit gespeichertem Fallback,
// duerfen niemals den globalen API-Key einer anderen Verbindung erben.
$hasFallback = $credentials['piwigo_user'] !== '' && $credentials['piwigo_password'] !== '';
if (!$hasFallback) fail_reconcile('Die Verbindung besitzt weder einen eigenen API-Zugang noch einen eigenen Fallback.');
$credentials['api_key_id'] = '';
$credentials['api_key_secret'] = '';
$config['piwigo_auth'] = 'connection-scoped';
$config['api_enabled'] = false;
$row['secret_blob'] = encrypt_reconcile($credentials, $hexKey);
}
$connectionScoped = (string)($config['piwigo_auth'] ?? '') === 'connection-scoped' || array_key_exists('api_enabled', $config);
if ($connectionScoped)
{
$apiAvailable = !empty($config['api_enabled']) && $credentials['api_key_id'] !== '' && $credentials['api_key_secret'] !== '';
$fallbackAvailable = $credentials['piwigo_user'] !== '' && $credentials['piwigo_password'] !== '';
if (!$apiAvailable && !$fallbackAvailable) fail_reconcile('Kein verbindungseigener Piwigo-Zugang gespeichert.');
}
if (!is_dir($stateDir) && !mkdir($stateDir, 0750, true)) fail_reconcile('State-Verzeichnis konnte nicht angelegt werden.');
chmod($stateDir, 0750);
$base = $configDir.'/connection-'.$id;
$dbPasswordPath = $base.'.db-password';
$piwigoPasswordPath = $base.'.piwigo-password';
$storagePath = $base.'.storages.tsv';
$configPath = $base.'.conf';
$statusFile = $stateDir.'/connector-status.json';
file_put_contents($dbPasswordPath, $credentials['db_password']."\n", LOCK_EX);
chmod($dbPasswordPath, 0600);
$fallbackAvailable = $credentials['piwigo_user'] !== '' && $credentials['piwigo_password'] !== '';
if ($fallbackAvailable)
{
file_put_contents($piwigoPasswordPath, $credentials['piwigo_password']."\n", LOCK_EX);
chmod($piwigoPasswordPath, 0600);
}
else
{
@unlink($piwigoPasswordPath);
}
$storageLines = array('# storage_id<TAB>source_prefix<TAB>local_mount<TAB>include_prefix');
foreach ($storages as $storage)
{
$storageLines[] = (string)($storage['storage_id'] ?? '')."\t"
.trim((string)($storage['source_prefix'] ?? ''), '/')."\t"
.(string)($storage['local_mount'] ?? '')."\t"
.trim((string)($storage['include_prefix'] ?? ''), '/');
}
file_put_contents($storagePath, implode("\n", $storageLines)."\n", LOCK_EX);
chmod($storagePath, 0600);
$lines = array(
'PIWIGO_ROOT='.$piwigoRoot,
'GALLERY_ROOT='.(string)$config['gallery_root'],
'STATE_DIR='.$stateDir,
'STATUS_FILE='.$statusFile,
'NC_DB_HOST='.(string)$config['host'],
'NC_DB_PORT='.(string)$config['port'],
'NC_DB_NAME='.(string)$config['database'],
'NC_DB_USER='.(string)$config['user'],
'NC_DB_VIEW='.(string)$config['source_view'],
'NC_ACTIVITY_VIEW='.(string)$config['activity_view'],
'NC_DB_PASSWORD_FILE='.$dbPasswordPath,
'STORAGE_CONFIG='.$storagePath,
'QUIET_SECONDS='.(int)($config['quiet_seconds'] ?? 120),
'MAX_WAIT_SECONDS='.(int)($config['max_wait_seconds'] ?? 900),
'FULL_SYNC_SECONDS='.(int)($config['full_sync_seconds'] ?? 86400),
'PIWIGO_SYNC_ENABLED=1',
);
if ($fallbackAvailable)
{
$lines[] = 'PIWIGO_SYNC_USER='.$credentials['piwigo_user'];
$lines[] = 'PIWIGO_SYNC_PASSWORD_FILE='.$piwigoPasswordPath;
}
file_put_contents($configPath, implode("\n", $lines)."\n", LOCK_EX);
chmod($configPath, 0600);
$config['state_dir'] = $stateDir;
$config['status_file'] = $statusFile;
$config['runtime'] = array('mode'=>'shared-runner','config'=>$configPath,'reconciled_at'=>date('Y-m-d H:i:s'));
$json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json)) fail_reconcile('Connector-Konfiguration konnte nicht serialisiert werden.');
$now = date('Y-m-d H:i:s');
$sql = "UPDATE `{$table}` SET enabled=1,takeover_state='active',config_json='".sql_reconcile($db,$json)."',secret_blob='".sql_reconcile($db,$row['secret_blob'])."',updated='".sql_reconcile($db,$now)."' WHERE id=".$id;
if (!$db->query($sql)) fail_reconcile('Runtime-Status konnte nicht gespeichert werden: '.$db->error);
if (!$isActive) echo "NC Connector: Verbindung #{$id} ({$row['name']}) automatisch in die gemeinsame Runtime uebernommen.\n";
}
catch (Throwable $e)
{
write_runtime_status($piwigoRoot, $id, $stateDir, $e->getMessage());
fwrite(STDERR, "NC Connector #{$id}: ".$e->getMessage()."\n");
}
}
exit(0);
}
catch (Throwable $e)
{
fwrite(STDERR, "NC Connector Reconcile: ".$e->getMessage()."\n");
exit(1);
}

View File

@@ -4,6 +4,17 @@ set -Eeuo pipefail
CONFIG_DIR="/etc/bratonien-tools/nc-connector"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
shopt -s nullglob
if ! php "$SCRIPT_DIR/reconcile.php"; then
echo "NC Connector: gespeicherte Verbindungen konnten nicht mit der Runtime abgeglichen werden." >&2
exit 1
fi
if ! php "$SCRIPT_DIR/reconcile-user-scope.php"; then
echo "NC Connector: benutzerbezogene Verbindungen konnten nicht sicher vorbereitet werden." >&2
exit 1
fi
configs=("$CONFIG_DIR"/connection-*.conf)
if [[ ${#configs[@]} -eq 0 ]]; then

View File

@@ -11,6 +11,17 @@ source "$CONFIG_FILE"
NC_ACTIVITY_VIEW="${NC_ACTIVITY_VIEW:-piwigo_showcase_activity}"
NC_DB_VIEW="${NC_DB_VIEW:-piwigo_showcase_sources}"
SOURCE_MODE="${SOURCE_MODE:-showcase-view}"
ACCESS_USER="${ACCESS_USER:-}"
case "$SOURCE_MODE" in
showcase-view|user-filesystem) ;;
*) echo "Unbekannter SOURCE_MODE: $SOURCE_MODE" >&2; exit 1 ;;
esac
if [[ "$SOURCE_MODE" == "user-filesystem" && -z "$ACCESS_USER" ]]; then
echo "ACCESS_USER fehlt fuer die benutzerbezogene Verbindung." >&2
exit 1
fi
if [[ -n "$PIWIGO_SYNC_OVERRIDE_VALUE" ]]; then
case "$PIWIGO_SYNC_OVERRIDE_VALUE" in
@@ -50,6 +61,12 @@ API_MESSAGE=""
FALLBACK_STATE="not_run"
FALLBACK_MESSAGE=""
ERROR_DETAIL=""
ERROR_STAGE="Vorbereitung"
ERROR_MESSAGE="Synchronisierung fehlgeschlagen"
compact_output() {
tail -n 8 | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^[[:space:]]//; s/[[:space:]]$//'
}
write_status() {
local state="$1" message="$2"
@@ -86,11 +103,48 @@ PY
}
failure() {
[[ -n "$ERROR_DETAIL" ]] || ERROR_DETAIL="Synchronisierung wurde durch einen technischen Fehler abgebrochen."
write_status error "Synchronisierung fehlgeschlagen"
local exit_code="${1:-1}"
local failed_command="${2:-unbekannt}"
local failed_line="${3:-?}"
trap - ERR
if [[ -z "$ERROR_DETAIL" ]]; then
ERROR_DETAIL="Schritt: $ERROR_STAGE. Exit-Code: $exit_code. Zeile: $failed_line. Fehlgeschlagener Befehl: $failed_command"
fi
write_status error "$ERROR_MESSAGE"
exit "$exit_code"
}
trap failure ERR
trap 'failure $? "$BASH_COMMAND" "$LINENO"' ERR
run_stage() {
local stage="$1"
local message="$2"
shift 2
local output=""
local exit_code=0
ERROR_STAGE="$stage"
ERROR_MESSAGE="$message"
if output="$("$@" 2>&1)"; then
exit_code=0
else
exit_code=$?
fi
if [[ -n "$output" ]]; then
printf '%s\n' "$output"
fi
if [[ "$exit_code" -ne 0 ]]; then
ERROR_DETAIL="Schritt: $stage. Exit-Code: $exit_code."
if [[ -n "$output" ]]; then
ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$output" | compact_output)"
fi
trap - ERR
write_status error "$message"
exit "$exit_code"
fi
}
ERROR_STAGE="Lokalen Zustand prüfen"
ERROR_MESSAGE="Lokaler Connector-Zustand konnte nicht geprüft werden"
NEEDS_LOCAL_REPAIR=0
if [[ ! -s "$MAP_FILE" ]]; then
NEEDS_LOCAL_REPAIR=1
@@ -122,18 +176,29 @@ if [[ "$NEEDS_LOCAL_REPAIR" == "0" && "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; the
[[ "$PIWIGO_ALBUMS_INTACT" == "0" ]] || NEEDS_LOCAL_REPAIR=1
fi
if [[ "$NEEDS_LOCAL_REPAIR" == "1" ]]; then
if [[ "$SOURCE_MODE" == "user-filesystem" ]]; then
# Benutzerbezogene Verbindungen werden pro Timerlauf aus ihrem eigenen
# lokalen Home-Dateibaum aufgebaut. Die alte globale Showcase-Aktivitaets-
# View darf hier weder Daten anderer Benutzer steuern noch Aenderungen
# dieses Benutzers verschlucken.
GATE_RESULT=0
elif [[ "$NEEDS_LOCAL_REPAIR" == "1" ]]; then
GATE_RESULT=0
else
if python3 "$SCRIPT_DIR/lib/activity_gate.py" check \
ERROR_STAGE="Nextcloud-Aktivität prüfen"
ERROR_MESSAGE="Nextcloud-Aktivität konnte nicht geprüft werden"
if GATE_OUTPUT="$(python3 "$SCRIPT_DIR/lib/activity_gate.py" check \
--state "$ACTIVITY_STATE" --host "$NC_DB_HOST" --port "$NC_DB_PORT" \
--database "$NC_DB_NAME" --user "$NC_DB_USER" --password-file "$NC_DB_PASSWORD_FILE" \
--view "$NC_ACTIVITY_VIEW" --source-view "$NC_DB_VIEW" \
--quiet "$QUIET_SECONDS" --max-wait "$MAX_WAIT_SECONDS" --full-after "$FULL_SYNC_SECONDS"; then
--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
if [[ "$GATE_RESULT" == "3" ]]; then
@@ -141,24 +206,49 @@ if [[ "$GATE_RESULT" == "3" ]]; then
write_status ok "Keine Änderungen gefunden"
exit 0
fi
[[ "$GATE_RESULT" == "0" ]] || { ERROR_DETAIL="Activity-Gate fehlgeschlagen (Exit-Code $GATE_RESULT)."; exit "$GATE_RESULT"; }
if [[ "$GATE_RESULT" != "0" ]]; then
ERROR_DETAIL="Schritt: Nextcloud-Aktivität prüfen. Exit-Code: $GATE_RESULT."
if [[ -n "${GATE_OUTPUT:-}" ]]; then
ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$GATE_OUTPUT" | compact_output)"
fi
trap - ERR
write_status error "Nextcloud-Aktivität konnte nicht geprüft werden"
exit "$GATE_RESULT"
fi
python3 "$SCRIPT_DIR/lib/build_manifest.py" \
--host "$NC_DB_HOST" --port "$NC_DB_PORT" --database "$NC_DB_NAME" --user "$NC_DB_USER" \
--password-file "$NC_DB_PASSWORD_FILE" --view "$NC_DB_VIEW" \
--storage-config "$STORAGE_CONFIG" --output "$MANIFEST"
if [[ "$SOURCE_MODE" == "user-filesystem" ]]; then
run_stage \
"Benutzer-Dateiliste lesen" \
"Dateiliste des Nextcloud-Benutzers konnte nicht erstellt werden" \
python3 "$SCRIPT_DIR/lib/build_user_manifest.py" \
--storage-config "$STORAGE_CONFIG" --output "$MANIFEST"
else
run_stage \
"Nextcloud-Dateiliste lesen" \
"Dateiliste aus Nextcloud konnte nicht erstellt werden" \
python3 "$SCRIPT_DIR/lib/build_manifest.py" \
--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"
fi
python3 "$SCRIPT_DIR/lib/shadow_tree.py" \
run_stage \
"Lokalen Galeriebaum aktualisieren" \
"Lokaler Galeriebaum konnte nicht aktualisiert werden" \
python3 "$SCRIPT_DIR/lib/shadow_tree.py" \
--manifest "$MANIFEST" --destination "$GALLERY_ROOT" --state "$MAP_FILE"
if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
set +e
PIWIGO_OUTPUT="$(php "$SCRIPT_DIR/lib/piwigo-sync.php" \
ERROR_STAGE="Piwigo synchronisieren"
ERROR_MESSAGE="Piwigo-Synchronisierung fehlgeschlagen"
if PIWIGO_OUTPUT="$(php "$SCRIPT_DIR/lib/piwigo-sync.php" \
--piwigo-root="$PIWIGO_ROOT" \
--connection-id="$CONNECTION_ID" \
--base-url="http://127.0.0.1" 2>&1)"
PIWIGO_EXIT=$?
set -e
--base-url="http://127.0.0.1" 2>&1)"; then
PIWIGO_EXIT=0
else
PIWIGO_EXIT=$?
fi
printf '%s\n' "$PIWIGO_OUTPUT"
if grep -q 'Piwigo-Synchronisierung per API erfolgreich' <<<"$PIWIGO_OUTPUT"; then
@@ -189,23 +279,45 @@ if [[ "${PIWIGO_SYNC_ENABLED:-0}" == "1" ]]; then
fi
if [[ "$PIWIGO_EXIT" -ne 0 ]]; then
ERROR_DETAIL="$(tail -n 3 <<<"$PIWIGO_OUTPUT" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/[[:space:]]$//')"
ERROR_DETAIL="Schritt: Piwigo synchronisieren. Exit-Code: $PIWIGO_EXIT. Ausgabe: $(printf '%s\n' "$PIWIGO_OUTPUT" | compact_output)"
trap - ERR
write_status error "Synchronisierung fehlgeschlagen"
write_status error "Piwigo-Synchronisierung fehlgeschlagen"
exit "$PIWIGO_EXIT"
fi
fi
python3 "$SCRIPT_DIR/lib/activity_gate.py" commit \
--state "$ACTIVITY_STATE" --host "$NC_DB_HOST" --port "$NC_DB_PORT" \
--database "$NC_DB_NAME" --user "$NC_DB_USER" --password-file "$NC_DB_PASSWORD_FILE" \
--view "$NC_ACTIVITY_VIEW" --source-view "$NC_DB_VIEW"
if [[ "$SOURCE_MODE" != "user-filesystem" ]]; then
ERROR_STAGE="Aktivitätsstand speichern"
ERROR_MESSAGE="Aktivitätsstand konnte nicht gespeichert werden"
if COMMIT_OUTPUT="$(python3 "$SCRIPT_DIR/lib/activity_gate.py" commit \
--state "$ACTIVITY_STATE" --host "$NC_DB_HOST" --port "$NC_DB_PORT" \
--database "$NC_DB_NAME" --user "$NC_DB_USER" --password-file "$NC_DB_PASSWORD_FILE" \
--view "$NC_ACTIVITY_VIEW" --source-view "$NC_DB_VIEW" 2>&1)"; then
COMMIT_EXIT=0
else
COMMIT_EXIT=$?
fi
if [[ -n "$COMMIT_OUTPUT" ]]; then
printf '%s\n' "$COMMIT_OUTPUT"
fi
if [[ "$COMMIT_EXIT" -ne 0 ]]; then
ERROR_DETAIL="Schritt: Aktivitätsstand speichern. Exit-Code: $COMMIT_EXIT."
if [[ -n "$COMMIT_OUTPUT" ]]; then
ERROR_DETAIL+=" Ausgabe: $(printf '%s\n' "$COMMIT_OUTPUT" | compact_output)"
fi
trap - ERR
write_status error "Aktivitätsstand konnte nicht gespeichert werden"
exit "$COMMIT_EXIT"
fi
fi
trap - ERR
if [[ "$AUTH_MODE" == "fallback" ]]; then
write_status warning "Synchronisierung erfolgreich über Fallback; API war nicht nutzbar"
elif [[ "$AUTH_MODE" == "api" ]]; then
write_status ok "Synchronisierung erfolgreich über API"
elif [[ "$SOURCE_MODE" == "user-filesystem" ]]; then
write_status ok "Benutzerbezogene Synchronisierung erfolgreich"
else
write_status ok "Synchronisierung erfolgreich"
fi

View File

@@ -95,6 +95,7 @@
var lastRunNode=valueNodeForLabel('Letzter Lauf');
var nextRunNode=valueNodeForLabel('Nächster Lauf');
var lastResultNode=valueNodeForLabel('Letztes Ergebnis');
function poll(){
fetch(endpoint+'?_='+Date.now(),{credentials:'same-origin',cache:'no-store'})
@@ -102,6 +103,7 @@
.then(function(data){
if(lastRunNode&&data.last_run_label)lastRunNode.textContent=data.last_run_label;
if(nextRunNode&&data.next_run_label)nextRunNode.textContent=data.next_run_label;
if(lastResultNode&&typeof data.message==='string'&&data.message!=='')lastResultNode.textContent=data.message;
})
.catch(function(){});
}