639 lines
32 KiB
PHP
639 lines
32 KiB
PHP
<?php
|
|
/**
|
|
* Applying, undoing and publishing change sets (agent phase P3).
|
|
*
|
|
* Writes go through one transaction helper: locks (global apply lock, then every file in
|
|
* sorted order), a snapshot of each file, a journal, the writes, and a rollback from the
|
|
* snapshots when anything fails. A journal that survives a crash is rolled back by
|
|
* raijuAgentRecoverJournals(), which runs at the start of every apply/undo/publish.
|
|
*
|
|
* The usual backups (backups/) are written as well, with the names the editors use.
|
|
*/
|
|
|
|
require_once __DIR__ . '/changeset.php';
|
|
|
|
const RAIJU_AGENT_SNAPSHOT_DAYS = 30;
|
|
const RAIJU_AGENT_STAGING_HOURS = 24;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secret and undo token
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function raijuAgentSecret(): string {
|
|
$file = raijuAgentDir() . '/.secret';
|
|
if (is_file($file)) {
|
|
$secret = trim((string)file_get_contents($file));
|
|
if (strlen($secret) >= 32) return $secret;
|
|
}
|
|
if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
|
|
$secret = bin2hex(random_bytes(32));
|
|
$handle = @fopen($file, 'c+');
|
|
if ($handle === false || !flock($handle, LOCK_EX)) throw new RuntimeException('Could not store the agent secret.');
|
|
$existing = trim((string)stream_get_contents($handle));
|
|
if (strlen($existing) >= 32) {
|
|
flock($handle, LOCK_UN);
|
|
fclose($handle);
|
|
return $existing;
|
|
}
|
|
ftruncate($handle, 0);
|
|
fwrite($handle, $secret);
|
|
fflush($handle);
|
|
@chmod($file, 0600);
|
|
flock($handle, LOCK_UN);
|
|
fclose($handle);
|
|
return $secret;
|
|
}
|
|
|
|
/** Undo token: change set id and a signature that survives a logout. */
|
|
function raijuAgentUndoToken(string $csId): string {
|
|
return $csId . '.' . hash_hmac('sha256', 'undo:' . $csId, raijuAgentSecret());
|
|
}
|
|
|
|
function raijuAgentVerifyUndoToken(string $csId, string $token): bool {
|
|
return hash_equals(raijuAgentUndoToken($csId), $token);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Locks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** @return resource|false */
|
|
function raijuAgentGlobalLock(int $waitSeconds = 10) {
|
|
$file = raijuAgentDir() . '/apply.lock';
|
|
if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
|
|
$handle = @fopen($file, 'c');
|
|
if ($handle === false) return false;
|
|
$deadline = microtime(true) + $waitSeconds;
|
|
do {
|
|
if (flock($handle, LOCK_EX | LOCK_NB)) return $handle;
|
|
usleep(50000);
|
|
} while (microtime(true) < $deadline);
|
|
fclose($handle);
|
|
return false;
|
|
}
|
|
|
|
/** Locks for files in sorted order, so two writers can never wait for each other. */
|
|
function raijuAgentLockFiles(array $files): array {
|
|
$files = array_values(array_unique($files));
|
|
sort($files);
|
|
$handles = [];
|
|
foreach ($files as $file) {
|
|
$handle = raijuJsonLock($file);
|
|
if ($handle === false) {
|
|
raijuAgentUnlock($handles);
|
|
throw new RuntimeException('Could not lock ' . basename($file) . '.');
|
|
}
|
|
$handles[] = $handle;
|
|
}
|
|
return $handles;
|
|
}
|
|
|
|
function raijuAgentUnlock(array $handles): void {
|
|
foreach ($handles as $handle) {
|
|
if (is_resource($handle)) {
|
|
flock($handle, LOCK_UN);
|
|
fclose($handle);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backups
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function raijuAgentBackupDir(): string {
|
|
return defined('BACKUP_PATH') ? rtrim((string)BACKUP_PATH, '/') : raijuAgentSiteRoot() . '/backups';
|
|
}
|
|
|
|
/** Copy an existing content file to backups/ with the name the editors use for it. */
|
|
function raijuAgentBackupFile(string $file): void {
|
|
if (!is_file($file)) return;
|
|
$dir = raijuAgentBackupDir();
|
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return;
|
|
$base = basename($file, '.json');
|
|
$kind = raijuAgentTargetKindOfFile($file);
|
|
$prefix = $kind === 'news' ? 'news_' . $base : ($kind === 'events' ? 'events' : $base);
|
|
$stamp = date('Y-m-d_His') . '_' . bin2hex(random_bytes(3));
|
|
@copy($file, $dir . '/' . $prefix . '_' . $stamp . '.json');
|
|
$limit = defined('MAX_BACKUPS') ? (int)MAX_BACKUPS : 30;
|
|
$backups = glob($dir . '/' . $prefix . '_*.json') ?: [];
|
|
usort($backups, static fn($a, $b) => filemtime($b) <=> filemtime($a));
|
|
while (count($backups) > $limit) @unlink(array_pop($backups));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Transaction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function raijuAgentSnapshotDir(string $csId, string $phase): string {
|
|
return raijuAgentDir() . '/snapshots/' . $csId . '/' . $phase;
|
|
}
|
|
|
|
function raijuAgentJournalPath(string $csId, string $phase): string {
|
|
return raijuAgentDir() . '/journal/' . $csId . '-' . $phase . '.json';
|
|
}
|
|
|
|
function raijuAgentRelative(string $file): string {
|
|
$root = rtrim(realpath(raijuAgentSiteRoot()) ?: raijuAgentSiteRoot(), '/') . '/';
|
|
$real = (realpath(dirname($file)) ?: dirname($file)) . '/' . basename($file);
|
|
return str_starts_with($real, $root) ? substr($real, strlen($root)) : $file;
|
|
}
|
|
|
|
function raijuAgentAbsolute(string $relative): string {
|
|
return rtrim(realpath(raijuAgentSiteRoot()) ?: raijuAgentSiteRoot(), '/') . '/' . $relative;
|
|
}
|
|
|
|
function raijuAgentRawWrite(string $file, string $bytes): bool {
|
|
$dir = dirname($file);
|
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) return false;
|
|
$temporary = @tempnam($dir, '.raiju-raw-');
|
|
if ($temporary === false) return false;
|
|
try {
|
|
if (file_put_contents($temporary, $bytes) !== strlen($bytes)) return false;
|
|
@chmod($temporary, is_file($file) ? (fileperms($file) & 0777) : (0666 & ~umask()));
|
|
return rename($temporary, $file);
|
|
} finally {
|
|
if (is_file($temporary)) @unlink($temporary);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write several files as one unit. The caller holds the locks.
|
|
*
|
|
* @param array $changes [{file, data?: array, raw?: string, remove?: bool, trash?: string}]
|
|
* @param array $options testHook: callable(string $event, string $file) for failure injection (CLI only)
|
|
* @throws Throwable after rolling back
|
|
*/
|
|
function raijuAgentWriteTransaction(string $csId, string $phase, array $changes, array $options = []): void {
|
|
$hook = in_array(PHP_SAPI, ['cli', 'cli-server'], true) && is_callable($options['testHook'] ?? null) ? $options['testHook'] : null;
|
|
$snapshots = raijuAgentSnapshotDir($csId, $phase);
|
|
$journal = ['csId' => $csId, 'phase' => $phase, 'state' => 'applying', 'startedAt' => date('c'), 'files' => []];
|
|
foreach (array_values($changes) as $index => $change) {
|
|
$file = $change['file'];
|
|
$entry = ['path' => raijuAgentRelative($file), 'existed' => is_file($file), 'beforeHash' => is_file($file) ? hash_file('sha256', $file) : null, 'snapshot' => null, 'trash' => null];
|
|
if ($entry['existed']) {
|
|
$snapshot = $snapshots . '/' . $index . '-' . basename($file);
|
|
if (!is_dir($snapshots) && !@mkdir($snapshots, 0755, true)) throw new RuntimeException('Could not create the snapshot folder.');
|
|
if (!@copy($file, $snapshot)) throw new RuntimeException('Could not snapshot ' . basename($file) . '.');
|
|
$entry['snapshot'] = raijuAgentRelative($snapshot);
|
|
}
|
|
if (!empty($change['trash'])) $entry['trash'] = raijuAgentRelative($change['trash']);
|
|
$journal['files'][] = $entry;
|
|
}
|
|
if (!raijuJsonAtomicWrite(raijuAgentJournalPath($csId, $phase), $journal)) throw new RuntimeException('Could not write the journal.');
|
|
|
|
try {
|
|
foreach (array_values($changes) as $index => $change) {
|
|
$file = $change['file'];
|
|
if (!empty($change['remove'])) {
|
|
if (!empty($change['trash'])) {
|
|
if (!is_dir(dirname($change['trash'])) && !@mkdir(dirname($change['trash']), 0755, true)) throw new RuntimeException('Could not create the trash folder.');
|
|
if (!@rename($file, $change['trash'])) throw new RuntimeException('Could not move ' . basename($file) . ' to the trash.');
|
|
} elseif (!@unlink($file)) {
|
|
throw new RuntimeException('Could not remove ' . basename($file) . '.');
|
|
}
|
|
} elseif (isset($change['raw'])) {
|
|
if (!raijuAgentRawWrite($file, (string)$change['raw'])) throw new RuntimeException('Could not write ' . basename($file) . '.');
|
|
} elseif (!raijuJsonAtomicWrite($file, (array)$change['data'])) {
|
|
throw new RuntimeException('Could not write ' . basename($file) . '.');
|
|
}
|
|
if ($hook) $hook('written', $file);
|
|
}
|
|
} catch (Throwable $error) {
|
|
raijuAgentRollbackJournal($journal);
|
|
@unlink(raijuAgentJournalPath($csId, $phase));
|
|
throw $error;
|
|
}
|
|
@unlink(raijuAgentJournalPath($csId, $phase));
|
|
}
|
|
|
|
/** Put every file of a journal back to its state before the transaction. */
|
|
function raijuAgentRollbackJournal(array $journal): void {
|
|
foreach (array_reverse($journal['files']) as $entry) {
|
|
$file = raijuAgentAbsolute($entry['path']);
|
|
if ($entry['trash'] !== null) {
|
|
$trash = raijuAgentAbsolute($entry['trash']);
|
|
if (!is_file($file) && is_file($trash)) @rename($trash, $file);
|
|
}
|
|
if ($entry['existed']) {
|
|
$snapshot = $entry['snapshot'] !== null ? raijuAgentAbsolute($entry['snapshot']) : '';
|
|
if ($snapshot !== '' && is_file($snapshot) && (!is_file($file) || hash_file('sha256', $file) !== $entry['beforeHash'])) {
|
|
raijuAgentRawWrite($file, (string)file_get_contents($snapshot));
|
|
}
|
|
} elseif (is_file($file)) {
|
|
@unlink($file); // created by the interrupted transaction
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Roll back transactions that were interrupted (crash, killed process) and mark their
|
|
* change sets failed. Runs under the global lock, so nothing is in flight.
|
|
*
|
|
* @return string[] ids of the recovered change sets
|
|
*/
|
|
function raijuAgentRecoverJournalsLocked(): array {
|
|
$recovered = [];
|
|
foreach (glob(raijuAgentDir() . '/journal/*.json') ?: [] as $path) {
|
|
$journal = raijuAgentReadJsonFile($path);
|
|
if (!$journal || ($journal['state'] ?? '') !== 'applying') {
|
|
@unlink($path);
|
|
continue;
|
|
}
|
|
$locks = [];
|
|
try {
|
|
$locks = raijuAgentLockFiles(array_map(static fn($e) => raijuAgentAbsolute($e['path']), $journal['files']));
|
|
raijuAgentRollbackJournal($journal);
|
|
} finally {
|
|
raijuAgentUnlock($locks);
|
|
}
|
|
@unlink($path);
|
|
$csId = (string)$journal['csId'];
|
|
raijuAgentChangesetMutate($csId, static function (array &$cs) use ($journal) {
|
|
if (in_array($cs['status'], ['applying', 'ready', 'applied'], true) && ($journal['phase'] ?? '') === 'apply') {
|
|
$cs['status'] = 'failed';
|
|
$cs['error'] = 'The apply was interrupted; the files were restored.';
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
raijuAgentAudit('agent-recover', true, ['cs' => $csId, 'phase' => $journal['phase'] ?? '']);
|
|
$recovered[] = $csId;
|
|
}
|
|
return $recovered;
|
|
}
|
|
|
|
/** @return string[] */
|
|
function raijuAgentRecoverJournals(): array {
|
|
if (!glob(raijuAgentDir() . '/journal/*.json')) return [];
|
|
$lock = raijuAgentGlobalLock(0);
|
|
if ($lock === false) return []; // another process is writing right now
|
|
try {
|
|
return raijuAgentRecoverJournalsLocked();
|
|
} finally {
|
|
raijuAgentUnlock([$lock]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Apply
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Items the user can publish afterwards, from the replay of the applied package. */
|
|
function raijuAgentPublishableItems(array $cs, array $state): array {
|
|
$items = [];
|
|
foreach ($state['created'] as $target => $opId) {
|
|
$kind = str_starts_with($target, 'news:') ? 'news' : (str_starts_with($target, 'event:') ? 'event' : 'page');
|
|
$items[] = ['kind' => $kind, 'target' => $target, 'opId' => $opId, 'published' => false];
|
|
}
|
|
foreach ($cs['ops'] as $op) {
|
|
if ($op['type'] === 'section_insert') {
|
|
$items[] = ['kind' => 'section', 'target' => $op['target'], 'sectionId' => $op['section']['id'], 'opId' => $op['id'], 'published' => false];
|
|
}
|
|
}
|
|
foreach ($state['tokens'] as $key => $tokens) {
|
|
[$target, $listKey] = explode('|', $key, 2);
|
|
$doc = raijuAgentFinalDoc($state, $target);
|
|
if ($doc === null) continue;
|
|
$listItems = raijuAgentListItemsOf($doc, $listKey);
|
|
foreach ($tokens as $index => $opId) {
|
|
if ($opId === null || !isset($listItems[$index])) continue;
|
|
$op = null;
|
|
foreach ($cs['ops'] as $candidate) if ($candidate['id'] === $opId) $op = $candidate;
|
|
if ($op === null || $op['type'] !== 'list_insert' || empty($listItems[$index]['hidden'])) continue;
|
|
$items[] = ['kind' => 'list_item', 'target' => $target, 'listKey' => $listKey, 'index' => $index, 'opId' => $opId,
|
|
'titleHash' => hash('sha256', raijuAgentItemLabel($listItems[$index])), 'published' => false];
|
|
}
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/** What gets written for a changed file: the replayed document plus a modification time. */
|
|
function raijuAgentFileChanges(array $changed): array {
|
|
$changes = [];
|
|
foreach ($changed as $file => $change) {
|
|
$data = $change['after'];
|
|
if (is_array($data)) $data['lastModified'] = date('c');
|
|
$changes[] = ['file' => $file, 'data' => $data];
|
|
}
|
|
return $changes;
|
|
}
|
|
|
|
/**
|
|
* Apply a reviewed change set.
|
|
*
|
|
* @param string $previewHash the hash the user confirmed (from the review panel)
|
|
* @param array $actor {userKey, isAdmin}
|
|
* @param array $options testHook (CLI only)
|
|
* @return array{ok:bool,status:string,error?:string,undoToken?:string,files?:array}
|
|
*/
|
|
function raijuAgentChangesetApply(string $csId, string $previewHash, array $actor, array $options = []): array {
|
|
$cs = raijuAgentChangesetLoad($csId);
|
|
if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor)) return ['ok' => false, 'status' => 'missing', 'error' => 'Unknown change set.'];
|
|
if ($cs['status'] !== 'ready') return ['ok' => false, 'status' => $cs['status'], 'error' => 'This change set is ' . $cs['status'] . ' and cannot be applied.'];
|
|
if ($previewHash === '' || !hash_equals((string)$cs['previewHash'], $previewHash)) {
|
|
return ['ok' => false, 'status' => 'ready', 'error' => 'The change set changed after the preview. Review it again before applying.'];
|
|
}
|
|
|
|
$global = raijuAgentGlobalLock();
|
|
if ($global === false) return ['ok' => false, 'status' => 'ready', 'error' => 'Another change is being applied. Try again in a moment.'];
|
|
$locks = [];
|
|
try {
|
|
raijuAgentRecoverJournalsLocked();
|
|
$cs = raijuAgentChangesetLoad($csId);
|
|
if ($cs === null || $cs['status'] !== 'ready') return ['ok' => false, 'status' => $cs['status'] ?? 'missing', 'error' => 'This change set is no longer ready.'];
|
|
|
|
$files = [];
|
|
foreach (raijuAgentOpTargets($cs['ops']) as $target) $files[] = raijuAgentTargetStorage($target)['file'];
|
|
$locks = raijuAgentLockFiles($files);
|
|
|
|
if ($conflicts = raijuAgentConflicts($cs)) {
|
|
raijuAgentChangesetMutate($csId, static function (array &$cs) { $cs['status'] = 'conflict'; return true; });
|
|
raijuAgentAudit('agent-apply', false, ['cs' => $csId, 'reason' => 'conflict', 'targets' => $conflicts]);
|
|
return ['ok' => false, 'status' => 'conflict', 'error' => 'The content changed since this change set was started (' . implode(', ', $conflicts) . '). Revalidate it first.'];
|
|
}
|
|
$replay = raijuAgentReplayOps($cs['ops']);
|
|
$validation = raijuAgentValidate($cs, $replay, true);
|
|
if (!$validation['ok']) {
|
|
$blocked = array_values(array_filter(array_column($validation['errors'], 'code'), static fn($code) => is_string($code) && str_starts_with($code, 'safety_')));
|
|
if ($blocked) raijuAgentAudit('agent-safety', false, ['stage' => 'apply', 'cs' => $csId, 'codes' => $blocked]);
|
|
return ['ok' => false, 'status' => 'ready', 'error' => 'Validation failed: ' . ($validation['errors'][0]['message'] ?? 'unknown error')];
|
|
}
|
|
// A change set with a safety note needs one more explicit confirmation, on top of the apply click.
|
|
if (($validation['risk']['level'] ?? 'ok') === 'caution' && empty($options['confirmRisk'])) {
|
|
return ['ok' => false, 'status' => 'ready', 'needsRiskConfirm' => true, 'error' => raijuAgentOpText('agent.safety_confirm_needed', 'This change set has safety notes. Read them in the review and confirm them to apply it.')];
|
|
}
|
|
if (($cs['renderCheck']['status'] ?? '') !== 'passed' || !hash_equals(raijuAgentPreviewHash($cs, $replay), $previewHash)) {
|
|
return ['ok' => false, 'status' => 'ready', 'error' => 'The render check has not passed for the current content. Review the change set again.'];
|
|
}
|
|
|
|
raijuAgentChangesetMutate($csId, static function (array &$cs) { $cs['status'] = 'applying'; return true; });
|
|
$changed = raijuAgentChangedFiles($replay['state']);
|
|
$changes = raijuAgentFileChanges($changed);
|
|
foreach ($changed as $file => $change) {
|
|
if ($change['exists']) raijuAgentBackupFile($file);
|
|
}
|
|
$movedMedia = [];
|
|
try {
|
|
// Images the package uses move into the library first; if the files cannot be written they move back.
|
|
if ($cs['media']) $movedMedia = raijuAgentApplyMedia($cs, $changed);
|
|
raijuAgentWriteTransaction($csId, 'apply', $changes, $options);
|
|
} catch (Throwable $error) {
|
|
if ($movedMedia) raijuAgentRollbackMedia($movedMedia);
|
|
raijuAgentChangesetMutate($csId, static function (array &$cs) use ($error) {
|
|
$cs['status'] = 'failed';
|
|
$cs['error'] = mb_substr($error->getMessage(), 0, 300, 'UTF-8');
|
|
return true;
|
|
});
|
|
raijuAgentAudit('agent-apply', false, ['cs' => $csId, 'reason' => 'write-failed']);
|
|
return ['ok' => false, 'status' => 'failed', 'error' => 'Applying failed and nothing was changed: ' . $error->getMessage()];
|
|
}
|
|
|
|
$revisions = [];
|
|
$fileInfo = [];
|
|
foreach ($changed as $file => $change) {
|
|
$relative = raijuAgentRelative($file);
|
|
$revisions[$relative] = raijuJsonRevision($file);
|
|
$fileInfo[$relative] = ['existed' => $change['exists'], 'kind' => raijuAgentTargetKindOfFile($file)];
|
|
}
|
|
$applied = ['at' => date('c'), 'by' => (string)($actor['userKey'] ?? ''), 'revisions' => $revisions, 'files' => $fileInfo,
|
|
'items' => raijuAgentPublishableItems($cs, $replay['state']),
|
|
'media' => array_map(static fn($m) => ['ref' => $m['ref'], 'name' => $m['name']], $movedMedia)];
|
|
raijuAgentChangesetMutate($csId, static function (array &$cs) use ($applied) {
|
|
$cs['status'] = 'applied';
|
|
$cs['applied'] = $applied;
|
|
unset($cs['error']);
|
|
return true;
|
|
});
|
|
raijuAgentRemoveTree(raijuAgentDir() . '/staging/' . $csId); // images the package did not use are not kept
|
|
raijuAgentAudit('agent-apply', true, [
|
|
'cs' => $csId, 'run' => $cs['runId'], 'targets' => raijuAgentOpTargets($cs['ops']),
|
|
'operations' => array_map(static fn($op) => ['id' => $op['id'], 'type' => $op['type'], 'path' => $op['path'] ?? ($op['listKey'] ?? null), 'valueHash' => raijuAgentValueHash($op['value'] ?? ($op['item'] ?? ($op['post'] ?? ($op['page'] ?? ($op['event'] ?? ($op['src'] ?? null))))))], $cs['ops']),
|
|
]);
|
|
return ['ok' => true, 'status' => 'applied', 'undoToken' => raijuAgentUndoToken($csId), 'files' => array_keys($revisions), 'items' => $applied['items']];
|
|
} finally {
|
|
raijuAgentUnlock($locks);
|
|
raijuAgentUnlock([$global]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Undo
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Undo an applied change set as a whole.
|
|
*
|
|
* Refused when a file no longer has the revision the apply left behind (someone else
|
|
* changed it since). New pages go to the pages trash, new news posts are removed (a copy
|
|
* stays in the snapshots), events are restored with the rest of events.json.
|
|
*
|
|
* @return array{ok:bool,status:string,error?:string}
|
|
*/
|
|
function raijuAgentChangesetUndo(string $csId, string $token, array $actor, array $options = []): array {
|
|
$cs = raijuAgentChangesetLoad($csId);
|
|
if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor) || !raijuAgentVerifyUndoToken($csId, $token)) {
|
|
return ['ok' => false, 'status' => 'missing', 'error' => 'Undo is not possible for this change set.'];
|
|
}
|
|
if ($cs['status'] !== 'applied' || empty($cs['applied'])) return ['ok' => false, 'status' => $cs['status'], 'error' => 'Only an applied change set can be undone.'];
|
|
|
|
$global = raijuAgentGlobalLock();
|
|
if ($global === false) return ['ok' => false, 'status' => 'applied', 'error' => 'Another change is being applied. Try again in a moment.'];
|
|
$locks = [];
|
|
try {
|
|
raijuAgentRecoverJournalsLocked();
|
|
$cs = raijuAgentChangesetLoad($csId);
|
|
$applied = $cs['applied'];
|
|
$files = array_map('raijuAgentAbsolute', array_keys($applied['revisions']));
|
|
$locks = raijuAgentLockFiles($files);
|
|
|
|
$changes = [];
|
|
foreach ($applied['revisions'] as $relative => $revision) {
|
|
$file = raijuAgentAbsolute($relative);
|
|
if (raijuJsonRevision($file) !== $revision) {
|
|
return ['ok' => false, 'status' => 'applied', 'error' => basename($file) . ' was changed after this change set was applied, so it cannot be undone safely.'];
|
|
}
|
|
$info = $applied['files'][$relative];
|
|
if ($info['existed']) {
|
|
$snapshot = raijuAgentSnapshotDir($csId, 'apply');
|
|
$source = null;
|
|
foreach (glob($snapshot . '/*-' . basename($file)) ?: [] as $candidate) $source = $candidate;
|
|
if ($source === null) return ['ok' => false, 'status' => 'applied', 'error' => 'The snapshot of ' . basename($file) . ' is gone.'];
|
|
$changes[] = ['file' => $file, 'raw' => (string)file_get_contents($source)];
|
|
} elseif ($info['kind'] === 'page') {
|
|
$trashDir = defined('PAGES_TRASH_PATH') ? rtrim((string)PAGES_TRASH_PATH, '/') : raijuAgentSiteRoot() . '/content/pages-trash';
|
|
$changes[] = ['file' => $file, 'remove' => true, 'trash' => $trashDir . '/' . basename($file, '.json') . '_' . date('Y-m-d_His') . '.json'];
|
|
} else {
|
|
// News posts have no trash: keep a copy next to the snapshots.
|
|
$keep = raijuAgentDir() . '/snapshots/' . $csId . '/undone-' . basename($file);
|
|
if (!is_dir(dirname($keep)) && !@mkdir(dirname($keep), 0775, true)) return ['ok' => false, 'status' => 'applied', 'error' => 'Could not keep a copy of ' . basename($file) . '.'];
|
|
if (!@copy($file, $keep)) return ['ok' => false, 'status' => 'applied', 'error' => 'Could not keep a copy of ' . basename($file) . '.'];
|
|
$changes[] = ['file' => $file, 'remove' => true];
|
|
}
|
|
}
|
|
foreach ($changes as $change) {
|
|
if (is_file($change['file'])) raijuAgentBackupFile($change['file']);
|
|
}
|
|
try {
|
|
raijuAgentWriteTransaction($csId, 'undo', $changes, $options);
|
|
} catch (Throwable $error) {
|
|
raijuAgentAudit('agent-undo', false, ['cs' => $csId, 'reason' => 'write-failed']);
|
|
return ['ok' => false, 'status' => 'applied', 'error' => 'Undo failed and nothing was changed: ' . $error->getMessage()];
|
|
}
|
|
raijuAgentChangesetMutate($csId, static function (array &$cs) use ($actor) {
|
|
$cs['status'] = 'undone';
|
|
$cs['undone'] = ['at' => date('c'), 'by' => (string)($actor['userKey'] ?? '')];
|
|
return true;
|
|
});
|
|
$trashed = raijuAgentUndoMedia((array)($applied['media'] ?? []));
|
|
raijuAgentAudit('agent-undo', true, ['cs' => $csId, 'files' => array_keys($applied['revisions']), 'images' => count($trashed)]);
|
|
return ['ok' => true, 'status' => 'undone'];
|
|
} finally {
|
|
raijuAgentUnlock($locks);
|
|
raijuAgentUnlock([$global]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Publish
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Make one applied item visible: a news post, event, page (leaves private), list item or
|
|
* section. Needs the publish right (admins have it). Updates the stored post-apply
|
|
* revision, so the change set can still be undone as a whole afterwards.
|
|
*
|
|
* @param array $item {kind, target, listKey?, index?|sectionId?}, as listed in the apply result
|
|
* @param array $actor {userKey, isAdmin, canPublish}
|
|
* @return array{ok:bool,error?:string}
|
|
*/
|
|
function raijuAgentChangesetPublish(string $csId, array $item, array $actor, array $options = []): array {
|
|
$cs = raijuAgentChangesetLoad($csId);
|
|
if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor)) return ['ok' => false, 'error' => 'Unknown change set.'];
|
|
if (empty($actor['isAdmin']) && empty($actor['canPublish'])) return ['ok' => false, 'error' => 'You are not allowed to publish.'];
|
|
if ($cs['status'] !== 'applied' || empty($cs['applied'])) return ['ok' => false, 'error' => 'Only applied change sets can be published.'];
|
|
|
|
$found = null;
|
|
foreach ($cs['applied']['items'] as $index => $candidate) {
|
|
if ($candidate['kind'] === ($item['kind'] ?? '') && $candidate['target'] === ($item['target'] ?? '')
|
|
&& (int)($candidate['index'] ?? -1) === (int)($item['index'] ?? ($candidate['index'] ?? -1))
|
|
&& ($candidate['sectionId'] ?? '') === ($item['sectionId'] ?? ($candidate['sectionId'] ?? ''))
|
|
&& ($candidate['listKey'] ?? '') === ($item['listKey'] ?? ($candidate['listKey'] ?? ''))) {
|
|
$found = $index;
|
|
break;
|
|
}
|
|
}
|
|
if ($found === null) return ['ok' => false, 'error' => 'This item is not part of the change set.'];
|
|
$entry = $cs['applied']['items'][$found];
|
|
if (!empty($entry['published'])) return ['ok' => false, 'error' => 'This item is already published.'];
|
|
|
|
$storage = raijuAgentTargetStorage($entry['target']);
|
|
$global = raijuAgentGlobalLock();
|
|
if ($global === false) return ['ok' => false, 'error' => 'Another change is being applied. Try again in a moment.'];
|
|
$locks = [];
|
|
try {
|
|
raijuAgentRecoverJournalsLocked();
|
|
$locks = raijuAgentLockFiles([$storage['file']]);
|
|
$data = raijuAgentReadJsonFile($storage['file']);
|
|
if ($data === null) return ['ok' => false, 'error' => 'The item no longer exists.'];
|
|
$relative = raijuAgentRelative($storage['file']);
|
|
$matched = raijuJsonRevision($storage['file']) === ($cs['applied']['revisions'][$relative] ?? '');
|
|
|
|
switch ($entry['kind']) {
|
|
case 'news':
|
|
if (empty($data['hidden'])) return ['ok' => false, 'error' => 'The post is already visible.'];
|
|
$data = raijuCopilotPublishNewsData($data);
|
|
break;
|
|
case 'event':
|
|
try {
|
|
$data = raijuCopilotPublishEventData($data, $storage['key']);
|
|
} catch (RuntimeException $e) {
|
|
return ['ok' => false, 'error' => $e->getMessage()];
|
|
}
|
|
break;
|
|
case 'page':
|
|
if (empty($data['visibility'])) return ['ok' => false, 'error' => 'The page is already public.'];
|
|
$data = raijuCopilotPublishPageData($data);
|
|
break;
|
|
case 'section':
|
|
$done = false;
|
|
foreach ((array)($data['sections'] ?? []) as $i => $section) {
|
|
if (is_array($section) && ($section['id'] ?? null) === $entry['sectionId']) {
|
|
unset($data['sections'][$i]['hidden']);
|
|
$done = true;
|
|
}
|
|
}
|
|
if (!$done) return ['ok' => false, 'error' => 'The section no longer exists.'];
|
|
break;
|
|
case 'list_item':
|
|
$items = raijuAgentListItemsOf($data, $entry['listKey']);
|
|
$index = (int)$entry['index'];
|
|
if (!isset($items[$index]) || empty($items[$index]['hidden']) || !hash_equals($entry['titleHash'], hash('sha256', raijuAgentItemLabel($items[$index])))) {
|
|
return ['ok' => false, 'error' => 'The list changed since it was applied; publish this item in the editor.'];
|
|
}
|
|
unset($items[$index]['hidden']);
|
|
setNestedValue($data, $entry['listKey'], array_values($items));
|
|
break;
|
|
default:
|
|
return ['ok' => false, 'error' => 'Unknown item.'];
|
|
}
|
|
$data['lastModified'] = date('c');
|
|
raijuAgentBackupFile($storage['file']);
|
|
$phase = 'publish-' . ($found + 1);
|
|
try {
|
|
raijuAgentWriteTransaction($csId, $phase, [['file' => $storage['file'], 'data' => $data]], $options);
|
|
} catch (Throwable $error) {
|
|
raijuAgentAudit('agent-publish', false, ['cs' => $csId, 'item' => $entry['kind'], 'reason' => 'write-failed']);
|
|
return ['ok' => false, 'error' => 'Publishing failed and nothing was changed: ' . $error->getMessage()];
|
|
}
|
|
$newRevision = raijuJsonRevision($storage['file']);
|
|
raijuAgentChangesetMutate($csId, static function (array &$cs) use ($found, $relative, $matched, $newRevision) {
|
|
$cs['applied']['items'][$found]['published'] = true;
|
|
// The publish belongs to this change set; keep Undo possible as long as nobody else touched the file.
|
|
if ($matched) $cs['applied']['revisions'][$relative] = $newRevision;
|
|
return true;
|
|
});
|
|
raijuAgentAudit('agent-publish', true, ['cs' => $csId, 'item' => $entry['kind'], 'target' => $entry['target']]);
|
|
return ['ok' => true];
|
|
} finally {
|
|
raijuAgentUnlock($locks);
|
|
raijuAgentUnlock([$global]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cleanup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Remove old runs' leftovers: unfinished change sets and staging after 24 hours, snapshots after 30 days. */
|
|
function raijuAgentCleanup(?int $now = null): array {
|
|
$now ??= time();
|
|
$removed = ['changesets' => 0, 'staging' => 0, 'snapshots' => 0];
|
|
foreach (glob(raijuAgentDir() . '/changesets/*.json') ?: [] as $file) {
|
|
$cs = raijuAgentReadJsonFile($file);
|
|
if (!$cs) continue;
|
|
$age = $now - (int)strtotime((string)($cs['updatedAt'] ?? 'now'));
|
|
$finishedOrStale = in_array($cs['status'], ['discarded', 'undone', 'failed', 'staging', 'ready', 'conflict'], true);
|
|
if ($finishedOrStale && $age > RAIJU_AGENT_STAGING_HOURS * 3600 && @unlink($file)) {
|
|
$removed['changesets']++;
|
|
raijuAgentRemoveTree(raijuAgentDir() . '/staging/' . $cs['id']);
|
|
}
|
|
}
|
|
foreach (glob(raijuAgentDir() . '/staging/*', GLOB_ONLYDIR) ?: [] as $dir) {
|
|
if (!is_file(raijuAgentChangesetPath(basename($dir))) && $now - filemtime($dir) > RAIJU_AGENT_STAGING_HOURS * 3600) {
|
|
raijuAgentRemoveTree($dir);
|
|
$removed['staging']++;
|
|
}
|
|
}
|
|
foreach (glob(raijuAgentDir() . '/snapshots/*', GLOB_ONLYDIR) ?: [] as $dir) {
|
|
if ($now - filemtime($dir) > RAIJU_AGENT_SNAPSHOT_DAYS * 86400) {
|
|
raijuAgentRemoveTree($dir);
|
|
$removed['snapshots']++;
|
|
}
|
|
}
|
|
if (function_exists('raijuAgentRunCleanup')) $removed['runs'] = raijuAgentRunCleanup($now);
|
|
return $removed;
|
|
}
|
|
|
|
require_once __DIR__ . '/media.php';
|