Skip to content

Commit

Permalink
Merge branch 'dev'
Browse files Browse the repository at this point in the history
  • Loading branch information
fetus-hina committed Jan 6, 2024
2 parents 8b48a40 + afaf94c commit bdadf29
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 181 deletions.
220 changes: 90 additions & 130 deletions commands/asset/CleanupAction.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php

/**
* @copyright Copyright (C) 2015-2021 AIZAWA Hina
* @copyright Copyright (C) 2015-2024 AIZAWA Hina
* @license https://github.com/fetus-hina/stat.ink/blob/master/LICENSE MIT
* @author AIZAWA Hina <hina@fetus.jp>
*/
Expand All @@ -10,173 +10,133 @@

namespace app\commands\asset;

use CallbackFilterIterator;
use DateTimeImmutable;
use DateTimeZone;
use FilesystemIterator;
use SplFileInfo;
use Yii;
use app\components\helpers\TypeHelper;
use yii\base\Action;
use yii\helpers\ArrayHelper;
use yii\console\ExitCode;
use yii\helpers\FileHelper;

use function array_reduce;
use function basename;
use function checkdate;
use function dirname;
use function array_filter;
use function array_keys;
use function array_slice;
use function count;
use function file_exists;
use function fprintf;
use function fwrite;
use function is_dir;
use function is_readable;
use function preg_match;
use function rename;
use function sprintf;
use function strlen;
use function substr;
use function uasort;

use const STDERR;

