Tstore/app/Services/VersionPublishService.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;
}
}