Tstore/app/Services/VersionPublishService.php

267 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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();
}
}