diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f80ecd3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore +.env +.env.* +!.env.example +node_modules +vendor +public/build +public/hot +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +bootstrap/cache/*.php +database/database.sqlite +npm-debug.log* +yarn-error.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e1fd244 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1.7 + +FROM composer:2 AS vendor + +WORKDIR /app + +COPY composer.json composer.lock ./ + +RUN composer install \ + --no-dev \ + --prefer-dist \ + --no-interaction \ + --no-progress \ + --no-scripts + +COPY . . + +RUN composer dump-autoload --optimize --no-dev \ + && php artisan package:discover --ansi + +FROM node:20-alpine AS frontend + +WORKDIR /app + +COPY --from=vendor /app /app + +RUN npm ci && npm run build + +FROM php:8.2-apache AS runtime + +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libonig-dev \ + libsqlite3-dev \ + libzip-dev \ + unzip \ + && docker-php-ext-install \ + mbstring \ + opcache \ + pdo_mysql \ + pdo_sqlite \ + zip \ + && a2enmod expires headers rewrite \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /var/www/html + +COPY --from=vendor /app /var/www/html +COPY --from=frontend /app/public/build /var/www/html/public/build +COPY docker/apache/000-default.conf /etc/apache2/sites-available/000-default.conf +COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini +COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint + +RUN chmod +x /usr/local/bin/app-entrypoint \ + && mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views bootstrap/cache \ + && chown -R www-data:www-data storage bootstrap/cache + +EXPOSE 80 + +ENTRYPOINT ["app-entrypoint"] +CMD ["apache2-foreground"] diff --git a/README.md b/README.md index e0ef809..5474f42 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,101 @@ composer run setup composer run dev ``` +## Docker + +仓库已提供通用容器化文件: + +- `Dockerfile` +- `.dockerignore` +- `docker/apache/000-default.conf` +- `docker/entrypoint.sh` +- `docker/php/opcache.ini` +- `docker-compose.example.yml` + +### 1. 构建镜像 + +```bash +docker build -t tstore:latest . +``` + +这个镜像是多阶段构建,包含: + +- Composer 依赖安装 +- Vite 前端资源构建 +- Apache + PHP 8.2 运行时 + +容器内网站根目录已经指向 `public/`,不需要再额外改 Web 根目录。 + +### 2. 单容器运行 + +更适合已经有外部 MySQL 的场景。 + +```bash +docker run -d \ + --name tstore \ + -p 8080:80 \ + --env-file .env \ + -e APP_ENV=production \ + -e APP_DEBUG=false \ + -e APP_URL=http://localhost:8080 \ + -e LOG_CHANNEL=stderr \ + -e DB_CONNECTION=mysql \ + -e DB_HOST=your-db-host \ + -e DB_PORT=3306 \ + -e DB_DATABASE=tstore \ + -e DB_USERNAME=tstore \ + -e DB_PASSWORD=your-password \ + -e SESSION_DRIVER=file \ + -e CACHE_STORE=file \ + -e QUEUE_CONNECTION=sync \ + -e RUN_MIGRATIONS=1 \ + -e RUN_OPTIMIZE=1 \ + tstore:latest +``` + +如果你希望容器启动时自动创建后台管理员账号,可以再加: + +```bash +-e RUN_ADMIN_SEEDER=1 +``` + +说明: + +- `RUN_MIGRATIONS=1` 会在容器启动时执行 `php artisan migrate --force` +- `RUN_ADMIN_SEEDER=1` 会执行 `AdminUserSeeder` +- `RUN_OPTIMIZE=1` 会执行 `php artisan optimize` + +### 3. 使用 Docker Compose + +仓库提供了一个通用示例文件: + +```bash +cp docker-compose.example.yml docker-compose.yml +docker compose up -d --build +``` + +默认暴露端口: + +- 应用:`8080` +- MySQL:不对宿主机暴露,仅供容器内部访问 + +首次启动前,建议先修改: + +- `docker-compose.yml` 中的数据库密码 +- `.env` 中的 `APP_URL` +- `.env` 中的 `SESSION_DRIVER`、`CACHE_STORE`、`QUEUE_CONNECTION` +- `.env` 中的 `STORE_ADMIN_TOKEN` +- `.env` 中的 `STORE_PLUGIN_ACCESS_TOKEN` +- `.env` 中的 `ADMIN_EMAIL` +- `.env` 中的 `ADMIN_PASSWORD` + +### 4. 容器化运行时建议 + +- 生产环境优先使用 MySQL,不建议继续使用 SQLite +- 如果要持久化上传的 ZIP 包和日志,保留 `storage` 卷挂载 +- 不要把真实 `.env` 打进镜像,运行时通过 `--env-file` 或 Compose 注入 +- 如果上线到生产,建议把 `APP_URL` 改成正式域名,并在网关层处理 HTTPS + ## 生产部署 以下步骤以 `Linux + Nginx + PHP-FPM` 为例。生产环境不要运行 `php artisan serve` 和 `npm run dev`。 diff --git a/app/Http/Controllers/WebAdmin/DashboardController.php b/app/Http/Controllers/WebAdmin/DashboardController.php index 567eff5..566c812 100644 --- a/app/Http/Controllers/WebAdmin/DashboardController.php +++ b/app/Http/Controllers/WebAdmin/DashboardController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\WebAdmin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\PublishZipVersionRequest; use App\Http\Requests\Admin\StoreCategoryRequest; use App\Http\Requests\Admin\StorePackageRequest; use App\Models\Category; @@ -220,17 +221,10 @@ class DashboardController extends Controller return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '版本已添加:v' . $version->version); } - public function publishVersion(Request $request, string $type, string $slug): RedirectResponse + public function publishVersion(PublishZipVersionRequest $request, string $type, string $slug): RedirectResponse { $package = $this->findPackage($type, $slug); - - $validated = $request->validate([ - 'package_file' => ['required', 'file', 'mimes:zip', 'max:51200'], - 'is_stable' => ['nullable', 'boolean'], - 'mark_as_latest' => ['nullable', 'boolean'], - 'changelog' => ['nullable', 'string'], - 'published_at' => ['nullable', 'date'], - ]); + $validated = $request->validated(); try { $version = $this->publishService->publishFromZip($package, $request->file('package_file'), $validated); diff --git a/app/Http/Requests/Admin/PublishZipVersionRequest.php b/app/Http/Requests/Admin/PublishZipVersionRequest.php index 10911fe..cf9523b 100644 --- a/app/Http/Requests/Admin/PublishZipVersionRequest.php +++ b/app/Http/Requests/Admin/PublishZipVersionRequest.php @@ -3,6 +3,8 @@ namespace App\Http\Requests\Admin; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; +use ZipArchive; class PublishZipVersionRequest extends FormRequest { @@ -11,14 +13,75 @@ class PublishZipVersionRequest extends FormRequest return true; } + protected function prepareForValidation(): void + { + $extensions = $this->input('php_extensions'); + + if (is_string($extensions)) { + $this->merge([ + 'php_extensions' => collect(preg_split('/[,,\s]+/u', $extensions)) + ->map(fn ($item) => trim((string) $item)) + ->filter() + ->values() + ->all(), + ]); + } + } + public function rules(): array { return [ 'package_file' => ['required', 'file', 'mimes:zip', 'max:51200'], + 'version' => ['nullable', 'string', 'max:32', 'regex:/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/'], + 'typecho_min' => ['nullable', 'string', 'max:16'], + 'typecho_max' => ['nullable', 'string', 'max:16'], + 'php_min' => ['nullable', 'string', 'max:16'], + 'php_max' => ['nullable', 'string', 'max:16'], + 'php_extensions' => ['nullable', 'array'], + 'php_extensions.*' => ['string', 'max:32'], 'is_stable' => ['nullable', 'boolean'], 'mark_as_latest' => ['nullable', 'boolean'], 'changelog' => ['nullable', 'string'], 'published_at' => ['nullable', 'date'], ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $file = $this->file('package_file'); + + if (!$file || !$file->isValid() || $this->zipHasManifestAtRoot($file->getRealPath())) { + return; + } + + if (blank((string) $this->input('version', ''))) { + $validator->errors()->add('version', 'zip 根目录没有 manifest.json 时,必须手动填写版本号。'); + } + }); + } + + private function zipHasManifestAtRoot(string $path): bool + { + $zip = new ZipArchive(); + + if ($zip->open($path) !== true) { + return false; + } + + try { + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $name = trim((string) ($stat['name'] ?? ''), '/'); + + if ($name === 'manifest.json') { + return true; + } + } + + return false; + } finally { + $zip->close(); + } + } } diff --git a/app/Services/VersionPublishService.php b/app/Services/VersionPublishService.php index 72d870b..deb021d 100644 --- a/app/Services/VersionPublishService.php +++ b/app/Services/VersionPublishService.php @@ -32,8 +32,8 @@ class VersionPublishService $sha256 = hash_file('sha256', $incomingPath); $size = filesize($incomingPath) ?: 0; - $manifest = $this->extractAndValidate($incomingPath, $extractDir, $package, $sha256); - $versionNumber = (string) $manifest['version']; + $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'); @@ -46,12 +46,12 @@ class VersionPublishService $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', []), + '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, @@ -74,7 +74,7 @@ class VersionPublishService } } - private function extractAndValidate(string $zipPath, string $extractDir, Package $package, string $sha256): array + private function extractAndValidate(string $zipPath, string $extractDir, Package $package, string $sha256, array $options): array { $zip = new ZipArchive(); $opened = $zip->open($zipPath); @@ -84,7 +84,6 @@ class VersionPublishService $topLevel = []; $manifestRaw = null; - $rootDirExists = false; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); @@ -104,41 +103,42 @@ class VersionPublishService } } - if ($manifestRaw === null) { - $zip->close(); - throw new RuntimeException('manifest.json not found at zip root'); - } + try { + if ($manifestRaw !== null) { + $manifest = json_decode($manifestRaw, true); + if (!is_array($manifest)) { + throw new RuntimeException('invalid manifest.json'); + } - $manifest = json_decode($manifestRaw, true); - if (!is_array($manifest)) { - $zip->close(); - 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); + } - $this->validateManifest($manifest, $package, $sha256); - - $allowedTopLevel = ['manifest.json', $package->slug]; - $topLevelNames = array_keys($topLevel); - sort($topLevelNames); - sort($allowedTopLevel); - if ($topLevelNames !== $allowedTopLevel) { + if (!$zip->extractTo($extractDir)) { + throw new RuntimeException('failed to extract zip package'); + } + } finally { $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) { + if (!is_dir($rootDir)) { throw new RuntimeException('package root dir missing after extraction'); } - return $manifest; + return $metadata; } private function validateManifest(array $manifest, Package $package, string $sha256): void @@ -212,4 +212,55 @@ class VersionPublishService 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(); + } } diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..c563791 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,52 @@ +services: + app: + build: + context: . + image: tstore:latest + ports: + - "8080:80" + env_file: + - .env + environment: + APP_ENV: production + APP_DEBUG: "false" + APP_URL: http://localhost:8080 + LOG_CHANNEL: stderr + DB_CONNECTION: mysql + DB_HOST: mysql + DB_PORT: "3306" + DB_DATABASE: tstore + DB_USERNAME: tstore + DB_PASSWORD: tstore_password + SESSION_DRIVER: file + CACHE_STORE: file + QUEUE_CONNECTION: sync + RUN_MIGRATIONS: "1" + RUN_ADMIN_SEEDER: "0" + RUN_OPTIMIZE: "1" + depends_on: + mysql: + condition: service_healthy + volumes: + - storage_data:/var/www/html/storage + + mysql: + image: mysql:8.4 + restart: unless-stopped + environment: + MYSQL_DATABASE: tstore + MYSQL_USER: tstore + MYSQL_PASSWORD: tstore_password + MYSQL_ROOT_PASSWORD: root_password + command: --default-authentication-plugin=mysql_native_password + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot_password"] + interval: 10s + timeout: 5s + retries: 10 + volumes: + - mysql_data:/var/lib/mysql + +volumes: + mysql_data: + storage_data: diff --git a/docker/apache/000-default.conf b/docker/apache/000-default.conf new file mode 100644 index 0000000..5b79da6 --- /dev/null +++ b/docker/apache/000-default.conf @@ -0,0 +1,13 @@ + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html/public + + + AllowOverride All + Options FollowSymLinks + Require all granted + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..71be5a2 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +set -eu + +mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views bootstrap/cache +chown -R www-data:www-data storage bootstrap/cache + +if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then + php artisan migrate --force +fi + +if [ "${RUN_ADMIN_SEEDER:-0}" = "1" ]; then + php artisan db:seed --class="Database\\Seeders\\AdminUserSeeder" --force +fi + +if [ "${RUN_OPTIMIZE:-0}" = "1" ]; then + php artisan optimize +fi + +exec "$@" diff --git a/docker/php/opcache.ini b/docker/php/opcache.ini new file mode 100644 index 0000000..019a6d6 --- /dev/null +++ b/docker/php/opcache.ini @@ -0,0 +1,7 @@ +opcache.enable=1 +opcache.enable_cli=0 +opcache.memory_consumption=128 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=20000 +opcache.validate_timestamps=0 +opcache.revalidate_freq=0 diff --git a/resources/views/admin/packages/show.blade.php b/resources/views/admin/packages/show.blade.php index 31f2efc..dc2a044 100644 --- a/resources/views/admin/packages/show.blade.php +++ b/resources/views/admin/packages/show.blade.php @@ -115,19 +115,27 @@

zip 上传发布

-

直接调用 publishFromZip,按 manifest 自动生成版本。

+

优先读取 zip 根目录的 manifest.json;如果没有 manifest.json,则使用下方手动填写的版本和兼容性字段。

@csrf
-
+
+
+
+
+
+
+
+
+
+
-
- - + +
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8364a84..22ef0c5 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -2,11 +2,13 @@ namespace Tests\Feature; -// use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ExampleTest extends TestCase { + use RefreshDatabase; + /** * A basic test example. */ diff --git a/tests/Feature/VersionPublishServiceTest.php b/tests/Feature/VersionPublishServiceTest.php new file mode 100644 index 0000000..53a3bc7 --- /dev/null +++ b/tests/Feature/VersionPublishServiceTest.php @@ -0,0 +1,143 @@ + + */ + private array $temporaryZipPaths = []; + + public function test_it_can_publish_zip_without_manifest_using_manual_metadata(): void + { + $package = Package::query()->create([ + 'type' => 'plugin', + 'slug' => 'DemoPlugin', + 'name' => 'Demo Plugin', + 'summary' => '', + 'description' => '', + 'author' => '', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'draft', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '', + ]); + + $uploadedFile = $this->makeZipUpload($package->slug, [ + 'Plugin.php' => 'publishFromZip($package, $uploadedFile, [ + 'version' => '1.2.3', + 'typecho_min' => '1.2.0', + 'typecho_max' => '1.3.*', + 'php_min' => '8.0', + 'php_max' => '8.3', + 'php_extensions' => ['curl', 'json'], + 'is_stable' => true, + 'mark_as_latest' => true, + ]); + + $this->assertSame('1.2.3', $version->version); + $this->assertSame('1.2.0', $version->typecho_min); + $this->assertSame('1.3.*', $version->typecho_max); + $this->assertSame('8.0', $version->php_min); + $this->assertSame('8.3', $version->php_max); + $this->assertSame(['curl', 'json'], $version->php_extensions_array); + $this->assertTrue($version->is_stable); + $this->assertTrue($version->is_latest); + $this->assertFileExists(storage_path('app/packages/plugin/DemoPlugin/1.2.3.zip')); + + $this->assertSame('1.2.3', $package->fresh()->latest_version); + } + + public function test_it_requires_manual_version_when_manifest_is_missing(): void + { + $package = Package::query()->create([ + 'type' => 'plugin', + 'slug' => 'DemoPlugin', + 'name' => 'Demo Plugin', + 'summary' => '', + 'description' => '', + 'author' => '', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'draft', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '', + ]); + + $uploadedFile = $this->makeZipUpload($package->slug, [ + 'Plugin.php' => 'expectException(RuntimeException::class); + $this->expectExceptionMessage('manifest.json not found at zip root; version is required for zip publish'); + + app(VersionPublishService::class)->publishFromZip($package, $uploadedFile, []); + } + + protected function tearDown(): void + { + foreach ($this->temporaryZipPaths as $path) { + if (is_file($path)) { + @unlink($path); + } + } + + $storedZip = storage_path('app/packages/plugin/DemoPlugin/1.2.3.zip'); + if (is_file($storedZip)) { + @unlink($storedZip); + } + + $storedDir = storage_path('app/packages/plugin/DemoPlugin'); + if (is_dir($storedDir)) { + @rmdir($storedDir); + } + + parent::tearDown(); + } + + private function makeZipUpload(string $slug, array $files): UploadedFile + { + $directory = storage_path('framework/testing'); + File::ensureDirectoryExists($directory); + + $path = tempnam($directory, 'zip'); + $this->temporaryZipPaths[] = $path; + + $zip = new ZipArchive(); + $opened = $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); + + if ($opened !== true) { + throw new RuntimeException('failed to create test zip'); + } + + foreach ($files as $name => $contents) { + $zip->addFromString($slug . '/' . $name, $contents); + } + + $zip->close(); + + return new UploadedFile($path, $slug . '.zip', 'application/zip', null, true); + } +}