{{ $pageTitle }}
+{{ $pageSubtitle }}
+diff --git a/README.md b/README.md index 5474f42..58ffe5c 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ docker compose up -d --build - `.env` 中的 `APP_URL` - `.env` 中的 `SESSION_DRIVER`、`CACHE_STORE`、`QUEUE_CONNECTION` - `.env` 中的 `STORE_ADMIN_TOKEN` -- `.env` 中的 `STORE_PLUGIN_ACCESS_TOKEN` +- `.env` 中的 `STORE_PLUGIN_ACCESS_TOKEN`(仅私有分发时需要) - `.env` 中的 `ADMIN_EMAIL` - `.env` 中的 `ADMIN_PASSWORD` @@ -195,7 +195,7 @@ CACHE_STORE=file QUEUE_CONNECTION=sync STORE_ADMIN_TOKEN=replace-with-a-long-random-token -STORE_PLUGIN_ACCESS_TOKEN=replace-with-a-long-random-token +STORE_PLUGIN_ACCESS_TOKEN= ADMIN_NAME=Tstore Admin ADMIN_EMAIL=admin@example.com @@ -205,7 +205,7 @@ ADMIN_PASSWORD=change-this-password 说明: - `STORE_ADMIN_TOKEN` 用于 `/api/admin/*` 管理接口鉴权 -- `STORE_PLUGIN_ACCESS_TOKEN` 用于下载接口鉴权 +- `STORE_PLUGIN_ACCESS_TOKEN` 是可选项,仅在私有分发或需要限制下载时使用;公开商店建议留空 - 网页后台 `/admin/login` 使用 `users` 表中的账号密码登录 - 如果生产环境使用 Redis,可以把 `SESSION_DRIVER`、`CACHE_STORE`、`QUEUE_CONNECTION` 改为 Redis 相关配置 diff --git a/app/Http/Controllers/Api/ClientController.php b/app/Http/Controllers/Api/ClientController.php new file mode 100644 index 0000000..807ca8f --- /dev/null +++ b/app/Http/Controllers/Api/ClientController.php @@ -0,0 +1,197 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/RepoController.php b/app/Http/Controllers/Api/RepoController.php index d2b3652..c3a0681 100644 --- a/app/Http/Controllers/Api/RepoController.php +++ b/app/Http/Controllers/Api/RepoController.php @@ -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(); } } diff --git a/app/Http/Controllers/Web/StorefrontController.php b/app/Http/Controllers/Web/StorefrontController.php index 0204e39..83b21b2 100644 --- a/app/Http/Controllers/Web/StorefrontController.php +++ b/app/Http/Controllers/Web/StorefrontController.php @@ -86,6 +86,16 @@ class StorefrontController extends Controller ]); } + public function showPlugin(string $slug): View + { + return $this->show('plugin', $slug); + } + + public function showTheme(string $slug): View + { + return $this->show('theme', $slug); + } + public function show(string $type, string $slug): View { abort_unless(in_array($type, ['plugin', 'theme'], true), 404); @@ -103,20 +113,9 @@ class StorefrontController extends Controller $detail = RepoFormatter::packageDetail($package, false); - $related = Package::query() - ->with(['categories', 'latestStableVersion']) - ->where('status', 'published') - ->where('type', $type) - ->where('id', '!=', $package->id) - ->orderByDesc('is_featured') - ->orderByDesc('updated_at') - ->limit(4) - ->get(); - return view('storefront.show', [ 'package' => $package, 'detail' => $detail, - 'related' => $related, ]); } } diff --git a/app/Http/Controllers/WebAdmin/AuthController.php b/app/Http/Controllers/WebAdmin/AuthController.php index ad949b1..6346f79 100644 --- a/app/Http/Controllers/WebAdmin/AuthController.php +++ b/app/Http/Controllers/WebAdmin/AuthController.php @@ -50,4 +50,57 @@ class AuthController extends Controller return redirect()->route('webadmin.login')->with('success', '已退出后台'); } + + public function showAccount(Request $request): View + { + return view('admin.account', [ + 'user' => $request->user(), + ]); + } + + public function updatePassword(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'current_password' => ['required', 'current_password'], + 'new_password' => ['required', 'string', 'min:8', 'confirmed', 'different:current_password'], + ], [ + 'current_password.required' => '请输入当前密码', + 'current_password.current_password' => '当前密码不正确', + 'new_password.required' => '请输入新密码', + 'new_password.min' => '新密码至少需要 8 位', + 'new_password.confirmed' => '两次输入的新密码不一致', + 'new_password.different' => '新密码不能与当前密码相同', + ]); + + $user = $request->user(); + $user->forceFill([ + 'password' => $validated['new_password'], + ])->save(); + + $request->session()->regenerate(); + + return redirect()->route('webadmin.account')->with('success', '密码已更新'); + } + + public function updateProfile(Request $request): RedirectResponse + { + $user = $request->user(); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email,' . $user->id], + ], [ + 'name.required' => '请输入用户名', + 'email.required' => '请输入邮箱地址', + 'email.email' => '邮箱格式不正确', + 'email.unique' => '该邮箱已被其它用户使用', + ]); + + $user->forceFill([ + 'name' => trim($validated['name']), + 'email' => trim($validated['email']), + ])->save(); + + return redirect()->route('webadmin.account')->with('success', '账户资料已更新'); + } } diff --git a/app/Http/Controllers/WebAdmin/DashboardController.php b/app/Http/Controllers/WebAdmin/DashboardController.php index 566c812..f2cb962 100644 --- a/app/Http/Controllers/WebAdmin/DashboardController.php +++ b/app/Http/Controllers/WebAdmin/DashboardController.php @@ -8,11 +8,13 @@ use App\Http\Requests\Admin\StoreCategoryRequest; use App\Http\Requests\Admin\StorePackageRequest; use App\Models\Category; use App\Models\Package; +use App\Models\StoreClient; use App\Models\Version; use App\Services\AdminPackageService; use App\Services\VersionPublishService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; use Illuminate\View\View; class DashboardController extends Controller @@ -32,7 +34,7 @@ class DashboardController extends Controller 'versions' => Version::count(), 'downloads' => (int) Package::sum('download_count'), 'categories' => Category::count(), - ]; + ] + $this->buildClientStats(); $recentPackages = Package::query() ->with(['categories', 'latestStableVersion']) @@ -46,7 +48,57 @@ class DashboardController extends Controller ->limit(8) ->get(); - return view('admin.dashboard', compact('stats', 'recentPackages', 'recentVersions')); + $recentClients = StoreClient::query() + ->orderByDesc('last_seen_at') + ->orderByDesc('registered_at') + ->limit(6) + ->get(); + + return view('admin.dashboard', compact('stats', 'recentPackages', 'recentVersions', 'recentClients')); + } + + public function clients(Request $request): View + { + $query = StoreClient::query(); + $status = (string) $request->query('status', ''); + $keyword = trim((string) $request->query('keyword', '')); + $onlineThreshold = now()->subMinutes((int) config('store.client_online_window_minutes', 15)); + + if ($status === 'online') { + $query->whereNotNull('last_seen_at') + ->where('last_seen_at', '>=', $onlineThreshold); + } elseif ($status === 'offline') { + $query->where(function ($q) use ($onlineThreshold) { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', $onlineThreshold); + }); + } + + if ($keyword !== '') { + $query->where(function ($q) use ($keyword) { + $q->where('site_url', 'like', '%' . $keyword . '%') + ->orWhere('site_name', 'like', '%' . $keyword . '%') + ->orWhere('typecho_version', 'like', '%' . $keyword . '%') + ->orWhere('php_version', 'like', '%' . $keyword . '%') + ->orWhere('plugin_version', 'like', '%' . $keyword . '%'); + }); + } + + $clients = $query + ->orderByDesc('last_seen_at') + ->orderByDesc('registered_at') + ->paginate(20) + ->withQueryString(); + + return view('admin.clients.index', [ + 'clients' => $clients, + 'stats' => $this->buildClientStats(), + 'onlineWindowMinutes' => (int) config('store.client_online_window_minutes', 15), + 'filters' => [ + 'status' => $status, + 'keyword' => $keyword, + ], + ]); } public function packages(Request $request): View @@ -112,6 +164,15 @@ class DashboardController extends Controller return redirect()->route('webadmin.packages')->with('success', '状态已更新'); } + public function destroyPackage(string $type, string $slug): RedirectResponse + { + $package = $this->findPackage($type, $slug); + $label = $package->name; + $this->service->deletePackage($package); + + return redirect()->route('webadmin.packages', ['type' => $type])->with('success', '扩展已删除:' . $label); + } + public function categories(Request $request): View { $query = Category::query()->withCount('packages'); @@ -176,6 +237,34 @@ class DashboardController extends Controller ]); } + public function storeScreenshot(Request $request, string $type, string $slug): RedirectResponse + { + $package = $this->findPackage($type, $slug); + + $validated = $request->validate([ + 'image_url' => ['required', 'url', 'max:1024'], + 'caption' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['nullable', 'integer'], + ]); + + $package->screenshots()->create([ + 'image_url' => $validated['image_url'], + 'caption' => trim((string) ($validated['caption'] ?? '')), + 'sort_order' => (int) ($validated['sort_order'] ?? 0), + ]); + + return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '截图已添加'); + } + + public function destroyScreenshot(string $type, string $slug, int $id): RedirectResponse + { + $package = $this->findPackage($type, $slug); + $screenshot = $package->screenshots()->whereKey($id)->firstOrFail(); + $screenshot->delete(); + + return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '截图已删除'); + } + public function storeVersion(Request $request, string $type, string $slug): RedirectResponse { $package = $this->findPackage($type, $slug); @@ -261,10 +350,24 @@ class DashboardController extends Controller { $raw = (string) $request->input('categories_text', ''); - return collect(preg_split('/[,,\s]+/u', $raw)) + $segments = preg_match('/[,,、;\r\n]+/u', $raw) + ? preg_split('/[,,、;\r\n]+/u', $raw) + : preg_split('/\s+/u', $raw); + + return collect($segments) ->map(fn ($item) => trim((string) $item)) ->filter() + ->unique(fn (string $item) => mb_strtolower($item, 'UTF-8')) ->values() ->all(); } + + private function buildClientStats(): array + { + return [ + 'registered_sites' => StoreClient::query()->count(), + 'online_sites' => StoreClient::query()->online()->count(), + 'tracked_users' => (int) StoreClient::query()->sum('user_count'), + ]; + } } diff --git a/app/Http/Controllers/WebAdmin/SettingsController.php b/app/Http/Controllers/WebAdmin/SettingsController.php new file mode 100644 index 0000000..db4bb2d --- /dev/null +++ b/app/Http/Controllers/WebAdmin/SettingsController.php @@ -0,0 +1,60 @@ + $this->settings->all(), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'site_name' => ['required', 'string', 'max:120'], + 'site_tagline' => ['required', 'string', 'max:160'], + 'home_title_suffix' => ['required', 'string', 'max:120'], + 'home_eyebrow' => ['required', 'string', 'max:80'], + 'home_headline' => ['required', 'string', 'max:160'], + 'home_lede' => ['required', 'string', 'max:1000'], + 'home_aside_kicker' => ['required', 'string', 'max:80'], + 'home_aside_title' => ['required', 'string', 'max:160'], + 'home_feature_one_title' => ['required', 'string', 'max:120'], + 'home_feature_one_body' => ['required', 'string', 'max:255'], + 'home_feature_two_title' => ['required', 'string', 'max:120'], + 'home_feature_two_body' => ['required', 'string', 'max:255'], + 'home_feature_three_title' => ['required', 'string', 'max:120'], + 'home_feature_three_body' => ['required', 'string', 'max:255'], + 'home_plugins_title' => ['required', 'string', 'max:120'], + 'home_plugins_subtitle' => ['required', 'string', 'max:255'], + 'home_themes_title' => ['required', 'string', 'max:120'], + 'home_themes_subtitle' => ['required', 'string', 'max:255'], + ]); + + $payload = collect($validated) + ->map(fn ($value) => trim((string) $value)) + ->all(); + + try { + $this->settings->setMany($payload); + } catch (Throwable $e) { + return back()->withInput()->with('error', '站点设置保存失败:' . $e->getMessage()); + } + + return redirect()->route('webadmin.settings')->with('success', '站点设置已更新'); + } +} diff --git a/app/Http/Requests/Admin/StoreCategoryRequest.php b/app/Http/Requests/Admin/StoreCategoryRequest.php index 3c85cba..7430784 100644 --- a/app/Http/Requests/Admin/StoreCategoryRequest.php +++ b/app/Http/Requests/Admin/StoreCategoryRequest.php @@ -17,7 +17,7 @@ class StoreCategoryRequest extends FormRequest return [ 'type' => ['required', Rule::in(['plugin', 'theme'])], 'name' => ['required', 'string', 'max:64'], - 'slug' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9-]+$/'], + 'slug' => ['required', 'string', 'max:64', 'regex:/^[\pL\pN][\pL\pN_-]*$/u'], 'description' => ['nullable', 'string', 'max:255'], 'sort_order' => ['nullable', 'integer'], ]; diff --git a/app/Models/StoreClient.php b/app/Models/StoreClient.php new file mode 100644 index 0000000..2ac7933 --- /dev/null +++ b/app/Models/StoreClient.php @@ -0,0 +1,59 @@ + 'integer', + 'registered_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + + public function scopeOnline(Builder $query): Builder + { + return $query->whereNotNull('last_seen_at') + ->where('last_seen_at', '>=', now()->subMinutes((int) config('store.client_online_window_minutes', 15))); + } + + public function getIsOnlineAttribute(): bool + { + if (!$this->last_seen_at) { + return false; + } + + return $this->last_seen_at->gte( + now()->subMinutes((int) config('store.client_online_window_minutes', 15)) + ); + } + + public function getTokenSuffixAttribute(): string + { + $token = trim((string) $this->access_token); + if ($token === '') { + return ''; + } + + return substr($token, -8); + } +} diff --git a/app/Models/StoreSetting.php b/app/Models/StoreSetting.php new file mode 100644 index 0000000..e3d3753 --- /dev/null +++ b/app/Models/StoreSetting.php @@ -0,0 +1,13 @@ +all(); + + if (!empty($settings['site_name'])) { + config(['app.name' => $settings['site_name']]); + } + + $view->with('storeSettings', $settings); + }); } } diff --git a/app/Services/AdminPackageService.php b/app/Services/AdminPackageService.php index 46eed22..2227e05 100644 --- a/app/Services/AdminPackageService.php +++ b/app/Services/AdminPackageService.php @@ -7,6 +7,8 @@ use App\Models\Package; use App\Models\Version; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; class AdminPackageService { @@ -114,6 +116,19 @@ class AdminPackageService }); } + public function deletePackage(Package $package): void + { + $storageDir = storage_path('app/packages/' . $package->type . '/' . $package->slug); + + DB::transaction(function () use ($package) { + $package->delete(); + }); + + if (is_dir($storageDir)) { + File::deleteDirectory($storageDir); + } + } + public function refreshLatestVersion(Package $package): void { $latestStable = $package->versions() @@ -141,19 +156,88 @@ class AdminPackageService private function syncCategories(Package $package, array $categorySlugs): void { - $categorySlugs = array_values(array_unique(array_filter(array_map('strval', $categorySlugs)))); + $categorySlugs = collect($categorySlugs) + ->map(fn ($item) => trim((string) $item)) + ->filter() + ->unique(fn (string $item) => mb_strtolower($item, 'UTF-8')) + ->values() + ->all(); if (empty($categorySlugs)) { $package->categories()->sync([]); return; } - $categoryIds = Category::query() + $categories = Category::query() ->where('type', $package->type) - ->whereIn('slug', $categorySlugs) - ->pluck('id') - ->all(); + ->get(['id', 'type', 'slug', 'name']); - $package->categories()->sync($categoryIds); + $categoryIds = []; + + foreach ($categorySlugs as $token) { + $normalizedToken = mb_strtolower($token, 'UTF-8'); + + $existing = $categories->first(function (Category $category) use ($token, $normalizedToken) { + return $category->name === $token + || mb_strtolower($category->slug, 'UTF-8') === $normalizedToken + || mb_strtolower($category->name, 'UTF-8') === $normalizedToken; + }); + + if ($existing) { + $categoryIds[] = $existing->id; + continue; + } + + $slug = $this->generateCategorySlug($token, $categories); + $category = Category::query()->create([ + 'type' => $package->type, + 'slug' => $slug, + 'name' => $this->generateCategoryName($token), + 'description' => '', + 'sort_order' => 0, + ]); + + $categories->push($category); + $categoryIds[] = $category->id; + } + + $package->categories()->sync(array_values(array_unique($categoryIds))); + } + + private function generateCategorySlug(string $token, $categories): string + { + $base = (string) Str::of($token) + ->trim() + ->replace('_', '-') + ->replaceMatches('/\s+/u', '-') + ->replaceMatches('/[^\pL\pN-]+/u', '-') + ->replaceMatches('/-+/u', '-') + ->lower() + ->trim('-'); + + if ($base === '') { + $base = 'category'; + } + + $slug = $base; + $suffix = 2; + + while ($categories->contains(fn (Category $category) => mb_strtolower($category->slug, 'UTF-8') === mb_strtolower($slug, 'UTF-8'))) { + $slug = $base . '-' . $suffix; + $suffix++; + } + + return $slug; + } + + private function generateCategoryName(string $token): string + { + if (preg_match('/^[a-z0-9_-]+$/i', $token) === 1) { + return (string) Str::of($token) + ->replace(['-', '_'], ' ') + ->title(); + } + + return trim($token); } } diff --git a/app/Services/StoreSettings.php b/app/Services/StoreSettings.php new file mode 100644 index 0000000..be0a257 --- /dev/null +++ b/app/Services/StoreSettings.php @@ -0,0 +1,85 @@ +resolved !== null) { + return $this->resolved; + } + + $defaults = $this->defaults(); + + if (!$this->tableExists()) { + return $this->resolved = $defaults; + } + + $stored = StoreSetting::query() + ->pluck('value', 'key') + ->map(fn ($value) => (string) $value) + ->all(); + + return $this->resolved = array_replace($defaults, $stored); + } + + public function get(string $key, mixed $default = null): mixed + { + $settings = $this->all(); + + return $settings[$key] ?? $default; + } + + public function defaults(): array + { + return (array) config('store.settings_defaults', []); + } + + public function keys(): array + { + return array_keys($this->defaults()); + } + + public function setMany(array $values): void + { + if (!$this->tableExists()) { + throw new RuntimeException('store_settings table is missing. Please run php artisan migrate.'); + } + + $now = now(); + $rows = collect($values) + ->only($this->keys()) + ->map(fn ($value, $key) => [ + 'key' => $key, + 'value' => (string) $value, + 'created_at' => $now, + 'updated_at' => $now, + ]) + ->values() + ->all(); + + if ($rows === []) { + return; + } + + StoreSetting::query()->upsert($rows, ['key'], ['value', 'updated_at']); + $this->resolved = null; + } + + private function tableExists(): bool + { + try { + return Schema::hasTable('store_settings'); + } catch (Throwable) { + return false; + } + } +} diff --git a/app/Services/VersionPublishService.php b/app/Services/VersionPublishService.php index deb021d..5030ee9 100644 --- a/app/Services/VersionPublishService.php +++ b/app/Services/VersionPublishService.php @@ -155,7 +155,7 @@ class VersionPublishService throw new RuntimeException('manifest slug does not match package slug'); } - if (!preg_match('/^[A-Za-z][A-Za-z0-9]*$/', (string) ($manifest['slug'] ?? ''))) { + if (!$this->isValidSlug((string) ($manifest['slug'] ?? ''))) { throw new RuntimeException('manifest slug format invalid'); } @@ -213,6 +213,11 @@ class VersionPublishService return $base === '' ? $path : $base . $path; } + private function isValidSlug(string $slug): bool + { + return preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/', $slug) === 1; + } + private function assertTopLevelStructure(array $topLevel, array $allowedTopLevel, string $message): void { $topLevelNames = array_keys($topLevel); diff --git a/config/store.php b/config/store.php index e6d6268..364a8f4 100644 --- a/config/store.php +++ b/config/store.php @@ -3,4 +3,25 @@ return [ 'admin_token' => env('STORE_ADMIN_TOKEN', ''), 'plugin_access_token' => env('STORE_PLUGIN_ACCESS_TOKEN', ''), + 'client_online_window_minutes' => (int) env('STORE_CLIENT_ONLINE_WINDOW_MINUTES', 15), + 'settings_defaults' => [ + 'site_name' => env('STORE_SITE_NAME', env('APP_NAME', 'Tstore')), + 'site_tagline' => env('STORE_SITE_TAGLINE', 'Typecho Extension Directory'), + 'home_title_suffix' => '扩展展示站', + 'home_eyebrow' => 'Public Showcase', + 'home_headline' => '更干净的 Typecho 扩展展示站', + 'home_lede' => '前台只负责展示插件与主题的资料、分类、兼容性和截图,真正的发布、上传和站点接入都由服务端统一维护,信息层次更清楚。', + 'home_aside_kicker' => 'How It Works', + 'home_aside_title' => '公开浏览,受控发布', + 'home_feature_one_title' => '前台聚焦展示', + 'home_feature_one_body' => '访客只看到扩展资料、截图、版本和兼容性,不直接暴露 zip 下载地址。', + 'home_feature_two_title' => '服务端统一维护', + 'home_feature_two_body' => '包信息、分类、版本、站点接入和 zip 发布流程都在服务端统一处理。', + 'home_feature_three_title' => '适合做目录站', + 'home_feature_three_body' => '既可以作为展示门户,也方便后续接入更多审核、统计和运营功能。', + 'home_plugins_title' => '推荐插件', + 'home_plugins_subtitle' => '优先展示适合作为目录首页入口的插件,突出摘要、作者、分类和版本信息。', + 'home_themes_title' => '推荐主题', + 'home_themes_subtitle' => '主题列表保持同样的展示逻辑,方便在统一视觉下浏览不同类型的扩展内容。', + ], ]; diff --git a/database/migrations/2026_03_20_150001_create_store_clients_table.php b/database/migrations/2026_03_20_150001_create_store_clients_table.php new file mode 100644 index 0000000..e778f31 --- /dev/null +++ b/database/migrations/2026_03_20_150001_create_store_clients_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('site_url', 512)->unique(); + $table->string('site_name', 255)->default(''); + $table->string('access_token', 120)->unique(); + $table->string('status', 32)->default('online'); + $table->unsignedInteger('user_count')->default(0); + $table->string('typecho_version', 32)->default(''); + $table->string('php_version', 32)->default(''); + $table->string('plugin_version', 32)->default(''); + $table->string('last_ip', 45)->default(''); + $table->string('last_user_agent', 512)->default(''); + $table->dateTime('registered_at')->useCurrent(); + $table->dateTime('last_seen_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_clients'); + } +}; diff --git a/database/migrations/2026_03_20_180001_create_store_settings_table.php b/database/migrations/2026_03_20_180001_create_store_settings_table.php new file mode 100644 index 0000000..cc16023 --- /dev/null +++ b/database/migrations/2026_03_20_180001_create_store_settings_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('key', 120)->unique(); + $table->text('value')->default(''); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/resources/views/admin/account.blade.php b/resources/views/admin/account.blade.php new file mode 100644 index 0000000..acd83a9 --- /dev/null +++ b/resources/views/admin/account.blade.php @@ -0,0 +1,90 @@ +@extends('admin.layout', [ + 'title' => 'Tstore Admin · 账户安全', + 'pageTitle' => '账户安全', + 'pageSubtitle' => '修改当前管理员的用户名、邮箱和登录密码。', + 'pageBadge' => 'Account', +]) + +@section('content') +
这里可以直接修改当前登录管理员的名称和邮箱地址。
+输入当前密码后设置新密码。为了避免误操作,新密码必须至少 8 位,并重复确认一次。
+{{ $listHint }}
+按类型和关键词快速收拢分类范围,避免后台列表过长。
支持就地编辑名称、slug、类型、排序与描述。
-{{ $createHint }}
+先把常用分类建好,下面的小卡片再负责维护已有分类。
改成更紧凑的小卡片,方便快速浏览并就地修改。
+| 站点 | +状态 | +用户数 | +环境 | +注册时间 | +最近同步 | +Token 尾缀 | +
|---|---|---|---|---|---|---|
|
+ {{ $client->site_name ?: '未命名站点' }}
+ {{ $client->site_url }}
+ |
+ {{ $client->is_online ? '在线' : '离线' }} | +{{ $client->user_count }} | +
+ Typecho {{ $client->typecho_version ?: '-' }}
+ PHP {{ $client->php_version ?: '-' }} · 插件 {{ $client->plugin_version ?: '-' }}
+ |
+ {{ optional($client->registered_at)->format('Y-m-d H:i:s') ?: '-' }} | +{{ optional($client->last_seen_at)->format('Y-m-d H:i:s') ?: '尚未同步' }} | +...{{ $client->token_suffix ?: '--------' }} |
+
|
+ 当前还没有站点完成注册。
+ |
+ ||||||
可以直接进入详情页编辑信息、上传 zip,或查看当前最新稳定版本。
+这里优先看扩展元数据、分类和当前发布状态,方便继续进入详情页维护。
{{ $package->type }} · {{ $package->slug }}
{{ $package->summary ?: '暂无摘要' }}
+{{ $package->summary ?: '暂无摘要。' }}
-这一版已经能覆盖联调闭环里最关键的管理动作。
+快速核对最近写入的版本号、兼容区间和稳定状态。
+{{ $version->package?->name ?: '未知扩展' }}
+发布时间:{{ optional($version->published_at)->format('Y-m-d H:i') ?: '-' }}
+确认插件端是否完成注册、是否在线,以及最近一次同步时间。
+{{ $client->site_url }}
+最近同步:{{ optional($client->last_seen_at)->format('Y-m-d H:i:s') ?: '尚未同步' }}
+{{ $pageSubtitle }}
+{{ $filterHint }}
+先缩小范围,再进入详情页处理版本发布或资料修改。
{{ $listHint }}
+保持列表信息密度,重点展示状态、当前版本、分类和最近更新时间。
| 状态 | 最新版本 | 分类 | +更新时间 | 操作 | @@ -91,108 +101,134 @@
{{ $package->name }}
{{ $package->slug }}
- {{ $package->summary ?: '暂无摘要' }}
+ {{ $package->summary ?: '暂无摘要。' }}
|
- {{ $package->type }} | -{{ $package->status }} | +{{ strtoupper($package->type) }} | +{{ $statusLabels[$package->status] ?? $package->status }} | {{ $package->latestStableVersion?->version ?: ($package->latest_version ?: '-') }} | + | {{ optional($package->updated_at)->format('Y-m-d H:i') ?: '-' }} | @empty - |
|---|---|---|---|---|---|---|
{{ $emptyHint }} | ||||||
|
+ 当前还没有{{ $entityLabel }}。
+ |
+ ||||||
{{ $createHint }}
+先创建扩展,再进入详情页继续补充版本、截图和发布信息。
编辑基础元数据、分类、图标与发布状态。
+这里维护扩展展示信息、排序、推荐状态以及分类关联。