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 @@
+
直接调用 publishFromZip,按 manifest 自动生成版本。
+优先读取 zip 根目录的 manifest.json;如果没有 manifest.json,则使用下方手动填写的版本和兼容性字段。