216 lines
8.1 KiB
PHP
216 lines
8.1 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;
|
|
|
|
$manifest = $this->extractAndValidate($incomingPath, $extractDir, $package, $sha256);
|
|
$versionNumber = (string) $manifest['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'] ?? ($manifest['changelog'] ?? ''),
|
|
'typecho_min' => data_get($manifest, 'compatibility.typecho_min', '1.2.0'),
|
|
'typecho_max' => data_get($manifest, 'compatibility.typecho_max', ''),
|
|
'php_min' => data_get($manifest, 'compatibility.php_min', '7.4'),
|
|
'php_max' => data_get($manifest, 'compatibility.php_max', ''),
|
|
'php_extensions' => data_get($manifest, 'compatibility.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
|
|
{
|
|
$zip = new ZipArchive();
|
|
$opened = $zip->open($zipPath);
|
|
if ($opened !== true) {
|
|
throw new RuntimeException('failed to open zip package');
|
|
}
|
|
|
|
$topLevel = [];
|
|
$manifestRaw = null;
|
|
$rootDirExists = false;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
if ($manifestRaw === null) {
|
|
$zip->close();
|
|
throw new RuntimeException('manifest.json not found at zip root');
|
|
}
|
|
|
|
$manifest = json_decode($manifestRaw, true);
|
|
if (!is_array($manifest)) {
|
|
$zip->close();
|
|
throw new RuntimeException('invalid manifest.json');
|
|
}
|
|
|
|
$this->validateManifest($manifest, $package, $sha256);
|
|
|
|
$allowedTopLevel = ['manifest.json', $package->slug];
|
|
$topLevelNames = array_keys($topLevel);
|
|
sort($topLevelNames);
|
|
sort($allowedTopLevel);
|
|
if ($topLevelNames !== $allowedTopLevel) {
|
|
$zip->close();
|
|
throw new RuntimeException('zip root structure must contain only manifest.json and package root dir');
|
|
}
|
|
|
|
if (!$zip->extractTo($extractDir)) {
|
|
$zip->close();
|
|
throw new RuntimeException('failed to extract zip package');
|
|
}
|
|
$zip->close();
|
|
|
|
$rootDir = $extractDir . DIRECTORY_SEPARATOR . $package->slug;
|
|
$rootDirExists = is_dir($rootDir);
|
|
if (!$rootDirExists) {
|
|
throw new RuntimeException('package root dir missing after extraction');
|
|
}
|
|
|
|
return $manifest;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|