dockerfile
This commit is contained in:
parent
04eccc850d
commit
62cffc6f5f
|
|
@ -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*
|
||||
|
|
@ -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"]
|
||||
95
README.md
95
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`。
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<VirtualHost *:80>
|
||||
ServerAdmin webmaster@localhost
|
||||
DocumentRoot /var/www/html/public
|
||||
|
||||
<Directory /var/www/html/public>
|
||||
AllowOverride All
|
||||
Options FollowSymLinks
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||
</VirtualHost>
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
@ -115,19 +115,27 @@
|
|||
<div class="section-title">
|
||||
<div>
|
||||
<h2>zip 上传发布</h2>
|
||||
<p>直接调用 publishFromZip,按 manifest 自动生成版本。</p>
|
||||
<p>优先读取 zip 根目录的 manifest.json;如果没有 manifest.json,则使用下方手动填写的版本和兼容性字段。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ route('webadmin.packages.publish', [$package->type, $package->slug]) }}" class="grid" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="field"><label>zip 文件</label><input class="input" type="file" name="package_file" accept=".zip" required></div>
|
||||
<div class="field"><label>发布说明(可选)</label><textarea name="changelog" placeholder="补充这次发布说明"></textarea></div>
|
||||
<div class="field"><label>版本号(无 manifest 时必填)</label><input class="input" name="version" value="{{ old('version') }}" placeholder="1.0.0"></div>
|
||||
<div class="field"><label>发布说明(可选)</label><textarea name="changelog" placeholder="补充这次发布说明">{{ old('changelog') }}</textarea></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Typecho Min</label><input class="input" name="typecho_min" value="{{ old('typecho_min', '1.2.0') }}" placeholder="1.2.0"></div>
|
||||
<div class="field"><label>Typecho Max</label><input class="input" name="typecho_max" value="{{ old('typecho_max') }}" placeholder="1.3.*"></div>
|
||||
<div class="field"><label>PHP Min</label><input class="input" name="php_min" value="{{ old('php_min', '7.4') }}" placeholder="7.4"></div>
|
||||
<div class="field"><label>PHP Max</label><input class="input" name="php_max" value="{{ old('php_max') }}" placeholder="8.3"></div>
|
||||
<div class="field"><label>PHP Extensions</label><input class="input" name="php_extensions" value="{{ is_array(old('php_extensions')) ? implode(',', old('php_extensions')) : old('php_extensions') }}" placeholder="curl,json"></div>
|
||||
<div class="field"><label>发布时间(可选)</label><input class="input" type="datetime-local" name="published_at" value="{{ old('published_at') }}"></div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>发布时间(可选)</label><input class="input" type="datetime-local" name="published_at"></div>
|
||||
<div class="field"><label>发布选项</label>
|
||||
<div class="tags">
|
||||
<label><input type="checkbox" name="is_stable" value="1" checked> 设为稳定版</label>
|
||||
<label><input type="checkbox" name="mark_as_latest" value="1" checked> 设为最新</label>
|
||||
<label><input type="checkbox" name="is_stable" value="1" @checked(old('is_stable', 1))> 设为稳定版</label>
|
||||
<label><input type="checkbox" name="mark_as_latest" value="1" @checked(old('mark_as_latest', 1))> 设为最新</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Services\VersionPublishService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class VersionPublishServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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' => '<?php echo "ok";',
|
||||
]);
|
||||
|
||||
$version = app(VersionPublishService::class)->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' => '<?php echo "ok";',
|
||||
]);
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue