前端极简黑白重设计,并纳入客户端注册、站点设置与后台相关改动

- storefront 全站改为极简黑白配色:直角、细线分隔、卡片 hover 浅底、深色模式(跟随系统+手动切换)
- 抽出 storefront/partials/card 复用卡片
- 一并提交此前暂存的客户端注册 API、站点设置、后台页面与测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
浪子
2026-09-22 08:35:49 +08:00
parent 62cffc6f5f
commit 04a2034975
41 changed files with 3447 additions and 658 deletions
@@ -0,0 +1,197 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StoreClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class ClientController extends Controller
{
public function register(Request $request): JsonResponse
{
$validated = $request->validate([
'site_url' => ['required', 'url', 'max:512'],
'site_name' => ['nullable', 'string', 'max:255'],
'typecho_version' => ['nullable', 'string', 'max:32'],
'php_version' => ['nullable', 'string', 'max:32'],
'plugin_version' => ['nullable', 'string', 'max:32'],
'user_count' => ['nullable', 'integer', 'min:0'],
]);
$siteUrl = $this->normalizeSiteUrl((string) $validated['site_url']);
$now = now();
$client = StoreClient::query()->firstOrNew([
'site_url' => $siteUrl,
]);
if (!$client->exists) {
$client->registered_at = $now;
}
$client->site_name = $this->truncate((string) ($validated['site_name'] ?? ''), 255);
$client->access_token = $this->issueAccessToken();
$client->status = 'online';
$client->user_count = max((int) ($validated['user_count'] ?? 0), 0);
$client->typecho_version = $this->truncate((string) ($validated['typecho_version'] ?? ''), 32);
$client->php_version = $this->truncate((string) ($validated['php_version'] ?? ''), 32);
$client->plugin_version = $this->truncate((string) ($validated['plugin_version'] ?? ''), 32);
$client->last_ip = $this->truncate((string) ($request->ip() ?? ''), 45);
$client->last_user_agent = $this->truncate((string) ($request->userAgent() ?? ''), 512);
$client->last_seen_at = $now;
$client->save();
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => $this->clientPayload($client, true),
]);
}
public function heartbeat(Request $request): JsonResponse
{
$client = $this->resolveClient($request);
if (!$client) {
return $this->unauthorizedResponse();
}
$validated = $request->validate([
'site_name' => ['nullable', 'string', 'max:255'],
'typecho_version' => ['nullable', 'string', 'max:32'],
'php_version' => ['nullable', 'string', 'max:32'],
'plugin_version' => ['nullable', 'string', 'max:32'],
'user_count' => ['nullable', 'integer', 'min:0'],
]);
if (array_key_exists('site_name', $validated)) {
$client->site_name = $this->truncate((string) $validated['site_name'], 255);
}
if (array_key_exists('typecho_version', $validated)) {
$client->typecho_version = $this->truncate((string) $validated['typecho_version'], 32);
}
if (array_key_exists('php_version', $validated)) {
$client->php_version = $this->truncate((string) $validated['php_version'], 32);
}
if (array_key_exists('plugin_version', $validated)) {
$client->plugin_version = $this->truncate((string) $validated['plugin_version'], 32);
}
if (array_key_exists('user_count', $validated)) {
$client->user_count = max((int) $validated['user_count'], 0);
}
$client->status = 'online';
$client->last_ip = $this->truncate((string) ($request->ip() ?? ''), 45);
$client->last_user_agent = $this->truncate((string) ($request->userAgent() ?? ''), 512);
$client->last_seen_at = now();
$client->save();
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => $this->clientPayload($client, false),
]);
}
public function status(Request $request): JsonResponse
{
$client = $this->resolveClient($request);
if (!$client) {
return $this->unauthorizedResponse();
}
$client->status = 'online';
$client->last_ip = $this->truncate((string) ($request->ip() ?? ''), 45);
$client->last_user_agent = $this->truncate((string) ($request->userAgent() ?? ''), 512);
$client->last_seen_at = now();
$client->save();
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => $this->clientPayload($client, false),
]);
}
private function resolveClient(Request $request): ?StoreClient
{
$token = $this->extractToken($request);
if ($token === '') {
return null;
}
return StoreClient::query()->where('access_token', $token)->first();
}
private function extractToken(Request $request): string
{
return trim((string) (
$request->bearerToken()
?: $request->header('X-Store-Plugin-Token')
?: $request->query('access_token', '')
));
}
private function unauthorizedResponse(): JsonResponse
{
return response()->json([
'code' => 401,
'message' => 'client unauthorized',
'data' => null,
], 401);
}
private function issueAccessToken(): string
{
do {
$token = Str::random(96);
} while (StoreClient::query()->where('access_token', $token)->exists());
return $token;
}
private function normalizeSiteUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
return rtrim($url, '/');
}
private function truncate(string $value, int $length): string
{
return mb_substr(trim($value), 0, $length);
}
private function clientPayload(StoreClient $client, bool $includeToken): array
{
$payload = [
'client_id' => $client->id,
'site_url' => $client->site_url,
'site_name' => $client->site_name,
'status' => $client->status,
'user_count' => (int) $client->user_count,
'typecho_version' => $client->typecho_version,
'php_version' => $client->php_version,
'plugin_version' => $client->plugin_version,
'registered_at' => optional($client->registered_at)->toAtomString(),
'last_seen_at' => optional($client->last_seen_at)->toAtomString(),
'server_time' => now()->toAtomString(),
'stats' => [
'registered_sites' => StoreClient::query()->count(),
'online_sites' => StoreClient::query()->online()->count(),
'tracked_users' => (int) StoreClient::query()->sum('user_count'),
],
];
if ($includeToken) {
$payload['access_token'] = $client->access_token;
}
return $payload;
}
}
+99 -11
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\DownloadLog;
use App\Models\Package;
use App\Models\StoreClient;
use App\Services\RepoFormatter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -166,7 +167,7 @@ class RepoController extends Controller
'package' => [
'size' => (int) $latest->package_size,
'sha256' => $latest->sha256,
'download_url' => $latest->package_url,
'download_url' => $this->buildDownloadUrl($request, $type, $slug, $latest->version),
],
];
}
@@ -187,12 +188,14 @@ class RepoController extends Controller
public function download(Request $request, string $type, string $slug, string $version): JsonResponse|BinaryFileResponse|RedirectResponse
{
$pluginToken = (string) config('store.plugin_access_token', '');
$requestToken = (string) ($request->header('X-Store-Plugin-Token') ?: $request->query('access_token', ''));
$requestToken = $this->extractRequestToken($request);
$client = $this->resolveClientByToken($requestToken);
$legacyTokenMatched = $pluginToken !== '' && $requestToken !== '' && hash_equals($pluginToken, $requestToken);
if ($pluginToken !== '' && $requestToken !== $pluginToken) {
if (!$legacyTokenMatched && !$client) {
return response()->json([
'code' => 403,
'message' => 'download forbidden',
'message' => 'client token required',
'data' => null,
], 403);
}
@@ -228,12 +231,20 @@ class RepoController extends Controller
$redirect = (string) $request->query('redirect', '0') === '1';
if ($redirect) {
if ($client) {
$client->status = 'online';
$client->last_ip = (string) ($request->ip() ?? '');
$client->last_user_agent = (string) ($request->userAgent() ?? '');
$client->last_seen_at = now();
$client->save();
}
DownloadLog::create([
'package_id' => $package->id,
'version_id' => $versionModel->id,
'site_url' => (string) $request->query('site_url', ''),
'typecho_version' => (string) $request->query('typecho_version', ''),
'php_version' => (string) $request->query('php_version', ''),
'site_url' => $client?->site_url ?: (string) $request->query('site_url', ''),
'typecho_version' => $client?->typecho_version ?: (string) $request->query('typecho_version', ''),
'php_version' => $client?->php_version ?: (string) $request->query('php_version', ''),
'ip' => (string) $request->ip(),
'user_agent' => (string) $request->userAgent(),
'created_at' => now(),
@@ -306,12 +317,89 @@ class RepoController extends Controller
private function buildDownloadUrl(Request $request, string $type, string $slug, string $version): string
{
$path = '/api/v1/repo/download/' . $type . '/' . $slug . '/' . $version . '?redirect=1';
$appUrl = rtrim((string) config('app.url', ''), '/');
$baseUrl = $this->resolveDownloadBaseUrl($request);
if ($appUrl !== '') {
return $appUrl . $path;
if ($baseUrl !== '') {
return $baseUrl . $path;
}
return $request->getSchemeAndHttpHost() . $path;
return $path;
}
private function resolveDownloadBaseUrl(Request $request): string
{
$appUrl = $this->normalizeBaseUrl((string) config('app.url', ''));
if ($this->isPublicHttpsBaseUrl($appUrl)) {
return $appUrl;
}
$requestRoot = $this->normalizeBaseUrl($request->root());
if ($requestRoot !== '') {
return $requestRoot;
}
return $appUrl;
}
private function normalizeBaseUrl(string $url): string
{
return rtrim(trim($url), '/');
}
private function isPublicHttpsBaseUrl(string $url): bool
{
if ($url === '') {
return false;
}
$parts = parse_url($url);
if (!is_array($parts)) {
return false;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
if ($scheme !== 'https' || $host === '') {
return false;
}
if (filter_var($host, FILTER_VALIDATE_IP)) {
return filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
if (in_array($host, ['localhost', 'host.docker.internal'], true)) {
return false;
}
foreach (['.localhost', '.local', '.internal', '.test'] as $suffix) {
if (str_ends_with($host, $suffix)) {
return false;
}
}
return true;
}
private function extractRequestToken(Request $request): string
{
return trim((string) (
$request->bearerToken()
?: $request->header('X-Store-Plugin-Token')
?: $request->query('access_token', '')
));
}
private function resolveClientByToken(string $token): ?StoreClient
{
if ($token === '') {
return null;
}
return StoreClient::query()->where('access_token', $token)->first();
}
}