feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 11:33:56 +03:00
1 parent 5b6b2d2053
commit 9515c1c62b
52 files changed
+2459 -34

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Auth
{
public function __construct(private Store $store) {}
public static function session(): void {
if(session_status()===PHP_SESSION_ACTIVE)return;
ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1');
session_name('preland_admin');
session_set_cookie_params(['lifetime'=>0,'path'=>'/','secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict']);
session_start();
}
public function login(string $username,string $password,string $ip): array {
$key=hash('sha256',$ip);$this->store->locked('login',function() use($username,$password,$key){
$attempts=$this->store->read('login-attempts.json');$now=time();$entry=$attempts[$key]??['count'=>0,'since'=>$now];if($entry['since']<$now-900)$entry=['count'=>0,'since'=>$now];
if($entry['count']>=10)throw new Problem('Слишком много попыток. Подождите 15 минут.',429);
$expected=getenv('ADMIN_USERNAME')?:'';$hash=getenv('ADMIN_PASSWORD_HASH')?:'';
if($expected===''||$hash==='')throw new Problem('Администратор ещё не настроен.',503);
$valid=password_verify($password,$hash)&&hash_equals($expected,$username);
if(!$valid){$entry['count']++;$attempts[$key]=$entry;$attempts=array_filter($attempts,static fn($e)=>$e['since']>$now-900);$this->store->write('login-attempts.json',$attempts);throw new Problem('Неверный логин или пароль.',401);}
unset($attempts[$key]);$this->store->write('login-attempts.json',$attempts);
});
session_regenerate_id(true);$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>time()];return ['user'=>$username,'csrf'=>$_SESSION['csrf']];
}
public function requireUser(): string {
if(empty($_SESSION['user'])||($_SESSION['last']??0)<time()-3600){$_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=time();return $_SESSION['user'];
}
public function csrf(string $token): void {if(!$token||!hash_equals($_SESSION['csrf']??'', $token))throw new Problem('Сессия формы истекла. Обновите страницу.',403);}
public function logout(): void {$_SESSION=[];session_regenerate_id(true);}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Catalog
{
public static function read(Store $store,\stdClass $config): ?array {
$catalog=$store->read('catalog.json',null);if(!is_array($catalog)||!is_string($catalog['generated_at']??null))return null;
$at=strtotime($catalog['generated_at']);if($at===false||$at<time()-180||$at>time()+30)return null;
$host=static fn($value)=>strtolower(rtrim((string)parse_url(str_contains((string)$value,'://')?(string)$value:'https://'.$value,PHP_URL_HOST),'.'));
if(!$host($config->host??'')||$host($catalog['host']??'')!==$host($config->host??''))return null;
foreach(['degr','tp','cons'] as $group){if(!is_array($catalog['groups'][$group]??null))return null;foreach($catalog['groups'][$group] as $entry)if(!is_array($entry)||!is_int($entry['number']??null)||$entry['number']<1||$entry['number']>9||!is_string($entry['name']??null))return null;}
return $catalog;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Config
{
public static function decode(string $json): \stdClass {
try { $value = json_decode($json, false, 64, JSON_THROW_ON_ERROR); } catch (\Throwable) { throw new Problem('Настройки содержат некорректный JSON.'); }
if (!$value instanceof \stdClass) throw new Problem('Настройки должны быть объектом.');
return $value;
}
public static function encode(\stdClass $value): string { return json_encode($value, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR) . "\n"; }
public static function patch(\stdClass $config, array $changes): \stdClass {
if (!$changes || count($changes)>500) throw new Problem('Выберите изменённые поля (не более 500).');
$copy = self::decode(self::encode($config));
foreach ($changes as $change) {
$change=self::adaptChange($copy,$change);
$path = $change['path'] ?? [];
if (!is_array($path) || !$path || count($path)>16) throw new Problem('Некорректный путь поля.');
$ref = &$copy;
foreach ($path as $i=>$part) {
if (!is_string($part) || $part==='' || strlen($part)>100 || in_array($part,['__proto__','constructor','prototype'],true) || preg_match('/[\x00-\x1f]/',$part)) throw new Problem('Недопустимый ключ поля.');
$last = $i===count($path)-1;
if ($ref instanceof \stdClass) {
if ($last) { if (!empty($change['delete'])) unset($ref->$part); else $ref->$part=$change['value'] ?? null; }
else { if (!property_exists($ref,$part)) $ref->$part=new \stdClass(); $ref=&$ref->$part; }
} elseif (is_array($ref) && ctype_digit($part)) {
$index=(int)$part;
if ($last) { if (!empty($change['delete'])) { array_splice($ref,$index,1); } else $ref[$index]=$change['value'] ?? null; }
elseif (array_key_exists($index,$ref)) $ref=&$ref[$index]; else throw new Problem('Элемент массива не найден.');
} else throw new Problem('Путь проходит через скалярное поле.');
}
unset($ref);
}
return $copy;
}
private static function adaptChange(\stdClass $config,array $change): array {
$path=$change['path']??[];if(($path[0]??null)!=='utm'||!isset($path[1]))return $change;
$aliases=['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
$key=$path[1];$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key]??$key;$settings=$config->utm??new \stdClass();$existing=[];
foreach($aliases[$canonical]??[$canonical] as $alias)if(isset($settings->$alias))$existing[]=$alias;
if(count($existing)>1)throw new Problem("Конфликт UTM-ключей {$canonical}. Перечитайте и явно устраните неоднозначность.");
$actual=$existing[0]??$canonical;$path[1]=$actual;$group=$settings->$actual??null;$map=$maps[$canonical]??[];$semantic=false;
if($group instanceof \stdClass){$semantic=in_array($group->default??null,array_values($map),true);foreach(get_object_vars($group->weights??new \stdClass()) as $variant=>$unused)if(in_array((string)$variant,array_values($map),true))$semantic=true;}
$convert=static function($value)use($map,$semantic){$original=$value;$digit=array_search((string)$value,$map,true);$digit=$digit===false?(string)$value:(string)$digit;return $semantic?($map[$digit]??$original):(is_int($original)?$original:$digit);};
if(($path[2]??null)==='weights'&&isset($path[3]))$path[3]=(string)$convert($path[3]);
if(array_key_exists('value',$change)){
if(($path[2]??null)==='default')$change['value']=$convert($change['value']);
if(count($path)===3&&$path[2]==='weights'&&$change['value'] instanceof \stdClass){$weights=new \stdClass();foreach(get_object_vars($change['value']) as $variant=>$weight){$variant=(string)$convert($variant);if(property_exists($weights,$variant))throw new Problem('Неоднозначные варианты весов.');$weights->$variant=$weight;}$change['value']=$weights;}
if(count($path)===2&&$change['value'] instanceof \stdClass){$value=self::decode(self::encode($change['value']));if(isset($value->default))$value->default=$convert($value->default);if(($value->weights??null)instanceof \stdClass){$weights=new \stdClass();foreach(get_object_vars($value->weights) as $variant=>$weight){$variant=(string)$convert($variant);if(property_exists($weights,$variant))throw new Problem('Неоднозначные варианты весов.');$weights->$variant=$weight;}$value->weights=$weights;}$change['value']=$value;}
}
$change['path']=$path;return $change;
}
public static function validate(\stdClass $config, callable $setExists,?array $catalog=null): array {
if (isset($config->host)) {
$host = $config->host;
if (!is_string($host) || !preg_match('~^(?:https://)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$~iD',$host) || str_contains($host,'..')) throw new Problem('Адрес сайта должен быть HTTPS-хостом без пути.');
}
if (isset($config->requisites) && (!is_array($config->requisites) || array_filter($config->requisites,static fn($line)=>!is_string($line)))) throw new Problem('Реквизиты должны содержать строки.');
$utm=$config->utm ?? new \stdClass();
if (!$utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
foreach (['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'] as $old=>$new) if (isset($utm->$old,$utm->$new)) throw new Problem("Конфликт старого {$old} и нового {$new} UTM-ключа.");
$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
$warnings=[];
foreach (get_object_vars($utm) as $key=>$group) {
$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key] ?? $key;
if (!in_array($canonical,['indpic','theme','degr','reg','banch','tp','cons','focus'],true)) continue;
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights ?? null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$key}.");
$number=static function($value) use($maps,$canonical): string {
if (!is_int($value) && !is_string($value)) throw new Problem('Вариант UTM должен быть номером.');
$value=(string)$value;
if (isset($maps[$canonical])) { $digit=array_search($value,$maps[$canonical],true); if ($digit!==false) return (string)$digit; if (!isset($maps[$canonical][$value])) throw new Problem("Недопустимый вариант {$canonical}."); }
if (!preg_match('/^[1-9]$/D',$value)) throw new Problem('Вариант UTM должен быть 1–9.');
return $value;
};
$default=$number($group->default);$seen=[];
foreach (get_object_vars($group->weights) as $variant=>$weight) {
$digit=$number($variant);
if (isset($seen[$digit])) throw new Problem('Конфликт semantic и numeric вариантов UTM.'); $seen[$digit]=true;
if ((!is_int($weight) && !is_float($weight)) || !is_finite((float)$weight) || $weight<0) throw new Problem('Вес должен быть конечным неотрицательным числом.');
if ($canonical==='indpic' && !$setExists((int)$digit)) throw new Problem("Набор {$digit} отсутствует или не прошёл проверку.");
if($catalog!==null&&in_array($canonical,['degr','tp','cons'],true)&&!in_array((int)$digit,array_column($catalog['groups'][$canonical]??[],'number'),true))throw new Problem("Группа {$canonical} {$digit} отсутствует в доступном каталоге основного сайта.");
}
if (in_array($canonical,['indpic','degr','tp'],true)&&!isset($seen[$default])) throw new Problem("Добавьте вариант {$default} в таблицу весов {$key} (допустим вес 0).");
if ($canonical==='indpic' && !$setExists((int)$default)) throw new Problem("Набор default {$default} отсутствует или не прошёл проверку.");
if (in_array($canonical,['degr','tp','cons'],true)) {
if($catalog===null)$warnings[]="{$canonical}: существование группы на основном сайте не проверено.";
elseif(!in_array((int)$default,array_column($catalog['groups'][$canonical]??[],'number'),true))throw new Problem("Группа default {$canonical} {$default} отсутствует в доступном каталоге основного сайта.");
}
}
return $warnings;
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace PrelandAdmin;
final class Conflict extends Problem { public function __construct() { parent::__construct('Файл изменился. Перечитайте настройки и повторите изменение.',409); } }
+384
View File
@@ -0,0 +1,384 @@
<?php
namespace PrelandAdmin;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use ZipArchive;
/**
* Файловый контракт Set демонстрационных изображений Index.
* Перенесено из API 591a67cc8302 components/indpic/IndpicSetStorage.php (preland#22).
*
* Компонент не знает о БД: он только проверяет и атомарно заменяет содержимое
* каталогов `images/indpic/<номер>`. Это позволяет одинаково применять его к
* Set, добавленному через SFTP или ZIP CPanel.
*/
final class IndpicSetStorage
{
/** @var array<string, array{width: int, height: int}> */
private const DIMENSIONS = [
'01_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'01_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'01_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'01_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'01_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'01_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'01_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'01_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'01_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'01_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'01_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'01_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'01_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'01_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'01_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'02_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'02_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'02_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'02_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'02_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'02_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'02_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'02_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'02_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'02_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'02_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'02_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'02_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'02_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'02_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'03_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'03_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'03_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'03_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'03_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'03_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'03_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'03_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'03_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'03_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'03_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'03_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'03_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'03_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'03_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'04_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'04_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'04_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'04_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'04_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'04_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'04_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'04_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'04_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'04_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'04_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'04_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'04_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'04_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'04_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'05_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'05_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'05_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'05_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'05_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'05_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'05_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'05_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'05_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'05_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'05_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'05_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'05_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'05_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'05_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
];
private readonly ?\Closure $imageInspector;
public function __construct(private readonly string $root, ?callable $imageInspector = null)
{
$this->imageInspector = $imageInspector === null ? null : \Closure::fromCallable($imageInspector);
}
/** @return array<string, array{width: int, height: int}> */
public static function expectedDimensions(): array
{
return self::DIMENSIONS;
}
public function isComplete(int $set): bool
{
try {
$this->validateDirectory($set);
return true;
} catch (RuntimeException) {
return false;
}
}
/** @throws RuntimeException если каталог не соответствует контракту Set. */
public function validateDirectory(int $set): void
{
$directory = $this->directory($set);
if (!is_dir($directory)) {
throw new RuntimeException("Папка Set {$set} не найдена.");
}
$actual = [];
$errors = [];
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $item) {
if ($item->isLink() || !$item->isFile()) {
$errors[] = "Лишний элемент в Set {$set}: {$item->getFilename()}.";
continue;
}
$actual[$item->getFilename()] = $item->getPathname();
}
$expected = self::DIMENSIONS;
foreach (array_keys($expected) as $filename) {
if (!isset($actual[$filename])) {
$errors[] = "Отсутствует обязательный файл {$filename}.";
}
}
foreach (array_keys($actual) as $filename) {
if (!isset($expected[$filename])) {
$errors[] = "Лишний файл {$filename}.";
}
}
foreach ($expected as $filename => $dimensions) {
if (!isset($actual[$filename])) {
continue;
}
try {
$actualDimensions = $this->dimensions($actual[$filename]);
if ($actualDimensions !== $dimensions) {
$errors[] = sprintf(
'Неверный размер %s: %d×%d, ожидалось %d×%d.',
$filename,
$actualDimensions['width'],
$actualDimensions['height'],
$dimensions['width'],
$dimensions['height']
);
}
} catch (RuntimeException $exception) {
$errors[] = $exception->getMessage();
}
}
if ($errors !== []) {
throw new RuntimeException(implode("\n", $errors));
}
}
/**
* Полностью и атомарно заменяет Set распакованным ZIP после проверки всех файлов.
*
* @throws RuntimeException
*/
public function replaceFromZip(int $set, string $zipPath): void
{
$this->assertSetNumber($set);
if (!is_file($zipPath)) {
throw new RuntimeException('ZIP-файл не получен.');
}
if (!class_exists(ZipArchive::class)) {
throw new RuntimeException('На сервере не доступна поддержка ZIP.');
}
$stagingParent = $this->root . DIRECTORY_SEPARATOR . '.staging';
$staging = $stagingParent . DIRECTORY_SEPARATOR . $set . '-' . bin2hex(random_bytes(8));
$stagedSet = $staging . DIRECTORY_SEPARATOR . $set;
if (!is_dir($stagingParent) && !mkdir($stagingParent, 0775, true) && !is_dir($stagingParent)) {
throw new RuntimeException('Не удалось создать staging-каталог Set.');
}
if (!mkdir($stagedSet, 0775, true)) {
throw new RuntimeException('Не удалось подготовить staging-каталог Set.');
}
try {
$this->extractZip($zipPath, $set, $stagedSet);
$this->validateDirectoryPath($stagedSet, $set);
$this->activateStagedSet($set, $stagedSet);
} catch (\Throwable $exception) {
if (is_dir($staging)) {
$this->removeDirectory($staging);
}
throw $exception instanceof RuntimeException
? $exception
: new RuntimeException('Не удалось заменить Set ZIP-архивом.', 0, $exception);
}
if (is_dir($staging)) {
$this->removeDirectory($staging);
}
}
private function extractZip(string $zipPath, int $set, string $target): void
{
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
throw new RuntimeException('Не удалось открыть ZIP-архив.');
}
try {
$prefix = $set . '/';
$files = [];
$directories = [];
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
if ($name === '' || str_contains($name, '\\') || str_starts_with($name, '/') || str_contains($name, '../')) {
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
}
if ($name === $prefix) {
$directories[] = $name;
continue;
}
if (!str_starts_with($name, $prefix) || str_contains(substr($name, strlen($prefix)), '/')) {
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
}
$filename = substr($name, strlen($prefix));
if (!isset(self::DIMENSIONS[$filename]) || isset($files[$filename])) {
throw new RuntimeException('ZIP содержит лишний или повторяющийся файл.');
}
$files[$filename] = $index;
}
// Некоторые ZIP-клиенты (в частности проводник Windows) не добавляют
// отдельную запись корневого каталога: достаточно 90 файлов с единым
// префиксом "<номер Set>/". Вложенные каталоги всё равно отклоняются выше.
if (count($directories) > 1 || count($files) !== count(self::DIMENSIONS)) {
throw new RuntimeException("ZIP должен содержать папку {$set} и ровно 90 файлов в ней.");
}
foreach (array_keys(self::DIMENSIONS) as $filename) {
if (!isset($files[$filename])) {
throw new RuntimeException("В ZIP отсутствует обязательный файл {$filename}.");
}
$stream = $zip->getStream($prefix . $filename);
if ($stream === false) {
throw new RuntimeException("Не удалось прочитать файл {$filename} из ZIP.");
}
$destination = $target . DIRECTORY_SEPARATOR . $filename;
$output = fopen($destination, 'wb');
if ($output === false) {
fclose($stream);
throw new RuntimeException("Не удалось подготовить файл {$filename}.");
}
$copied = stream_copy_to_stream($stream, $output, 8388609);
fclose($output);
fclose($stream);
if ($copied === false || $copied > 8388608) {
throw new RuntimeException("Не удалось распаковать файл {$filename}.");
}
chmod($destination, 0644);
}
} finally {
$zip->close();
}
}
private function activateStagedSet(int $set, string $stagedSet): void
{
$destination = $this->directory($set);
$backup = $this->root . DIRECTORY_SEPARATOR . '.backup-' . $set . '-' . bin2hex(random_bytes(8));
$hadDestination = is_dir($destination);
if ($hadDestination && !rename($destination, $backup)) {
throw new RuntimeException('Не удалось подготовить замену текущего Set.');
}
if (!rename($stagedSet, $destination)) {
if ($hadDestination) {
rename($backup, $destination);
}
throw new RuntimeException('Не удалось активировать новый Set.');
}
if ($hadDestination) {
$this->removeDirectory($backup);
}
}
/** @return array{width: int, height: int} */
private function dimensions(string $path): array
{
if ($this->imageInspector !== null) {
$dimensions = ($this->imageInspector)($path);
if (!is_array($dimensions) || !isset($dimensions['width'], $dimensions['height'])) {
throw new RuntimeException('Проверка изображения вернула некорректный размер.');
}
return ['width' => (int) $dimensions['width'], 'height' => (int) $dimensions['height']];
}
if (!class_exists(\Imagick::class)) {
throw new RuntimeException('На сервере не доступна проверка изображений Imagick.');
}
try {
$image = new \Imagick();
$image->readImage($path);
$format = strtoupper($image->getImageFormat());
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$allowed = ['jpg'=>['JPEG','JPG'], 'webp'=>['WEBP'], 'avif'=>['AVIF','HEIC']];
if (!in_array($format, $allowed[$extension] ?? [], true) || $image->getNumberImages() !== 1) {
throw new RuntimeException('Формат изображения не совпадает с расширением либо содержит несколько кадров.');
}
$dimensions = ['width' => $image->getImageWidth(), 'height' => $image->getImageHeight()];
$image->clear();
$image->destroy();
return $dimensions;
} catch (\Throwable $exception) {
throw new RuntimeException('Файл ' . basename($path) . ' не является корректным изображением.', 0, $exception);
}
}
private function validateDirectoryPath(string $directory, int $set): void
{
$storage = new self(dirname($directory), $this->imageInspector);
$storage->validateDirectory($set);
}
private function directory(int $set): string
{
$this->assertSetNumber($set);
return $this->root . DIRECTORY_SEPARATOR . $set;
}
private function assertSetNumber(int $set): void
{
if ($set < 1 || $set > 9) {
throw new RuntimeException('Номер Set должен быть от 1 до 9.');
}
}
private function removeDirectory(string $directory): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($directory);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Jobs
{
public function __construct(private Store $store) {}
public function create(string $type,array $payload): array {
if(!in_array($type,['apply','restore','upload','check','register'],true))throw new Problem('Неизвестный тип задания.');
$targets=$payload['targets']??(isset($payload['target'])?[$payload['target']]:[]);if(!$targets||count($targets)>100)throw new Problem('Выберите от 1 до 100 подключений.');
$results=[];$seen=[];foreach($targets as $t){$id=is_array($t)?($t['id']??''):$t;if(!is_string($id)||!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id))throw new Problem('Некорректная цель задания.');if(isset($seen[$id]))continue;$seen[$id]=true;$results[]=['target'=>$id,'status'=>'queued','message'=>'Ожидает выполнения.'];}
if(isset($payload['changes']))foreach($payload['changes'] as &$change){if(array_key_exists('value',$change)){$change['value_json']=json_encode($change['value'],JSON_THROW_ON_ERROR);unset($change['value']);}}unset($change);
$id=bin2hex(random_bytes(16));$job=['id'=>$id,'type'=>$type,'status'=>'queued','created_at'=>gmdate(DATE_ATOM),'results'=>$results,'payload'=>$payload];$this->store->write('jobs/'.$id.'.json',$job);return $this->public($job);
}
private function public(array $job): array {$job['changes']=array_map(static fn($c)=>['path'=>$c['path']],$job['payload']['changes']??[]);unset($job['payload']);foreach($job['results'] as &$result)unset($result['desired_hash'],$result['source_manifest'],$result['desired_manifest']);return $job;}
public function get(string $id): array {$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job)throw new Problem('Задание не найдено.',404);return $this->public($job);}
public function all(): array {$jobs=[];foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path)$jobs[]=$this->public($this->store->read('jobs/'.basename($path)));usort($jobs,static fn($a,$b)=>strcmp($b['created_at'],$a['created_at']));return array_slice($jobs,0,100);}
public function retry(string $id,array $targets): array {
return $this->store->locked('job-'.$id,function() use($id,$targets){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job)throw new Problem('Задание не найдено.',404);if($job['status']!=='completed')throw new Problem('Задание ещё выполняется.',409);
$count=0;foreach($job['results'] as &$result)if(in_array($result['target'],$targets,true)&&in_array($result['status'],['error','unknown'],true)){$result['status']='queued';$result['message']='Повтор ожидает выполнения.';$count++;}unset($result);
if(!$count)throw new Problem('Выберите цели с ошибкой либо неизвестным результатом. Конфликт требует перечитать конфиг.');$job['status']='queued';$this->store->write('jobs/'.$id.'.json',$job);return $this->public($job);
});
}
public function run(string $id): void {
$this->store->locked('job-'.$id,function() use($id){
$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;
$save=function() use(&$job,$id){$this->store->write('jobs/'.$id.'.json',$job);};$job['status']='running';$save();
foreach($job['results'] as $index=>$result){
if(!in_array($result['status'],['queued','running'],true))continue;
$recover=$result['status']==='running';$job['results'][$index]['status']='running';$save();
try{
$target=$this->store->target($result['target']);if(empty($target['enabled']))throw new Problem('Подключение отключено.');
if(empty($target['verified']))throw new Problem('Сначала проверьте подключение.');
$transport=new Transport($this->store,$target);$p=$job['payload'];$type=$job['type'];
if(in_array($type,['apply','restore'],true)){
$current=$transport->readConfig();$expected=$p['version']??'';
if($type==='apply')foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??'';
// После обрыва/рестарта сперва сверяем ожидаемый хеш уже подготовленного контента.
$desired=$job['results'][$index]['desired_hash']??null;
if($desired && hash_equals($desired,$current['version'])) {$job['results'][$index]=['target'=>$target['id'],'status'=>'success','message'=>'Сохранённое состояние подтверждено после повторного чтения.','version'=>$current['version']];$save();continue;}
if(!is_string($expected)||!hash_equals($current['version'],$expected))throw new Conflict();
if(isset($p['changes']))foreach($p['changes'] as &$change){if(isset($change['value_json']))$change['value']=json_decode($change['value_json'],false,64,JSON_THROW_ON_ERROR);}unset($change);
$next=$type==='restore'?Config::decode($this->store->backupContent($target['id'],$p['id'])):Config::patch($current['config'],$p['changes']??[]);
$checked=[];$warnings=Config::validate($next,function($number)use($target,&$checked){if(array_key_exists($number,$checked))return $checked[$number];try{(new Sets($this->store))->check($target,$number);return $checked[$number]=true;}catch(\Throwable){return $checked[$number]=false;}},Catalog::read($this->store,$next));
$content=Config::encode($next);$job['results'][$index]['desired_hash']=hash('sha256',$content);$save();
$written=$transport->writeConfig($expected,$content);$out=['message'=>'Настройки сохранены и проверены.','version'=>$written['version'],'warnings'=>$warnings];
}else{
$sets=new Sets($this->store);$number=Sets::number($p['number']??0);
if($type==='upload') {
$actual=$sets->manifest($target,$number);$saved=$job['results'][$index];
if(array_key_exists('desired_manifest',$saved)){
if($actual===$saved['desired_manifest']){
$out=$sets->check($target,$number,true,$p['name']??'');
$out['message']='Опубликованный набор подтверждён после повторного чтения.';
}elseif($actual!==($saved['source_manifest']??null))throw new Conflict();
else $out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$saved['source_manifest']??null,true);
}else{
$job['results'][$index]['source_manifest']=$actual;$job['results'][$index]['desired_manifest']=Sets::preparedManifest($p['prepared'],$number);$save();
$out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$actual,true);
}
}
else $out=$sets->check($target,$number,$type==='register',$p['name']??'');
}
$job['results'][$index]=['target'=>$target['id'],'status'=>'success']+$out;
}catch(Conflict $e){$job['results'][$index]=['target'=>$result['target'],'status'=>'conflict','message'=>$e->getMessage()];}
catch(\Throwable $e){$old=$job['results'][$index];$status=$e instanceof Problem&&$e->status===504?'unknown':'error';$job['results'][$index]=['target'=>$result['target'],'status'=>$status,'message'=>$e instanceof Problem?$e->getMessage():'Операция не выполнена. Проверьте доступность и повторите.'];foreach(['desired_hash','desired_manifest','source_manifest'] as $field)if(array_key_exists($field,$old))$job['results'][$index][$field]=$old[$field];}
$this->store->audit(['job'=>$id,'target'=>$result['target'],'type'=>$job['type'],'paths'=>array_column($job['payload']['changes']??[],'path'),'status'=>$job['results'][$index]['status']]);$save();
}
$job['status']='completed';$job['finished_at']=gmdate(DATE_ATOM);$save();
});
}
public function tick(): void {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))$this->run($job['id']);}}
public function pending(): ?string {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))return $job['id'];}return null;}
public function interrupted(string $id): void {
$this->store->locked('job-'.$id,function()use($id){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;$queued=false;
foreach($job['results'] as &$result){
if($result['status']==='running'){
$message='Исполнитель прервался. Повтор сначала перечитает фактическое состояние цели.';
if($job['type']==='upload'){
// Read выполняет journal recovery под транспортной блокировкой без Imagick.
// Supervisor обязан попытаться вернуть публичный путь до ожидания retry пользователя.
try{(new Transport($this->store,$this->store->target($result['target'])))->readConfig();$result['recovery_checked']=true;}
catch(\Throwable){$result['recovery_checked']=false;$message='Исполнитель прервался. Автоматическое восстановление не подтверждено: цель недоступна. Повтор сначала перечитает фактическое состояние.';}
}
$result['status']='unknown';$result['message']=$message;
}
if($result['status']==='queued')$queued=true;
}unset($result);
$job['status']=$queued?'queued':'completed';if(!$queued)$job['finished_at']=gmdate(DATE_ATOM);$this->store->write('jobs/'.$id.'.json',$job);
$this->store->audit(['job'=>$id,'status'=>'interrupted']);
});
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace PrelandAdmin;
class Problem extends \RuntimeException { public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); } }
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Самодостаточный обработчик: один и тот же CAS выполняется локально и под SSH. */
final class Remote
{
private static function cleanStage(string $directory): void {if(!is_dir($directory)||is_link($directory))return;foreach(new \FilesystemIterator($directory,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isFile()||$item->isLink())unlink($item->getPathname());else throw new \RuntimeException('Небезопасный элемент staging.');}rmdir($directory);}
private static function journal(string $path,array $data): void {
$temp=$path.'.tmp-'.bin2hex(random_bytes(8));$fp=fopen($temp,'xb');if(!$fp)throw new \RuntimeException('Не удалось записать журнал переключения.');
try{$json=json_encode($data,JSON_THROW_ON_ERROR);if(fwrite($fp,$json)!==strlen($json)||!fflush($fp)||!fsync($fp))throw new \RuntimeException('Не удалось зафиксировать журнал переключения.');}finally{fclose($fp);}
if(!rename($temp,$path))throw new \RuntimeException('Не удалось опубликовать журнал переключения.');
}
private static function recover(string $root,string $private,bool $local): void {
foreach(glob($private.'/swap-[1-9].json')?:[] as $path){$record=json_decode(file_get_contents($path),true);$number=(int)($record['number']??0);if($number<1||$number>9)throw new \RuntimeException('Повреждён журнал переключения набора.');
$parent=$local?$root.'/.preland-admin-staging':$private;$stage=$record['stage']??'';$backup=$record['backup']??'';
if(dirname($stage)!==$parent||dirname($backup)!==$parent||!preg_match('/^set-[a-f0-9]{16}$/D',basename($stage))||!preg_match('/^backup-'.$number.'-[a-f0-9]{16}$/D',basename($backup)))throw new \RuntimeException('Небезопасные пути журнала переключения.');
$active=$root.'/'.$number;
if(!file_exists($active)&&!is_link($active)){
if(is_dir($backup)&&!is_link($backup)){if(!rename($backup,$active))throw new \RuntimeException('Не удалось восстановить предыдущий набор.');}
elseif(is_dir($stage)&&self::manifest($stage)===($record['desired_manifest']??null)){if(!chmod($stage,0755)||!rename($stage,$active))throw new \RuntimeException('Не удалось завершить переключение набора.');}
else throw new \RuntimeException('Незавершённый набор требует восстановления из резерва.');
}
// При наличии активного каталога третью версию не перезаписываем.
if(is_link($active)||!is_dir($active))throw new \RuntimeException('Небезопасный активный набор при восстановлении.');
self::cleanStage($stage);if($local)self::cleanStage($backup);unlink($path);
}
}
public static function manifest(string $dir): ?string {
if(!is_dir($dir)){if(file_exists($dir)||is_link($dir))throw new \RuntimeException('Путь набора занят небезопасным элементом.');return null;}
if(is_link($dir))throw new \RuntimeException('Символические ссылки набора запрещены.');$hashes=[];$total=0;
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||$item->getSize()>8388608||count($hashes)>=1000)throw new \RuntimeException('Небезопасный элемент набора.');$total+=$item->getSize();if($total>67108864)throw new \RuntimeException('Превышен лимит набора.');$hashes[$item->getFilename()]=hash_file('sha256',$item->getPathname());}
ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));
}
public static function execute(array $p): array {
$root=$p['root'];
if (!is_string($root)||!str_starts_with($root,'/')||str_contains($root,'..')||str_contains($root,'\\')||realpath($root)!==$root||is_link($root)) return ['ok'=>false,'error'=>'Небезопасный корневой путь.'];
$private=$p['local_private']??dirname($root).'/.preland-admin-'.substr(hash('sha256',$root),0,16);
if (is_link($private)) return ['ok'=>false,'error'=>'Небезопасный приватный каталог.'];
if (!is_dir($private) && !mkdir($private,0700)) return ['ok'=>false,'error'=>'Не удалось подготовить приватный каталог вне webroot.'];
chmod($private,0700);
$lock=fopen($private.'/publish.lock','c');if(!$lock) return ['ok'=>false,'error'=>'Не удалось получить удалённую блокировку.'];
$deadline=microtime(true)+20;while(!flock($lock,LOCK_EX|LOCK_NB)){if(microtime(true)>$deadline){fclose($lock);return ['ok'=>false,'error'=>'Истекло время ожидания блокировки.'];}usleep(50000);}
try {
self::recover($root,$private,isset($p['local_private']));
$config=$root.'/config.json';
if (is_link($config)||!is_file($config)||filesize($config)>2097152) throw new \RuntimeException('Настройки отсутствуют либо путь небезопасен.');
$content=file_get_contents($config);$version=hash('sha256',$content);
$op=$p['op'];
if ($op==='read') return ['ok'=>true,'content'=>$content,'version'=>$version];
if ($op==='test') {
if(!function_exists('fsync'))throw new \RuntimeException('Для безопасной публикации требуется PHP CLI не ниже 8.1 с fsync.');
if(!is_writable($root)||!is_writable($config)) throw new \RuntimeException('Нет прав записи.');
if(!json_decode($content) instanceof \stdClass)throw new \RuntimeException('config.json не является корректным объектом.');
$probe=$root.'/.config-probe-'.bin2hex(random_bytes(8));$renamed=$probe.'-renamed';
try{if(file_put_contents($probe,'preland-admin-probe')===false||!rename($probe,$renamed)||file_get_contents($renamed)!=='preland-admin-probe')throw new \RuntimeException('Атомарная запись недоступна.');}finally{if(is_file($probe))unlink($probe);if(is_file($renamed))unlink($renamed);}
return ['ok'=>true,'version'=>$version];
}
if ($op==='set-list') {$numbers=[];foreach(range(1,9) as $n)if(is_dir($root.'/'.$n)&&!is_link($root.'/'.$n))$numbers[]=$n;return ['ok'=>true,'numbers'=>$numbers];}
if ($op==='write') {
if (!hash_equals($version,(string)$p['version'])) return ['ok'=>false,'conflict'=>true,'error'=>'Версия изменена.'];
$next=$p['content'];$decoded=json_decode($next);if(!$decoded instanceof \stdClass || strlen($next)>2097152) throw new \RuntimeException('Некорректные настройки.');
$temp=$root.'/.config-'.bin2hex(random_bytes(8));$backup=$private.'/config-'.gmdate('YmdHis').'-'.bin2hex(random_bytes(6)).'.json';
if(file_put_contents($backup,$content)===false) throw new \RuntimeException('Не удалось сохранить резерв.');chmod($backup,0600);
if(file_put_contents($temp,$next)===false) throw new \RuntimeException('Не удалось записать временный файл.');chmod($temp,0644);
if(hash_file('sha256',$temp)!==hash('sha256',$next)||!rename($temp,$config)){@unlink($temp);throw new \RuntimeException('Не удалось атомарно заменить настройки.');}
$actual=hash_file('sha256',$config);if($actual!==hash('sha256',$next)) throw new \RuntimeException('Повторная проверка настроек не совпала.');
return ['ok'=>true,'version'=>$actual,'previous'=>$content];
}
$number=(int)($p['number']??0);if($number<1||$number>9) throw new \RuntimeException('Номер набора должен быть 1–9.');
$dir=$root.'/'.$number;if(is_link($dir)) throw new \RuntimeException('Символические ссылки набора запрещены.');
if($op==='set-manifest')return ['ok'=>true,'manifest'=>self::manifest($dir)];
if($op==='set-read') {
if(!is_dir($dir)) throw new \RuntimeException('Папка набора отсутствует.');$files=[];$bytes=0;
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if(!$item->isFile()||$item->isLink()||$item->getSize()>8388608) throw new \RuntimeException('Недопустимый файл набора (лимит 8 МиБ).');$bytes+=$item->getSize();if($bytes>67108864||count($files)>=90) throw new \RuntimeException('Превышен лимит набора (64 МиБ, 90 файлов).');$files[$item->getFilename()]=base64_encode(file_get_contents($item->getPathname()));}
return ['ok'=>true,'files'=>$files];
}
if($op==='set-write') {
if(!array_key_exists('expected_manifest',$p))throw new \RuntimeException('Отсутствует исходная версия набора.');
if(self::manifest($dir)!==$p['expected_manifest'])return ['ok'=>false,'conflict'=>true,'error'=>'Набор изменился. Перечитайте его состояние перед повторной публикацией.'];
$stageParent=isset($p['local_private'])?$root.'/.preland-admin-staging':$private;
if(is_link($stageParent))throw new \RuntimeException('Небезопасный staging-каталог.');
if(!is_dir($stageParent)&&!mkdir($stageParent,0700))throw new \RuntimeException('Не удалось подготовить staging.');
if(isset($p['local_private']))file_put_contents($stageParent.'/.htaccess',"Require all denied\n");
$stage=$stageParent.'/set-'.bin2hex(random_bytes(8));if(!mkdir($stage,0755))throw new \RuntimeException('Не удалось подготовить набор.');$backup=$stageParent.'/backup-'.$number.'-'.bin2hex(random_bytes(8));$moved=false;
try {
if(count($p['files'])!==90) throw new \RuntimeException('Требуется ровно 90 файлов.');$total=0;
foreach($p['files'] as $name=>$file){if(!preg_match('/^[0-9]{2}_(desk|mob)_[123]-(source|result|thumb)\.(jpg|webp|avif)$/D',$name))throw new \RuntimeException('Недопустимое имя файла.');$data=base64_decode($file['data'],true);$total+=strlen($data===false?'':$data);if($data===false||strlen($data)>8388608||$total>67108864||hash('sha256',$data)!==$file['sha256'])throw new \RuntimeException('Не совпала проверка файла либо превышен лимит.');$path=$stage.'/'.$name;if(file_put_contents($path,$data)===false||hash_file('sha256',$path)!==$file['sha256'])throw new \RuntimeException('Ошибка доставки файла.');chmod($path,0644);}
if(!chmod($stage,0755))throw new \RuntimeException('Не удалось открыть чтение подготовленного набора.');
if(is_dir($dir)){
// Резерв локального bind mount копируется в state до смены активного каталога.
if(isset($p['local_private'])){
$saved=$private.'/backup-'.$number.'-'.bin2hex(random_bytes(8));if(!mkdir($saved,0700))throw new \RuntimeException('Не удалось подготовить резерв.');
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||!copy($item->getPathname(),$saved.'/'.$item->getFilename()))throw new \RuntimeException('Не удалось сохранить резерв набора.');}
}
self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
if(!rename($dir,$backup))throw new \RuntimeException('Не удалось сохранить старый набор.');$moved=true;
}
else self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
if(!rename($stage,$dir)) {if($moved)rename($backup,$dir);throw new \RuntimeException('Не удалось активировать набор.');}
foreach($p['files'] as $name=>$file)if(hash_file('sha256',$dir.'/'.$name)!==$file['sha256']) {rename($dir,$stage);if($moved)rename($backup,$dir);throw new \RuntimeException('Ошибка проверки после публикации; выполнен откат.');}
if($moved && isset($p['local_private'])) {
foreach(new \FilesystemIterator($backup,\FilesystemIterator::SKIP_DOTS) as $item)unlink($item->getPathname());rmdir($backup);
}
unlink($private.'/swap-'.$number.'.json');
return ['ok'=>true,'hash'=>hash('sha256',json_encode(array_map(static fn($f)=>$f['sha256'],$p['files'])))];
} finally {if(is_dir($stage)){foreach(new \FilesystemIterator($stage,\FilesystemIterator::SKIP_DOTS) as $item)if($item->isFile())unlink($item->getPathname());rmdir($stage);}}
}
throw new \RuntimeException('Неизвестная операция.');
} catch (\Throwable $e) {return ['ok'=>false,'error'=>$e->getMessage()];} finally {flock($lock,LOCK_UN);fclose($lock);}
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Sets
{
public function __construct(private Store $store) {}
public static function number(mixed $number): int {if(!preg_match('/^[1-9]$/D',(string)$number)) throw new Problem('Номер набора должен быть 1–9.');return (int)$number;}
public function list(array $target, ?\stdClass $config=null): array {
$names=$this->store->read('sets.json');$checks=$this->store->read('checks.json');$result=[];$available=(new Transport($this->store,$target))->call(['op'=>'set-list'])['numbers'];
$config ??= (new Transport($this->store,$target))->readConfig()['config'];$group=$config->utm->indpic ?? new \stdClass();
foreach(range(1,9) as $number){$key=$target['id'].':'.$number;$default=(string)($group->default??'')===(string)$number;$weight=(float)($group->weights->{(string)$number}??0);$entry=$names[$key]??null;
$result[]=['number'=>$number,'name'=>$entry['name']??'','exists'=>in_array($number,$available,true),'registered'=>$entry!==null,'default'=>$default,'in_use'=>$default||$weight>0,'checked_at'=>$checks[$key]['at']??null];
}return $result;
}
public function prepareZip(string $file,int $number): string {
self::number($number);if(!is_file($file)||filesize($file)>67108864)throw new Problem('ZIP превышает лимит 64 МиБ.');
$zip=new \ZipArchive();if($zip->open($file)!==true)throw new Problem('Не удалось открыть ZIP.');$total=0;
try{if($zip->numFiles>91)throw new Problem('ZIP содержит более 90 файлов.');for($i=0;$i<$zip->numFiles;$i++){$stat=$zip->statIndex($i);$size=(int)$stat['size'];$total+=$size;if($size>8388608||$total>67108864)throw new Problem('Превышен лимит распаковки (8 МиБ на файл, 64 МиБ на набор).');$opsys=0;$attr=0;if($zip->getExternalAttributesIndex($i,$opsys,$attr)&&(($attr>>16)&0170000)===0120000)throw new Problem('Символические ссылки в ZIP запрещены.');}}finally{$zip->close();}
$dir=$this->store->dir.'/uploads/'.bin2hex(random_bytes(16));if(!mkdir($dir,0700,true))throw new Problem('Не удалось подготовить проверку ZIP.');
(new IndpicSetStorage($dir))->replaceFromZip($number,$file);return $dir;
}
public function check(array $target,int $number,bool $register=false,string $name=''): array {
$number=self::number($number);$transport=new Transport($this->store,$target);$result=$transport->call(['op'=>'set-read','number'=>$number]);
$dir=$this->store->dir.'/checks/'.bin2hex(random_bytes(16));mkdir($dir.'/'.$number,0700,true);
try {
$expected=IndpicSetStorage::expectedDimensions();if(count($result['files'])!==90)throw new Problem('Набор должен содержать ровно 90 файлов.');$hashes=[];
foreach($result['files'] as $filename=>$encoded){if(!isset($expected[$filename]))throw new Problem('В наборе найден лишний файл.');$data=base64_decode($encoded,true);if($data===false)throw new Problem('Ошибка чтения изображения.');file_put_contents($dir.'/'.$number.'/'.$filename,$data);$hashes[$filename]=hash('sha256',$data);}
(new IndpicSetStorage($dir))->validateDirectory($number);ksort($hashes);$hash=hash('sha256',json_encode($hashes));
$this->store->locked('checks',function() use($target,$number,$hash){$checks=$this->store->read('checks.json');$checks[$target['id'].':'.$number]=['at'=>gmdate(DATE_ATOM),'hash'=>$hash];$this->store->write('checks.json',$checks);});
if($register)$this->name($target,$number,$name);
return ['message'=>'Проверены 90 изображений, размеры и форматы.','hash'=>$hash];
}finally{foreach(glob($dir.'/'.$number.'/*')?:[] as $path)unlink($path);@rmdir($dir.'/'.$number);@rmdir($dir);}
}
public function manifest(array $target,int $number): ?string {return (new Transport($this->store,$target))->call(['op'=>'set-manifest','number'=>self::number($number)])['manifest'];}
public static function preparedManifest(string $dir,int $number): string {$hashes=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.self::number($number).'/'.$filename;if(!is_file($path)||is_link($path))throw new Problem('Подготовленный набор повреждён.');$hashes[$filename]=hash_file('sha256',$path);}ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));}
public function upload(array $target,int $number,string $dir,string $name,?string $expectedManifest=null,bool $hasExpected=false): array {
(new IndpicSetStorage($dir))->validateDirectory($number);$files=[];
foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.$number.'/'.$filename;$files[$filename]=['data'=>base64_encode(file_get_contents($path)),'sha256'=>hash_file('sha256',$path)];}
if(!$hasExpected)$expectedManifest=$this->manifest($target,$number);
$result=(new Transport($this->store,$target))->call(['op'=>'set-write','number'=>$number,'files'=>$files,'expected_manifest'=>$expectedManifest]);
$checked=$this->check($target,$number,true,$name);return $checked;
}
public function name(array $target,int $number,string $name): void {
self::number($number);if(preg_match('//u',$name)!==1||preg_match_all('/./us',$name)>200||preg_match('/[\x00-\x1f]/',$name))throw new Problem('Название набора слишком длинное либо содержит управляющие символы.');
$this->store->locked('sets',function() use($target,$number,$name){$sets=$this->store->read('sets.json');$sets[$target['id'].':'.$number]=['name'=>$name];$this->store->write('sets.json',$sets);});
}
public function delete(array $target,int $number): void {
$config=(new Transport($this->store,$target))->readConfig()['config'];foreach($this->list($target,$config) as $set)if($set['number']===$number&&$set['in_use'])throw new Problem('Нельзя убрать используемый набор. Сначала измените default и веса.');
$this->store->locked('sets',function() use($target,$number){$sets=$this->store->read('sets.json');unset($sets[$target['id'].':'.$number]);$this->store->write('sets.json',$sets);});
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Store
{
public readonly string $dir;
public function __construct(?string $dir=null) {
$this->dir=$dir ?? (getenv('ADMIN_STATE_DIR') ?: '/var/lib/preland-admin');
foreach (['','jobs','backups','uploads','checks'] as $part) { $path=$this->dir . ($part ? '/'.$part : ''); if (!is_dir($path) && !mkdir($path,0700,true)) throw new Problem('Не удалось открыть приватное хранилище.',503); }
}
public function locked(string $key, callable $callback): mixed {
$fp=fopen($this->dir.'/lock-'.hash('sha256',$key),'c'); if (!$fp || !flock($fp,LOCK_EX)) throw new Problem('Не удалось получить блокировку.',503);
try {return $callback();} finally {flock($fp,LOCK_UN);fclose($fp);}
}
public function read(string $name, mixed $default=[]): mixed {
$path=$this->path($name); if (!is_file($path)) return $default;
try {return json_decode(file_get_contents($path),true,64,JSON_THROW_ON_ERROR);} catch(\Throwable) {throw new Problem('Повреждено приватное состояние.',503);}
}
public function write(string $name, mixed $value): void {
$path=$this->path($name);$temp=$path.'.'.bin2hex(random_bytes(8));
if (file_put_contents($temp,json_encode($value,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),LOCK_EX)===false || !rename($temp,$path)) throw new Problem('Не удалось сохранить приватное состояние.',503);
chmod($path,0600);
}
public function path(string $name): string { if (!preg_match('~^[a-zA-Z0-9_./-]+$~D',$name) || str_contains($name,'..') || str_starts_with($name,'/')) throw new Problem('Недопустимый идентификатор.'); return $this->dir.'/'.$name; }
public function targets(bool $public=true): array {
$targets=array_values($this->read('targets.json'));
if ($public) foreach($targets as &$target) {$target['has_secret']=!empty($target['secret']);unset($target['secret']);}
return $targets;
}
public function target(string $id): array {foreach($this->targets(false) as $target) if ($target['id']===$id) return $target;throw new Problem('Подключение не найдено.',404);}
public function saveTarget(array $input, ?string $secret=null): array {
return $this->locked('targets',function() use($input,$secret) {
$targets=$this->read('targets.json');$id=$input['id'] ?? bin2hex(random_bytes(8));
if (!is_string($id) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id)) throw new Problem('Некорректный ID подключения.');
$old=$targets[$id] ?? [];$target=[];
foreach(['name','domain','environment','type','host','user','root','host_key'] as $key) {$value=$input[$key] ?? $old[$key] ?? '';if(!is_string($value)||strlen($value)>2048||preg_match('/[\x00-\x1f]/',$value)) throw new Problem('Некорректное поле подключения.');$target[$key]=trim($value);}
if (!preg_match('/^[a-z0-9.-]+$/iD',$target['domain']) || !$target['domain']) throw new Problem('Некорректный домен.');
if (str_ends_with(strtolower($target['domain']),'.online') && strtolower($target['domain'])!=='zoomstage.online') throw new Problem('Отключённые домены .online подключать нельзя.');
if (!in_array($target['type'],['local','sftp'],true)) throw new Problem('Допускаются только local и SFTP/SSH.');
$root=rtrim($target['root'],'/');if (!$root || !str_starts_with($root,'/') || str_contains($root,'..') || str_contains($root,'\\')) throw new Problem('Корень должен быть абсолютным безопасным путём.');$target['root']=$root;
if ($target['type']==='local') {
$roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS') ?: '')));
if (!in_array($root,$roots,true) || !is_dir($root) || realpath($root)!==$root) throw new Problem('Локальный корень не входит в явно разрешённые mount.');
} else {
if (!preg_match('/^[a-z0-9_.:-]+$/iD',$target['host']) || !preg_match('/^[a-zA-Z0-9_.-]+$/D',$target['user']) || $target['user']==='root') throw new Problem('Укажите ограниченную SSH-учётную запись и хост.');
if (!preg_match('~^(SHA256:[A-Za-z0-9+/]{43}=?|(?:ssh-|ecdsa-)[A-Za-z0-9@._+-]+ [A-Za-z0-9+/=]+)$~D',$target['host_key'])) throw new Problem('Требуется закреплённый SSH host key либо SHA256 fingerprint.');
}
$target['port']=(int)($input['port'] ?? $old['port'] ?? 22);if($target['port']<1||$target['port']>65535) throw new Problem('Некорректный порт.');
$target['id']=$id;$target['enabled']=(bool)($input['enabled'] ?? $old['enabled'] ?? false);$target['secret']=$secret ?? $old['secret'] ?? '';
// Изменение транспорта сбрасывает проверку: запись включается только после test.
$identity=static fn($t)=>array_intersect_key($t,array_flip(['type','host','port','user','root','host_key','secret']));
$target['verified']=isset($old['verified']) && $identity($old)===$identity($target) ? $old['verified'] : false;
$targets[$id]=$target;$this->write('targets.json',$targets);$safe=$target;unset($safe['secret']);$safe['has_secret']=$target['secret']!=='';return $safe;
});
}
public function verifyTarget(string $id, ?array $expected=null): void {$this->locked('targets',function() use($id,$expected){$targets=$this->read('targets.json');if(!isset($targets[$id])) throw new Problem('Подключение не найдено.',404);if($expected!==null&&$targets[$id]!==$expected)throw new Problem('Подключение изменилось во время проверки. Повторите проверку.',409);$targets[$id]['verified']=true;$this->write('targets.json',$targets);});}
public function deleteTarget(string $id): void {$this->locked('targets',function() use($id){$targets=$this->read('targets.json');unset($targets[$id]);$this->write('targets.json',$targets);});}
public function backup(string $target,string $content): string {
$id=gmdate('YmdHis').'-'.bin2hex(random_bytes(6));$items=$this->read('backups/index.json');$items[]=['id'=>$id,'target'=>$target,'created_at'=>gmdate(DATE_ATOM)];
$this->write('backups/'.$id.'.json',['content'=>$content]);$this->locked('backup-index',function() use($items){$current=$this->read('backups/index.json');$current[]=end($items);$this->write('backups/index.json',$current);});return $id;
}
public function backups(string $target): array {return array_values(array_map(static fn($item)=>array_diff_key($item,['target'=>true]),array_filter($this->read('backups/index.json'),static fn($item)=>$item['target']===$target)));}
public function backupContent(string $target,string $id): string {foreach($this->backups($target) as $item) if($item['id']===$id) return $this->read('backups/'.$id.'.json')['content'];throw new Problem('Резервная версия не найдена.',404);}
public function audit(array $data): void {$this->locked('audit',function() use($data){file_put_contents($this->dir.'/audit.jsonl',json_encode(['at'=>gmdate(DATE_ATOM)]+$data,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)."\n",FILE_APPEND|LOCK_EX);});}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
use phpseclib3\Net\SSH2;
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
final class Transport
{
private ?SSH2 $ssh=null;
public function __construct(private Store $store, public readonly array $target) {}
private function connect(): SSH2 {
if($this->ssh) return $this->ssh;
$t=$this->target;$ssh=new SSH2($t['host'],$t['port'],20);$key=$ssh->getServerPublicHostKey();
if(!is_string($key)) throw new Problem('Не удалось получить ключ SSH.');
$expected=$t['host_key'];$parts=explode(' ',$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');
if(!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'='))) throw new Problem('Ключ SSH хоста не совпадает с закреплённым.');
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY')){try{$secret=PublicKeyLoader::loadPrivateKey($secret);}catch(\Throwable){throw new Problem('Некорректный приватный ключ.');}}
if(!$ssh->login($t['user'],$secret)) throw new Problem('Не удалось войти в SSH.');$ssh->setTimeout(60);return $this->ssh=$ssh;
}
public function call(array $payload): array {
$payload['root']=$this->target['root'];
if($this->target['type']==='local') {
$roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'')));
if(!in_array($this->target['root'],$roots,true))throw new Problem('Локальный путь больше не разрешён.');
$payload['local_private']=$this->store->dir.'/local-'.hash('sha256',$this->target['root']);$result=Remote::execute($payload);
}
else {
$ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code);
// Аргументы проходят shell quoting, обработчик не принимает команды от клиента.
$runner=$code."\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
$command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg(base64_encode(json_encode($payload,JSON_THROW_ON_ERROR)));
// Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется.
if(strlen($command)>60000) {
$sftp=$this->sftp();$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
$this->call(['op'=>'test']);$inbox=$private.'/inbox-'.bin2hex(random_bytes(16));
if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR)))throw new Problem('Не удалось доставить подготовленный набор.');$sftp->chmod(0600,$inbox);
$runner=$code."\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}";
$command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($inbox);
}
$timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout);
$output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null;
if($ssh->isTimeout()||!is_array($result)) { $this->ssh=null;throw new Problem('Неизвестный результат: соединение прервано. Перечитайте фактическое состояние.',504); }
}
if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result;
}
private function sftp(): SFTP {
$t=$this->target;$sftp=new SFTP($t['host'],$t['port'],20);$key=$sftp->getServerPublicHostKey();$parts=explode(' ',(string)$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');$expected=$t['host_key'];
if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.');
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY'))$secret=PublicKeyLoader::loadPrivateKey($secret);if(!$sftp->login($t['user'],$secret))throw new Problem('Не удалось войти в SFTP.');return $sftp;
}
public function test(): array {return $this->call(['op'=>'test']);}
public function readConfig(): array {$r=$this->call(['op'=>'read']);return ['config'=>Config::decode($r['content']),'version'=>$r['version'],'content'=>$r['content']];}
public function writeConfig(string $version,string $content): array {
$before=$this->call(['op'=>'read']);if(!hash_equals($version,$before['version']))throw new Conflict();
$this->store->backup($this->target['id'],$before['content']);
return $this->call(['op'=>'write','version'=>$version,'content'=>$content]);
}
}