class CleanupAction extends Action
final class CleanupAction extends Action
{
private const ASSET_REVISION_CLEANUP_THRESHOLD = 10; // 10 revisions
private const COMMIT_TIME_CLEANUP_THRESHOLD = 120 * 86400; // 120 days
private const MTIME_CLEANUP_THRESHOLD = 180 * 86400; // 180 days
private const ASSET_PRESERVE_REVISIONS = 2; // 2 revisions
private const ASSET_PRESERVE_SECONDS = 3600; // 1 hour

private ?int $currentRevision = null;
private DateTimeImmutable $now;

/** @return void */
public function init()
{
parent::init();
$this->currentRevision = ArrayHelper::getValue(Yii::$app->params, 'assetRevision');
$this->now = new DateTimeImmutable('now', new DateTimeZone('Etc/UTC'));
}

/** @return int */
/**
* @return int
*/
public function run()
{
$baseDir = Yii::getAlias('@app/web/assets');
if (!file_exists($baseDir) || !is_readable($baseDir) || !is_dir($baseDir)) {
fwrite(STDERR, "Assets directory {$baseDir} is not iteratable.\n");
return 1;
return ExitCode::UNSPECIFIED_ERROR;
}

$it = new CallbackFilterIterator(
new FilesystemIterator(
$baseDir,
array_reduce(
[
FilesystemIterator::CURRENT_AS_FILEINFO,
FilesystemIterator::KEY_AS_PATHNAME,
FilesystemIterator::SKIP_DOTS,
FilesystemIterator::UNIX_PATHS,
],
fn (int $carry, int $cur) => ($carry | $cur),
0, // init value
),
),
fn (SplFileInfo $f) => $f->isDir()
fwrite(STDERR, "Finding cleanup targets...\n");
$directories = $this->getCleanupTargets(
$this->findDirectories($baseDir),
);
foreach ($it as $path => $entry) {
$baseName = basename($path);
if (substr($baseName, 0, 7) === 'DELETE-') {
$this->procDirectory($entry, null, null);
} elseif (
preg_match(
'/^([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2})$/', // Ymd-His
$baseName,
$match,
) &&
(2021 <= (int)$match[1] && (int)$match[1] < 2100) && // year
(1 <= (int)$match[2] && (int)$match[2] <= 12) && // month
(1 <= (int)$match[3] && (int)$match[3] <= 31) && // day
checkdate((int)$match[2], (int)$match[3], (int)$match[1]) &&
(0 <= (int)$match[4] && (int)$match[4] < 24) && // hour
(0 <= (int)$match[5] && (int)$match[5] < 60) && // minute
(0 <= (int)$match[6] && (int)$match[6] <= 60) //second (may leap sec)
) {
// Ymd-His format
$this->procDirectory(
$entry,
(new DateTimeImmutable('@0', new DateTimeZone('Etc/UTC')))
->setDate((int)$match[1], (int)$match[2], (int)$match[3])
->setTime((int)$match[4], (int)$match[5], (int)$match[6]),
null,
);
} elseif (
preg_match(
'/^([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]+)$/', // Ymd-nnn format (n=seq)
$baseName,
$match,
) &&
(2021 <= (int)$match[1] && (int)$match[1] < 2100) && // year
(1 <= (int)$match[2] && (int)$match[2] <= 12) && // month
(1 <= (int)$match[3] && (int)$match[3] <= 31) && // day
checkdate((int)$match[2], (int)$match[3], (int)$match[1])
) {
// Ymd-nnn format
$this->procDirectory(
$entry,
(new DateTimeImmutable('@0', new DateTimeZone('Etc/UTC')))
->setDate((int)$match[1], (int)$match[2], (int)$match[3])
->setTime(23, 59, 59),
(int)$match[4],
);
} elseif (preg_match('/^[a-z2-7]{16}$/', $baseName)) {
$this->procDirectory($entry, null, null);
} else {
fwrite(STDERR, "Unknown format: {$baseName}\n");
}
if (!$directories) {
fwrite(STDERR, "No cleanup targets.\n");
return ExitCode::OK;
}

return 42;
}

private function procDirectory(
SplFileInfo $dir,
?DateTimeImmutable $commitTime,
?int $assetRevision,
): void {
if (!$this->shouldBeDeleted($dir, $commitTime, $assetRevision)) {
return;
fprintf(STDERR, "Found %d cleanup targets.\n", count($directories));
foreach (array_keys($directories) as $directory) {
fprintf(STDERR, " %s\n", $directory);
$this->cleanUp($directory);
}

fwrite(STDERR, "Delete: {$dir->getPathname()}\n");
return ExitCode::OK;
}

/**
* @return array<string, DateTimeImmutable>
*/
private function findDirectories(string $baseDir): array
{
$results = [];
$it = new FilesystemIterator(
$baseDir,
FilesystemIterator::CURRENT_AS_FILEINFO |
FilesystemIterator::KEY_AS_PATHNAME |
FilesystemIterator::SKIP_DOTS |
FilesystemIterator::UNIX_PATHS,
);

// atomic にするため、一旦名前を変更する
if (substr(basename($dir->getPathname()), 0, 7) !== 'DELETE-') {
$tmpDirName = dirname($dir->getPathname()) . '/DELETE-' . basename($dir->getPathname());
rename($dir->getPathname(), $tmpDirName);
} else {
$tmpDirName = $dir->getPathname();
foreach ($it as $entry) {
$entry = TypeHelper::instanceOf($entry, SplFileInfo::class);
if (
$entry->isDir() &&
substr($entry->getBasename(), 0, 1) !== '.'
) {
$results[$entry->getPathname()] = (new DateTimeImmutable())
->setTimezone(new DateTimeZOne('Etc/UTC'))
->setTimestamp(TypeHelper::int($entry->getMTime()));
}
}
unset($it, $entry);

uasort(
$results,
fn (DateTimeImmutable $a, DateTimeImmutable $b): int => $b <=> $a,
);

FileHelper::removeDirectory($tmpDirName);
return $results;
}

private function shouldBeDeleted(
SplFileInfo $dir,
?DateTimeImmutable $commitTime,
?int $assetRevision,
): bool {
// 一時的に変更されたはずの名前が見つかった
if (substr($dir->getBasename(), 0, 7) === 'DELETE-') {
return true;
}
/**
* @param array<string, DateTimeImmutable> $directories
*/
private function getCleanupTargets(array $directories): array
{
$threshold = (new DateTimeImmutable())
->setTimezone(new DateTimeZone('Etc/UTC'))
->setTimestamp($_SERVER['REQUEST_TIME'])
->modify(sprintf('-%d second', self::ASSET_PRESERVE_SECONDS));

$directories = array_slice(
$directories,
self::ASSET_PRESERVE_REVISIONS,
preserve_keys: true,
);

// リビジョン差が一定よりあるなら消す
if ($assetRevision !== null && $this->currentRevision !== null) {
$diff = $this->currentRevision - $assetRevision;
return $diff > static::ASSET_REVISION_CLEANUP_THRESHOLD;
}
return array_filter(
$directories,
fn (DateTimeImmutable $time): bool => $time < $threshold,
);
}

// コミット日情報が一定より古いなら消す
if ($commitTime !== null) {
$diff = $this->now->getTimestamp() - $commitTime->getTimestamp();
return $diff > static::COMMIT_TIME_CLEANUP_THRESHOLD;
private function cleanUp(string $directory): void
{
$baseDir = (string)Yii::getAlias('@app/web/assets') . '/';

// safety check
if (substr($directory, 0, strlen($baseDir)) !== $baseDir) {
fwrite(STDERR, "Invalid directory: {$directory}\n");
return;
}

// mtime が一定より古いなら消す
$diff = $this->now->getTimestamp() - $dir->getMTime();
return $diff > static::MTIME_CLEANUP_THRESHOLD;
FileHelper::removeDirectory($directory);
}
}
84 changes: 73 additions & 11 deletions commands/asset/UpRevisionAction.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php

/**
* @copyright Copyright (C) 2015-2021 AIZAWA Hina
* @copyright Copyright (C) 2015-2024 AIZAWA Hina
* @license https://github.com/fetus-hina/stat.ink/blob/master/LICENSE MIT
* @author AIZAWA Hina <hina@fetus.jp>
*/
Expand All @@ -10,18 +10,31 @@

namespace app\commands\asset;

use ParagonIE\ConstantTime\Base32;
use Random\Engine\Secure;
use Random\Randomizer;
use Yii;
use app\components\helpers\TypeHelper;
use yii\base\Action;
use yii\console\ExitCode;

use function escapeshellarg;
use function exec;
use function file_exists;
use function file_get_contents;
use function file_put_contents;
use function fprintf;
use function hex2bin;
use function implode;
use function is_readable;
use function is_string;
use function preg_match;
use function sprintf;
use function trim;

use const STDERR;

class UpRevisionAction extends Action
final class UpRevisionAction extends Action
{
/**
* Update revision number of assets
Expand All @@ -33,12 +46,8 @@ class UpRevisionAction extends Action
*/
public function run()
{
$version = 0;
$path = Yii::getAlias('@app/config/asset-revision.php');
if (file_exists($path)) {
$version = (int)require $path;
}
++$version;
$path = (string)Yii::getAlias('@app/config/asset-revision.php');
$revision = $this->getRevision();

$php = [];
$php[] = '<?php';
Expand All @@ -48,11 +57,64 @@ public function run()
$php[] = '// This config file is updated by `yii asset/up-revision`.';
$php[] = '// DO NOT EDIT';
$php[] = '';
$php[] = sprintf('return %d;', $version);
$php[] = sprintf("return '%s';", $revision);

file_put_contents($path, implode("\n", $php) . "\n");
fprintf(STDERR, "Asset revision is updated to %d.\n", $version);
fprintf(STDERR, "Asset revision is updated to %s.\n", $revision);

return ExitCode::OK;
}

private function getRevision(): string
{
return $this->getRevisionByDeployerFile()
?? $this->getRevisionByGit()
?? $this->generateRandomRevision();
}

private function getRevisionByDeployerFile(): ?string
{
$path = (string)Yii::getAlias('@app/REVISION');
if (file_exists($path) && is_readable($path)) {
$revision = trim((string)file_get_contents($path));
if (preg_match('/^[0-9a-f]{40,}$/i', $revision)) {
return $this->makeGitShortRevision($revision);
}
}

return null;
}

private function getRevisionByGit(): ?string
{
$cmdline = implode(' ', [
'/usr/bin/env',
escapeshellarg('git'),
escapeshellarg('rev-parse'),
escapeshellarg('HEAD'),
'2>/dev/null',
]);
$line = @exec($cmdline, $lines, $status);
if ($status === ExitCode::OK && is_string($line)) {
$revision = trim((string)$line);
if (preg_match('/^[0-9a-f]{40,}$/i', $revision)) {
return $this->makeGitShortRevision($revision);
}
}

return null;
}

return 0;
private function generateRandomRevision(): string
{
$randomizer = new Randomizer(new Secure());
return $this->makeGitShortRevision($randomizer->getRandomBytes(20));
}

private function makeGitShortRevision(string $revision): string
{
return Base32::encodeUnpadded(
TypeHelper::string(hex2bin($revision)),
);
}
}
Loading

0 comments on commit bdadf29

Please sign in to comment.