commands/SiteController.php
Rama main · réplica de lectura del repositorio original.
<?php
declare(strict_types=1);
namespace app\commands;
use app\components\yii\MessageSource;
use app\models\db\{Consultation, Site, User};
use app\models\forms\SiteCreateForm;
use app\models\settings\AntragsgruenApp;
use app\models\settings\Site as SiteSettings;
use yii\console\{Controller, ExitCode};
/**
* @extends Controller<\yii\console\Application>
*
* Bootstraps the very first site (or any additional site) on a multisite
* installation, including its admin user, without going through the
* interactive web installer (no need for the global config/INSTALLING flag).
*/
class SiteController extends Controller
{
public ?string $subdomain = null;
public ?string $title = null;
public ?string $contact = null;
public string $organization = '';
public ?string $language = null;
public ?string $password = null;
public string $givenName = '';
public string $familyName = '';
/**
* Comma-separated list of SiteCreateForm::FUNCTIONALITY_* codes.
* 1=motions, 2=manifesto, 3=applications, 4=agenda, 5=speech_lists,
* 6=statute_amendments, 7=votings, 8=documents.
*/
public string $functionality = '1';
/**
* Comma-separated list of Site::LOGIN_* codes.
* 0=standard, 1=gruenes_netz, 3=external (SSO), 4=openslides.
* Defaults to the site's built-in login methods when not given.
*/
public ?string $loginMethods = null;
public bool $openNow = true;
public bool $superuser = true;
public bool $forcePasswordChange = true;
public bool $force = false;
public function options($actionID): array
{
return match ($actionID) {
'create', 'init' => [
'subdomain', 'title', 'contact', 'organization', 'language',
'password', 'givenName', 'familyName', 'functionality',
'loginMethods', 'openNow', 'superuser', 'forcePasswordChange', 'force',
],
default => [],
};
}
public function optionAliases(): array
{
return [
'd' => 'subdomain',
't' => 'title',
'c' => 'contact',
'o' => 'organization',
'l' => 'language',
'p' => 'password',
];
}
/**
* Creates the site unless it already exists.
*
* Takes the same options as site/create, but is safe to run repeatedly: an existing
* site is left untouched and reported as success instead of an error. This is the
* site-level counterpart of database/init, so that a container can bootstrap itself
* on every start without the caller having to interpret exit codes.
*/
public function actionInit(string $email): int
{
$subdomain = $this->defaultSubdomain(AntragsgruenApp::getInstance());
if ($subdomain !== null && Site::findOne(['subdomain' => $subdomain]) !== null) {
$this->stdout('Site "' . $subdomain . '" already exists, nothing to do.' . "\n");
return ExitCode::OK;
}
return $this->actionCreate($email);
}
/**
* Creates a new site together with its first admin user.
*
* Usage:
* ./yii site/create admin@example.org \
* --title="My Organization" \
* --contact="My Org, Foo Street 1, foo@example.org"
*
* --subdomain is a single DNS label (e.g. "std", "dbbj"), not a full hostname.
* The full URL is configured separately via "domainPlain" / "domainSubdomain"
* in config.json. In single-site mode (multisiteMode=false), --subdomain
* defaults to the value of "siteSubdomain" in config.json, falling back to
* "std" — so you usually don't need to pass it.
*
* Optional flags:
* --subdomain=std DNS label of the site (single-label, no dots)
* --organization=... Display name of the organization
* --language=de|en|fr|... UI / wording base language (default: configured baseLanguage)
* --password=... If unset, a 16-char password is generated and printed
* --givenName=... --familyName=...
* --functionality=1,4 Comma-separated FUNCTIONALITY_* codes (default: 1 = motions only)
* 1=motions, 2=manifesto, 3=applications, 4=agenda,
* 5=speech_lists, 6=statute_amendments, 7=votings, 8=documents
* --openNow=0 Create site as inactive (default: 1)
* --superuser=0 Don't add user to adminUserIds in config.json (default: 1)
* --forcePasswordChange=0 Don't force a password change on first login (default: 1)
* --force=1 Skip the single-site siteSubdomain mismatch warning
*
* The command performs all DB writes inside a transaction and only touches
* config.json after the DB commit. config/INSTALLING is never created or
* read, so other instances sharing this codebase are unaffected.
*/
public function actionCreate(string $email): int
{
$email = trim($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->stderr('Invalid email address: ' . $email . "\n");
return ExitCode::USAGE;
}
if (!$this->title) {
$this->stderr("Missing required --title\n");
return ExitCode::USAGE;
}
if (!$this->contact) {
$this->stderr("Missing required --contact\n");
return ExitCode::USAGE;
}
$params = AntragsgruenApp::getInstance();
$subdomain = $this->resolveSubdomain($params);
if ($subdomain === null) {
return ExitCode::USAGE;
}
$language = $this->language ?: $params->baseLanguage;
if (!isset(MessageSource::getBaseLanguages()[$language])) {
$this->stderr('Unknown language: ' . $language . "\n");
$this->stderr('Available: ' . implode(', ', array_keys(MessageSource::getBaseLanguages())) . "\n");
return ExitCode::USAGE;
}
if (User::findOne(['auth' => 'email:' . $email])) {
$this->stderr('A user with this email already exists. Use yii user/create to attach an existing user to a different site.' . "\n");
return ExitCode::USAGE;
}
$functionalityCodes = array_values(array_filter(array_map(
static fn(string $v): int => (int)trim($v),
explode(',', $this->functionality)
)));
if ($functionalityCodes === []) {
$functionalityCodes = [SiteCreateForm::FUNCTIONALITY_MOTIONS];
}
$passwordWasGenerated = false;
$password = $this->password;
if ($password === null || $password === '') {
$password = \Yii::$app->getSecurity()->generateRandomString(16);
$passwordWasGenerated = true;
}
$forcePasswordChange = $this->forcePasswordChange;
// Pre-flight check for the config.json mutation so we don't have to roll back the DB.
$configFile = null;
if ($this->superuser) {
$configFile = $this->resolveConfigFile();
if (!file_exists($configFile)) {
$this->stderr(
'config.json not found at ' . $configFile . "\n" .
'Cannot persist superuser status. Re-run with --superuser=0 and set adminUserIds via your environment, ' .
"or create config.json first.\n"
);
return ExitCode::CONFIG;
}
if (!is_writable($configFile)) {
$this->stderr(
'config.json is not writable: ' . $configFile . "\n" .
"Fix permissions or re-run with --superuser=0.\n"
);
return ExitCode::CONFIG;
}
}
$transaction = \Yii::$app->db->beginTransaction();
try {
$user = $this->createUser($email, $password, $forcePasswordChange);
$form = new SiteCreateForm();
$form->subdomain = $subdomain;
$form->title = $this->title;
$form->contact = $this->contact;
$form->organization = $this->organization;
$form->language = $language;
$form->openNow = $this->openNow;
$form->functionality = $functionalityCodes;
$consultation = $form->create($user);
$transaction->commit();
} catch (\Throwable $e) {
$transaction->rollBack();
$this->stderr('Failed to create site: ' . $e->getMessage() . "\n");
return ExitCode::SOFTWARE;
}
if ($form->site === null) {
$this->stderr("Internal error: site was not created.\n");
return ExitCode::SOFTWARE;
}
$site = $form->site;
if ($this->loginMethods !== null) {
$loginMethods = self::parseLoginMethods($this->loginMethods);
if ($loginMethods === null) {
$this->stderr('Invalid --loginMethods: ' . $this->loginMethods . "\n");
return ExitCode::USAGE;
}
self::applyLoginMethods($site, $loginMethods);
}
$superuserPersisted = false;
if ($this->superuser) {
try {
$this->appendAdminUserId($configFile, (int)$user->id);
$superuserPersisted = true;
} catch (\Throwable $e) {
$this->stderr(
"WARNING: Site and user were created, but updating config.json failed:\n" .
' ' . $e->getMessage() . "\n" .
'Add user id ' . $user->id . ' to "adminUserIds" in ' . $configFile . " manually.\n"
);
}
}
$this->printSummary(
$params,
$user,
$site,
$consultation,
$password,
$passwordWasGenerated,
$superuserPersisted,
$configFile
);
return ExitCode::OK;
}
/**
* Resolves and validates the --subdomain option.
*
* Returns the validated single-label subdomain, or null on error
* (in which case a message has already been written to stderr).
*/
/**
* The subdomain this command would act on, before any validation.
*
* In single-site mode the wizard defaults to siteSubdomain (or "std"), so mirror
* that here and operators rarely have to think about this label. Returns null in
* multisite mode, where there is no sensible default.
*/
private function defaultSubdomain(AntragsgruenApp $params): ?string
{
if ($this->subdomain !== null && $this->subdomain !== '') {
return $this->subdomain;
}
return $params->multisiteMode ? null : ($params->siteSubdomain ?: 'std');
}
private function resolveSubdomain(AntragsgruenApp $params): ?string
{
$subdomain = $this->defaultSubdomain($params);
if ($subdomain === null) {
$this->stderr("Missing required --subdomain (multisiteMode is on, no default available)\n");
return null;
}
if ($subdomain !== $this->subdomain) {
$this->stdout('Using subdomain "' . $subdomain . '" (from the siteSubdomain setting)' . "\n");
}
if (!preg_match('/^[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$/siu', $subdomain)) {
$this->stderr(
'--subdomain must be a single DNS label (letters, digits, hyphens; no dots; max 63 chars).' . "\n" .
'Got: "' . $subdomain . '"' . "\n" .
'The full URL of the site is configured separately via "domainPlain" / "domainSubdomain" in config.json.' . "\n"
);
return null;
}
if (in_array($subdomain, $params->blockedSubdomains, true)) {
$this->stderr(
'Subdomain "' . $subdomain . '" is in blockedSubdomains in config.json.' . "\n"
);
return null;
}
if (Site::findOne(['subdomain' => $subdomain]) !== null) {
$this->stderr('A site with subdomain "' . $subdomain . '" already exists.' . "\n");
return null;
}
// Single-site safety net: warn (or fail) if the chosen label won't be reachable
// through the configured siteSubdomain mapping.
if (!$params->multisiteMode && $params->siteSubdomain && $params->siteSubdomain !== $subdomain) {
$msg = 'Warning: --subdomain="' . $subdomain . '" does not match siteSubdomain="' .
$params->siteSubdomain . '" in config.json.' . "\n" .
'In single-site mode (multisiteMode=false), only the site whose "subdomain" column matches' . "\n" .
'the "siteSubdomain" config value is reachable. The new site would not be served.' . "\n";
if (!$this->force) {
$this->stderr($msg . 'Pass --force=1 to create it anyway, or change one of the two values.' . "\n");
return null;
}
$this->stderr($msg . 'Continuing because --force=1 was passed.' . "\n");
}
return $subdomain;
}
private function createUser(string $email, string $password, bool $forcePasswordChange): User
{
$givenName = $this->givenName;
$familyName = $this->familyName;
if ($givenName === '' && $familyName === '') {
$localPart = strstr($email, '@', true) ?: $email;
$givenName = $localPart;
}
$user = new User();
$user->auth = 'email:' . $email;
$user->email = $email;
$user->status = User::STATUS_CONFIRMED;
$user->emailConfirmed = 1;
$user->pwdEnc = password_hash($password, PASSWORD_DEFAULT);
$user->name = trim($givenName . ' ' . $familyName);
$user->nameGiven = $givenName;
$user->nameFamily = $familyName;
$user->organization = $this->organization;
$user->organizationIds = '';
$user->fixedData = 0;
$user->dateCreation = date('Y-m-d H:i:s');
if ($forcePasswordChange) {
$settings = $user->getSettingsObj();
$settings->forcePasswordChange = true;
$user->setSettingsObj($settings);
}
if (!$user->save()) {
throw new \RuntimeException('Could not save user: ' . json_encode($user->getErrors()));
}
return $user;
}
private function resolveConfigFile(): string
{
if (isset($_SERVER['ANTRAGSGRUEN_CONFIG']) && $_SERVER['ANTRAGSGRUEN_CONFIG'] !== '') {
return (string)$_SERVER['ANTRAGSGRUEN_CONFIG'];
}
if (isset($_ENV['ANTRAGSGRUEN_CONFIG']) && $_ENV['ANTRAGSGRUEN_CONFIG'] !== '') {
return (string)$_ENV['ANTRAGSGRUEN_CONFIG'];
}
return dirname(__DIR__) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.json';
}
/**
* Append the user id to "adminUserIds" in the active config.json,
* preserving every other key. Writes atomically (temp file + rename).
*/
private function appendAdminUserId(string $configFile, int $userId): void
{
$raw = file_get_contents($configFile);
if ($raw === false) {
throw new \RuntimeException('Could not read config file: ' . $configFile);
}
$data = AntragsgruenApp::decodeJson5($raw);
if (!is_array($data)) {
throw new \RuntimeException('Could not decode config file as JSON: ' . $configFile);
}
$existing = $data['adminUserIds'] ?? [];
if (!is_array($existing)) {
$existing = [];
}
$existing = array_values(array_map('intval', $existing));
if (!in_array($userId, $existing, true)) {
$existing[] = $userId;
}
$data['adminUserIds'] = $existing;
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$tmp = $configFile . '.tmp.' . getmypid();
if (file_put_contents($tmp, $encoded . "\n", LOCK_EX) === false) {
throw new \RuntimeException('Could not write temp config file: ' . $tmp);
}
// Preserve original permissions (best effort).
$perms = @fileperms($configFile);
if ($perms !== false) {
@chmod($tmp, $perms & 0777);
}
if (!@rename($tmp, $configFile)) {
@unlink($tmp);
throw new \RuntimeException('Could not move temp config file into place: ' . $configFile);
}
}
private function printSummary(
AntragsgruenApp $params,
User $user,
Site $site,
Consultation $consultation,
string $password,
bool $passwordWasGenerated,
bool $superuserPersisted,
?string $configFile
): void {
if ($params->domainSubdomain) {
$siteUrl = str_replace('<subdomain:[\\w_-]+>', (string)$site->subdomain, $params->domainSubdomain);
} elseif ($params->domainPlain) {
$siteUrl = $params->domainPlain;
} else {
$siteUrl = '(set domainPlain or domainSubdomain in config.json)';
}
$this->stdout("\nSite created.\n");
$this->stdout("---------------------------------------------\n");
$this->stdout('Site id : ' . $site->id . "\n");
$this->stdout('Subdomain : ' . $site->subdomain . "\n");
$this->stdout('Site URL : ' . $siteUrl . "\n");
$this->stdout('Consultation id : ' . $consultation->id . "\n");
$this->stdout('Consultation path : ' . $consultation->urlPath . "\n");
$this->stdout("---------------------------------------------\n");
$this->stdout('Admin user id : ' . $user->id . "\n");
$this->stdout('Admin email : ' . $user->email . "\n");
$this->stdout('Admin auth : ' . $user->auth . "\n");
if ($this->superuser) {
if ($superuserPersisted) {
$this->stdout('Superuser : yes (added to adminUserIds in ' . $configFile . ')' . "\n");
} else {
$this->stdout('Superuser : NOT persisted -- add ' . $user->id . ' to adminUserIds manually' . "\n");
}
} else {
$this->stdout('Superuser : no (per --superuser=0; site-admin only via the site user group)' . "\n");
}
if ($passwordWasGenerated) {
$this->stdout("---------------------------------------------\n");
$this->stdout("Generated password (store it now, it is not recoverable):\n");
$this->stdout('PASSWORD=' . $password . "\n");
$this->stdout("The user will be required to change it on first login.\n");
}
$this->stdout("---------------------------------------------\n");
}
/**
* Sets the login methods of an existing site.
*
* Login methods are stored inside the site's serialized settings, so without this
* there is no scriptable way to enable SSO logins on an already-created site.
*
* @param string $subdomain Subdomain of the site to change
* @param string $loginMethods Comma-separated list of Site::LOGIN_* codes,
* e.g. "0,3" for standard plus external (SSO)
*/
public function actionSetLoginMethods(string $subdomain, string $loginMethods): int
{
$site = Site::findOne(['subdomain' => $subdomain]);
if (!$site) {
$this->stderr('No site found with subdomain: ' . $subdomain . "\n");
return ExitCode::DATAERR;
}
$parsed = self::parseLoginMethods($loginMethods);
if ($parsed === null) {
$this->stderr('Invalid login methods: ' . $loginMethods . "\n");
return ExitCode::USAGE;
}
self::applyLoginMethods($site, $parsed);
$this->stdout('Login methods of "' . $subdomain . '" set to: ' . implode(', ', $parsed) . "\n");
return ExitCode::OK;
}
/**
* @return int[]|null Parsed login method codes, or null if the input is invalid
*/
private static function parseLoginMethods(string $loginMethods): ?array
{
$valid = [
SiteSettings::LOGIN_STD,
SiteSettings::LOGIN_GRUENES_NETZ,
SiteSettings::LOGIN_EXTERNAL,
SiteSettings::LOGIN_OPENSLIDES,
];
$parsed = [];
foreach (explode(',', $loginMethods) as $method) {
$method = trim($method);
if ($method === '' || !ctype_digit($method) || !in_array((int)$method, $valid, true)) {
return null;
}
$parsed[] = (int)$method;
}
return array_values(array_unique($parsed));
}
/**
* @param int[] $loginMethods
*/
private static function applyLoginMethods(Site $site, array $loginMethods): void
{
$settings = $site->getSettings();
$settings->loginMethods = $loginMethods;
$site->setSettings($settings);
$site->save();
}
}