267 lines
10 KiB
PHP
267 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Package;
|
||
use App\Models\Version;
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\File;
|
||
use Illuminate\Support\Str;
|
||
use RuntimeException;
|
||
use ZipArchive;
|
||
|
||
class VersionPublishService
|
||
{
|
||
public function __construct(private readonly AdminPackageService $packageService)
|
||
{
|
||
}
|
||
|
||
public function publishFromZip(Package $package, UploadedFile $file, array $options = []): Version
|
||
{
|
||
$tmpDir = storage_path('app/tmp/store-publish/' . Str::uuid()->toString());
|
||
$extractDir = $tmpDir . '/extract';
|
||
$incomingPath = $tmpDir . '/incoming.zip';
|
||
|
||
File::ensureDirectoryExists($tmpDir);
|
||
File::ensureDirectoryExists($extractDir);
|
||
|
||
try {
|
||
File::copy($file->getRealPath(), $incomingPath);
|
||
|
||
$sha256 = hash_file('sha256', $incomingPath);
|
||
$size = filesize($incomingPath) ?: 0;
|
||
|
||
$metadata = $this->extractAndValidate($incomingPath, $extractDir, $package, $sha256, $options);
|
||
$versionNumber = (string) $metadata['version'];
|
||
|
||
if ($package->versions()->where('version', $versionNumber)->exists()) {
|
||
throw new RuntimeException('version already exists');
|
||
}
|
||
|
||
$relativePath = 'packages/' . $package->type . '/' . $package->slug . '/' . $versionNumber . '.zip';
|
||
$storagePath = storage_path('app/' . $relativePath);
|
||
File::ensureDirectoryExists(dirname($storagePath));
|
||
File::copy($incomingPath, $storagePath);
|
||
|
||
$payload = [
|
||
'version' => $versionNumber,
|
||
'changelog' => $options['changelog'] ?? ($metadata['changelog'] ?? ''),
|
||
'typecho_min' => $metadata['typecho_min'] ?? '1.2.0',
|
||
'typecho_max' => $metadata['typecho_max'] ?? '',
|
||
'php_min' => $metadata['php_min'] ?? '7.4',
|
||
'php_max' => $metadata['php_max'] ?? '',
|
||
'php_extensions' => $metadata['php_extensions'] ?? [],
|
||
'package_url' => $this->buildPackageUrl($package, $versionNumber),
|
||
'package_size' => $size,
|
||
'sha256' => $sha256,
|
||
'is_stable' => array_key_exists('is_stable', $options)
|
||
? (bool) $options['is_stable']
|
||
: !Str::contains($versionNumber, ['-beta', '-alpha', '-rc']),
|
||
'published_at' => $options['published_at'] ?? now(),
|
||
'mark_as_latest' => array_key_exists('mark_as_latest', $options)
|
||
? (bool) $options['mark_as_latest']
|
||
: true,
|
||
];
|
||
|
||
return DB::transaction(function () use ($package, $payload) {
|
||
return $this->packageService->createVersion($package, $payload);
|
||
});
|
||
} finally {
|
||
if (is_dir($tmpDir)) {
|
||
File::deleteDirectory($tmpDir);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function extractAndValidate(string $zipPath, string $extractDir, Package $package, string $sha256, array $options): array
|
||
{
|
||
$zip = new ZipArchive();
|
||
$opened = $zip->open($zipPath);
|
||
if ($opened !== true) {
|
||
throw new RuntimeException('failed to open zip package');
|
||
}
|
||
|
||
$topLevel = [];
|
||
$manifestRaw = null;
|
||
|
||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||
$stat = $zip->statIndex($i);
|
||
$name = $stat['name'] ?? '';
|
||
$this->assertSafeEntry($name);
|
||
|
||
$trimmed = trim($name, '/');
|
||
if ($trimmed === '') {
|
||
continue;
|
||
}
|
||
|
||
$first = explode('/', $trimmed)[0];
|
||
$topLevel[$first] = true;
|
||
|
||
if ($trimmed === 'manifest.json') {
|
||
$manifestRaw = $zip->getFromIndex($i);
|
||
}
|
||
}
|
||
|
||
try {
|
||
if ($manifestRaw !== null) {
|
||
$manifest = json_decode($manifestRaw, true);
|
||
if (!is_array($manifest)) {
|
||
throw new RuntimeException('invalid manifest.json');
|
||
}
|
||
|
||
$this->validateManifest($manifest, $package, $sha256);
|
||
$this->assertTopLevelStructure($topLevel, ['manifest.json', $package->slug], 'zip root structure must contain only manifest.json and package root dir');
|
||
$metadata = [
|
||
'version' => (string) $manifest['version'],
|
||
'changelog' => (string) ($manifest['changelog'] ?? ''),
|
||
'typecho_min' => (string) data_get($manifest, 'compatibility.typecho_min', '1.2.0'),
|
||
'typecho_max' => (string) data_get($manifest, 'compatibility.typecho_max', ''),
|
||
'php_min' => (string) data_get($manifest, 'compatibility.php_min', '7.4'),
|
||
'php_max' => (string) data_get($manifest, 'compatibility.php_max', ''),
|
||
'php_extensions' => $this->normalizePhpExtensions(data_get($manifest, 'compatibility.php_extensions', [])),
|
||
];
|
||
} else {
|
||
$this->assertTopLevelStructure($topLevel, [$package->slug], 'zip root structure must contain only package root dir when manifest.json is absent');
|
||
$metadata = $this->buildFallbackMetadata($options);
|
||
}
|
||
|
||
if (!$zip->extractTo($extractDir)) {
|
||
throw new RuntimeException('failed to extract zip package');
|
||
}
|
||
} finally {
|
||
$zip->close();
|
||
}
|
||
|
||
$rootDir = $extractDir . DIRECTORY_SEPARATOR . $package->slug;
|
||
if (!is_dir($rootDir)) {
|
||
throw new RuntimeException('package root dir missing after extraction');
|
||
}
|
||
|
||
return $metadata;
|
||
}
|
||
|
||
private function validateManifest(array $manifest, Package $package, string $sha256): void
|
||
{
|
||
if (($manifest['schema_version'] ?? '') !== '1.0') {
|
||
throw new RuntimeException('unsupported manifest schema_version');
|
||
}
|
||
|
||
if (($manifest['type'] ?? '') !== $package->type) {
|
||
throw new RuntimeException('manifest type does not match package type');
|
||
}
|
||
|
||
if (($manifest['slug'] ?? '') !== $package->slug) {
|
||
throw new RuntimeException('manifest slug does not match package slug');
|
||
}
|
||
|
||
if (!preg_match('/^[A-Za-z][A-Za-z0-9]*$/', (string) ($manifest['slug'] ?? ''))) {
|
||
throw new RuntimeException('manifest slug format invalid');
|
||
}
|
||
|
||
if (empty($manifest['name']) || empty($manifest['author']) || empty($manifest['version'])) {
|
||
throw new RuntimeException('manifest missing required fields');
|
||
}
|
||
|
||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/', (string) $manifest['version'])) {
|
||
throw new RuntimeException('manifest version is not valid semver');
|
||
}
|
||
|
||
if (($manifest['install']['root_dir'] ?? '') !== $package->slug) {
|
||
throw new RuntimeException('manifest install.root_dir invalid');
|
||
}
|
||
|
||
$expectedTargetDir = $package->type === 'plugin'
|
||
? 'usr/plugins/' . $package->slug
|
||
: 'usr/themes/' . $package->slug;
|
||
if (($manifest['install']['target_dir'] ?? '') !== $expectedTargetDir) {
|
||
throw new RuntimeException('manifest install.target_dir invalid');
|
||
}
|
||
|
||
if (empty($manifest['compatibility']['typecho_min']) || empty($manifest['compatibility']['php_min'])) {
|
||
throw new RuntimeException('manifest compatibility fields missing');
|
||
}
|
||
|
||
$manifestSha = strtolower((string) data_get($manifest, 'package.sha256', ''));
|
||
if ($manifestSha === '' || $manifestSha !== strtolower($sha256)) {
|
||
throw new RuntimeException('manifest package sha256 mismatch');
|
||
}
|
||
}
|
||
|
||
private function assertSafeEntry(string $name): void
|
||
{
|
||
if ($name === '' || str_contains($name, '../') || str_contains($name, '..\\')) {
|
||
throw new RuntimeException('zip contains unsafe path');
|
||
}
|
||
|
||
if (preg_match('#^[A-Za-z]:[\\/]#', $name) || str_starts_with($name, '/') || str_starts_with($name, '\\')) {
|
||
throw new RuntimeException('zip contains absolute path');
|
||
}
|
||
|
||
$blockedExt = ['phar', 'sh', 'bat', 'exe', 'cmd', 'com', 'ps1'];
|
||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||
if ($ext !== '' && in_array($ext, $blockedExt, true)) {
|
||
throw new RuntimeException('zip contains blocked file type');
|
||
}
|
||
}
|
||
|
||
private function buildPackageUrl(Package $package, string $version): string
|
||
{
|
||
$path = '/api/v1/repo/download/' . $package->type . '/' . $package->slug . '/' . $version . '?redirect=1';
|
||
$base = rtrim(config('app.url', ''), '/');
|
||
|
||
return $base === '' ? $path : $base . $path;
|
||
}
|
||
|
||
private function assertTopLevelStructure(array $topLevel, array $allowedTopLevel, string $message): void
|
||
{
|
||
$topLevelNames = array_keys($topLevel);
|
||
sort($topLevelNames);
|
||
sort($allowedTopLevel);
|
||
|
||
if ($topLevelNames !== $allowedTopLevel) {
|
||
throw new RuntimeException($message);
|
||
}
|
||
}
|
||
|
||
private function buildFallbackMetadata(array $options): array
|
||
{
|
||
$version = trim((string) ($options['version'] ?? ''));
|
||
|
||
if ($version === '') {
|
||
throw new RuntimeException('manifest.json not found at zip root; version is required for zip publish');
|
||
}
|
||
|
||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/', $version)) {
|
||
throw new RuntimeException('fallback version is not valid semver');
|
||
}
|
||
|
||
return [
|
||
'version' => $version,
|
||
'changelog' => (string) ($options['changelog'] ?? ''),
|
||
'typecho_min' => (string) ($options['typecho_min'] ?? '1.2.0'),
|
||
'typecho_max' => (string) ($options['typecho_max'] ?? ''),
|
||
'php_min' => (string) ($options['php_min'] ?? '7.4'),
|
||
'php_max' => (string) ($options['php_max'] ?? ''),
|
||
'php_extensions' => $this->normalizePhpExtensions($options['php_extensions'] ?? []),
|
||
];
|
||
}
|
||
|
||
private function normalizePhpExtensions(mixed $value): array
|
||
{
|
||
if (is_string($value)) {
|
||
$value = preg_split('/[,,\s]+/u', $value);
|
||
}
|
||
|
||
if (!is_array($value)) {
|
||
return [];
|
||
}
|
||
|
||
return collect($value)
|
||
->map(fn ($item) => trim((string) $item))
|
||
->filter()
|
||
->values()
|
||
->all();
|
||
}
|
||
}
|