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