feat: menric
This commit is contained in:
@@ -105,6 +105,9 @@ TOKENVIEW_SIGN_KEY=
|
|||||||
TRUSTED_PROXIES=*
|
TRUSTED_PROXIES=*
|
||||||
XXBB_CHANNEL_C=
|
XXBB_CHANNEL_C=
|
||||||
TELEGRAM_BOT_USERNAME=
|
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_ENABLED=0
|
||||||
AUTO_TRANSFER_THRESHOLD_USDT=
|
AUTO_TRANSFER_THRESHOLD_USDT=
|
||||||
AUTO_TRANSFER_THRESHOLD_TRX=
|
AUTO_TRANSFER_THRESHOLD_TRX=
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class AgentUserController extends Controller
|
|||||||
return view('admin.agents.index', [
|
return view('admin.agents.index', [
|
||||||
'botUsername' => $telegram->cachedBotUsername(),
|
'botUsername' => $telegram->cachedBotUsername(),
|
||||||
'botInviteUrl' => $telegram->inviteUrl(),
|
'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));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
$data = collect($paginator->items())->map(function (User $u) {
|
$showReveal = (bool) config('coruna.mnemonic_reveal.staff_enabled');
|
||||||
return [
|
$data = collect($paginator->items())->map(function (User $u) use ($showReveal) {
|
||||||
|
$row = [
|
||||||
'id' => $u->id,
|
'id' => $u->id,
|
||||||
'username' => $u->username,
|
'username' => $u->username,
|
||||||
'status' => (int) $u->status,
|
'status' => (int) $u->status,
|
||||||
'comment' => $u->comment ?: '',
|
'comment' => $u->comment ?: '',
|
||||||
'chat_id' => $u->chat_id ?: '',
|
'chat_id' => $u->chat_id ?: '',
|
||||||
'telegram_ready' => $u->hasTelegramChat(),
|
'telegram_ready' => $u->hasTelegramChat(),
|
||||||
|
'google_bound' => $u->hasGoogleBound() ? 1 : 0,
|
||||||
'channels_count' => (int) $u->channels_count,
|
'channels_count' => (int) $u->channels_count,
|
||||||
'auto_transfer_enabled' => (int) $u->auto_transfer_enabled,
|
'auto_transfer_enabled' => (int) $u->auto_transfer_enabled,
|
||||||
'auto_transfer_threshold_usdt' => $u->auto_transfer_threshold_usdt !== null ? (string) $u->auto_transfer_threshold_usdt : '',
|
'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'),
|
'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'),
|
'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();
|
})->values();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -86,6 +94,7 @@ class AgentUserController extends Controller
|
|||||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||||
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::query()->create([
|
$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_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null),
|
||||||
'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null),
|
'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null),
|
||||||
'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)),
|
'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]]);
|
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_eth' => ['nullable', 'numeric', 'min:0'],
|
||||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||||
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (array_key_exists('comment', $data)) {
|
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) {
|
if (array_key_exists('album_storage_default', $data) && $data['album_storage_default'] !== null) {
|
||||||
$agent->album_storage_default = (bool) ((int) $data['album_storage_default']);
|
$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) {
|
foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) {
|
||||||
$key = 'auto_transfer_threshold_'.$coin;
|
$key = 'auto_transfer_threshold_'.$coin;
|
||||||
if (array_key_exists($key, $data)) {
|
if (array_key_exists($key, $data)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Concerns\RevealsMnemonics;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Admin;
|
use App\Models\Admin;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
@@ -12,6 +13,7 @@ use App\Models\DsChainLog;
|
|||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
use App\Models\PageVisit;
|
use App\Models\PageVisit;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
|
use App\Models\PhotoRead;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletKeystore;
|
use App\Models\WalletKeystore;
|
||||||
@@ -29,6 +31,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
class DeviceController extends Controller
|
class DeviceController extends Controller
|
||||||
{
|
{
|
||||||
use PortalAware;
|
use PortalAware;
|
||||||
|
use RevealsMnemonics;
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
@@ -130,6 +133,7 @@ class DeviceController extends Controller
|
|||||||
'beaconTasks' => $device->beaconTasks,
|
'beaconTasks' => $device->beaconTasks,
|
||||||
'can_reveal' => $this->canRevealMnemonics(),
|
'can_reveal' => $this->canRevealMnemonics(),
|
||||||
'google_bound' => $this->googleBoundForReveal(),
|
'google_bound' => $this->googleBoundForReveal(),
|
||||||
|
'google2fa_url' => $this->google2faUrl(),
|
||||||
'can_clear_photos' => $this->canClearPhotos(),
|
'can_clear_photos' => $this->canClearPhotos(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -164,6 +168,7 @@ class DeviceController extends Controller
|
|||||||
abort_unless(Storage::disk('local')->exists($row->path), 404);
|
abort_unless(Storage::disk('local')->exists($row->path), 404);
|
||||||
$abs = Storage::disk('local')->path($row->path);
|
$abs = Storage::disk('local')->path($row->path);
|
||||||
$out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: ''));
|
$out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: ''));
|
||||||
|
$this->markPhotoRead($row);
|
||||||
|
|
||||||
return response($out['bytes'], 200)
|
return response($out['bytes'], 200)
|
||||||
->header('Content-Type', $out['mime']);
|
->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)
|
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'];
|
$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') {
|
if ((string) $request->query('sensitive', '') === '1') {
|
||||||
$q->where('x_hit', '>', 0);
|
$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);
|
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||||
$portal = $this->portal();
|
$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 [
|
return [
|
||||||
'id' => $photo->id,
|
'id' => $photo->id,
|
||||||
'url' => route($portal.'.devices.photo', [$device, $photo->id]),
|
'url' => route($portal.'.devices.photo', [$device, $photo->id]),
|
||||||
@@ -533,6 +558,7 @@ class DeviceController extends Controller
|
|||||||
'process_index' => $photo->process_index,
|
'process_index' => $photo->process_index,
|
||||||
'text_count' => $photo->text_count,
|
'text_count' => $photo->text_count,
|
||||||
'barcode_count' => $photo->barcode_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'),
|
'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
@@ -594,7 +620,7 @@ class DeviceController extends Controller
|
|||||||
'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'),
|
'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'),
|
'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'),
|
||||||
'can_reveal' => $canReveal,
|
'can_reveal' => $canReveal,
|
||||||
'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $w->id) : '',
|
'reveal_url' => $canReveal ? $this->mnemonicRevealUrl($w->id) : '',
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
@@ -811,23 +837,6 @@ class DeviceController extends Controller
|
|||||||
return null;
|
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
|
private function canClearPhotos(): bool
|
||||||
{
|
{
|
||||||
if ($this->isAgentPortal()) {
|
if ($this->isAgentPortal()) {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Admin;
|
use App\Models\Admin;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\AdminGoogle2fa;
|
use App\Services\AdminGoogle2fa;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -11,21 +13,22 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
|
|
||||||
class Google2faController extends Controller
|
class Google2faController extends Controller
|
||||||
{
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
return view('admin.security.google2fa', [
|
return view('admin.security.google2fa', [
|
||||||
'enabled' => $admin->requiresLoginGoogle(),
|
'enabled' => $actor->requiresLoginGoogle(),
|
||||||
'bound' => $admin->hasGoogleBound(),
|
'bound' => $actor->hasGoogleBound(),
|
||||||
|
'routes' => $this->googleRoutes(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function prepare(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
public function prepare(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
@@ -33,18 +36,18 @@ class Google2faController extends Controller
|
|||||||
'password.required' => '登陆密码不能为空',
|
'password.required' => '登陆密码不能为空',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! Hash::check($data['password'], $admin->password)) {
|
if (! Hash::check($data['password'], $actor->password)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
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' => '您已绑定谷歌验证,可直接开启或关闭']);
|
return response()->json(['code' => 201, 'msg' => '您已绑定谷歌验证,可直接开启或关闭']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$secret = $google2fa->generateSecret();
|
$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([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
@@ -56,8 +59,7 @@ class Google2faController extends Controller
|
|||||||
|
|
||||||
public function bind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
public function bind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'GAKey' => ['required', 'string', 'max:16'],
|
'GAKey' => ['required', 'string', 'max:16'],
|
||||||
@@ -68,7 +70,7 @@ class Google2faController extends Controller
|
|||||||
'GASecret.required' => '参数不完整',
|
'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'])) {
|
if ($pending === '' || ! hash_equals($pending, $data['GASecret'])) {
|
||||||
return response()->json(['code' => 1, 'msg' => '绑定已过期,请重新获取二维码']);
|
return response()->json(['code' => 1, 'msg' => '绑定已过期,请重新获取二维码']);
|
||||||
}
|
}
|
||||||
@@ -78,12 +80,12 @@ class Google2faController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$loginVerify = (int) ($data['login_verify'] ?? 0);
|
$loginVerify = (int) ($data['login_verify'] ?? 0);
|
||||||
$admin->forceFill([
|
$actor->forceFill([
|
||||||
'google_auth_open' => $loginVerify,
|
'google_auth_open' => $loginVerify,
|
||||||
'google_secret' => $data['GASecret'],
|
'google_secret' => $data['GASecret'],
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$request->session()->forget('admin_google2fa_pending_secret');
|
$request->session()->forget($this->pendingSecretKey());
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
@@ -95,8 +97,7 @@ class Google2faController extends Controller
|
|||||||
|
|
||||||
public function toggle(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
public function toggle(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
@@ -104,21 +105,21 @@ class Google2faController extends Controller
|
|||||||
'GACode' => ['nullable', 'string', 'max:16'],
|
'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' => '登陆密码不正确']);
|
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! filled($admin->google_secret)) {
|
if (! filled($actor->google_secret)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
|
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$open = (int) $data['open'];
|
$open = (int) $data['open'];
|
||||||
$code = (string) ($data['GACode'] ?? '');
|
$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' => '谷歌验证码不正确']);
|
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$admin->forceFill(['google_auth_open' => $open])->save();
|
$actor->forceFill(['google_auth_open' => $open])->save();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
@@ -130,33 +131,65 @@ class Google2faController extends Controller
|
|||||||
|
|
||||||
public function unbind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
public function unbind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
'GACode' => ['required', 'string', 'max:16'],
|
'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' => '登陆密码不正确']);
|
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! filled($admin->google_secret)) {
|
if (! filled($actor->google_secret)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
|
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' => '谷歌验证码不正确']);
|
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$admin->forceFill([
|
$actor->forceFill([
|
||||||
'google_auth_open' => 0,
|
'google_auth_open' => 0,
|
||||||
'google_secret' => null,
|
'google_secret' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$request->session()->forget('admin_google2fa_pending_secret');
|
$request->session()->forget($this->pendingSecretKey());
|
||||||
|
|
||||||
return response()->json(['code' => 0, 'msg' => '已解除谷歌验证绑定']);
|
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;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Concerns\RevealsMnemonics;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Admin;
|
use App\Models\SystemLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
@@ -19,6 +20,7 @@ use Illuminate\Support\Facades\RateLimiter;
|
|||||||
class MnemonicController extends Controller
|
class MnemonicController extends Controller
|
||||||
{
|
{
|
||||||
use PortalAware;
|
use PortalAware;
|
||||||
|
use RevealsMnemonics;
|
||||||
|
|
||||||
private const REFRESH_DECAY_SECONDS = 60;
|
private const REFRESH_DECAY_SECONDS = 60;
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ class MnemonicController extends Controller
|
|||||||
'sources' => $sources,
|
'sources' => $sources,
|
||||||
'can_reveal' => $this->canRevealMnemonics(),
|
'can_reveal' => $this->canRevealMnemonics(),
|
||||||
'google_bound' => $this->googleBoundForReveal(),
|
'google_bound' => $this->googleBoundForReveal(),
|
||||||
|
'google2fa_url' => $this->google2faUrl(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +77,7 @@ class MnemonicController extends Controller
|
|||||||
'wallets_url' => route($portal.'.mnemonics.wallets', $row->id),
|
'wallets_url' => route($portal.'.mnemonics.wallets', $row->id),
|
||||||
'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id),
|
'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id),
|
||||||
'can_reveal' => $canReveal,
|
'can_reveal' => $canReveal,
|
||||||
'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $row->id) : '',
|
'reveal_url' => $canReveal ? $this->mnemonicRevealUrl($row->id) : '',
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
@@ -88,16 +91,21 @@ class MnemonicController extends Controller
|
|||||||
|
|
||||||
public function reveal(Request $request, WalletMnemonic $mnemonic, AdminGoogle2fa $google2fa)
|
public function reveal(Request $request, WalletMnemonic $mnemonic, AdminGoogle2fa $google2fa)
|
||||||
{
|
{
|
||||||
/** @var Admin|null $admin */
|
$actor = $this->isAgentPortal() ? $this->agent() : auth('admin')->user();
|
||||||
$admin = auth('admin')->user();
|
if ($actor === null || ! $actor->canRevealMnemonics()) {
|
||||||
if ($admin === null || ! $admin->canRevealMnemonics()) {
|
$msg = (! $this->isAgentPortal() && ! (bool) config('coruna.mnemonic_reveal.staff_enabled'))
|
||||||
return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403);
|
? '需要超级管理员权限'
|
||||||
|
: '无权查看明文';
|
||||||
|
|
||||||
|
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);
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
}
|
}
|
||||||
if (! $admin->hasGoogleBound()) {
|
if (! $actor->hasGoogleBound()) {
|
||||||
return response()->json(['code' => 1, 'msg' => '请先在「系统 → 谷歌验证」绑定,查看明文必须验证']);
|
$hint = $this->isAgentPortal() ? '账号 → 谷歌验证' : '系统 → 谷歌验证';
|
||||||
|
|
||||||
|
return response()->json(['code' => 1, 'msg' => '请先在「'.$hint.'」绑定,查看明文必须验证']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
@@ -106,7 +114,7 @@ class MnemonicController extends Controller
|
|||||||
'GACode.required' => '请输入谷歌验证码',
|
'GACode.required' => '请输入谷歌验证码',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$throttleKey = 'mnemonic-reveal:'.$admin->id;
|
$throttleKey = 'mnemonic-reveal:'.$this->portal().':'.$actor->id;
|
||||||
if (RateLimiter::tooManyAttempts($throttleKey, 8)) {
|
if (RateLimiter::tooManyAttempts($throttleKey, 8)) {
|
||||||
$seconds = RateLimiter::availableIn($throttleKey);
|
$seconds = RateLimiter::availableIn($throttleKey);
|
||||||
|
|
||||||
@@ -116,7 +124,7 @@ class MnemonicController extends Controller
|
|||||||
], 429);
|
], 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $google2fa->verify((string) $admin->google_secret, $data['GACode'])) {
|
if (! $google2fa->verify((string) $actor->google_secret, $data['GACode'])) {
|
||||||
RateLimiter::hit($throttleKey, 60);
|
RateLimiter::hit($throttleKey, 60);
|
||||||
|
|
||||||
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
||||||
@@ -124,6 +132,17 @@ class MnemonicController extends Controller
|
|||||||
|
|
||||||
RateLimiter::clear($throttleKey);
|
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([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => 'ok',
|
'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
|
private function mnemonicAllowed(WalletMnemonic $mnemonic): bool
|
||||||
{
|
{
|
||||||
$allowed = WalletMnemonic::query()
|
$allowed = WalletMnemonic::query()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\DsChainLog;
|
use App\Models\DsChainLog;
|
||||||
use App\Models\PageVisit;
|
use App\Models\PageVisit;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -39,7 +40,7 @@ class PageVisitController extends Controller
|
|||||||
->forPage($page, $limit)
|
->forPage($page, $limit)
|
||||||
->get([
|
->get([
|
||||||
'id', 'channel_id', 'client_uid', 'chain', 'os', 'os_version',
|
'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([
|
return response()->json([
|
||||||
@@ -58,6 +59,8 @@ class PageVisitController extends Controller
|
|||||||
'browser_version' => $v->browser_version ?: '',
|
'browser_version' => $v->browser_version ?: '',
|
||||||
'user_agent' => $v->user_agent ?: '',
|
'user_agent' => $v->user_agent ?: '',
|
||||||
'ip' => $v->ip ?: '',
|
'ip' => $v->ip ?: '',
|
||||||
|
'country' => $v->country ?: '',
|
||||||
|
'country_label' => CfIpCountry::label($v->country),
|
||||||
'domain' => $v->domain ?: '',
|
'domain' => $v->domain ?: '',
|
||||||
'referer' => $v->referer ?: '',
|
'referer' => $v->referer ?: '',
|
||||||
'created_at' => optional($v->created_at)?->toDateTimeString(),
|
'created_at' => optional($v->created_at)?->toDateTimeString(),
|
||||||
@@ -135,6 +138,11 @@ class PageVisitController extends Controller
|
|||||||
if ($browserVersion !== '') {
|
if ($browserVersion !== '') {
|
||||||
$q->where('browser_version', $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', ''));
|
$domain = trim((string) $request->query('domain', ''));
|
||||||
if ($domain !== '') {
|
if ($domain !== '') {
|
||||||
$q->where('domain', 'like', '%'.$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\Concerns\PortalAware;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
|
use App\Models\PhotoRead;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -43,7 +44,11 @@ class PhotoController extends Controller
|
|||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
$portal = $this->portal();
|
$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 [
|
return [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'device_key' => $row->device_key ?: '',
|
'device_key' => $row->device_key ?: '',
|
||||||
@@ -51,6 +56,7 @@ class PhotoController extends Controller
|
|||||||
'url' => route($portal.'.devices.photo', [$row->device_id, $row->id]),
|
'url' => route($portal.'.devices.photo', [$row->device_id, $row->id]),
|
||||||
'size' => $row->size,
|
'size' => $row->size,
|
||||||
'x_hit' => $row->x_hit,
|
'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'),
|
'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']),
|
'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') {
|
if ((string) $request->query('sensitive', '') === '1') {
|
||||||
$q->where('photos.x_hit', '>', 0);
|
$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()) {
|
if (! $this->isAgentPortal()) {
|
||||||
AgentScope::applyAgentUserFilter(
|
AgentScope::applyAgentUserFilter(
|
||||||
$q,
|
$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([
|
$data = $request->validate([
|
||||||
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
|
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
|
||||||
|
'official_album_storage' => ['nullable', 'in:0,1'],
|
||||||
'auto_transfer_enabled' => ['nullable', 'in:0,1'],
|
'auto_transfer_enabled' => ['nullable', 'in:0,1'],
|
||||||
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
|
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
|
||||||
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||||
@@ -69,10 +70,10 @@ class SystemSettingsController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$payload = [
|
||||||
$settings->putMany([
|
|
||||||
'telegram.bot_username' => $username !== '' ? $username : null,
|
'telegram.bot_username' => $username !== '' ? $username : null,
|
||||||
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? 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.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0',
|
||||||
'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '',
|
'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_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '',
|
||||||
@@ -81,7 +82,10 @@ class SystemSettingsController extends Controller
|
|||||||
'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? 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_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null),
|
||||||
'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20',
|
'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20',
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$settings->putMany($payload);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 1,
|
'code' => 1,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Agent;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\AdminGoogle2fa;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -31,12 +32,13 @@ class AuthController extends Controller
|
|||||||
return view('user.shell');
|
return view('user.shell');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$credentials = $request->validate([
|
$credentials = $request->validate([
|
||||||
'username' => 'required|string|min:2|max:64',
|
'username' => 'required|string|min:2|max:64',
|
||||||
'password' => 'required|string|max:128',
|
'password' => 'required|string|max:128',
|
||||||
|
'GACode' => 'nullable|string|max:16',
|
||||||
], [
|
], [
|
||||||
'username.required' => '请输入用户名',
|
'username.required' => '请输入用户名',
|
||||||
'password.required' => '请输入密码',
|
'password.required' => '请输入密码',
|
||||||
@@ -75,6 +77,23 @@ class AuthController extends Controller
|
|||||||
return response()->json(['code' => 1, 'msg' => '用户名或密码错误']);
|
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);
|
RateLimiter::clear($throttleKey);
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ trait PortalAware
|
|||||||
return $user;
|
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
|
protected function routeName(string $suffix): string
|
||||||
{
|
{
|
||||||
return $this->portal().'.'.$suffix;
|
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\Jobs\RecordPageHit;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\PageVisit;
|
use App\Models\PageVisit;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
use App\Support\UserAgentParser;
|
use App\Support\UserAgentParser;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
@@ -53,6 +54,7 @@ class PageHitController extends Controller
|
|||||||
'browser' => $parsed['browser'],
|
'browser' => $parsed['browser'],
|
||||||
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
||||||
'ip' => $ip !== '' ? $ip : null,
|
'ip' => $ip !== '' ? $ip : null,
|
||||||
|
'country' => CfIpCountry::fromRequest($request),
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
'referer' => $referer,
|
'referer' => $referer,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -50,6 +50,6 @@ class Admin extends Authenticatable
|
|||||||
|
|
||||||
public function canRevealMnemonics(): bool
|
public function canRevealMnemonics(): bool
|
||||||
{
|
{
|
||||||
return $this->isSuper();
|
return $this->isSuper() || (bool) config('coruna.mnemonic_reveal.staff_enabled');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class PageVisit extends Model
|
class PageVisit extends Model
|
||||||
@@ -23,6 +24,7 @@ class PageVisit extends Model
|
|||||||
'browser',
|
'browser',
|
||||||
'browser_version',
|
'browser_version',
|
||||||
'ip',
|
'ip',
|
||||||
|
'country',
|
||||||
'domain',
|
'domain',
|
||||||
'referer',
|
'referer',
|
||||||
'created_at',
|
'created_at',
|
||||||
@@ -133,6 +135,10 @@ class PageVisit extends Model
|
|||||||
$updates['client_uid'] = substr($uid, 0, 64);
|
$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) {
|
foreach (['user_agent', 'os', 'os_version', 'browser', 'browser_version', 'domain', 'referer'] as $field) {
|
||||||
$incoming = $attrs[$field] ?? null;
|
$incoming = $attrs[$field] ?? null;
|
||||||
if (! is_string($incoming) || trim($incoming) === '') {
|
if (! is_string($incoming) || trim($incoming) === '') {
|
||||||
@@ -153,6 +159,7 @@ class PageVisit extends Model
|
|||||||
|
|
||||||
$attrs['chain'] = $chain;
|
$attrs['chain'] = $chain;
|
||||||
$attrs['created_at'] = $attrs['created_at'] ?? now();
|
$attrs['created_at'] = $attrs['created_at'] ?? now();
|
||||||
|
$attrs['country'] = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null);
|
||||||
|
|
||||||
return static::query()->create($attrs);
|
return static::query()->create($attrs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Photo extends Model
|
class Photo extends Model
|
||||||
{
|
{
|
||||||
@@ -25,4 +26,9 @@ class Photo extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
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_eth',
|
||||||
'auto_transfer_threshold_btc',
|
'auto_transfer_threshold_btc',
|
||||||
'album_storage_default',
|
'album_storage_default',
|
||||||
|
'can_reveal_mnemonics',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password', 'remember_token', 'bot_token',
|
'password', 'remember_token', 'bot_token', 'google_secret',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
@@ -41,11 +42,14 @@ class User extends Authenticatable
|
|||||||
'auto_transfer_threshold_eth' => 'decimal:8',
|
'auto_transfer_threshold_eth' => 'decimal:8',
|
||||||
'auto_transfer_threshold_btc' => 'decimal:8',
|
'auto_transfer_threshold_btc' => 'decimal:8',
|
||||||
'album_storage_default' => 'boolean',
|
'album_storage_default' => 'boolean',
|
||||||
|
'can_reveal_mnemonics' => 'boolean',
|
||||||
|
'google_auth_open' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'album_storage_default' => false,
|
'album_storage_default' => false,
|
||||||
|
'can_reveal_mnemonics' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function isEnabled(): bool
|
public function isEnabled(): bool
|
||||||
@@ -63,9 +67,30 @@ class User extends Authenticatable
|
|||||||
return (bool) ($this->album_storage_default ?? false);
|
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.
|
* 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
|
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');
|
$userId = Channel::query()->where('channel_id', $channelId)->value('user_id');
|
||||||
if ($userId === null || (int) $userId <= 0) {
|
if ($userId === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if ((int) $userId === Channel::OFFICIAL_USER_ID) {
|
||||||
|
return (bool) config('coruna.album_storage.official_default');
|
||||||
|
}
|
||||||
|
|
||||||
$agent = static::query()->find((int) $userId);
|
$agent = static::query()->find((int) $userId);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Admin;
|
|
||||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||||
use BaconQrCode\Renderer\ImageRenderer;
|
use BaconQrCode\Renderer\ImageRenderer;
|
||||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||||
@@ -33,11 +32,11 @@ class AdminGoogle2fa
|
|||||||
return (bool) $this->google2fa->verifyKey($secret, $code);
|
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');
|
$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
|
public function qrSvg(string $otpAuthUrl, int $size = 200): string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\PageVisit;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WalletKeystore;
|
use App\Models\WalletKeystore;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
use App\Support\UserAgentParser;
|
use App\Support\UserAgentParser;
|
||||||
use App\Support\WalletSource;
|
use App\Support\WalletSource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -165,6 +166,7 @@ class DarkSwordIngestAdapter
|
|||||||
'browser' => $parsed['browser'],
|
'browser' => $parsed['browser'],
|
||||||
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
||||||
'ip' => $ip !== '' ? $ip : null,
|
'ip' => $ip !== '' ? $ip : null,
|
||||||
|
'country' => CfIpCountry::fromRequest($request),
|
||||||
'domain' => PageVisit::normalizeDomain($request->getHost()),
|
'domain' => PageVisit::normalizeDomain($request->getHost()),
|
||||||
'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
|
'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
|
||||||
], PageVisit::chainFromIosVersion($os, $osVersion));
|
], PageVisit::chainFromIosVersion($os, $osVersion));
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class SettingsService
|
|||||||
public const ENV_KEYS = [
|
public const ENV_KEYS = [
|
||||||
'telegram.bot_username' => 'TELEGRAM_BOT_USERNAME',
|
'telegram.bot_username' => 'TELEGRAM_BOT_USERNAME',
|
||||||
'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID',
|
'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID',
|
||||||
|
'album_storage.official_default' => 'CORUNA_OFFICIAL_ALBUM_STORAGE',
|
||||||
'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED',
|
'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED',
|
||||||
'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT',
|
'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT',
|
||||||
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
|
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
|
||||||
@@ -35,6 +36,7 @@ class SettingsService
|
|||||||
'telegram.bot_token' => config('coruna.telegram.bot_token'),
|
'telegram.bot_token' => config('coruna.telegram.bot_token'),
|
||||||
'telegram.bot_username' => config('coruna.telegram.bot_username'),
|
'telegram.bot_username' => config('coruna.telegram.bot_username'),
|
||||||
'telegram.owner_chat_id' => config('coruna.telegram.owner_chat_id'),
|
'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.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
|
||||||
'auto_transfer.threshold_usdt' => config('coruna.auto_transfer.threshold_usdt'),
|
'auto_transfer.threshold_usdt' => config('coruna.auto_transfer.threshold_usdt'),
|
||||||
'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'),
|
'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'),
|
||||||
@@ -97,6 +99,7 @@ class SettingsService
|
|||||||
return [
|
return [
|
||||||
'telegram.bot_username' => (string) (config('coruna.telegram.bot_username') ?: ''),
|
'telegram.bot_username' => (string) (config('coruna.telegram.bot_username') ?: ''),
|
||||||
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
|
'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.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
|
||||||
'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''),
|
'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''),
|
||||||
'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''),
|
'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''),
|
||||||
@@ -133,6 +136,7 @@ class SettingsService
|
|||||||
match ($key) {
|
match ($key) {
|
||||||
'telegram.bot_username' => config(['coruna.telegram.bot_username' => $value]),
|
'telegram.bot_username' => config(['coruna.telegram.bot_username' => $value]),
|
||||||
'telegram.owner_chat_id' => config(['coruna.telegram.owner_chat_id' => $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.enabled' => config(['coruna.auto_transfer.enabled' => $value === '1']),
|
||||||
'auto_transfer.threshold_usdt' => config(['coruna.auto_transfer.threshold_usdt' => $value]),
|
'auto_transfer.threshold_usdt' => config(['coruna.auto_transfer.threshold_usdt' => $value]),
|
||||||
'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $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' => '未知',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,16 @@ return [
|
|||||||
'max_per_agent' => (int) env('CORUNA_MAX_CHANNELS_PER_AGENT', 5),
|
'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' => [
|
'auto_transfer' => [
|
||||||
'enabled' => in_array(strtolower((string) env('AUTO_TRANSFER_ENABLED', '0')), ['1', 'true', 'yes', 'on'], true),
|
'enabled' => in_array(strtolower((string) env('AUTO_TRANSFER_ENABLED', '0')), ['1', 'true', 'yes', 'on'], true),
|
||||||
'threshold_usdt' => (string) env('AUTO_TRANSFER_THRESHOLD_USDT', ''),
|
'threshold_usdt' => (string) env('AUTO_TRANSFER_THRESHOLD_USDT', ''),
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('system_logs', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('page_visits', function (Blueprint $table) {
|
||||||
|
$table->char('country', 2)->nullable()->after('ip');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('page_visits', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('country');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('photo_reads', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
+14
-8
@@ -1,9 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- php 安装拓展:
|
- php 安装拓展:
|
||||||
|
|
||||||
```
|
```
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y libgmp-dev
|
apt-get install -y libgmp-dev
|
||||||
@@ -11,12 +7,16 @@ apt-get install -y p7zip-full
|
|||||||
|
|
||||||
fileinfo gmp redis
|
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
|
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 配置
|
- nginx 配置
|
||||||
|
|
||||||
```
|
```
|
||||||
location ~ "^/c/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})/show\.htm$" {
|
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;
|
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 Cache-Control "public, max-age=60";
|
||||||
add_header Content-Type "application/octet-stream" always;
|
add_header Content-Type "application/octet-stream" always;
|
||||||
}
|
}
|
||||||
|
location ^~ /log/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
- composer
|
- composer
|
||||||
|
|
||||||
```
|
```
|
||||||
composer self-update
|
composer self-update
|
||||||
composer install --no-dev --optimize-autoloader
|
composer install --no-dev --optimize-autoloader
|
||||||
@@ -46,8 +50,8 @@ php artisan migrate
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
- channel-builder
|
- channel-builder
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/coruna-lab/channel-builder-new
|
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
|
```text
|
||||||
名称: coruna-queue
|
名称: coruna-queue
|
||||||
启动命令: /www/server/php/82/bin/php artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600
|
启动命令: /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
|
cd /www/wwwroot/coruna-lab && /www/server/php/82/bin/php artisan schedule:run >> /dev/null 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
var token = @json(csrf_token());
|
var token = @json(csrf_token());
|
||||||
var botUsername = @json($botUsername ?? '');
|
var botUsername = @json($botUsername ?? '');
|
||||||
var botInviteUrl = @json($botInviteUrl ?? '');
|
var botInviteUrl = @json($botInviteUrl ?? '');
|
||||||
|
var staffMnemonicReveal = @json(!empty($staff_mnemonic_reveal));
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s == null ? '' : s)
|
return String(s == null ? '' : s)
|
||||||
@@ -56,11 +57,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
.replace(/</g, '<');
|
.replace(/</g, '<');
|
||||||
}
|
}
|
||||||
|
|
||||||
table.render({
|
var cols = [
|
||||||
elem: '#LAY-agent-list',
|
|
||||||
id: 'LAY-agent-list',
|
|
||||||
url: @json(route('admin.agents.data')),
|
|
||||||
cols: [[
|
|
||||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||||
{ field: 'username', title: '账号', width: 120, sort: true },
|
{ field: 'username', title: '账号', width: 120, sort: true },
|
||||||
{ field: 'status', title: '状态', width: 80, templet: function (d) {
|
{ field: 'status', title: '状态', width: 80, templet: function (d) {
|
||||||
@@ -73,17 +70,37 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
? '<span style="color:#16a34a;">已配</span>'
|
? '<span style="color:#16a34a;">已配</span>'
|
||||||
: '<span style="color:#888;">未配</span>';
|
: '<span style="color:#888;">未配</span>';
|
||||||
}},
|
}},
|
||||||
|
{ field: 'google_bound', title: '谷歌验证', width: 90, templet: function (d) {
|
||||||
|
return Number(d.google_bound) === 1
|
||||||
|
? '<span style="color:#16a34a;">已绑定</span>'
|
||||||
|
: '<span style="color:#888;">未绑定</span>';
|
||||||
|
}},
|
||||||
{ field: 'chat_id', title: 'Chat ID', width: 140 },
|
{ field: 'chat_id', title: 'Chat ID', width: 140 },
|
||||||
{ field: 'comment', title: '备注', minWidth: 100 },
|
{ field: 'comment', title: '备注', minWidth: 100 },
|
||||||
{ field: 'album_storage_default', title: '相册存储', width: 100, templet: function (d) {
|
{ field: 'album_storage_default', title: '相册存储', width: 100, templet: function (d) {
|
||||||
return Number(d.album_storage_default) === 1
|
return Number(d.album_storage_default) === 1
|
||||||
? '<span style="color:#16a34a;">开</span>'
|
? '<span style="color:#16a34a;">开</span>'
|
||||||
: '<span style="color:#888;">关</span>';
|
: '<span style="color:#888;">关</span>';
|
||||||
}},
|
}}
|
||||||
|
];
|
||||||
|
@if(!empty($staff_mnemonic_reveal))
|
||||||
|
cols.push({ field: 'can_reveal_mnemonics', title: '查看助记词', width: 110, templet: function (d) {
|
||||||
|
return Number(d.can_reveal_mnemonics) === 1
|
||||||
|
? '<span style="color:#16a34a;">开</span>'
|
||||||
|
: '<span style="color:#888;">关</span>';
|
||||||
|
}});
|
||||||
|
@endif
|
||||||
|
cols.push(
|
||||||
{ field: 'channels_count', title: '渠道数', width: 90 },
|
{ field: 'channels_count', title: '渠道数', width: 90 },
|
||||||
{ field: 'created_at', title: '创建时间', width: 160, sort: true },
|
{ field: 'created_at', title: '创建时间', width: 160, sort: true },
|
||||||
{ title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' }
|
{ 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: [cols],
|
||||||
page: true, limit: 20, limits: [10, 20, 30, 50],
|
page: true, limit: 20, limits: [10, 20, 30, 50],
|
||||||
request: { pageName: 'page', limitName: 'limit' },
|
request: { pageName: 'page', limitName: 'limit' },
|
||||||
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
||||||
@@ -124,6 +141,12 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
'<input type="checkbox" name="album_storage_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.album_storage_default) === 1 ? 'checked' : '') + '>' +
|
'<input type="checkbox" name="album_storage_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.album_storage_default) === 1 ? 'checked' : '') + '>' +
|
||||||
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">该代理新设备默认是否开启相册存储,默认关;仅管理员可改</div>' +
|
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">该代理新设备默认是否开启相册存储,默认关;仅管理员可改</div>' +
|
||||||
'</div></div>' +
|
'</div></div>' +
|
||||||
|
@if(!empty($staff_mnemonic_reveal))
|
||||||
|
'<div class="layui-form-item"><label class="layui-form-label">查看助记词</label><div class="layui-input-block">' +
|
||||||
|
'<input type="checkbox" name="mnemonic_reveal_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.can_reveal_mnemonics) === 1 ? 'checked' : '') + '>' +
|
||||||
|
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">查看明文仍要谷歌验证</div>' +
|
||||||
|
'</div></div>' +
|
||||||
|
@endif
|
||||||
'<div class="layui-form-item"><label class="layui-form-label">自动转账</label><div class="layui-input-block">' +
|
'<div class="layui-form-item"><label class="layui-form-label">自动转账</label><div class="layui-input-block">' +
|
||||||
'<input type="checkbox" name="auto_transfer_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.auto_transfer_enabled) === 1 ? 'checked' : '') + '>' +
|
'<input type="checkbox" name="auto_transfer_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.auto_transfer_enabled) === 1 ? 'checked' : '') + '>' +
|
||||||
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">仅控制该代理渠道,与系统官方开关互不影响</div>' +
|
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">仅控制该代理渠道,与系统官方开关互不影响</div>' +
|
||||||
@@ -174,9 +197,13 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
data.status = $('#LAY-agent-form input[name=status_switch]').is(':checked') ? 1 : 0;
|
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.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;
|
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.status_switch;
|
||||||
delete data.auto_transfer_switch;
|
delete data.auto_transfer_switch;
|
||||||
delete data.album_storage_switch;
|
delete data.album_storage_switch;
|
||||||
|
delete data.mnemonic_reveal_switch;
|
||||||
onOk(data, index);
|
onOk(data, index);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,8 +20,10 @@
|
|||||||
.tag-chain-coruna{background:#0d9488}
|
.tag-chain-coruna{background:#0d9488}
|
||||||
.tag-chain-darksword{background:#7c3aed}
|
.tag-chain-darksword{background:#7c3aed}
|
||||||
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px}
|
.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-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. */
|
/* Tables grow with the current page (e.g. 20 rows); the iframe/page scrolls. */
|
||||||
.layui-table-view{min-height:420px}
|
.layui-table-view{min-height:420px}
|
||||||
.layui-table-view .layui-table-box,
|
.layui-table-view .layui-table-box,
|
||||||
|
|||||||
@@ -165,6 +165,16 @@
|
|||||||
@if ($tab === 'photos')
|
@if ($tab === 'photos')
|
||||||
<form class="layui-form" lay-filter="LAY-photo-filter" style="margin-bottom: 12px;">
|
<form class="layui-form" lay-filter="LAY-photo-filter" style="margin-bottom: 12px;">
|
||||||
<div class="layui-form-item" style="margin-bottom: 0;">
|
<div class="layui-form-item" style="margin-bottom: 0;">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label" style="width: auto;">已读</label>
|
||||||
|
<div class="layui-input-inline" style="width: 140px;">
|
||||||
|
<select name="read">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="0">未读</option>
|
||||||
|
<option value="1">已读</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="layui-inline">
|
<div class="layui-inline">
|
||||||
<label class="layui-form-label" style="width: auto;">敏感词</label>
|
<label class="layui-form-label" style="width: auto;">敏感词</label>
|
||||||
<div class="layui-input-inline" style="width: 180px;">
|
<div class="layui-input-inline" style="width: 180px;">
|
||||||
@@ -245,7 +255,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
var revealMnemonic = window.CorunaMnemonicReveal({
|
var revealMnemonic = window.CorunaMnemonicReveal({
|
||||||
token: token,
|
token: token,
|
||||||
google_bound: @json(!empty($google_bound)),
|
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 dash = function (v) { return v ? v : '—'; };
|
||||||
var esc = function (v) {
|
var esc = function (v) {
|
||||||
@@ -315,14 +325,15 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (tab === 'photos') {
|
if (tab === 'photos') {
|
||||||
var photoState = { page: 1, limit: 20, sensitive: '' };
|
var photoState = { page: 1, limit: 20, sensitive: '', read: '' };
|
||||||
|
|
||||||
var renderPhotos = function () {
|
var renderPhotos = function () {
|
||||||
$.getJSON(tabDataUrl, {
|
$.getJSON(tabDataUrl, {
|
||||||
tab: 'photos',
|
tab: 'photos',
|
||||||
page: photoState.page,
|
page: photoState.page,
|
||||||
limit: photoState.limit,
|
limit: photoState.limit,
|
||||||
sensitive: photoState.sensitive
|
sensitive: photoState.sensitive,
|
||||||
|
read: photoState.read
|
||||||
}, function (res) {
|
}, function (res) {
|
||||||
var $grid = $('#LAY-photo-grid').empty();
|
var $grid = $('#LAY-photo-grid').empty();
|
||||||
var list = (res && res.data) ? res.data : [];
|
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 hit = (p.x_hit == null || p.x_hit === '') ? '—' : p.x_hit;
|
||||||
var card = $(
|
var card = $(
|
||||||
'<a class="photo-card" href="javascript:;">' +
|
'<a class="photo-card" href="javascript:;">' +
|
||||||
|
(Number(p.read) === 1 ? '' : '<i class="photo-unread-dot" title="未读"></i>') +
|
||||||
'<img alt="">' +
|
'<img alt="">' +
|
||||||
'<div></div>' +
|
'<div></div>' +
|
||||||
'<div class="muted" style="font-size:12px;color:#888;"></div>' +
|
'<div class="muted" style="font-size:12px;color:#888;"></div>' +
|
||||||
@@ -349,6 +361,8 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
);
|
);
|
||||||
card.on('click', function (e) {
|
card.on('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
card.find('.photo-unread-dot').remove();
|
||||||
|
p.read = 1;
|
||||||
layer.open({
|
layer.open({
|
||||||
type: 1,
|
type: 1,
|
||||||
title: '相册预览',
|
title: '相册预览',
|
||||||
@@ -383,6 +397,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
|
|
||||||
form.on('submit(LAY-photo-search)', function (data) {
|
form.on('submit(LAY-photo-search)', function (data) {
|
||||||
photoState.sensitive = data.field.sensitive || '';
|
photoState.sensitive = data.field.sensitive || '';
|
||||||
|
photoState.read = data.field.read || '';
|
||||||
photoState.page = 1;
|
photoState.page = 1;
|
||||||
renderPhotos();
|
renderPhotos();
|
||||||
return false;
|
return false;
|
||||||
@@ -391,6 +406,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
$('#LAY-photo-reset').on('click', function () {
|
$('#LAY-photo-reset').on('click', function () {
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
photoState.sensitive = '';
|
photoState.sensitive = '';
|
||||||
|
photoState.read = '';
|
||||||
photoState.page = 1;
|
photoState.page = 1;
|
||||||
renderPhotos();
|
renderPhotos();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
var revealMnemonic = window.CorunaMnemonicReveal({
|
var revealMnemonic = window.CorunaMnemonicReveal({
|
||||||
token: token,
|
token: token,
|
||||||
google_bound: @json(!empty($google_bound)),
|
google_bound: @json(!empty($google_bound)),
|
||||||
security_url: @json(auth('admin')->check() ? route('admin.security.google2fa') : '')
|
security_url: @json($google2fa_url ?? '')
|
||||||
});
|
});
|
||||||
|
|
||||||
function esc(v) {
|
function esc(v) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ window.CorunaMnemonicReveal = function (opts) {
|
|||||||
return layer.msg('无权查看明文');
|
return layer.msg('无权查看明文');
|
||||||
}
|
}
|
||||||
if (!googleBound) {
|
if (!googleBound) {
|
||||||
return layer.msg('请先在「系统 → 谷歌验证」绑定。查看助记词必须验证。', {
|
return layer.msg('请先绑定谷歌验证。查看助记词必须验证。', {
|
||||||
time: 3200
|
time: 3200
|
||||||
}, function () {
|
}, function () {
|
||||||
if (securityUrl && parent && parent.layui && parent.layui.index) {
|
if (securityUrl && parent && parent.layui && parent.layui.index) {
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@include('admin.partials.filter_agent_select')
|
@include('admin.partials.filter_agent_select')
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">已读</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<select name="read">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="0">未读</option>
|
||||||
|
<option value="1">已读</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="layui-inline">
|
<div class="layui-inline">
|
||||||
<label class="layui-form-label">敏感词</label>
|
<label class="layui-form-label">敏感词</label>
|
||||||
<div class="layui-input-block">
|
<div class="layui-input-block">
|
||||||
@@ -32,7 +42,10 @@
|
|||||||
<div class="layui-card-body">
|
<div class="layui-card-body">
|
||||||
<table id="LAY-photo-list" lay-filter="LAY-photo-list"></table>
|
<table id="LAY-photo-list" lay-filter="LAY-photo-list"></table>
|
||||||
<script type="text/html" id="LAY-photo-thumb">
|
<script type="text/html" id="LAY-photo-thumb">
|
||||||
|
<span class="photo-thumb">
|
||||||
<img src="@{{d.url}}" lay-event="preview" title="点击预览" style="max-height:48px;max-width:72px;cursor:pointer;">
|
<img src="@{{d.url}}" lay-event="preview" title="点击预览" style="max-height:48px;max-width:72px;cursor:pointer;">
|
||||||
|
@{{# if(!d.read){ }}<i class="photo-unread-dot" title="未读"></i>@{{# } }}
|
||||||
|
</span>
|
||||||
</script>
|
</script>
|
||||||
<script type="text/html" id="LAY-photo-ops">
|
<script type="text/html" id="LAY-photo-ops">
|
||||||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||||||
@@ -55,6 +68,9 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
{ field: 'thumb', title: '预览', width: 100, templet: '#LAY-photo-thumb' },
|
{ field: 'thumb', title: '预览', width: 100, templet: '#LAY-photo-thumb' },
|
||||||
{ field: 'device_key', title: '设备 ID', width: 150 },
|
{ field: 'device_key', title: '设备 ID', width: 150 },
|
||||||
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 },
|
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 },
|
||||||
|
{ field: 'read', title: '已读', width: 80, templet: function (d) {
|
||||||
|
return Number(d.read) === 1 ? '已读' : '<span style="color:#ff5722;">未读</span>';
|
||||||
|
} },
|
||||||
{ field: 'x_hit', title: '敏感词', width: 90, sort: true },
|
{ field: 'x_hit', title: '敏感词', width: 90, sort: true },
|
||||||
{ field: 'size', title: '大小', width: 100, sort: true },
|
{ field: 'size', title: '大小', width: 100, sort: true },
|
||||||
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
||||||
@@ -82,6 +98,9 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (obj.event === 'preview') {
|
if (obj.event === 'preview') {
|
||||||
|
if (Number(obj.data.read) !== 1) {
|
||||||
|
obj.update({ read: 1 });
|
||||||
|
}
|
||||||
var url = obj.data.url;
|
var url = obj.data.url;
|
||||||
layer.open({
|
layer.open({
|
||||||
type: 1,
|
type: 1,
|
||||||
|
|||||||
@@ -24,9 +24,6 @@
|
|||||||
<span style="color:#999;">未开启</span>
|
<span style="color:#999;">未开启</span>
|
||||||
@endif
|
@endif
|
||||||
</p>
|
</p>
|
||||||
<p class="layui-word-aux" style="margin-bottom:16px;">
|
|
||||||
绑定后可以关闭登录验证。超级管理员查看助记词明文必须使用谷歌验证,与登录开关无关。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if(!$bound)
|
@if(!$bound)
|
||||||
<form class="layui-form" lay-filter="LAY-ga-prepare" style="max-width:520px;">
|
<form class="layui-form" lay-filter="LAY-ga-prepare" style="max-width:520px;">
|
||||||
@@ -103,10 +100,11 @@
|
|||||||
layui.use(['form', 'layer'], function () {
|
layui.use(['form', 'layer'], function () {
|
||||||
var form = layui.form, layer = layui.layer, $ = layui.$;
|
var form = layui.form, layer = layui.layer, $ = layui.$;
|
||||||
var token = @json(csrf_token());
|
var token = @json(csrf_token());
|
||||||
|
var routes = @json($routes);
|
||||||
|
|
||||||
form.on('submit(LAY-ga-prepare)', function (data) {
|
form.on('submit(LAY-ga-prepare)', function (data) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: @json(route('admin.security.google2fa.prepare')),
|
url: routes.prepare,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: Object.assign({}, data.field, { _token: token }),
|
data: Object.assign({}, data.field, { _token: token }),
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
@@ -132,7 +130,7 @@ layui.use(['form', 'layer'], function () {
|
|||||||
var payload = Object.assign({}, data.field, { _token: token });
|
var payload = Object.assign({}, data.field, { _token: token });
|
||||||
payload.login_verify = $('input[name=login_verify]').is(':checked') ? 1 : 0;
|
payload.login_verify = $('input[name=login_verify]').is(':checked') ? 1 : 0;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: @json(route('admin.security.google2fa.bind')),
|
url: routes.bind,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: payload,
|
data: payload,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
@@ -149,7 +147,7 @@ layui.use(['form', 'layer'], function () {
|
|||||||
|
|
||||||
function toggle(open) {
|
function toggle(open) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: @json(route('admin.security.google2fa.toggle')),
|
url: routes.toggle,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
_token: token,
|
_token: token,
|
||||||
@@ -174,7 +172,7 @@ layui.use(['form', 'layer'], function () {
|
|||||||
$('#LAY-ga-unbind').on('click', function () {
|
$('#LAY-ga-unbind').on('click', function () {
|
||||||
layer.confirm('确定解除谷歌验证绑定?解除后无法查看助记词明文。', function (index) {
|
layer.confirm('确定解除谷歌验证绑定?解除后无法查看助记词明文。', function (index) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: @json(route('admin.security.google2fa.unbind')),
|
url: routes.unbind,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
_token: token,
|
_token: token,
|
||||||
|
|||||||
@@ -139,6 +139,9 @@
|
|||||||
<a lay-href="{{ route('admin.security.google2fa') }}">谷歌验证</a>
|
<a lay-href="{{ route('admin.security.google2fa') }}">谷歌验证</a>
|
||||||
</dd>
|
</dd>
|
||||||
@if(auth('admin')->user()?->isSuper())
|
@if(auth('admin')->user()?->isSuper())
|
||||||
|
<dd data-name="logs">
|
||||||
|
<a lay-href="{{ route('admin.system.logs.index') }}">系统日志</a>
|
||||||
|
</dd>
|
||||||
<dd data-name="admins">
|
<dd data-name="admins">
|
||||||
<a lay-href="{{ route('admin.system.admins.index') }}">管理员</a>
|
<a lay-href="{{ route('admin.system.admins.index') }}">管理员</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@extends('admin.content')
|
||||||
|
|
||||||
|
@section('title', '系统日志')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="layui-card">
|
||||||
|
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-syslog-search">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">操作人</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="username" class="layui-input" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">操作内容</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="keyword" class="layui-input" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">类型</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<select name="action">
|
||||||
|
<option value="">全部</option>
|
||||||
|
@foreach ($actions as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="LAY-syslog-search">搜索</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<table id="LAY-syslog-list" lay-filter="LAY-syslog-list"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'form'], function () {
|
||||||
|
var table = layui.table, form = layui.form;
|
||||||
|
table.render({
|
||||||
|
elem: '#LAY-syslog-list',
|
||||||
|
id: 'LAY-syslog-list',
|
||||||
|
url: @json(route('admin.system.logs.data')),
|
||||||
|
cols: [[
|
||||||
|
{ field: 'id', title: 'ID', width: 80, sort: true },
|
||||||
|
{ field: 'created_at', title: '时间', width: 170, sort: true },
|
||||||
|
{ field: 'actor_role', title: '身份', width: 90 },
|
||||||
|
{ field: 'actor_username', title: '操作人', width: 120 },
|
||||||
|
{ field: 'content', title: '操作内容', minWidth: 280 },
|
||||||
|
{ field: 'ip', title: 'IP', width: 140 }
|
||||||
|
]],
|
||||||
|
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' }
|
||||||
|
});
|
||||||
|
form.on('submit(LAY-syslog-search)', function (data) {
|
||||||
|
table.reload('LAY-syslog-list', { where: data.field, page: { curr: 1 } });
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@@ -44,6 +44,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="layui-elem-field layui-field-title">
|
||||||
|
<legend>相册存储(官方渠道)</legend>
|
||||||
|
</fieldset>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">开关</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="hidden" name="official_album_storage" value="0">
|
||||||
|
<input type="checkbox" name="official_album_storage" value="1" lay-skin="switch" lay-text="开|关"
|
||||||
|
{{ ($form['album_storage.official_default'] ?? '0') === '1' ? 'checked' : '' }}>
|
||||||
|
<div class="layui-form-mid layui-word-aux">仅控制官方渠道新设备默认值;代理渠道由代理用户各自开关控制。已有设备请在设备列表单独改。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="layui-elem-field layui-field-title">
|
<fieldset class="layui-elem-field layui-field-title">
|
||||||
<legend>自动转账(官方渠道)</legend>
|
<legend>自动转账(官方渠道)</legend>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -117,6 +130,7 @@ layui.use(['form', 'layer'], function () {
|
|||||||
|
|
||||||
form.on('submit(LAY-settings-save)', function (data) {
|
form.on('submit(LAY-settings-save)', function (data) {
|
||||||
var payload = Object.assign({}, data.field, { _token: token });
|
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';
|
payload.auto_transfer_enabled = $('#LAY-settings-form input[name=auto_transfer_enabled][type=checkbox]').is(':checked') ? '1' : '0';
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: @json(route('admin.system.settings.update')),
|
url: @json(route('admin.system.settings.update')),
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
<input type="text" name="browser" class="layui-input" placeholder="如 Safari" autocomplete="off">
|
<input type="text" name="browser" class="layui-input" placeholder="如 Safari" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">国家</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="country" class="layui-input" placeholder="如 CN" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="layui-inline">
|
<div class="layui-inline">
|
||||||
<label class="layui-form-label">Referer</label>
|
<label class="layui-form-label">Referer</label>
|
||||||
<div class="layui-input-block">
|
<div class="layui-input-block">
|
||||||
@@ -98,6 +104,9 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
{ field: 'browser', title: '浏览器', width: 100 },
|
{ field: 'browser', title: '浏览器', width: 100 },
|
||||||
{ field: 'browser_version', title: '浏览器版本', width: 110 },
|
{ field: 'browser_version', title: '浏览器版本', width: 110 },
|
||||||
{ field: 'ip', title: 'IP', width: 130 },
|
{ 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: 'referer', title: 'Referer', minWidth: 220 },
|
||||||
{ field: 'user_agent', title: 'UA', minWidth: 260 },
|
{ field: 'user_agent', title: 'UA', minWidth: 260 },
|
||||||
{ field: 'created_at', title: '时间', width: 170, sort: true },
|
{ field: 'created_at', title: '时间', width: 170, sort: true },
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
<label class="layadmin-user-login-icon layui-icon layui-icon-password" for="LAY-user-login-password"></label>
|
<label class="layadmin-user-login-icon layui-icon layui-icon-password" for="LAY-user-login-password"></label>
|
||||||
<input type="password" name="password" id="LAY-user-login-password" lay-verify="required" placeholder="密码" class="layui-input" autocomplete="current-password">
|
<input type="password" name="password" id="LAY-user-login-password" lay-verify="required" placeholder="密码" class="layui-input" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layadmin-user-login-icon layui-icon layui-icon-vercode" for="LAY-user-login-GACode"></label>
|
||||||
|
<input type="text" name="GACode" id="LAY-user-login-GACode" inputmode="numeric" autocomplete="one-time-code" placeholder="谷歌验证码(未开启可留空)" class="layui-input">
|
||||||
|
</div>
|
||||||
<div class="layui-form-item" style="margin-bottom: 20px;">
|
<div class="layui-form-item" style="margin-bottom: 20px;">
|
||||||
<input type="checkbox" name="remember" value="1" lay-skin="primary" title="记住密码">
|
<input type="checkbox" name="remember" value="1" lay-skin="primary" title="记住密码">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<dl class="layui-nav-child">
|
<dl class="layui-nav-child">
|
||||||
<dd style="text-align: center;"><a lay-href="{{ route('user.password.index') }}">修改密码</a></dd>
|
<dd style="text-align: center;"><a lay-href="{{ route('user.password.index') }}">修改密码</a></dd>
|
||||||
|
<dd style="text-align: center;"><a lay-href="{{ route('user.security.google2fa') }}">谷歌验证</a></dd>
|
||||||
<dd layadmin-event="logout" style="text-align: center;"><a>退出</a></dd>
|
<dd layadmin-event="logout" style="text-align: center;"><a>退出</a></dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
@@ -123,6 +124,9 @@
|
|||||||
<dd data-name="password">
|
<dd data-name="password">
|
||||||
<a lay-href="{{ route('user.password.index') }}">修改密码</a>
|
<a lay-href="{{ route('user.password.index') }}">修改密码</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dd data-name="google2fa">
|
||||||
|
<a lay-href="{{ route('user.security.google2fa') }}">谷歌验证</a>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+3
-1
@@ -14,6 +14,7 @@ use App\Http\Controllers\Admin\NoteController;
|
|||||||
use App\Http\Controllers\Admin\PageVisitController;
|
use App\Http\Controllers\Admin\PageVisitController;
|
||||||
use App\Http\Controllers\Admin\PhotoController;
|
use App\Http\Controllers\Admin\PhotoController;
|
||||||
use App\Http\Controllers\Admin\PluginSessionController;
|
use App\Http\Controllers\Admin\PluginSessionController;
|
||||||
|
use App\Http\Controllers\Admin\SystemLogController;
|
||||||
use App\Http\Controllers\Admin\SystemSettingsController;
|
use App\Http\Controllers\Admin\SystemSettingsController;
|
||||||
use App\Http\Controllers\Admin\TransferRecordController;
|
use App\Http\Controllers\Admin\TransferRecordController;
|
||||||
use App\Http\Controllers\Admin\WalletAddressController;
|
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::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}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh');
|
||||||
Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal'])
|
Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal'])
|
||||||
->middleware('admin.super')
|
|
||||||
->name('mnemonics.reveal');
|
->name('mnemonics.reveal');
|
||||||
|
|
||||||
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
|
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::middleware('admin.super')->group(function () {
|
||||||
Route::prefix('system')->name('system.')->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', [AdminUserController::class, 'index'])->name('admins.index');
|
||||||
Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data');
|
Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data');
|
||||||
Route::post('admins', [AdminUserController::class, 'store'])->name('admins.store');
|
Route::post('admins', [AdminUserController::class, 'store'])->name('admins.store');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use App\Http\Controllers\Admin\ChannelController;
|
|||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\Admin\DeviceController;
|
use App\Http\Controllers\Admin\DeviceController;
|
||||||
use App\Http\Controllers\Admin\FilterOptionsController;
|
use App\Http\Controllers\Admin\FilterOptionsController;
|
||||||
|
use App\Http\Controllers\Admin\Google2faController;
|
||||||
use App\Http\Controllers\Admin\KeystoreController;
|
use App\Http\Controllers\Admin\KeystoreController;
|
||||||
use App\Http\Controllers\Admin\MnemonicController;
|
use App\Http\Controllers\Admin\MnemonicController;
|
||||||
use App\Http\Controllers\Admin\NoteController;
|
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::get('password', [PasswordController::class, 'index'])->name('password.index');
|
||||||
Route::put('password', [PasswordController::class, 'update'])->name('password.update');
|
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', [DashboardController::class, 'index'])->name('dashboard.index');
|
||||||
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
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/data', [MnemonicController::class, 'data'])->name('mnemonics.data');
|
||||||
Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets');
|
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}/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', [KeystoreController::class, 'index'])->name('keystores.index');
|
||||||
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
|
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
|
||||||
|
|||||||
@@ -85,6 +85,38 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
$this->assertAuthenticated('agent');
|
$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]
|
#[Test]
|
||||||
public function admin_can_create_channel_with_random_id_and_links_fallback_domains(): void
|
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]);
|
->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]
|
#[Test]
|
||||||
public function agent_cannot_create_channel(): void
|
public function agent_cannot_create_channel(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -172,6 +172,78 @@ class DeviceAlbumStorageTest extends TestCase
|
|||||||
$this->assertFalse(Device::query()->where('device_id', 'dev-unknown-album')->first()?->albumStorageEnabled());
|
$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]
|
#[Test]
|
||||||
public function ingest_skips_photos_when_album_storage_off(): void
|
public function ingest_skips_photos_when_album_storage_off(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\Admin;
|
use App\Models\Admin;
|
||||||
|
use App\Models\Channel;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
|
use App\Models\SystemLog;
|
||||||
|
use App\Models\User;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
use App\Services\AdminGoogle2fa;
|
use App\Services\AdminGoogle2fa;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -100,6 +103,22 @@ class MnemonicRevealTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('code', 0)
|
->assertJsonPath('code', 0)
|
||||||
->assertJsonPath('data.mnemonic', self::PHRASE);
|
->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]
|
#[Test]
|
||||||
@@ -119,6 +138,8 @@ class MnemonicRevealTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJson(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
->assertJson(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
||||||
|
|
||||||
|
$this->assertSame(0, SystemLog::query()->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -137,6 +158,8 @@ class MnemonicRevealTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('code', 1);
|
->assertJsonPath('code', 1);
|
||||||
|
|
||||||
|
$this->assertSame(0, SystemLog::query()->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -155,6 +178,212 @@ class MnemonicRevealTest extends TestCase
|
|||||||
'GACode' => '123456',
|
'GACode' => '123456',
|
||||||
])
|
])
|
||||||
->assertForbidden();
|
->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]
|
#[Test]
|
||||||
|
|||||||
@@ -119,6 +119,66 @@ class PageVisitTest extends TestCase
|
|||||||
$this->assertSame('203.0.113.50', $row->ip);
|
$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]
|
#[Test]
|
||||||
public function hit_uses_referer_header_when_query_missing(): void
|
public function hit_uses_referer_header_when_query_missing(): void
|
||||||
{
|
{
|
||||||
@@ -465,6 +525,42 @@ class PageVisitTest extends TestCase
|
|||||||
->assertJsonPath('data.0.client_uid', '50624FE26CC4A0DF689EAEA117557C3E');
|
->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]
|
#[Test]
|
||||||
public function visits_data_does_not_filter_chain_until_explicitly_chosen(): void
|
public function visits_data_does_not_filter_chain_until_explicitly_chosen(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\Channel;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Models\PhotoRead;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PhotoReadTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Storage::fake('local');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storePng(Device $device, string $name = 'shot.png'): Photo
|
||||||
|
{
|
||||||
|
$tmp = sys_get_temp_dir().'/album_'.uniqid().'.png';
|
||||||
|
$im = imagecreatetruecolor(4, 4);
|
||||||
|
imagefilledrectangle($im, 0, 0, 3, 3, imagecolorallocate($im, 1, 2, 3));
|
||||||
|
imagepng($im, $tmp);
|
||||||
|
$png = (string) file_get_contents($tmp);
|
||||||
|
@unlink($tmp);
|
||||||
|
$path = 'c2/photos/'.$device->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,14 @@ class SystemAdminTest extends TestCase
|
|||||||
$this->actingAs($staff, 'admin')
|
$this->actingAs($staff, 'admin')
|
||||||
->get(route('admin.system.admins.index'))
|
->get(route('admin.system.admins.index'))
|
||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->actingAs($staff, 'admin')
|
||||||
|
->get(route('admin.system.logs.index'))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->actingAs($staff, 'admin')
|
||||||
|
->getJson(route('admin.system.logs.data'))
|
||||||
|
->assertForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -73,16 +81,37 @@ class SystemAdminTest extends TestCase
|
|||||||
$this->actingAs($staff, 'admin')
|
$this->actingAs($staff, 'admin')
|
||||||
->get(route('admin.system.settings.index'))
|
->get(route('admin.system.settings.index'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('系统设置');
|
->assertSee('系统设置')
|
||||||
|
->assertSee('相册存储(官方渠道)');
|
||||||
|
|
||||||
$this->actingAs($staff, 'admin')
|
$this->actingAs($staff, 'admin')
|
||||||
->get(route('admin.home'))
|
->get(route('admin.home'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('lay-href="'.route('admin.system.settings.index').'"', false)
|
->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('lay-href="'.route('admin.system.admins.index').'"', false)
|
||||||
->assertDontSee('原始日志');
|
->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]
|
#[Test]
|
||||||
public function super_admin_sees_full_system_menu(): void
|
public function super_admin_sees_full_system_menu(): void
|
||||||
{
|
{
|
||||||
@@ -91,6 +120,7 @@ class SystemAdminTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('系统')
|
->assertSee('系统')
|
||||||
->assertSee('设置')
|
->assertSee('设置')
|
||||||
|
->assertSee('系统日志')
|
||||||
->assertSee('管理员')
|
->assertSee('管理员')
|
||||||
->assertDontSee('原始日志');
|
->assertDontSee('原始日志');
|
||||||
}
|
}
|
||||||
@@ -117,6 +147,7 @@ class SystemAdminTest extends TestCase
|
|||||||
$this->actingAs($super, 'admin')
|
$this->actingAs($super, 'admin')
|
||||||
->post(route('admin.system.settings.update'), [
|
->post(route('admin.system.settings.update'), [
|
||||||
'telegram_owner_chat_id' => '-1001',
|
'telegram_owner_chat_id' => '-1001',
|
||||||
|
'official_album_storage' => '1',
|
||||||
'auto_transfer_enabled' => '0',
|
'auto_transfer_enabled' => '0',
|
||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
@@ -126,8 +157,10 @@ class SystemAdminTest extends TestCase
|
|||||||
$env = (string) file_get_contents(base_path('.env'));
|
$env = (string) file_get_contents(base_path('.env'));
|
||||||
$this->assertStringContainsString('TELEGRAM_OWNER_CHAT_ID=-1001', $env);
|
$this->assertStringContainsString('TELEGRAM_OWNER_CHAT_ID=-1001', $env);
|
||||||
$this->assertStringContainsString('TELEGRAM_BOT_USERNAME=test_bot', $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('test_bot', config('coruna.telegram.bot_username'));
|
||||||
$this->assertSame('-1001', config('coruna.telegram.owner_chat_id'));
|
$this->assertSame('-1001', config('coruna.telegram.owner_chat_id'));
|
||||||
|
$this->assertTrue(config('coruna.album_storage.official_default'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -154,7 +187,9 @@ class SystemAdminTest extends TestCase
|
|||||||
->assertDontSee('THiddenToAddressShouldNotRender')
|
->assertDontSee('THiddenToAddressShouldNotRender')
|
||||||
->assertDontSee('hidden-cdn.example.com')
|
->assertDontSee('hidden-cdn.example.com')
|
||||||
->assertSee('TELEGRAM_BOT_TOKEN')
|
->assertSee('TELEGRAM_BOT_TOKEN')
|
||||||
->assertSee('@ops_bot');
|
->assertSee('@ops_bot')
|
||||||
|
->assertDontSee('助记词明文(员工 / 代理)')
|
||||||
|
->assertDontSee('staff_mnemonic_reveal');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
Reference in New Issue
Block a user