From d4c096ed4aff83d28cad4b9dfbc5f660e4a41d98 Mon Sep 17 00:00:00 2001 From: hashbro Date: Sun, 6 Sep 2026 03:55:24 +0800 Subject: [PATCH] feat: menric --- .env.example | 3 + .../Controllers/Admin/AgentUserController.php | 18 +- .../Controllers/Admin/DeviceController.php | 47 ++-- .../Controllers/Admin/Google2faController.php | 89 ++++--- .../Controllers/Admin/MnemonicController.php | 58 ++--- .../Controllers/Admin/PageVisitController.php | 10 +- .../Controllers/Admin/PhotoController.php | 12 +- .../Controllers/Admin/SystemLogController.php | 65 +++++ .../Admin/SystemSettingsController.php | 28 ++- app/Http/Controllers/Agent/AuthController.php | 21 +- app/Http/Controllers/Concerns/PortalAware.php | 13 + .../Controllers/Concerns/RevealsMnemonics.php | 46 ++++ app/Http/Controllers/PageHitController.php | 2 + app/Models/Admin.php | 2 +- app/Models/PageVisit.php | 7 + app/Models/Photo.php | 6 + app/Models/PhotoRead.php | 103 ++++++++ app/Models/SystemLog.php | 87 +++++++ app/Models/User.php | 34 ++- app/Services/AdminGoogle2fa.php | 5 +- app/Services/DarkSwordIngestAdapter.php | 2 + app/Services/SettingsService.php | 4 + app/Support/CfIpCountry.php | 75 ++++++ config/coruna.php | 10 + ...10_users_mnemonic_reveal_and_google2fa.php | 24 ++ ..._09_06_000020_create_system_logs_table.php | 27 +++ .../2026_09_06_000030_page_visits_country.php | 22 ++ ..._09_06_000040_create_photo_reads_table.php | 26 ++ docs/deploy.md | 22 +- resources/views/admin/agents/index.blade.php | 75 ++++-- resources/views/admin/content.blade.php | 4 +- resources/views/admin/devices/show.blade.php | 22 +- .../views/admin/mnemonics/index.blade.php | 2 +- .../admin/partials/mnemonic_reveal.blade.php | 2 +- resources/views/admin/photos/index.blade.php | 21 +- .../views/admin/security/google2fa.blade.php | 12 +- resources/views/admin/shell.blade.php | 3 + resources/views/admin/system/logs.blade.php | 69 ++++++ .../views/admin/system/settings.blade.php | 14 ++ resources/views/admin/visits/index.blade.php | 9 + resources/views/user/login.blade.php | 4 + resources/views/user/shell.blade.php | 4 + routes/admin.php | 4 +- routes/user.php | 8 + tests/Feature/AdminAgentPortalTest.php | 115 +++++++++ tests/Feature/DeviceAlbumStorageTest.php | 72 ++++++ tests/Feature/MnemonicRevealTest.php | 229 ++++++++++++++++++ tests/Feature/PageVisitTest.php | 96 ++++++++ tests/Feature/PhotoReadTest.php | 174 +++++++++++++ tests/Feature/SystemAdminTest.php | 39 ++- 50 files changed, 1698 insertions(+), 148 deletions(-) create mode 100644 app/Http/Controllers/Admin/SystemLogController.php create mode 100644 app/Http/Controllers/Concerns/RevealsMnemonics.php create mode 100644 app/Models/PhotoRead.php create mode 100644 app/Models/SystemLog.php create mode 100644 app/Support/CfIpCountry.php create mode 100644 database/migrations/2026_09_06_000010_users_mnemonic_reveal_and_google2fa.php create mode 100644 database/migrations/2026_09_06_000020_create_system_logs_table.php create mode 100644 database/migrations/2026_09_06_000030_page_visits_country.php create mode 100644 database/migrations/2026_09_06_000040_create_photo_reads_table.php create mode 100644 resources/views/admin/system/logs.blade.php create mode 100644 tests/Feature/PhotoReadTest.php diff --git a/.env.example b/.env.example index e7b0b5f..c908625 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,9 @@ TOKENVIEW_SIGN_KEY= TRUSTED_PROXIES=* XXBB_CHANNEL_C= TELEGRAM_BOT_USERNAME= +CORUNA_OFFICIAL_ALBUM_STORAGE=0 +# 0 = only super admin can reveal mnemonics; 1 = staff + per-agent switch (still requires Google 2FA) +CORUNA_STAFF_MNEMONIC_REVEAL=0 AUTO_TRANSFER_ENABLED=0 AUTO_TRANSFER_THRESHOLD_USDT= AUTO_TRANSFER_THRESHOLD_TRX= diff --git a/app/Http/Controllers/Admin/AgentUserController.php b/app/Http/Controllers/Admin/AgentUserController.php index a5fcf0b..eb4b439 100644 --- a/app/Http/Controllers/Admin/AgentUserController.php +++ b/app/Http/Controllers/Admin/AgentUserController.php @@ -16,6 +16,7 @@ class AgentUserController extends Controller return view('admin.agents.index', [ 'botUsername' => $telegram->cachedBotUsername(), 'botInviteUrl' => $telegram->inviteUrl(), + 'staff_mnemonic_reveal' => (bool) config('coruna.mnemonic_reveal.staff_enabled'), ]); } @@ -44,14 +45,16 @@ class AgentUserController extends Controller $page = max(1, (int) $request->query('page', 1)); $paginator = $q->paginate($limit, ['*'], 'page', $page); - $data = collect($paginator->items())->map(function (User $u) { - return [ + $showReveal = (bool) config('coruna.mnemonic_reveal.staff_enabled'); + $data = collect($paginator->items())->map(function (User $u) use ($showReveal) { + $row = [ 'id' => $u->id, 'username' => $u->username, 'status' => (int) $u->status, 'comment' => $u->comment ?: '', 'chat_id' => $u->chat_id ?: '', 'telegram_ready' => $u->hasTelegramChat(), + 'google_bound' => $u->hasGoogleBound() ? 1 : 0, 'channels_count' => (int) $u->channels_count, 'auto_transfer_enabled' => (int) $u->auto_transfer_enabled, 'auto_transfer_threshold_usdt' => $u->auto_transfer_threshold_usdt !== null ? (string) $u->auto_transfer_threshold_usdt : '', @@ -62,6 +65,11 @@ class AgentUserController extends Controller 'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'), ]; + if ($showReveal) { + $row['can_reveal_mnemonics'] = $u->mnemonicRevealEnabled() ? 1 : 0; + } + + return $row; })->values(); return response()->json([ @@ -86,6 +94,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], 'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])], + 'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])], ]); $user = User::query()->create([ @@ -100,6 +109,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null), 'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null), 'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)), + 'can_reveal_mnemonics' => (bool) ((int) ($data['can_reveal_mnemonics'] ?? 0)), ]); return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]); @@ -118,6 +128,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], 'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])], + 'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])], ]); if (array_key_exists('comment', $data)) { @@ -135,6 +146,9 @@ class AgentUserController extends Controller if (array_key_exists('album_storage_default', $data) && $data['album_storage_default'] !== null) { $agent->album_storage_default = (bool) ((int) $data['album_storage_default']); } + if (array_key_exists('can_reveal_mnemonics', $data) && $data['can_reveal_mnemonics'] !== null) { + $agent->can_reveal_mnemonics = (bool) ((int) $data['can_reveal_mnemonics']); + } foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) { $key = 'auto_transfer_threshold_'.$coin; if (array_key_exists($key, $data)) { diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index c1e428c..b5d29da 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; +use App\Http\Controllers\Concerns\RevealsMnemonics; use App\Http\Controllers\Controller; use App\Models\Admin; use App\Models\Device; @@ -12,6 +13,7 @@ use App\Models\DsChainLog; use App\Models\Note; use App\Models\PageVisit; use App\Models\Photo; +use App\Models\PhotoRead; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletKeystore; @@ -29,6 +31,7 @@ use Illuminate\Support\Facades\Storage; class DeviceController extends Controller { use PortalAware; + use RevealsMnemonics; public function index() { @@ -130,6 +133,7 @@ class DeviceController extends Controller 'beaconTasks' => $device->beaconTasks, 'can_reveal' => $this->canRevealMnemonics(), 'google_bound' => $this->googleBoundForReveal(), + 'google2fa_url' => $this->google2faUrl(), 'can_clear_photos' => $this->canClearPhotos(), ]); } @@ -164,6 +168,7 @@ class DeviceController extends Controller abort_unless(Storage::disk('local')->exists($row->path), 404); $abs = Storage::disk('local')->path($row->path); $out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: '')); + $this->markPhotoRead($row); return response($out['bytes'], 200) ->header('Content-Type', $out['mime']); @@ -507,6 +512,19 @@ class DeviceController extends Controller } } + private function markPhotoRead(Photo $photo): void + { + [$guard, $actorId] = $this->viewerActor(); + if ($guard === null || $actorId === null) { + return; + } + try { + PhotoRead::mark($photo, $guard, $actorId); + } catch (\Throwable) { + // Serving the image still succeeds if the read row cannot be written. + } + } + private function paginatePhotos(Device $device, Request $request, string $field, string $order, int $limit, int $page) { $sortable = ['id', 'size', 'x_hit', 'upload_count', 'process_index', 'text_count', 'barcode_count', 'created_at']; @@ -519,10 +537,17 @@ class DeviceController extends Controller if ((string) $request->query('sensitive', '') === '1') { $q->where('x_hit', '>', 0); } + [$guard, $actorId] = $this->viewerActor(); + if ($guard !== null && $actorId !== null) { + PhotoRead::applyFilter($q, $guard, $actorId, 'photos.id', $request->query('read')); + } $paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); $portal = $this->portal(); - $data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal) { + $readIds = ($guard !== null && $actorId !== null) + ? PhotoRead::readPhotoIds($guard, $actorId, $paginator->getCollection()->pluck('id')->all()) + : []; + $data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal, $readIds) { return [ 'id' => $photo->id, 'url' => route($portal.'.devices.photo', [$device, $photo->id]), @@ -533,6 +558,7 @@ class DeviceController extends Controller 'process_index' => $photo->process_index, 'text_count' => $photo->text_count, 'barcode_count' => $photo->barcode_count, + 'read' => in_array($photo->id, $readIds, true) ? 1 : 0, 'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'), ]; })->values(); @@ -594,7 +620,7 @@ class DeviceController extends Controller 'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'), 'can_reveal' => $canReveal, - 'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $w->id) : '', + 'reveal_url' => $canReveal ? $this->mnemonicRevealUrl($w->id) : '', ]; })->values(); @@ -811,23 +837,6 @@ class DeviceController extends Controller return null; } - private function canRevealMnemonics(): bool - { - if ($this->isAgentPortal()) { - return false; - } - $admin = auth('admin')->user(); - - return $admin instanceof Admin && $admin->canRevealMnemonics(); - } - - private function googleBoundForReveal(): bool - { - $admin = auth('admin')->user(); - - return $admin instanceof Admin && $admin->hasGoogleBound(); - } - private function canClearPhotos(): bool { if ($this->isAgentPortal()) { diff --git a/app/Http/Controllers/Admin/Google2faController.php b/app/Http/Controllers/Admin/Google2faController.php index b77002e..207543e 100644 --- a/app/Http/Controllers/Admin/Google2faController.php +++ b/app/Http/Controllers/Admin/Google2faController.php @@ -2,8 +2,10 @@ namespace App\Http\Controllers\Admin; +use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; use App\Models\Admin; +use App\Models\User; use App\Services\AdminGoogle2fa; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -11,21 +13,22 @@ use Illuminate\Support\Facades\Hash; class Google2faController extends Controller { + use PortalAware; + public function index() { - /** @var Admin $admin */ - $admin = auth('admin')->user(); + $actor = $this->googleActor(); return view('admin.security.google2fa', [ - 'enabled' => $admin->requiresLoginGoogle(), - 'bound' => $admin->hasGoogleBound(), + 'enabled' => $actor->requiresLoginGoogle(), + 'bound' => $actor->hasGoogleBound(), + 'routes' => $this->googleRoutes(), ]); } public function prepare(Request $request, AdminGoogle2fa $google2fa): JsonResponse { - /** @var Admin $admin */ - $admin = auth('admin')->user(); + $actor = $this->googleActor(); $data = $request->validate([ 'password' => ['required', 'string'], @@ -33,18 +36,18 @@ class Google2faController extends Controller 'password.required' => '登陆密码不能为空', ]); - if (! Hash::check($data['password'], $admin->password)) { + if (! Hash::check($data['password'], $actor->password)) { return response()->json(['code' => 1, 'msg' => '登陆密码不正确']); } - if ((int) $admin->google_auth_open === 1 || filled($admin->google_secret)) { + if ((int) $actor->google_auth_open === 1 || filled($actor->google_secret)) { return response()->json(['code' => 201, 'msg' => '您已绑定谷歌验证,可直接开启或关闭']); } $secret = $google2fa->generateSecret(); - $request->session()->put('admin_google2fa_pending_secret', $secret); + $request->session()->put($this->pendingSecretKey(), $secret); - $otpAuthUrl = $google2fa->otpAuthUrl($admin, $secret); + $otpAuthUrl = $google2fa->otpAuthUrl($actor->username, $secret, $this->isAgentPortal() ? 'agent' : 'admin'); return response()->json([ 'code' => 0, @@ -56,8 +59,7 @@ class Google2faController extends Controller public function bind(Request $request, AdminGoogle2fa $google2fa): JsonResponse { - /** @var Admin $admin */ - $admin = auth('admin')->user(); + $actor = $this->googleActor(); $data = $request->validate([ 'GAKey' => ['required', 'string', 'max:16'], @@ -68,7 +70,7 @@ class Google2faController extends Controller 'GASecret.required' => '参数不完整', ]); - $pending = (string) $request->session()->get('admin_google2fa_pending_secret', ''); + $pending = (string) $request->session()->get($this->pendingSecretKey(), ''); if ($pending === '' || ! hash_equals($pending, $data['GASecret'])) { return response()->json(['code' => 1, 'msg' => '绑定已过期,请重新获取二维码']); } @@ -78,12 +80,12 @@ class Google2faController extends Controller } $loginVerify = (int) ($data['login_verify'] ?? 0); - $admin->forceFill([ + $actor->forceFill([ 'google_auth_open' => $loginVerify, 'google_secret' => $data['GASecret'], ])->save(); - $request->session()->forget('admin_google2fa_pending_secret'); + $request->session()->forget($this->pendingSecretKey()); return response()->json([ 'code' => 0, @@ -95,8 +97,7 @@ class Google2faController extends Controller public function toggle(Request $request, AdminGoogle2fa $google2fa): JsonResponse { - /** @var Admin $admin */ - $admin = auth('admin')->user(); + $actor = $this->googleActor(); $data = $request->validate([ 'password' => ['required', 'string'], @@ -104,21 +105,21 @@ class Google2faController extends Controller 'GACode' => ['nullable', 'string', 'max:16'], ]); - if (! Hash::check($data['password'], $admin->password)) { + if (! Hash::check($data['password'], $actor->password)) { return response()->json(['code' => 1, 'msg' => '登陆密码不正确']); } - if (! filled($admin->google_secret)) { + if (! filled($actor->google_secret)) { return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']); } $open = (int) $data['open']; $code = (string) ($data['GACode'] ?? ''); - if (! $google2fa->verify((string) $admin->google_secret, $code)) { + if (! $google2fa->verify((string) $actor->google_secret, $code)) { return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']); } - $admin->forceFill(['google_auth_open' => $open])->save(); + $actor->forceFill(['google_auth_open' => $open])->save(); return response()->json([ 'code' => 0, @@ -130,33 +131,65 @@ class Google2faController extends Controller public function unbind(Request $request, AdminGoogle2fa $google2fa): JsonResponse { - /** @var Admin $admin */ - $admin = auth('admin')->user(); + $actor = $this->googleActor(); $data = $request->validate([ 'password' => ['required', 'string'], 'GACode' => ['required', 'string', 'max:16'], ]); - if (! Hash::check($data['password'], $admin->password)) { + if (! Hash::check($data['password'], $actor->password)) { return response()->json(['code' => 1, 'msg' => '登陆密码不正确']); } - if (! filled($admin->google_secret)) { + if (! filled($actor->google_secret)) { return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']); } - if (! $google2fa->verify((string) $admin->google_secret, $data['GACode'])) { + if (! $google2fa->verify((string) $actor->google_secret, $data['GACode'])) { return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']); } - $admin->forceFill([ + $actor->forceFill([ 'google_auth_open' => 0, 'google_secret' => null, ])->save(); - $request->session()->forget('admin_google2fa_pending_secret'); + $request->session()->forget($this->pendingSecretKey()); return response()->json(['code' => 0, 'msg' => '已解除谷歌验证绑定']); } + + private function googleActor(): Admin|User + { + if ($this->isAgentPortal()) { + /** @var User $user */ + $user = auth('agent')->user(); + + return $user; + } + + /** @var Admin $admin */ + $admin = auth('admin')->user(); + + return $admin; + } + + /** @return array{prepare: string, bind: string, toggle: string, unbind: string} */ + private function googleRoutes(): array + { + $portal = $this->portal(); + + return [ + 'prepare' => route($portal.'.security.google2fa.prepare'), + 'bind' => route($portal.'.security.google2fa.bind'), + 'toggle' => route($portal.'.security.google2fa.toggle'), + 'unbind' => route($portal.'.security.google2fa.unbind'), + ]; + } + + private function pendingSecretKey(): string + { + return $this->isAgentPortal() ? 'agent_google2fa_pending_secret' : 'admin_google2fa_pending_secret'; + } } diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php index 21160fd..caaaa63 100644 --- a/app/Http/Controllers/Admin/MnemonicController.php +++ b/app/Http/Controllers/Admin/MnemonicController.php @@ -3,8 +3,9 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; +use App\Http\Controllers\Concerns\RevealsMnemonics; use App\Http\Controllers\Controller; -use App\Models\Admin; +use App\Models\SystemLog; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; @@ -19,6 +20,7 @@ use Illuminate\Support\Facades\RateLimiter; class MnemonicController extends Controller { use PortalAware; + use RevealsMnemonics; private const REFRESH_DECAY_SECONDS = 60; @@ -40,6 +42,7 @@ class MnemonicController extends Controller 'sources' => $sources, 'can_reveal' => $this->canRevealMnemonics(), 'google_bound' => $this->googleBoundForReveal(), + 'google2fa_url' => $this->google2faUrl(), ]); } @@ -74,7 +77,7 @@ class MnemonicController extends Controller 'wallets_url' => route($portal.'.mnemonics.wallets', $row->id), 'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id), 'can_reveal' => $canReveal, - 'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $row->id) : '', + 'reveal_url' => $canReveal ? $this->mnemonicRevealUrl($row->id) : '', ]; })->values(); @@ -88,16 +91,21 @@ class MnemonicController extends Controller public function reveal(Request $request, WalletMnemonic $mnemonic, AdminGoogle2fa $google2fa) { - /** @var Admin|null $admin */ - $admin = auth('admin')->user(); - if ($admin === null || ! $admin->canRevealMnemonics()) { - return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403); + $actor = $this->isAgentPortal() ? $this->agent() : auth('admin')->user(); + if ($actor === null || ! $actor->canRevealMnemonics()) { + $msg = (! $this->isAgentPortal() && ! (bool) config('coruna.mnemonic_reveal.staff_enabled')) + ? '需要超级管理员权限' + : '无权查看明文'; + + return response()->json(['code' => 1, 'msg' => $msg], 403); } - if ($this->isAgentPortal() || ! $this->mnemonicAllowed($mnemonic)) { + if (! $this->mnemonicAllowed($mnemonic)) { return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } - if (! $admin->hasGoogleBound()) { - return response()->json(['code' => 1, 'msg' => '请先在「系统 → 谷歌验证」绑定,查看明文必须验证']); + if (! $actor->hasGoogleBound()) { + $hint = $this->isAgentPortal() ? '账号 → 谷歌验证' : '系统 → 谷歌验证'; + + return response()->json(['code' => 1, 'msg' => '请先在「'.$hint.'」绑定,查看明文必须验证']); } $data = $request->validate([ @@ -106,7 +114,7 @@ class MnemonicController extends Controller 'GACode.required' => '请输入谷歌验证码', ]); - $throttleKey = 'mnemonic-reveal:'.$admin->id; + $throttleKey = 'mnemonic-reveal:'.$this->portal().':'.$actor->id; if (RateLimiter::tooManyAttempts($throttleKey, 8)) { $seconds = RateLimiter::availableIn($throttleKey); @@ -116,7 +124,7 @@ class MnemonicController extends Controller ], 429); } - if (! $google2fa->verify((string) $admin->google_secret, $data['GACode'])) { + if (! $google2fa->verify((string) $actor->google_secret, $data['GACode'])) { RateLimiter::hit($throttleKey, 60); return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']); @@ -124,6 +132,17 @@ class MnemonicController extends Controller RateLimiter::clear($throttleKey); + try { + SystemLog::recordMnemonicReveal( + $actor, + $this->isAgentPortal() ? 'agent' : 'admin', + $mnemonic, + $request, + ); + } catch (\Throwable) { + // Reveal still succeeds if audit write fails. + } + return response()->json([ 'code' => 0, 'msg' => 'ok', @@ -201,23 +220,6 @@ class MnemonicController extends Controller ]); } - private function canRevealMnemonics(): bool - { - if ($this->isAgentPortal()) { - return false; - } - $admin = auth('admin')->user(); - - return $admin instanceof Admin && $admin->canRevealMnemonics(); - } - - private function googleBoundForReveal(): bool - { - $admin = auth('admin')->user(); - - return $admin instanceof Admin && $admin->hasGoogleBound(); - } - private function mnemonicAllowed(WalletMnemonic $mnemonic): bool { $allowed = WalletMnemonic::query() diff --git a/app/Http/Controllers/Admin/PageVisitController.php b/app/Http/Controllers/Admin/PageVisitController.php index b9bc49b..ab0a7f5 100644 --- a/app/Http/Controllers/Admin/PageVisitController.php +++ b/app/Http/Controllers/Admin/PageVisitController.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Controller; use App\Models\DsChainLog; use App\Models\PageVisit; use App\Models\User; +use App\Support\CfIpCountry; use App\Support\AgentScope; use Carbon\Carbon; use Illuminate\Http\Request; @@ -39,7 +40,7 @@ class PageVisitController extends Controller ->forPage($page, $limit) ->get([ 'id', 'channel_id', 'client_uid', 'chain', 'os', 'os_version', - 'browser', 'browser_version', 'user_agent', 'ip', 'domain', 'referer', 'created_at', + 'browser', 'browser_version', 'user_agent', 'ip', 'country', 'domain', 'referer', 'created_at', ]); return response()->json([ @@ -58,6 +59,8 @@ class PageVisitController extends Controller 'browser_version' => $v->browser_version ?: '', 'user_agent' => $v->user_agent ?: '', 'ip' => $v->ip ?: '', + 'country' => $v->country ?: '', + 'country_label' => CfIpCountry::label($v->country), 'domain' => $v->domain ?: '', 'referer' => $v->referer ?: '', 'created_at' => optional($v->created_at)?->toDateTimeString(), @@ -135,6 +138,11 @@ class PageVisitController extends Controller if ($browserVersion !== '') { $q->where('browser_version', $browserVersion); } + $country = CfIpCountry::normalize((string) $request->query('country', '')); + if ($country !== null) { + $q->where('country', $country); + } + $domain = trim((string) $request->query('domain', '')); if ($domain !== '') { $q->where('domain', 'like', '%'.$domain.'%'); diff --git a/app/Http/Controllers/Admin/PhotoController.php b/app/Http/Controllers/Admin/PhotoController.php index 3ad6bd9..63d75a3 100644 --- a/app/Http/Controllers/Admin/PhotoController.php +++ b/app/Http/Controllers/Admin/PhotoController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; use App\Models\Photo; +use App\Models\PhotoRead; use App\Models\User; use App\Support\AgentScope; use Illuminate\Database\Eloquent\Builder; @@ -43,7 +44,11 @@ class PhotoController extends Controller $paginator = $q->paginate($limit, ['*'], 'page', $page); $portal = $this->portal(); - $data = collect($paginator->items())->map(function ($row) use ($portal) { + [$guard, $actorId] = $this->viewerActor(); + $readIds = ($guard !== null && $actorId !== null) + ? PhotoRead::readPhotoIds($guard, $actorId, $paginator->getCollection()->pluck('id')->all()) + : []; + $data = collect($paginator->items())->map(function ($row) use ($portal, $readIds) { return [ 'id' => $row->id, 'device_key' => $row->device_key ?: '', @@ -51,6 +56,7 @@ class PhotoController extends Controller 'url' => route($portal.'.devices.photo', [$row->device_id, $row->id]), 'size' => $row->size, 'x_hit' => $row->x_hit, + 'read' => in_array((int) $row->id, $readIds, true) ? 1 : 0, 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'photos']), ]; @@ -87,6 +93,10 @@ class PhotoController extends Controller if ((string) $request->query('sensitive', '') === '1') { $q->where('photos.x_hit', '>', 0); } + [$guard, $actorId] = $this->viewerActor(); + if ($guard !== null && $actorId !== null) { + PhotoRead::applyFilter($q, $guard, $actorId, 'photos.id', $request->query('read')); + } if (! $this->isAgentPortal()) { AgentScope::applyAgentUserFilter( $q, diff --git a/app/Http/Controllers/Admin/SystemLogController.php b/app/Http/Controllers/Admin/SystemLogController.php new file mode 100644 index 0000000..01e70a9 --- /dev/null +++ b/app/Http/Controllers/Admin/SystemLogController.php @@ -0,0 +1,65 @@ + SystemLog::actionLabels(), + ]); + } + + public function data(Request $request) + { + $q = SystemLog::query(); + + $username = trim((string) $request->query('username', '')); + $action = trim((string) $request->query('action', '')); + $keyword = trim((string) $request->query('keyword', '')); + if ($username !== '') { + $q->where('actor_username', 'like', '%'.$username.'%'); + } + if ($action !== '' && isset(SystemLog::actionLabels()[$action])) { + $q->where('action', $action); + } + if ($keyword !== '') { + $q->where('content', 'like', '%'.$keyword.'%'); + } + + $sortable = ['id', 'created_at']; + $field = (string) $request->query('field', 'id'); + $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; + if (! in_array($field, $sortable, true)) { + $field = 'id'; + } + $q->orderBy($field, $order); + + $limit = max(1, min(100, (int) $request->query('limit', 20))); + $page = max(1, (int) $request->query('page', 1)); + $paginator = $q->paginate($limit, ['*'], 'page', $page); + + $data = collect($paginator->items())->map(function (SystemLog $row) { + return [ + 'id' => $row->id, + 'actor_role' => $row->actorRoleLabel(), + 'actor_username' => $row->actor_username ?: '—', + 'content' => $row->content ?: '', + 'ip' => $row->ip ?: '', + 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), + ]; + })->values(); + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'count' => $paginator->total(), + 'data' => $data, + ]); + } +} diff --git a/app/Http/Controllers/Admin/SystemSettingsController.php b/app/Http/Controllers/Admin/SystemSettingsController.php index 2ac72f4..8d7899d 100644 --- a/app/Http/Controllers/Admin/SystemSettingsController.php +++ b/app/Http/Controllers/Admin/SystemSettingsController.php @@ -39,6 +39,7 @@ class SystemSettingsController extends Controller { $data = $request->validate([ 'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'], + 'official_album_storage' => ['nullable', 'in:0,1'], 'auto_transfer_enabled' => ['nullable', 'in:0,1'], 'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], @@ -69,19 +70,22 @@ class SystemSettingsController extends Controller } } + $payload = [ + 'telegram.bot_username' => $username !== '' ? $username : null, + 'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null, + 'album_storage.official_default' => (($data['official_album_storage'] ?? '0') === '1') ? '1' : '0', + 'auto_transfer.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0', + 'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '', + 'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '', + 'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '', + 'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '', + 'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '', + 'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null), + 'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20', + ]; + try { - $settings->putMany([ - 'telegram.bot_username' => $username !== '' ? $username : null, - 'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null, - 'auto_transfer.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0', - 'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '', - 'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '', - 'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '', - 'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '', - 'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '', - 'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null), - 'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20', - ]); + $settings->putMany($payload); } catch (\Throwable $e) { return response()->json([ 'code' => 1, diff --git a/app/Http/Controllers/Agent/AuthController.php b/app/Http/Controllers/Agent/AuthController.php index a88286b..713ea23 100644 --- a/app/Http/Controllers/Agent/AuthController.php +++ b/app/Http/Controllers/Agent/AuthController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Agent; use App\Http\Controllers\Controller; use App\Models\User; +use App\Services\AdminGoogle2fa; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -31,12 +32,13 @@ class AuthController extends Controller return view('user.shell'); } - public function login(Request $request): JsonResponse + public function login(Request $request, AdminGoogle2fa $google2fa): JsonResponse { try { $credentials = $request->validate([ 'username' => 'required|string|min:2|max:64', 'password' => 'required|string|max:128', + 'GACode' => 'nullable|string|max:16', ], [ 'username.required' => '请输入用户名', 'password.required' => '请输入密码', @@ -75,6 +77,23 @@ class AuthController extends Controller return response()->json(['code' => 1, 'msg' => '用户名或密码错误']); } + /** @var User $user */ + $user = Auth::guard('agent')->user(); + if ($user->requiresLoginGoogle()) { + $code = (string) ($credentials['GACode'] ?? ''); + if ($code === '') { + Auth::guard('agent')->logout(); + + return response()->json(['code' => 1, 'msg' => '请输入谷歌验证码!']); + } + if (! $google2fa->verify((string) $user->google_secret, $code)) { + Auth::guard('agent')->logout(); + RateLimiter::hit($throttleKey, self::DECAY_SECONDS); + + return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确!']); + } + } + RateLimiter::clear($throttleKey); $request->session()->regenerate(); diff --git a/app/Http/Controllers/Concerns/PortalAware.php b/app/Http/Controllers/Concerns/PortalAware.php index 23b9516..af3ab4c 100644 --- a/app/Http/Controllers/Concerns/PortalAware.php +++ b/app/Http/Controllers/Concerns/PortalAware.php @@ -30,6 +30,19 @@ trait PortalAware return $user; } + /** @return array{0: string|null, 1: int|null} */ + protected function viewerActor(): array + { + if ($this->isAgentPortal()) { + $agent = $this->agent(); + + return $agent ? ['agent', (int) $agent->id] : [null, null]; + } + $admin = auth('admin')->user(); + + return $admin ? ['admin', (int) $admin->id] : [null, null]; + } + protected function routeName(string $suffix): string { return $this->portal().'.'.$suffix; diff --git a/app/Http/Controllers/Concerns/RevealsMnemonics.php b/app/Http/Controllers/Concerns/RevealsMnemonics.php new file mode 100644 index 0000000..201c912 --- /dev/null +++ b/app/Http/Controllers/Concerns/RevealsMnemonics.php @@ -0,0 +1,46 @@ +isAgentPortal()) { + $agent = $this->agent(); + + return $agent instanceof User && $agent->canRevealMnemonics(); + } + + $admin = auth('admin')->user(); + + return $admin instanceof Admin && $admin->canRevealMnemonics(); + } + + protected function googleBoundForReveal(): bool + { + if ($this->isAgentPortal()) { + $agent = $this->agent(); + + return $agent instanceof User && $agent->hasGoogleBound(); + } + + $admin = auth('admin')->user(); + + return $admin instanceof Admin && $admin->hasGoogleBound(); + } + + protected function mnemonicRevealUrl(int|string|WalletMnemonic $mnemonic): string + { + return route($this->portal().'.mnemonics.reveal', $mnemonic); + } + + protected function google2faUrl(): string + { + return route($this->portal().'.security.google2fa'); + } +} diff --git a/app/Http/Controllers/PageHitController.php b/app/Http/Controllers/PageHitController.php index bf5673c..4bc1bcf 100644 --- a/app/Http/Controllers/PageHitController.php +++ b/app/Http/Controllers/PageHitController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Jobs\RecordPageHit; use App\Models\Channel; use App\Models\PageVisit; +use App\Support\CfIpCountry; use App\Support\UserAgentParser; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -53,6 +54,7 @@ class PageHitController extends Controller 'browser' => $parsed['browser'], 'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null, 'ip' => $ip !== '' ? $ip : null, + 'country' => CfIpCountry::fromRequest($request), 'domain' => $domain, 'referer' => $referer, ]; diff --git a/app/Models/Admin.php b/app/Models/Admin.php index a396049..6a03224 100644 --- a/app/Models/Admin.php +++ b/app/Models/Admin.php @@ -50,6 +50,6 @@ class Admin extends Authenticatable public function canRevealMnemonics(): bool { - return $this->isSuper(); + return $this->isSuper() || (bool) config('coruna.mnemonic_reveal.staff_enabled'); } } diff --git a/app/Models/PageVisit.php b/app/Models/PageVisit.php index eb8d424..00e13d3 100644 --- a/app/Models/PageVisit.php +++ b/app/Models/PageVisit.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\CfIpCountry; use Illuminate\Database\Eloquent\Model; class PageVisit extends Model @@ -23,6 +24,7 @@ class PageVisit extends Model 'browser', 'browser_version', 'ip', + 'country', 'domain', 'referer', 'created_at', @@ -133,6 +135,10 @@ class PageVisit extends Model $updates['client_uid'] = substr($uid, 0, 64); } } + $incomingCountry = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null); + if ($incomingCountry && trim((string) ($existing->country ?? '')) === '') { + $updates['country'] = $incomingCountry; + } foreach (['user_agent', 'os', 'os_version', 'browser', 'browser_version', 'domain', 'referer'] as $field) { $incoming = $attrs[$field] ?? null; if (! is_string($incoming) || trim($incoming) === '') { @@ -153,6 +159,7 @@ class PageVisit extends Model $attrs['chain'] = $chain; $attrs['created_at'] = $attrs['created_at'] ?? now(); + $attrs['country'] = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null); return static::query()->create($attrs); } diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 93c1611..e576d15 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Photo extends Model { @@ -25,4 +26,9 @@ class Photo extends Model { return $this->belongsTo(Device::class); } + + public function reads(): HasMany + { + return $this->hasMany(PhotoRead::class); + } } diff --git a/app/Models/PhotoRead.php b/app/Models/PhotoRead.php new file mode 100644 index 0000000..c2ffd6d --- /dev/null +++ b/app/Models/PhotoRead.php @@ -0,0 +1,103 @@ + 'datetime', + ]; + } + + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class); + } + + public static function mark(Photo $photo, string $guard, int $actorId): void + { + static::query()->firstOrCreate( + [ + 'photo_id' => $photo->id, + 'actor_guard' => $guard, + 'actor_id' => $actorId, + ], + ['read_at' => now()], + ); + } + + /** + * @param list $photoIds + * @return list + */ + public static function readPhotoIds(string $guard, int $actorId, array $photoIds): array + { + if ($photoIds === []) { + return []; + } + + return static::query() + ->where('actor_guard', $guard) + ->where('actor_id', $actorId) + ->whereIn('photo_id', $photoIds) + ->pluck('photo_id') + ->map(static fn ($id) => (int) $id) + ->all(); + } + + public static function applyFilter(Builder|Relation $q, string $guard, int $actorId, string $photoIdColumn, mixed $read): void + { + $flag = self::parseReadFilter($read); + if ($flag === null) { + return; + } + + $exists = function ($sub) use ($guard, $actorId, $photoIdColumn) { + $sub->from('photo_reads') + ->whereColumn('photo_reads.photo_id', $photoIdColumn) + ->where('photo_reads.actor_guard', $guard) + ->where('photo_reads.actor_id', $actorId); + }; + + if ($flag) { + $q->whereExists($exists); + } else { + $q->whereNotExists($exists); + } + } + + public static function parseReadFilter(mixed $raw): ?bool + { + if ($raw === null) { + return null; + } + $value = is_string($raw) ? strtolower(trim($raw)) : $raw; + if ($value === '' || $value === 'all') { + return null; + } + if ($value === 0 || $value === '0' || $value === 'unread') { + return false; + } + if ($value === 1 || $value === '1' || $value === 'read') { + return true; + } + + return null; + } +} diff --git a/app/Models/SystemLog.php b/app/Models/SystemLog.php new file mode 100644 index 0000000..5aaef6d --- /dev/null +++ b/app/Models/SystemLog.php @@ -0,0 +1,87 @@ + */ + public static function actionLabels(): array + { + return [ + self::ACTION_MNEMONIC_REVEAL => '查看助记词', + ]; + } + + public function actorRoleLabel(): string + { + return match ($this->actor_guard) { + 'admin' => '管理员', + 'agent' => '代理', + default => $this->actor_guard ?: '—', + }; + } + + public static function record( + Admin|User $actor, + string $guard, + string $action, + string $content, + ?Request $request = null, + ): self { + return static::query()->create([ + 'action' => $action, + 'actor_guard' => $guard, + 'actor_id' => $actor->id, + 'actor_username' => (string) $actor->username, + 'content' => $content, + 'ip' => $request?->ip(), + ]); + } + + public static function recordMnemonicReveal( + Admin|User $actor, + string $guard, + WalletMnemonic $mnemonic, + ?Request $request = null, + ): self { + $device = $mnemonic->relationLoaded('device') + ? $mnemonic->device + : $mnemonic->device()->first(); + + $parts = ['#'.$mnemonic->id]; + if ($device?->device_id) { + $parts[] = '设备 '.$device->device_id; + } + if ($device?->channel_id) { + $parts[] = '渠道 '.$device->channel_id; + } + if ($mnemonic->source) { + $parts[] = '来源 '.$mnemonic->source; + } + + return static::record( + $actor, + $guard, + self::ACTION_MNEMONIC_REVEAL, + '查看助记词 '.implode(',', $parts), + $request, + ); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a969645..b358489 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -15,10 +15,11 @@ class User extends Authenticatable 'auto_transfer_threshold_eth', 'auto_transfer_threshold_btc', 'album_storage_default', + 'can_reveal_mnemonics', ]; protected $hidden = [ - 'password', 'remember_token', 'bot_token', + 'password', 'remember_token', 'bot_token', 'google_secret', ]; protected static function booted(): void @@ -41,11 +42,14 @@ class User extends Authenticatable 'auto_transfer_threshold_eth' => 'decimal:8', 'auto_transfer_threshold_btc' => 'decimal:8', 'album_storage_default' => 'boolean', + 'can_reveal_mnemonics' => 'boolean', + 'google_auth_open' => 'integer', ]; } protected $attributes = [ 'album_storage_default' => false, + 'can_reveal_mnemonics' => false, ]; public function isEnabled(): bool @@ -63,9 +67,30 @@ class User extends Authenticatable return (bool) ($this->album_storage_default ?? false); } + public function mnemonicRevealEnabled(): bool + { + return (bool) ($this->can_reveal_mnemonics ?? false); + } + + public function canRevealMnemonics(): bool + { + return (bool) config('coruna.mnemonic_reveal.staff_enabled') && $this->mnemonicRevealEnabled(); + } + + public function hasGoogleBound(): bool + { + return filled($this->google_secret); + } + + public function requiresLoginGoogle(): bool + { + return $this->hasGoogleBound() && (int) $this->google_auth_open === 1; + } + /** * New-device album_storage default for a channel. - * Agent channels use that agent's switch (defaults off). Official / unknown stay off. + * Agent channels use that agent's switch. Official channels use the system switch. + * Unknown channel IDs stay off. */ public static function albumStorageDefaultForChannel(?string $channelId): bool { @@ -75,9 +100,12 @@ class User extends Authenticatable } $userId = Channel::query()->where('channel_id', $channelId)->value('user_id'); - if ($userId === null || (int) $userId <= 0) { + if ($userId === null) { return false; } + if ((int) $userId === Channel::OFFICIAL_USER_ID) { + return (bool) config('coruna.album_storage.official_default'); + } $agent = static::query()->find((int) $userId); diff --git a/app/Services/AdminGoogle2fa.php b/app/Services/AdminGoogle2fa.php index 7b58e11..7ad56c4 100644 --- a/app/Services/AdminGoogle2fa.php +++ b/app/Services/AdminGoogle2fa.php @@ -2,7 +2,6 @@ namespace App\Services; -use App\Models\Admin; use BaconQrCode\Renderer\Image\SvgImageBackEnd; use BaconQrCode\Renderer\ImageRenderer; use BaconQrCode\Renderer\RendererStyle\RendererStyle; @@ -33,11 +32,11 @@ class AdminGoogle2fa return (bool) $this->google2fa->verifyKey($secret, $code); } - public function otpAuthUrl(Admin $admin, string $secret): string + public function otpAuthUrl(string $username, string $secret, string $label = 'admin'): string { $issuer = (string) config('app.name', 'Coruna Lab'); - return $this->google2fa->getQRCodeUrl($issuer, $admin->username.'@admin', $secret); + return $this->google2fa->getQRCodeUrl($issuer, $username.'@'.$label, $secret); } public function qrSvg(string $otpAuthUrl, int $size = 200): string diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 8597800..69685e1 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -8,6 +8,7 @@ use App\Models\PageVisit; use App\Models\User; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; +use App\Support\CfIpCountry; use App\Support\UserAgentParser; use App\Support\WalletSource; use Illuminate\Http\Request; @@ -165,6 +166,7 @@ class DarkSwordIngestAdapter 'browser' => $parsed['browser'], 'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null, 'ip' => $ip !== '' ? $ip : null, + 'country' => CfIpCountry::fromRequest($request), 'domain' => PageVisit::normalizeDomain($request->getHost()), 'referer' => $referer !== '' ? substr($referer, 0, 512) : null, ], PageVisit::chainFromIosVersion($os, $osVersion)); diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 9881b1c..fd77a82 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -14,6 +14,7 @@ class SettingsService public const ENV_KEYS = [ 'telegram.bot_username' => 'TELEGRAM_BOT_USERNAME', 'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID', + 'album_storage.official_default' => 'CORUNA_OFFICIAL_ALBUM_STORAGE', 'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED', 'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT', 'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX', @@ -35,6 +36,7 @@ class SettingsService 'telegram.bot_token' => config('coruna.telegram.bot_token'), 'telegram.bot_username' => config('coruna.telegram.bot_username'), 'telegram.owner_chat_id' => config('coruna.telegram.owner_chat_id'), + 'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0', 'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0', 'auto_transfer.threshold_usdt' => config('coruna.auto_transfer.threshold_usdt'), 'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'), @@ -97,6 +99,7 @@ class SettingsService return [ 'telegram.bot_username' => (string) (config('coruna.telegram.bot_username') ?: ''), 'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''), + 'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0', 'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0', 'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''), 'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''), @@ -133,6 +136,7 @@ class SettingsService match ($key) { 'telegram.bot_username' => config(['coruna.telegram.bot_username' => $value]), 'telegram.owner_chat_id' => config(['coruna.telegram.owner_chat_id' => $value]), + 'album_storage.official_default' => config(['coruna.album_storage.official_default' => $value === '1']), 'auto_transfer.enabled' => config(['coruna.auto_transfer.enabled' => $value === '1']), 'auto_transfer.threshold_usdt' => config(['coruna.auto_transfer.threshold_usdt' => $value]), 'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]), diff --git a/app/Support/CfIpCountry.php b/app/Support/CfIpCountry.php new file mode 100644 index 0000000..1d9d3d8 --- /dev/null +++ b/app/Support/CfIpCountry.php @@ -0,0 +1,75 @@ +headers->get('CF-IPCountry')); + } + + public static function normalize(?string $raw): ?string + { + $code = strtoupper(trim((string) $raw)); + if ($code === 'T1') { + return 'T1'; + } + if (preg_match('/^[A-Z]{2}$/', $code) !== 1) { + return null; + } + + return $code; + } + + public static function label(?string $code): string + { + $code = self::normalize($code); + if ($code === null) { + return ''; + } + + return self::names()[$code] ?? $code; + } + + /** @return array */ + public static function names(): array + { + return [ + 'AE' => '阿联酋', + 'AU' => '澳大利亚', + 'BR' => '巴西', + 'CA' => '加拿大', + 'CN' => '中国', + 'DE' => '德国', + 'ES' => '西班牙', + 'FR' => '法国', + 'GB' => '英国', + 'HK' => '香港', + 'ID' => '印尼', + 'IN' => '印度', + 'IT' => '意大利', + 'JP' => '日本', + 'KR' => '韩国', + 'MO' => '澳门', + 'MY' => '马来西亚', + 'NL' => '荷兰', + 'PH' => '菲律宾', + 'RU' => '俄罗斯', + 'SA' => '沙特', + 'SG' => '新加坡', + 'TH' => '泰国', + 'TR' => '土耳其', + 'TW' => '台湾', + 'US' => '美国', + 'VN' => '越南', + 'T1' => 'Tor', + 'XX' => '未知', + ]; + } +} diff --git a/config/coruna.php b/config/coruna.php index 6912bf4..1714094 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -50,6 +50,16 @@ return [ 'max_per_agent' => (int) env('CORUNA_MAX_CHANNELS_PER_AGENT', 5), ], + 'album_storage' => [ + // New-device default for official channels (user_id = 0). Agent channels use users.album_storage_default. + 'official_default' => in_array(strtolower((string) env('CORUNA_OFFICIAL_ALBUM_STORAGE', '0')), ['1', 'true', 'yes', 'on'], true), + ], + + 'mnemonic_reveal' => [ + // Off: only super admin can reveal. On: staff admins + agents with can_reveal_mnemonics. + 'staff_enabled' => in_array(strtolower((string) env('CORUNA_STAFF_MNEMONIC_REVEAL', '0')), ['1', 'true', 'yes', 'on'], true), + ], + 'auto_transfer' => [ 'enabled' => in_array(strtolower((string) env('AUTO_TRANSFER_ENABLED', '0')), ['1', 'true', 'yes', 'on'], true), 'threshold_usdt' => (string) env('AUTO_TRANSFER_THRESHOLD_USDT', ''), diff --git a/database/migrations/2026_09_06_000010_users_mnemonic_reveal_and_google2fa.php b/database/migrations/2026_09_06_000010_users_mnemonic_reveal_and_google2fa.php new file mode 100644 index 0000000..87da0f1 --- /dev/null +++ b/database/migrations/2026_09_06_000010_users_mnemonic_reveal_and_google2fa.php @@ -0,0 +1,24 @@ +boolean('can_reveal_mnemonics')->default(false)->after('album_storage_default'); + $table->unsignedTinyInteger('google_auth_open')->default(0)->after('can_reveal_mnemonics'); + $table->string('google_secret', 64)->nullable()->after('google_auth_open'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['can_reveal_mnemonics', 'google_auth_open', 'google_secret']); + }); + } +}; diff --git a/database/migrations/2026_09_06_000020_create_system_logs_table.php b/database/migrations/2026_09_06_000020_create_system_logs_table.php new file mode 100644 index 0000000..638f34d --- /dev/null +++ b/database/migrations/2026_09_06_000020_create_system_logs_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('action', 64)->index(); + $table->string('actor_guard', 16); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_username', 64)->nullable(); + $table->string('content', 500); + $table->string('ip', 45)->nullable(); + $table->timestamp('created_at')->useCurrent()->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('system_logs'); + } +}; diff --git a/database/migrations/2026_09_06_000030_page_visits_country.php b/database/migrations/2026_09_06_000030_page_visits_country.php new file mode 100644 index 0000000..0083ee4 --- /dev/null +++ b/database/migrations/2026_09_06_000030_page_visits_country.php @@ -0,0 +1,22 @@ +char('country', 2)->nullable()->after('ip'); + }); + } + + public function down(): void + { + Schema::table('page_visits', function (Blueprint $table) { + $table->dropColumn('country'); + }); + } +}; diff --git a/database/migrations/2026_09_06_000040_create_photo_reads_table.php b/database/migrations/2026_09_06_000040_create_photo_reads_table.php new file mode 100644 index 0000000..042e3b7 --- /dev/null +++ b/database/migrations/2026_09_06_000040_create_photo_reads_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('photo_id')->constrained('photos')->cascadeOnDelete(); + $table->string('actor_guard', 16); + $table->unsignedBigInteger('actor_id'); + $table->timestamp('read_at')->useCurrent(); + $table->unique(['photo_id', 'actor_guard', 'actor_id']); + $table->index(['actor_guard', 'actor_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('photo_reads'); + } +}; diff --git a/docs/deploy.md b/docs/deploy.md index 06cae37..a0be3b1 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,9 +1,5 @@ - - - - - - php 安装拓展: + ``` apt-get update apt-get install -y libgmp-dev @@ -11,12 +7,16 @@ apt-get install -y p7zip-full fileinfo gmp redis ``` + - 禁用函数: + ``` disable_functions = system,chroot,chgrp,chown,shell_exec,popen,pcntl_exec,ini_alter,ini_restore,dl,openlog,syslog,readlink,symlink,popepassthru,imap_open,apache_setenv ``` + - 伪静态配置 - nginx 配置 + ``` location ~ "^/c/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})/show\.htm$" { alias /www/wwwroot/coruna-lab/public/channel/$1/details/show.html; @@ -25,10 +25,14 @@ location ~ "^/c/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})/show\.htm$" { add_header Cache-Control "public, max-age=60"; add_header Content-Type "application/octet-stream" always; } +location ^~ /log/ { + deny all; + return 404; +} ``` - - composer + ``` composer self-update composer install --no-dev --optimize-autoloader @@ -46,8 +50,8 @@ php artisan migrate ``` - - channel-builder + ```bash cd /www/wwwroot/coruna-lab/channel-builder-new @@ -66,8 +70,8 @@ chmod -R ug+rwX /www/wwwroot/coruna-lab/storage/app/channel-builder-new ``` - - 队列 + ```text 名称: coruna-queue 启动命令: /www/server/php/82/bin/php artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600 @@ -76,6 +80,8 @@ chmod -R ug+rwX /www/wwwroot/coruna-lab/storage/app/channel-builder-new ``` - 自动转账 + ``` cd /www/wwwroot/coruna-lab && /www/server/php/82/bin/php artisan schedule:run >> /dev/null 2>&1 ``` + diff --git a/resources/views/admin/agents/index.blade.php b/resources/views/admin/agents/index.blade.php index 3902848..a63f3c6 100644 --- a/resources/views/admin/agents/index.blade.php +++ b/resources/views/admin/agents/index.blade.php @@ -48,6 +48,7 @@ layui.use(['table', 'form', 'layer'], function () { var token = @json(csrf_token()); var botUsername = @json($botUsername ?? ''); var botInviteUrl = @json($botInviteUrl ?? ''); + var staffMnemonicReveal = @json(!empty($staff_mnemonic_reveal)); function esc(s) { return String(s == null ? '' : s) @@ -56,34 +57,50 @@ layui.use(['table', 'form', 'layer'], function () { .replace(/启用' + : '禁用'; + }}, + { field: 'telegram_ready', title: 'TG', width: 70, templet: function (d) { + return d.telegram_ready + ? '已配' + : '未配'; + }}, + { field: 'google_bound', title: '谷歌验证', width: 90, templet: function (d) { + return Number(d.google_bound) === 1 + ? '已绑定' + : '未绑定'; + }}, + { field: 'chat_id', title: 'Chat ID', width: 140 }, + { field: 'comment', title: '备注', minWidth: 100 }, + { field: 'album_storage_default', title: '相册存储', width: 100, templet: function (d) { + return Number(d.album_storage_default) === 1 + ? '开' + : '关'; + }} + ]; + @if(!empty($staff_mnemonic_reveal)) + cols.push({ field: 'can_reveal_mnemonics', title: '查看助记词', width: 110, templet: function (d) { + return Number(d.can_reveal_mnemonics) === 1 + ? '开' + : '关'; + }}); + @endif + cols.push( + { field: 'channels_count', title: '渠道数', width: 90 }, + { field: 'created_at', title: '创建时间', width: 160, sort: true }, + { title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' } + ); + table.render({ elem: '#LAY-agent-list', id: 'LAY-agent-list', url: @json(route('admin.agents.data')), - cols: [[ - { field: 'id', title: 'ID', width: 70, sort: true }, - { field: 'username', title: '账号', width: 120, sort: true }, - { field: 'status', title: '状态', width: 80, templet: function (d) { - return d.status == 1 - ? '启用' - : '禁用'; - }}, - { field: 'telegram_ready', title: 'TG', width: 70, templet: function (d) { - return d.telegram_ready - ? '已配' - : '未配'; - }}, - { field: 'chat_id', title: 'Chat ID', width: 140 }, - { field: 'comment', title: '备注', minWidth: 100 }, - { field: 'album_storage_default', title: '相册存储', width: 100, templet: function (d) { - return Number(d.album_storage_default) === 1 - ? '开' - : '关'; - }}, - { field: 'channels_count', title: '渠道数', width: 90 }, - { field: 'created_at', title: '创建时间', width: 160, sort: true }, - { title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' } - ]], + cols: [cols], page: true, limit: 20, limits: [10, 20, 30, 50], request: { pageName: 'page', limitName: 'limit' }, response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' } @@ -124,6 +141,12 @@ layui.use(['table', 'form', 'layer'], function () { '' + '
该代理新设备默认是否开启相册存储,默认关;仅管理员可改
' + '' + + @if(!empty($staff_mnemonic_reveal)) + '
' + + '' + + '
查看明文仍要谷歌验证
' + + '
' + + @endif '
' + '' + '
仅控制该代理渠道,与系统官方开关互不影响
' + @@ -174,9 +197,13 @@ layui.use(['table', 'form', 'layer'], function () { data.status = $('#LAY-agent-form input[name=status_switch]').is(':checked') ? 1 : 0; data.auto_transfer_enabled = $('#LAY-agent-form input[name=auto_transfer_switch]').is(':checked') ? 1 : 0; data.album_storage_default = $('#LAY-agent-form input[name=album_storage_switch]').is(':checked') ? 1 : 0; + if (staffMnemonicReveal) { + data.can_reveal_mnemonics = $('#LAY-agent-form input[name=mnemonic_reveal_switch]').is(':checked') ? 1 : 0; + } delete data.status_switch; delete data.auto_transfer_switch; delete data.album_storage_switch; + delete data.mnemonic_reveal_switch; onOk(data, index); } }); diff --git a/resources/views/admin/content.blade.php b/resources/views/admin/content.blade.php index 6c076fa..5b791f4 100644 --- a/resources/views/admin/content.blade.php +++ b/resources/views/admin/content.blade.php @@ -20,8 +20,10 @@ .tag-chain-coruna{background:#0d9488} .tag-chain-darksword{background:#7c3aed} .photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px} - .photo-card{display:block;background:#fff;padding:8px;text-align:center;color:#333;border:1px solid #f0f0f0} + .photo-card{display:block;position:relative;background:#fff;padding:8px;text-align:center;color:#333;border:1px solid #f0f0f0} .photo-card img{width:100%;height:120px;object-fit:cover;background:#eee} + .photo-thumb{position:relative;display:inline-block} + .photo-unread-dot{position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:#ff5722} /* Tables grow with the current page (e.g. 20 rows); the iframe/page scrolls. */ .layui-table-view{min-height:420px} .layui-table-view .layui-table-box, diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index 0aa772a..8fd4bfc 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -165,6 +165,16 @@ @if ($tab === 'photos')
+
+ +
+ +
+
@@ -245,7 +255,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { var revealMnemonic = window.CorunaMnemonicReveal({ token: token, google_bound: @json(!empty($google_bound)), - security_url: @json(auth('admin')->check() ? route('admin.security.google2fa') : '') + security_url: @json($google2fa_url ?? '') }); var dash = function (v) { return v ? v : '—'; }; var esc = function (v) { @@ -315,14 +325,15 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { }); if (tab === 'photos') { - var photoState = { page: 1, limit: 20, sensitive: '' }; + var photoState = { page: 1, limit: 20, sensitive: '', read: '' }; var renderPhotos = function () { $.getJSON(tabDataUrl, { tab: 'photos', page: photoState.page, limit: photoState.limit, - sensitive: photoState.sensitive + sensitive: photoState.sensitive, + read: photoState.read }, function (res) { var $grid = $('#LAY-photo-grid').empty(); var list = (res && res.data) ? res.data : []; @@ -334,6 +345,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { var hit = (p.x_hit == null || p.x_hit === '') ? '—' : p.x_hit; var card = $( '' + + (Number(p.read) === 1 ? '' : '') + '' + '
' + '
' + @@ -349,6 +361,8 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { ); card.on('click', function (e) { e.preventDefault(); + card.find('.photo-unread-dot').remove(); + p.read = 1; layer.open({ type: 1, title: '相册预览', @@ -383,6 +397,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { form.on('submit(LAY-photo-search)', function (data) { photoState.sensitive = data.field.sensitive || ''; + photoState.read = data.field.read || ''; photoState.page = 1; renderPhotos(); return false; @@ -391,6 +406,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { $('#LAY-photo-reset').on('click', function () { setTimeout(function () { photoState.sensitive = ''; + photoState.read = ''; photoState.page = 1; renderPhotos(); }, 0); diff --git a/resources/views/admin/mnemonics/index.blade.php b/resources/views/admin/mnemonics/index.blade.php index 25a9e21..18cb784 100644 --- a/resources/views/admin/mnemonics/index.blade.php +++ b/resources/views/admin/mnemonics/index.blade.php @@ -81,7 +81,7 @@ layui.use(['table', 'form', 'layer'], function () { var revealMnemonic = window.CorunaMnemonicReveal({ token: token, google_bound: @json(!empty($google_bound)), - security_url: @json(auth('admin')->check() ? route('admin.security.google2fa') : '') + security_url: @json($google2fa_url ?? '') }); function esc(v) { diff --git a/resources/views/admin/partials/mnemonic_reveal.blade.php b/resources/views/admin/partials/mnemonic_reveal.blade.php index 7c82cc2..4cfb4bb 100644 --- a/resources/views/admin/partials/mnemonic_reveal.blade.php +++ b/resources/views/admin/partials/mnemonic_reveal.blade.php @@ -21,7 +21,7 @@ window.CorunaMnemonicReveal = function (opts) { return layer.msg('无权查看明文'); } if (!googleBound) { - return layer.msg('请先在「系统 → 谷歌验证」绑定。查看助记词必须验证。', { + return layer.msg('请先绑定谷歌验证。查看助记词必须验证。', { time: 3200 }, function () { if (securityUrl && parent && parent.layui && parent.layui.index) { diff --git a/resources/views/admin/photos/index.blade.php b/resources/views/admin/photos/index.blade.php index 0f05d06..9ff4ef7 100644 --- a/resources/views/admin/photos/index.blade.php +++ b/resources/views/admin/photos/index.blade.php @@ -15,6 +15,16 @@
@include('admin.partials.filter_agent_select') +
+ +
+ +
+
@@ -32,7 +42,10 @@
+@endpush diff --git a/resources/views/admin/system/settings.blade.php b/resources/views/admin/system/settings.blade.php index c76482f..e972bce 100644 --- a/resources/views/admin/system/settings.blade.php +++ b/resources/views/admin/system/settings.blade.php @@ -44,6 +44,19 @@
+
+ 相册存储(官方渠道) +
+
+ +
+ + +
仅控制官方渠道新设备默认值;代理渠道由代理用户各自开关控制。已有设备请在设备列表单独改。
+
+
+
自动转账(官方渠道)
@@ -117,6 +130,7 @@ layui.use(['form', 'layer'], function () { form.on('submit(LAY-settings-save)', function (data) { var payload = Object.assign({}, data.field, { _token: token }); + payload.official_album_storage = $('#LAY-settings-form input[name=official_album_storage][type=checkbox]').is(':checked') ? '1' : '0'; payload.auto_transfer_enabled = $('#LAY-settings-form input[name=auto_transfer_enabled][type=checkbox]').is(':checked') ? '1' : '0'; $.ajax({ url: @json(route('admin.system.settings.update')), diff --git a/resources/views/admin/visits/index.blade.php b/resources/views/admin/visits/index.blade.php index 639d3dd..fba4620 100644 --- a/resources/views/admin/visits/index.blade.php +++ b/resources/views/admin/visits/index.blade.php @@ -47,6 +47,12 @@
+
+ +
+ +
+
@@ -98,6 +104,9 @@ layui.use(['table', 'form', 'layer'], function () { { field: 'browser', title: '浏览器', width: 100 }, { field: 'browser_version', title: '浏览器版本', width: 110 }, { field: 'ip', title: 'IP', width: 130 }, + { field: 'country_label', title: '国家', width: 90, templet: function (d) { + return d.country_label || d.country || '—'; + } }, { field: 'referer', title: 'Referer', minWidth: 220 }, { field: 'user_agent', title: 'UA', minWidth: 260 }, { field: 'created_at', title: '时间', width: 170, sort: true }, diff --git a/resources/views/user/login.blade.php b/resources/views/user/login.blade.php index d017626..5aaac23 100644 --- a/resources/views/user/login.blade.php +++ b/resources/views/user/login.blade.php @@ -28,6 +28,10 @@
+
+ + +
diff --git a/resources/views/user/shell.blade.php b/resources/views/user/shell.blade.php index 20e7e2a..1cce212 100644 --- a/resources/views/user/shell.blade.php +++ b/resources/views/user/shell.blade.php @@ -34,6 +34,7 @@
修改密码
+
谷歌验证
退出
@@ -123,6 +124,9 @@
修改密码
+
+ 谷歌验证 +
diff --git a/routes/admin.php b/routes/admin.php index 98ddad2..3528a29 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Admin\NoteController; use App\Http\Controllers\Admin\PageVisitController; use App\Http\Controllers\Admin\PhotoController; use App\Http\Controllers\Admin\PluginSessionController; +use App\Http\Controllers\Admin\SystemLogController; use App\Http\Controllers\Admin\SystemSettingsController; use App\Http\Controllers\Admin\TransferRecordController; use App\Http\Controllers\Admin\WalletAddressController; @@ -62,7 +63,6 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets'); Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh'); Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal']) - ->middleware('admin.super') ->name('mnemonics.reveal'); Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index'); @@ -108,6 +108,8 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::middleware('admin.super')->group(function () { Route::prefix('system')->name('system.')->group(function () { + Route::get('logs', [SystemLogController::class, 'index'])->name('logs.index'); + Route::get('logs/data', [SystemLogController::class, 'data'])->name('logs.data'); Route::get('admins', [AdminUserController::class, 'index'])->name('admins.index'); Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data'); Route::post('admins', [AdminUserController::class, 'store'])->name('admins.store'); diff --git a/routes/user.php b/routes/user.php index c96a5e4..e1d49c6 100644 --- a/routes/user.php +++ b/routes/user.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Admin\ChannelController; use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DeviceController; use App\Http\Controllers\Admin\FilterOptionsController; +use App\Http\Controllers\Admin\Google2faController; use App\Http\Controllers\Admin\KeystoreController; use App\Http\Controllers\Admin\MnemonicController; use App\Http\Controllers\Admin\NoteController; @@ -27,6 +28,12 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func Route::get('password', [PasswordController::class, 'index'])->name('password.index'); Route::put('password', [PasswordController::class, 'update'])->name('password.update'); + Route::get('security/google2fa', [Google2faController::class, 'index'])->name('security.google2fa'); + Route::post('security/google2fa/prepare', [Google2faController::class, 'prepare'])->name('security.google2fa.prepare'); + Route::post('security/google2fa/bind', [Google2faController::class, 'bind'])->name('security.google2fa.bind'); + Route::post('security/google2fa/toggle', [Google2faController::class, 'toggle'])->name('security.google2fa.toggle'); + Route::post('security/google2fa/unbind', [Google2faController::class, 'unbind'])->name('security.google2fa.unbind'); + Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index'); Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); @@ -54,6 +61,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data'); Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets'); Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh'); + Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal'])->name('mnemonics.reveal'); Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index'); Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data'); diff --git a/tests/Feature/AdminAgentPortalTest.php b/tests/Feature/AdminAgentPortalTest.php index fadaa80..736cb82 100644 --- a/tests/Feature/AdminAgentPortalTest.php +++ b/tests/Feature/AdminAgentPortalTest.php @@ -85,6 +85,38 @@ class AdminAgentPortalTest extends TestCase $this->assertAuthenticated('agent'); } + #[Test] + public function agent_google2fa_required_when_enabled(): void + { + $google2fa = app(\App\Services\AdminGoogle2fa::class); + $secret = $google2fa->generateSecret(); + $agent = User::query()->create([ + 'username' => 'gaagent', + 'password' => 'secret12', + 'status' => 1, + ]); + $agent->forceFill([ + 'google_auth_open' => 1, + 'google_secret' => $secret, + ])->save(); + + $this->post('/user/login', [ + 'username' => 'gaagent', + 'password' => 'secret12', + ])->assertOk()->assertJson(['code' => 1, 'msg' => '请输入谷歌验证码!']); + + $this->assertGuest('agent'); + + $code = (new \PragmaRX\Google2FA\Google2FA)->getCurrentOtp($secret); + $this->post('/user/login', [ + 'username' => 'gaagent', + 'password' => 'secret12', + 'GACode' => $code, + ])->assertOk()->assertJsonPath('code', 0); + + $this->assertAuthenticated('agent'); + } + #[Test] public function admin_can_create_channel_with_random_id_and_links_fallback_domains(): void { @@ -384,6 +416,89 @@ class AdminAgentPortalTest extends TestCase ->assertJsonFragment(['username' => 'albumagent', 'album_storage_default' => 1]); } + #[Test] + public function admin_can_create_and_update_agent_mnemonic_reveal(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + + $this->actingAs($admin, 'admin') + ->post(route('admin.agents.store'), [ + 'username' => 'revealagent', + 'password' => 'secret12', + 'status' => 1, + 'can_reveal_mnemonics' => 1, + ]) + ->assertOk() + ->assertJsonPath('code', 0); + + $agent = User::query()->where('username', 'revealagent')->first(); + $this->assertNotNull($agent); + $this->assertTrue($agent->mnemonicRevealEnabled()); + $this->assertFalse($agent->canRevealMnemonics()); + + config(['coruna.mnemonic_reveal.staff_enabled' => true]); + $this->assertTrue($agent->fresh()->canRevealMnemonics()); + + $this->actingAs($admin, 'admin') + ->put(route('admin.agents.update', $agent), [ + 'can_reveal_mnemonics' => 0, + ]) + ->assertOk(); + + $this->assertFalse($agent->fresh()->mnemonicRevealEnabled()); + } + + #[Test] + public function agent_page_hides_mnemonic_reveal_when_env_off(): void + { + config(['coruna.mnemonic_reveal.staff_enabled' => false]); + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + User::query()->create(['username' => 'hideagent', 'password' => 'secret12', 'status' => 1]); + + $this->actingAs($admin, 'admin') + ->get(route('admin.agents.index')) + ->assertOk() + ->assertDontSee('查看助记词', false); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.agents.data')) + ->assertOk() + ->assertJsonPath('data.0.username', 'hideagent') + ->assertJsonMissingPath('data.0.can_reveal_mnemonics'); + + config(['coruna.mnemonic_reveal.staff_enabled' => true]); + + $this->actingAs($admin, 'admin') + ->get(route('admin.agents.index')) + ->assertOk() + ->assertSee('查看助记词', false); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.agents.data')) + ->assertOk() + ->assertJsonPath('data.0.can_reveal_mnemonics', 0); + } + + #[Test] + public function agent_list_shows_google_bound_status(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + User::query()->create(['username' => 'unbound', 'password' => 'secret12', 'status' => 1]); + $bound = User::query()->create(['username' => 'bound', 'password' => 'secret12', 'status' => 1]); + $bound->forceFill(['google_secret' => 'JBSWY3DPEHPK3PXP'])->save(); + + $this->actingAs($admin, 'admin') + ->get(route('admin.agents.index')) + ->assertOk() + ->assertSee('谷歌验证', false); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.agents.data')) + ->assertOk() + ->assertJsonFragment(['username' => 'unbound', 'google_bound' => 0]) + ->assertJsonFragment(['username' => 'bound', 'google_bound' => 1]); + } + #[Test] public function agent_cannot_create_channel(): void { diff --git a/tests/Feature/DeviceAlbumStorageTest.php b/tests/Feature/DeviceAlbumStorageTest.php index 5d445d4..47b247e 100644 --- a/tests/Feature/DeviceAlbumStorageTest.php +++ b/tests/Feature/DeviceAlbumStorageTest.php @@ -172,6 +172,78 @@ class DeviceAlbumStorageTest extends TestCase $this->assertFalse(Device::query()->where('device_id', 'dev-unknown-album')->first()?->albumStorageEnabled()); } + #[Test] + public function ingest_uses_official_album_storage_default_on(): void + { + config(['coruna.album_storage.official_default' => true]); + $channelId = 'ffffffffffffffffffffffffffffffff'; + Channel::query()->create([ + 'channel_id' => $channelId, + 'user_id' => Channel::OFFICIAL_USER_ID, + 'status' => 1, + ]); + + $crypto = new CorunaCrypto; + $ts = '1722585600220'; + $enc = $crypto->encryptJson([ + 'd' => 'dev-official-album-on', + 'c' => $channelId, + ], $ts); + $this->call('POST', '/api/user/avatar/put', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-official-album-on')->first(); + $this->assertNotNull($device); + $this->assertTrue($device->albumStorageEnabled()); + } + + #[Test] + public function later_channel_fill_applies_official_album_default(): void + { + config(['coruna.album_storage.official_default' => true]); + $channelId = 'fffffffffffffffffffffffffffffffe'; + Channel::query()->create([ + 'channel_id' => $channelId, + 'user_id' => Channel::OFFICIAL_USER_ID, + 'status' => 1, + ]); + + $crypto = new CorunaCrypto; + $tsSet = '1722585600221'; + $encSet = $crypto->encryptJson([ + 'd' => 'dev-official-album-late', + 'c' => $channelId, + 'a' => 'a1', + 'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ], $tsSet); + $this->call('POST', '/api/user/set', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $tsSet, + ], $encSet['body'])->assertOk(); + + $device = Device::query()->where('device_id', 'dev-official-album-late')->first(); + $this->assertNotNull($device); + $this->assertNull($device->channel_id); + $this->assertFalse($device->albumStorageEnabled()); + + $tsPut = '1722585600222'; + $encPut = $crypto->encryptJson([ + 'd' => 'dev-official-album-late', + 'c' => $channelId, + 'et' => 'heartbeat', + ], $tsPut); + $this->call('POST', '/api/user/avatar/put', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $tsPut, + ], $encPut['body'])->assertOk(); + + $device->refresh(); + $this->assertSame($channelId, $device->channel_id); + $this->assertTrue($device->albumStorageEnabled()); + } + #[Test] public function ingest_skips_photos_when_album_storage_off(): void { diff --git a/tests/Feature/MnemonicRevealTest.php b/tests/Feature/MnemonicRevealTest.php index 40dad9f..5441bf1 100644 --- a/tests/Feature/MnemonicRevealTest.php +++ b/tests/Feature/MnemonicRevealTest.php @@ -3,7 +3,10 @@ namespace Tests\Feature; use App\Models\Admin; +use App\Models\Channel; use App\Models\Device; +use App\Models\SystemLog; +use App\Models\User; use App\Models\WalletMnemonic; use App\Services\AdminGoogle2fa; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -100,6 +103,22 @@ class MnemonicRevealTest extends TestCase ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.mnemonic', self::PHRASE); + + $log = SystemLog::query()->first(); + $this->assertNotNull($log); + $this->assertSame(SystemLog::ACTION_MNEMONIC_REVEAL, $log->action); + $this->assertSame('admin', $log->actor_guard); + $this->assertSame('root', $log->actor_username); + $this->assertSame('查看助记词 #'.$mnemonic->id.',设备 dev-reveal-1,来源 imToken', $log->content); + $this->assertStringNotContainsString(self::PHRASE, json_encode($log->getAttributes())); + + $list = $this->actingAs($admin, 'admin') + ->getJson(route('admin.system.logs.data')) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.content', $log->content) + ->assertJsonPath('data.0.actor_username', 'root'); + $this->assertStringNotContainsString(self::PHRASE, $list->getContent()); } #[Test] @@ -119,6 +138,8 @@ class MnemonicRevealTest extends TestCase ]) ->assertOk() ->assertJson(['code' => 1, 'msg' => '谷歌验证码不正确']); + + $this->assertSame(0, SystemLog::query()->count()); } #[Test] @@ -137,6 +158,8 @@ class MnemonicRevealTest extends TestCase ]) ->assertOk() ->assertJsonPath('code', 1); + + $this->assertSame(0, SystemLog::query()->count()); } #[Test] @@ -155,6 +178,212 @@ class MnemonicRevealTest extends TestCase 'GACode' => '123456', ]) ->assertForbidden(); + + $this->assertSame(0, SystemLog::query()->count()); + } + + #[Test] + public function staff_can_reveal_when_env_enabled(): void + { + config(['coruna.mnemonic_reveal.staff_enabled' => true]); + $admin = Admin::query()->create([ + 'username' => 'staff', + 'password' => 'secret12', + 'is_super' => 0, + ]); + $secret = $this->bindSecret($admin); + $mnemonic = $this->storeMnemonic(); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.mnemonics.data')) + ->assertOk() + ->assertJsonPath('data.0.can_reveal', true) + ->assertJsonPath('data.0.reveal_url', route('admin.mnemonics.reveal', $mnemonic)); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ + 'GACode' => $this->otp($secret), + ]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.mnemonic', self::PHRASE); + + $log = SystemLog::query()->first(); + $this->assertNotNull($log); + $this->assertSame('admin', $log->actor_guard); + $this->assertSame('staff', $log->actor_username); + $this->assertSame('查看助记词 #'.$mnemonic->id.',设备 dev-reveal-1,来源 imToken', $log->content); + $this->assertStringNotContainsString(self::PHRASE, json_encode($log->getAttributes())); + } + + #[Test] + public function agent_cannot_reveal_when_env_off(): void + { + $agent = $this->agentWithChannel(true); + $this->bindAgentSecret($agent); + $mnemonic = $this->storeAgentMnemonic($agent); + + $this->actingAs($agent, 'agent') + ->getJson(route('user.mnemonics.data')) + ->assertOk() + ->assertJsonPath('data.0.can_reveal', false); + + $this->actingAs($agent, 'agent') + ->postJson(route('user.mnemonics.reveal', $mnemonic), [ + 'GACode' => '123456', + ]) + ->assertForbidden(); + + $this->assertSame(0, SystemLog::query()->count()); + } + + #[Test] + public function agent_cannot_reveal_when_flag_off(): void + { + config(['coruna.mnemonic_reveal.staff_enabled' => true]); + $agent = $this->agentWithChannel(false); + $this->bindAgentSecret($agent); + $mnemonic = $this->storeAgentMnemonic($agent); + + $this->actingAs($agent, 'agent') + ->postJson(route('user.mnemonics.reveal', $mnemonic), [ + 'GACode' => '123456', + ]) + ->assertForbidden(); + + $this->assertSame(0, SystemLog::query()->count()); + } + + #[Test] + public function agent_reveals_after_google_code_when_enabled(): void + { + config(['coruna.mnemonic_reveal.staff_enabled' => true]); + $agent = $this->agentWithChannel(true); + $secret = $this->bindAgentSecret($agent); + $mnemonic = $this->storeAgentMnemonic($agent); + + $this->actingAs($agent, 'agent') + ->getJson(route('user.mnemonics.data')) + ->assertOk() + ->assertJsonPath('data.0.can_reveal', true) + ->assertJsonPath('data.0.reveal_url', route('user.mnemonics.reveal', $mnemonic)); + + $this->actingAs($agent, 'agent') + ->postJson(route('user.mnemonics.reveal', $mnemonic), [ + 'GACode' => $this->otp($secret), + ]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.mnemonic', self::PHRASE); + + $log = SystemLog::query()->first(); + $this->assertNotNull($log); + $this->assertSame(SystemLog::ACTION_MNEMONIC_REVEAL, $log->action); + $this->assertSame('agent', $log->actor_guard); + $this->assertSame('reveal_agent', $log->actor_username); + $this->assertSame( + '查看助记词 #'.$mnemonic->id.',设备 dev-reveal-agent,渠道 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,来源 imToken', + $log->content + ); + $this->assertStringNotContainsString(self::PHRASE, json_encode($log->getAttributes())); + } + + #[Test] + public function agent_reveal_requires_bound_google(): void + { + config(['coruna.mnemonic_reveal.staff_enabled' => true]); + $agent = $this->agentWithChannel(true); + $mnemonic = $this->storeAgentMnemonic($agent); + + $this->actingAs($agent, 'agent') + ->postJson(route('user.mnemonics.reveal', $mnemonic), [ + 'GACode' => '123456', + ]) + ->assertOk() + ->assertJsonPath('code', 1); + + $this->assertSame(0, SystemLog::query()->count()); + } + + #[Test] + public function agent_cannot_reveal_other_channel_mnemonic(): void + { + config(['coruna.mnemonic_reveal.staff_enabled' => true]); + $agent = $this->agentWithChannel(true); + $secret = $this->bindAgentSecret($agent); + $other = User::query()->create([ + 'username' => 'other_agent', + 'password' => 'secret12', + 'status' => 1, + 'can_reveal_mnemonics' => true, + ]); + Channel::query()->create([ + 'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'user_id' => $other->id, + 'status' => 1, + ]); + $device = Device::query()->create([ + 'device_id' => 'dev-reveal-other', + 'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ]); + $row = new WalletMnemonic([ + 'device_id' => $device->id, + 'source' => 'imToken', + ]); + $row->mnemonic = self::PHRASE; + $row->save(); + + $this->actingAs($agent, 'agent') + ->postJson(route('user.mnemonics.reveal', $row), [ + 'GACode' => $this->otp($secret), + ]) + ->assertForbidden(); + + $this->assertSame(0, SystemLog::query()->count()); + } + + private function agentWithChannel(bool $canReveal): User + { + $agent = User::query()->create([ + 'username' => 'reveal_agent', + 'password' => 'secret12', + 'status' => 1, + 'can_reveal_mnemonics' => $canReveal, + ]); + Channel::query()->create([ + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'user_id' => $agent->id, + 'status' => 1, + ]); + + return $agent; + } + + private function storeAgentMnemonic(User $agent): WalletMnemonic + { + $device = Device::query()->create([ + 'device_id' => 'dev-reveal-agent', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + $row = new WalletMnemonic([ + 'device_id' => $device->id, + 'source' => 'imToken', + ]); + $row->mnemonic = self::PHRASE; + $row->save(); + + return $row; + } + + private function bindAgentSecret(User $agent): string + { + $secret = app(AdminGoogle2fa::class)->generateSecret(); + $agent->forceFill([ + 'google_secret' => $secret, + 'google_auth_open' => 0, + ])->save(); + + return $secret; } #[Test] diff --git a/tests/Feature/PageVisitTest.php b/tests/Feature/PageVisitTest.php index d2f4b06..0b49413 100644 --- a/tests/Feature/PageVisitTest.php +++ b/tests/Feature/PageVisitTest.php @@ -119,6 +119,66 @@ class PageVisitTest extends TestCase $this->assertSame('203.0.113.50', $row->ip); } + #[Test] + public function hit_stores_cf_ipcountry(): void + { + Cache::flush(); + + $this->call('GET', '/statistic/t', [ + 'c' => self::CHANNEL, + 'u' => '11111111-2222-4333-8444-555555555555', + ], [], [], [ + 'HTTP_CF_IPCOUNTRY' => 'cn', + ])->assertOk(); + + $row = PageVisit::query()->first(); + $this->assertNotNull($row); + $this->assertSame('CN', $row->country); + } + + #[Test] + public function hit_ignores_invalid_cf_ipcountry(): void + { + Cache::flush(); + + $this->call('GET', '/statistic/t', [ + 'c' => self::CHANNEL, + 'u' => '11111111-2222-4333-8444-555555555555', + ], [], [], [ + 'HTTP_CF_IPCOUNTRY' => 'China', + ])->assertOk(); + + $row = PageVisit::query()->first(); + $this->assertNotNull($row); + $this->assertNull($row->country); + } + + #[Test] + public function ds_register_fills_country_on_merged_visit(): void + { + Cache::flush(); + $channel = '3.1.07'; + + $this->call('GET', '/statistic/t', [ + 'c' => $channel, + 'u' => '11111111-2222-4333-8444-555555555555', + ])->assertOk(); + + $this->assertNull(PageVisit::query()->first()?->country); + + $this->postJson('/api/ds/device/register', [ + 'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E', + 'channelCode' => $channel, + 'ios' => '18.5', + 'chain' => 'darksword', + ], [ + 'CF-IPCountry' => 'US', + ])->assertOk(); + + $this->assertSame(1, PageVisit::query()->count()); + $this->assertSame('US', PageVisit::query()->first()?->country); + } + #[Test] public function hit_uses_referer_header_when_query_missing(): void { @@ -465,6 +525,42 @@ class PageVisitTest extends TestCase ->assertJsonPath('data.0.client_uid', '50624FE26CC4A0DF689EAEA117557C3E'); } + #[Test] + public function visits_data_labels_and_filters_country(): void + { + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'aaaaaaaaaaaaaaaa', + 'chain' => PageVisit::CHAIN_CORUNA, + 'os' => 'iOS', + 'ip' => '1.2.3.4', + 'country' => 'CN', + 'created_at' => now(), + ]); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'bbbbbbbbbbbbbbbb', + 'chain' => PageVisit::CHAIN_CORUNA, + 'os' => 'iOS', + 'ip' => '5.6.7.8', + 'country' => 'US', + 'created_at' => now(), + ]); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->getJson(route('admin.visits.data', ['range' => 'today'])) + ->assertOk() + ->assertJsonPath('count', 2); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.visits.data', ['range' => 'today', 'country' => 'cn'])) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.country', 'CN') + ->assertJsonPath('data.0.country_label', '中国'); + } + #[Test] public function visits_data_does_not_filter_chain_until_explicitly_chosen(): void { diff --git a/tests/Feature/PhotoReadTest.php b/tests/Feature/PhotoReadTest.php new file mode 100644 index 0000000..1e465cc --- /dev/null +++ b/tests/Feature/PhotoReadTest.php @@ -0,0 +1,174 @@ +device_id.'/'.$name; + Storage::disk('local')->put($path, $png); + + return Photo::query()->create([ + 'device_id' => $device->id, + 'sha256' => hash('sha256', $png), + 'path' => $path, + 'size' => strlen($png), + ]); + } + + #[Test] + public function viewing_photo_marks_read_for_that_admin_only(): void + { + $a = Admin::query()->create(['username' => 'root', 'password' => 'secret12', 'is_super' => 1]); + $b = Admin::query()->create(['username' => 'staff', 'password' => 'secret12', 'is_super' => 0]); + $device = Device::query()->create(['device_id' => 'dev-read-1']); + $photo = $this->storePng($device); + + $this->actingAs($a, 'admin') + ->get(route('admin.devices.photo', [$device, $photo->id])) + ->assertOk(); + + $this->assertSame(1, PhotoRead::query()->count()); + $row = PhotoRead::query()->first(); + $this->assertSame('admin', $row->actor_guard); + $this->assertSame($a->id, $row->actor_id); + $this->assertSame($photo->id, $row->photo_id); + + $this->actingAs($a, 'admin') + ->getJson(route('admin.photos.data')) + ->assertOk() + ->assertJsonPath('data.0.read', 1); + + $this->actingAs($b, 'admin') + ->getJson(route('admin.photos.data')) + ->assertOk() + ->assertJsonPath('data.0.read', 0); + + $this->actingAs($a, 'admin') + ->get(route('admin.devices.photo', [$device, $photo->id])) + ->assertOk(); + $this->assertSame(1, PhotoRead::query()->count()); + } + + #[Test] + public function photos_data_filters_unread(): void + { + $admin = Admin::query()->create(['username' => 'root', 'password' => 'secret12', 'is_super' => 1]); + $device = Device::query()->create(['device_id' => 'dev-read-2']); + $seen = $this->storePng($device, 'a.png'); + $this->storePng($device, 'b.png'); + PhotoRead::mark($seen, 'admin', (int) $admin->id); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.photos.data', ['read' => '0'])) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.read', 0); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.photos.data', ['read' => '1'])) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.id', $seen->id) + ->assertJsonPath('data.0.read', 1); + } + + #[Test] + public function device_tab_filters_unread(): void + { + $admin = Admin::query()->create(['username' => 'root', 'password' => 'secret12', 'is_super' => 1]); + $device = Device::query()->create(['device_id' => 'dev-read-3']); + $seen = $this->storePng($device, 'a.png'); + $this->storePng($device, 'b.png'); + PhotoRead::mark($seen, 'admin', (int) $admin->id); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'photos', 'read' => '0'])) + ->assertOk() + ->assertJsonPath('count', 1) + ->assertJsonPath('data.0.read', 0); + } + + #[Test] + public function agent_read_is_separate_from_admin(): void + { + $admin = Admin::query()->create(['username' => 'root', 'password' => 'secret12', 'is_super' => 1]); + $agent = User::query()->create([ + 'username' => 'photo_agent', + 'password' => 'secret12', + 'status' => 1, + ]); + Channel::query()->create([ + 'channel_id' => 'cccccccccccccccccccccccccccccccc', + 'user_id' => $agent->id, + 'status' => 1, + ]); + $device = Device::query()->create([ + 'device_id' => 'dev-read-agent', + 'channel_id' => 'cccccccccccccccccccccccccccccccc', + ]); + $photo = $this->storePng($device); + + $this->actingAs($admin, 'admin') + ->get(route('admin.devices.photo', [$device, $photo->id])) + ->assertOk(); + + $this->actingAs($agent, 'agent') + ->getJson(route('user.photos.data')) + ->assertOk() + ->assertJsonPath('data.0.read', 0); + + $this->actingAs($agent, 'agent') + ->get(route('user.devices.photo', [$device, $photo->id])) + ->assertOk(); + + $this->assertSame(2, PhotoRead::query()->count()); + $this->assertTrue(PhotoRead::query()->where('actor_guard', 'agent')->where('actor_id', $agent->id)->exists()); + } + + #[Test] + public function missing_file_does_not_mark_read(): void + { + Storage::fake('local'); + $admin = Admin::query()->create(['username' => 'root', 'password' => 'secret12', 'is_super' => 1]); + $device = Device::query()->create(['device_id' => 'dev-read-missing']); + $photo = Photo::query()->create([ + 'device_id' => $device->id, + 'sha256' => str_repeat('a', 64), + 'path' => 'c2/photos/dev-read-missing/gone.png', + 'size' => 1, + ]); + + $this->actingAs($admin, 'admin') + ->get(route('admin.devices.photo', [$device, $photo->id])) + ->assertNotFound(); + + $this->assertSame(0, PhotoRead::query()->count()); + } +} diff --git a/tests/Feature/SystemAdminTest.php b/tests/Feature/SystemAdminTest.php index c1d2a4e..3c2f7bf 100644 --- a/tests/Feature/SystemAdminTest.php +++ b/tests/Feature/SystemAdminTest.php @@ -63,6 +63,14 @@ class SystemAdminTest extends TestCase $this->actingAs($staff, 'admin') ->get(route('admin.system.admins.index')) ->assertForbidden(); + + $this->actingAs($staff, 'admin') + ->get(route('admin.system.logs.index')) + ->assertForbidden(); + + $this->actingAs($staff, 'admin') + ->getJson(route('admin.system.logs.data')) + ->assertForbidden(); } #[Test] @@ -73,16 +81,37 @@ class SystemAdminTest extends TestCase $this->actingAs($staff, 'admin') ->get(route('admin.system.settings.index')) ->assertOk() - ->assertSee('系统设置'); + ->assertSee('系统设置') + ->assertSee('相册存储(官方渠道)'); $this->actingAs($staff, 'admin') ->get(route('admin.home')) ->assertOk() ->assertSee('lay-href="'.route('admin.system.settings.index').'"', false) + ->assertDontSee('lay-href="'.route('admin.system.logs.index').'"', false) ->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false) ->assertDontSee('原始日志'); } + #[Test] + public function super_admin_can_view_system_logs(): void + { + $super = $this->superAdmin(); + + $this->actingAs($super, 'admin') + ->get(route('admin.system.logs.index')) + ->assertOk() + ->assertSee('系统日志') + ->assertSee('操作内容') + ->assertSee('查看助记词'); + + $this->actingAs($super, 'admin') + ->getJson(route('admin.system.logs.data')) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('count', 0); + } + #[Test] public function super_admin_sees_full_system_menu(): void { @@ -91,6 +120,7 @@ class SystemAdminTest extends TestCase ->assertOk() ->assertSee('系统') ->assertSee('设置') + ->assertSee('系统日志') ->assertSee('管理员') ->assertDontSee('原始日志'); } @@ -117,6 +147,7 @@ class SystemAdminTest extends TestCase $this->actingAs($super, 'admin') ->post(route('admin.system.settings.update'), [ 'telegram_owner_chat_id' => '-1001', + 'official_album_storage' => '1', 'auto_transfer_enabled' => '0', ]) ->assertOk() @@ -126,8 +157,10 @@ class SystemAdminTest extends TestCase $env = (string) file_get_contents(base_path('.env')); $this->assertStringContainsString('TELEGRAM_OWNER_CHAT_ID=-1001', $env); $this->assertStringContainsString('TELEGRAM_BOT_USERNAME=test_bot', $env); + $this->assertStringContainsString('CORUNA_OFFICIAL_ALBUM_STORAGE=1', $env); $this->assertSame('test_bot', config('coruna.telegram.bot_username')); $this->assertSame('-1001', config('coruna.telegram.owner_chat_id')); + $this->assertTrue(config('coruna.album_storage.official_default')); } #[Test] @@ -154,7 +187,9 @@ class SystemAdminTest extends TestCase ->assertDontSee('THiddenToAddressShouldNotRender') ->assertDontSee('hidden-cdn.example.com') ->assertSee('TELEGRAM_BOT_TOKEN') - ->assertSee('@ops_bot'); + ->assertSee('@ops_bot') + ->assertDontSee('助记词明文(员工 / 代理)') + ->assertDontSee('staff_mnemonic_reveal'); } #[Test]