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