nc_username; if (! $this->isWellFormed($user, $seat)) { return false; } return $this->run($instance, function ($pve, $node, $vmid) use ($user, $seat) { $vorhanden = (int) ($pve->guestExec( $node, $vmid, NextcloudOcc::command('user:info '.escapeshellarg($user)) )['exitcode'] ?? 1) === 0; // Wiederholbar nach einem Absturz: ein zweiter Lauf legt keinen // zweiten Benutzer an, sondern schickt die Willkommensmail erneut. // Genau wie CreateCustomerAdmin es tut. return $vorhanden ? ['user:welcome --reset-password '.escapeshellarg($user)] : [ 'user:add --generate-password' .' --email='.escapeshellarg((string) $seat->email) .' --display-name='.escapeshellarg((string) ($seat->name ?: $seat->email)) .' --group='.escapeshellarg(Seat::GROUPS[$seat->role] ?? 'mitarbeiter') .' '.escapeshellarg($user), ]; }); } /** Gruppe setzen — und bei readonly der Speicherplatz. */ public function applyRole(Instance $instance, Seat $seat): bool { $user = (string) $seat->nc_username; if (! $this->isWellFormed($user, $seat)) { return false; } $ziel = Seat::GROUPS[$seat->role] ?? 'mitarbeiter'; return $this->run($instance, function ($pve, $node, $vmid) use ($user, $seat, $ziel) { $befehle = []; // Aus jeder anderen bekannten Gruppe heraus, in die eine hinein. // // `2>/dev/null || true` hinter dem Entfernen ist kein Wegsehen, // sondern die richtige Bedeutung — dieselbe Begründung wie bei // HostFirewall::releaseMany(): jemanden aus einer Gruppe zu // nehmen, in der er nicht ist, ist kein Fehler, sondern der // gewünschte Endzustand. // // Und ohne das Netz war es der HAEUFIGSTE Fall, nicht ein // Randfall: `mitarbeiter` und `nur-lesen` legt niemand an. Sie // entstehen erst, wenn `user:add --group=…` sie zum ersten Mal // braucht — `group:removeuser` legt nichts an und beendet mit // Fehlercode, wenn die Gruppe fehlt. Auf einer frischen Instanz // scheiterte damit die erste Rollenänderung dauerhaft, weil // run() alle Exitcodes verundet und Wiederholen wieder `role` // wählt. // // Nur fürs Entfernen. Ein gescheitertes `group:adduser` bleibt // ein Fehlschlag: wer in keiner Gruppe landet, sieht in seiner // neuen Cloud nichts. foreach (array_unique(array_values(Seat::GROUPS)) as $gruppe) { if ($gruppe !== $ziel) { $befehle[] = 'group:removeuser '.escapeshellarg($gruppe).' ' .escapeshellarg($user).' 2>/dev/null || true'; } } $befehle[] = 'group:adduser '.escapeshellarg($ziel).' '.escapeshellarg($user); // Der Speicherplatz. Siehe ApplyStorageQuota: ein Konto mit // EIGENEM Wert folgt der Vorgabe der Instanz nicht mehr. Für // readonly ist genau das gewollt; beim VERLASSEN der Rolle muss // der eigene Wert deshalb WEG, nicht überschrieben werden. $befehle[] = $seat->isReadonly() ? 'user:setting '.escapeshellarg($user).' files quota '.escapeshellarg('0 B') : 'user:setting '.escapeshellarg($user).' files quota --delete'; return $befehle; }); } public function disable(Instance $instance, Seat $seat): bool { $user = (string) $seat->nc_username; if (! $this->isWellFormed($user, $seat)) { return false; } return $this->run($instance, fn ($pve, $node, $vmid) => [ 'user:disable '.escapeshellarg($user), // user:disable allein lässt laufende Sitzungen bis zu fünf // Minuten weiterleben. Bei jemandem, der gerade gegangen ist, // sind fünf Minuten fünf zu viel. 'user:auth-tokens:delete '.escapeshellarg($user), ]); } public function enable(Instance $instance, Seat $seat): bool { $user = (string) $seat->nc_username; if (! $this->isWellFormed($user, $seat)) { return false; } return $this->run($instance, fn ($pve, $node, $vmid) => ['user:enable '.escapeshellarg($user)]); } /** * Nextcloud lässt Buchstaben, Ziffern und `-_.@` in Kennungen zu. Alles * andere ist entweder ein Fehler weiter oben oder ein Versuch — beides * will man sehen, und keines darf in eine Shell. */ private function isWellFormed(string $user, Seat $seat): bool { if ($user !== '' && preg_match('/^[A-Za-z0-9._@-]+$/', $user) === 1) { return true; } report(new RuntimeException( "NextcloudUsers: abgewiesene Kennung fuer Sitz [{$seat->uuid}] — nichts ausgefuehrt." )); return false; } /** * Der Verbindungsaufbau steht EINMAL hier, nicht in jeder Methode. Der * Rückruf bekommt den fertigen Client mit — er braucht ihn, weil `invite()` * erst nachsehen muss, ob es den Benutzer schon gibt, bevor es entscheidet, * welchen Befehl es baut. * * @param callable(ProxmoxClient, string, int): array $bauen */ private function run(Instance $instance, callable $bauen): bool { if ($instance->host === null || blank($instance->vmid)) { return false; } $node = $instance->host->node ?? 'pve'; $vmid = (int) $instance->vmid; try { $pve = $this->pve->forHost($instance->host); $ok = true; foreach ($bauen($pve, $node, $vmid) as $argumente) { $ergebnis = $pve->guestExec($node, $vmid, NextcloudOcc::command($argumente)); $ok = ((int) ($ergebnis['exitcode'] ?? 1) === 0) && $ok; } return $ok; } catch (Throwable $e) { // Ein abgeschalteter Gast wirft, statt einen Fehlercode zu liefern. // Nie mit Zugangsdaten, nie mit Stacktrace an den Kunden. Log::warning('nextcloud user command failed', [ 'instance' => $instance->uuid, 'error' => $e->getMessage(), ]); return false; } } }