重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Middleware;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Core\Jwt;
|
||||
|
||||
/**
|
||||
* 管理员鉴权:JWT 校验 + 数据库 role 检查。
|
||||
*/
|
||||
final class AdminAuth
|
||||
{
|
||||
public static function require(): int
|
||||
{
|
||||
$token = Auth::bearerFromGlobals();
|
||||
if ($token === '') {
|
||||
Json::fail('unauthorized', '需要管理员令牌', 401);
|
||||
}
|
||||
$payload = Jwt::decode($token);
|
||||
if ($payload === null) {
|
||||
Json::fail('unauthorized', '令牌无效或已过期', 401);
|
||||
}
|
||||
if (($payload['typ'] ?? '') !== 'access') {
|
||||
Json::fail('unauthorized', '需要访问令牌', 401);
|
||||
}
|
||||
$uid = (int)($payload['sub'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
Json::fail('unauthorized', '令牌主体错误', 401);
|
||||
}
|
||||
$stmt = Db::pdo()->prepare('SELECT role, status FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $uid]);
|
||||
$u = $stmt->fetch();
|
||||
if (!$u || $u['status'] === 'disabled' || $u['role'] !== 'admin') {
|
||||
Json::fail('forbidden', '需要管理员权限', 403);
|
||||
}
|
||||
return $uid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Middleware;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Core\Jwt;
|
||||
|
||||
/**
|
||||
* 普通用户 JWT 鉴权。
|
||||
*/
|
||||
final class Auth
|
||||
{
|
||||
public static function bearerFromGlobals(): string
|
||||
{
|
||||
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if ($h === '' && function_exists('apache_request_headers')) {
|
||||
$headers = apache_request_headers();
|
||||
$h = $headers['Authorization'] ?? '';
|
||||
}
|
||||
if ($h === '' || stripos($h, 'Bearer ') !== 0) {
|
||||
return '';
|
||||
}
|
||||
return trim(substr($h, 7));
|
||||
}
|
||||
|
||||
public static function require(): array
|
||||
{
|
||||
$token = self::bearerFromGlobals();
|
||||
if ($token === '') {
|
||||
Json::fail('unauthorized', '缺少访问令牌', 401);
|
||||
}
|
||||
$payload = Jwt::decode($token);
|
||||
if ($payload === null) {
|
||||
Json::fail('unauthorized', '令牌无效或已过期', 401);
|
||||
}
|
||||
if (($payload['typ'] ?? '') !== 'access') {
|
||||
Json::fail('unauthorized', '需要访问令牌', 401);
|
||||
}
|
||||
$uid = (int)($payload['sub'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
Json::fail('unauthorized', '令牌主体错误', 401);
|
||||
}
|
||||
$stmt = Db::pdo()->prepare('SELECT id, email, role, admin_level, status FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $uid]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user || $user['status'] === 'disabled') {
|
||||
Json::fail('unauthorized', '账号不存在或已停用', 401);
|
||||
}
|
||||
$out = [
|
||||
'id' => (int)$user['id'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role'],
|
||||
];
|
||||
if ($user['role'] === 'admin') {
|
||||
$level = (string)($user['admin_level'] ?? 'full');
|
||||
$out['admin_level'] = $level === 'ops' ? 'ops' : 'full';
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Middleware;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
/**
|
||||
* 基于文件桶的简单限速。
|
||||
* 规则来自 config.rate_limits:
|
||||
* ['admin/*' => ['capacity' => 60, 'window' => 60], 'POST /api/v1/auth/login' => [...]]
|
||||
*/
|
||||
final class RateLimit
|
||||
{
|
||||
public static function dir(): string
|
||||
{
|
||||
$dir = SOON_SERVER_ROOT . '/storage/cache/rate';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function keyFor(string $method, string $path, ?int $userId): string
|
||||
{
|
||||
if ($userId !== null) {
|
||||
return 'u' . $userId;
|
||||
}
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
return 'ip' . md5($ip);
|
||||
}
|
||||
|
||||
public static function matchRule(string $method, string $path, array $rules): ?array
|
||||
{
|
||||
$needle = $method . ' ' . $path;
|
||||
foreach ($rules as $pattern => $rule) {
|
||||
if ($pattern === 'admin/*' && str_starts_with($path, '/api/admin/')) {
|
||||
return $rule;
|
||||
}
|
||||
if (str_contains($pattern, ' ')) {
|
||||
if ($pattern === $needle) {
|
||||
return $rule;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$regex = '#^' . str_replace('\*', '.*', preg_quote($pattern, '#')) . '$#';
|
||||
if (preg_match($regex, $path)) {
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function check(string $routeKey, string $method, string $path, ?int $userId): void
|
||||
{
|
||||
$rules = Config::get('rate_limits', []);
|
||||
$rule = self::matchRule($method, $path, $rules);
|
||||
if ($rule === null && $routeKey === 'admin/*') {
|
||||
$rule = $rules['admin/*'] ?? null;
|
||||
}
|
||||
if ($rule === null) {
|
||||
return;
|
||||
}
|
||||
$capacity = (int)($rule['capacity'] ?? 60);
|
||||
$window = (int)($rule['window'] ?? 60);
|
||||
$bucket = self::dir() . '/' . md5($routeKey . ':' . self::keyFor($method, $path, $userId)) . '.json';
|
||||
$now = time();
|
||||
$data = ['start' => $now, 'count' => 0];
|
||||
if (is_file($bucket)) {
|
||||
$raw = json_decode((string)file_get_contents($bucket), true);
|
||||
if (is_array($raw) && isset($raw['start'], $raw['count'])) {
|
||||
if ($now - (int)$raw['start'] < $window) {
|
||||
$data = $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['count']++;
|
||||
@file_put_contents($bucket, json_encode($data));
|
||||
if ($data['count'] > $capacity) {
|
||||
Json::fail('rate_limited', '请求过于频繁,请稍后再试', 429);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user