feat(07-03): bundle-gates + cloudflare-probes — the shipped-bundle scan and the live probes (T-07-04, T-07-16)
- runBundleTelemetryGate: PRODUCTION web/dist ONLY (dist-harness forbidden, BA5); analytics/beacon token set (sendBeacon explicit form); positive host allowlist with the host inventory printed, never assumed (A4); deep-link crypto (D-11) and storage tokens JS-assets-only; a missing build is notRun, never a silent pass - runKvExpiryProbe: npx wrangler kv key list (current syntax, deprecated colon form forbidden), namespace id read from bot/wrangler.jsonc at runtime; parses name/expiration/metadata + list_complete/cursor and follows the cursor; unexpected families REPORTED (A5); oldest/newest expiry reported; raw key names never printed (category-only evidence) - runAssetHeaderProbe: curl -sSI cache-control/cf-cache-status reported VERBATIM (probabilistic — printed, never interpreted) - documented adjudications: MapLibre static-text hosts, W3C namespace identifiers, bare-array = CLI's internally-paginated complete set
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* bundle-gates.mjs — the PRODUCTION bundle telemetry gate (07-03, D-01/D-02,
|
||||
* T-07-04). Consolidates the build-artifact law (BA5/BA8) into the audit.
|
||||
* ---------------------------------------------------------------------------
|
||||
* A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src,
|
||||
* web/src, routing/src, or search/src imports it. GPL-3.0: forks rerun it over
|
||||
* their own built bundle.
|
||||
*
|
||||
* PRODUCTION ONLY: this gate reads `web/dist` and NEVER `web/dist-harness` —
|
||||
* BA5's law: the harness build is deliberately NOT clean (it carries the
|
||||
* Telegram mock, fetch stubs, and the crypto-bearing test surface), so a gate
|
||||
* pointed at it inverts the very rule it exists to enforce.
|
||||
*
|
||||
* D-02: v1 carries ZERO telemetry. There is nothing to opt into — this gate
|
||||
* proves an ABSENCE: no analytics, no beacon, no fetch to any host outside
|
||||
* the positive allowlist, no storage API in the shipped JS, no deep-link
|
||||
* crypto. A missing `web/dist` is NEVER a silent pass — it is environment
|
||||
* drift (notRun, the exit-2 class).
|
||||
*
|
||||
* ——— Scope adjudications (documented, deliberate; the theme-audit precedent) ———
|
||||
*
|
||||
* 1. `beacon` is checked ONLY as `navigator.sendBeacon` — a bare `beacon`
|
||||
* substring would false-positive on unrelated words (the plan's own note).
|
||||
* 2. The absolute-URL scan covers http AND https. Beyond the fetch-host
|
||||
* allowlist, a SECOND documented class exists: URLs that are STATIC TEXT
|
||||
* inside library constants or our own dataset metadata — never fetched,
|
||||
* only ever displayed or logged. These are enumerated explicitly below;
|
||||
* any host outside BOTH lists fails the gate.
|
||||
* 3. `http://www.w3.org/2000/svg` is an SVG spec IDENTIFIER (createElementNS),
|
||||
* never fetched.
|
||||
* 4. Crypto tokens are scanned in JS ASSETS ONLY, never the HTML — the
|
||||
* official Telegram SDK script is external (telegram.org) and carries its
|
||||
* own crypto (BA8's law).
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const REPO_ROOT = process.env.PRIVACY_AUDIT_ROOT ?? MODULE_ROOT;
|
||||
|
||||
/** The POSITIVE runtime fetch-host allowlist (D-01): the only hosts the
|
||||
* shipped bundle may reach. The app's own Worker origin is a RELATIVE path
|
||||
* (`/api/*`) — not a host — and is therefore not on this list. */
|
||||
const ALLOWED_FETCH_HOSTS = ["tiles.openfreemap.org", "telegram.org"];
|
||||
|
||||
/** Documented static-text hosts (adjudication 2 above) — each names WHY it
|
||||
* can appear in the bundle without being a runtime fetch. A host here that
|
||||
* ever shows up in a fetch call site is still a source-gate failure. */
|
||||
const DOCUMENTED_STATIC_HOSTS = {
|
||||
"github.com": "MapLibre's error-diagnostic strings (mapbox-gl issue links) — text, never fetched",
|
||||
"maplibre.org": "MapLibre's LogoControl/attribution constants — user-clicked navigation, never fetched",
|
||||
"wiki.openstreetmap.org": "MapLibre's WebGL warning text + the OSM attribution link — never fetched",
|
||||
"opendatacommons.org": "our own gazetteer/fixture ODbL license metadata strings — never fetched",
|
||||
"maplibre.invalid": "MapLibre's URL-protocol test placeholder — by construction never fetched",
|
||||
};
|
||||
|
||||
/** The W3C namespace identifiers — spec constants, never fetched. */
|
||||
const SPEC_NAMESPACE_IDENTIFIERS = ["http://www.w3.org/2000/svg", "http://www.w3.org/1999/xhtml"];
|
||||
|
||||
/** The analytics/beacon token set (zero-telemetry, D-02). `beacon` itself is
|
||||
* checked ONLY in its explicit `navigator.sendBeacon` form (adjudication 1). */
|
||||
const ANALYTICS_TOKENS = [
|
||||
"google-analytics.com", "googletagmanager.com", "analytics.google",
|
||||
"segment.io", "mixpanel", "sentry.io", "amplitude", "datadog", "bugsnag",
|
||||
"posthog", "plausible.io", "hotjar", "intercom", "navigator.sendBeacon",
|
||||
"gtag(", "fbq(",
|
||||
];
|
||||
|
||||
/** Deep-link crypto tokens (D-11/BA8) — JS assets only. */
|
||||
const CRYPTO_TOKENS = ["createHmac", "crypto.subtle", "sha256", "HMAC("];
|
||||
|
||||
/** Storage tokens (the memory-only client law, re-checked in the SHIPPED
|
||||
* artifact — the platform allowlist's `__telegram__initParams` sessionStorage
|
||||
* write lives in the EXTERNAL telegram-web-app.js, which is NOT in web/dist,
|
||||
* so the shipped bundle must carry NO storage token at all). */
|
||||
const STORAGE_TOKENS = ["localStorage", "sessionStorage", "indexedDB", "document.cookie"];
|
||||
|
||||
/** DirEnt walk — copied from web/test/build-artifact.test.mjs lines 79–87. */
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function runBundleTelemetryGate(opts = {}) {
|
||||
const id = "bundle-telemetry";
|
||||
const dist = opts.dist ?? path.join(REPO_ROOT, "web", "dist");
|
||||
if (!existsSync(dist)) {
|
||||
return {
|
||||
id,
|
||||
pass: false,
|
||||
notRun: true,
|
||||
evidence: "web/dist does not exist — run `npm run build -w theme && npm run build -w web` first",
|
||||
};
|
||||
}
|
||||
|
||||
const allFiles = walk(dist);
|
||||
const jsFiles = allFiles.filter((f) => f.endsWith(".js"));
|
||||
const htmlFiles = allFiles.filter((f) => f.endsWith(".html"));
|
||||
const violations = [];
|
||||
const hostInventory = new Map();
|
||||
|
||||
function noteHost(urlText) {
|
||||
try {
|
||||
const host = new URL(urlText).hostname;
|
||||
hostInventory.set(host, (hostInventory.get(host) ?? 0) + 1);
|
||||
return host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkHosts(rel, content) {
|
||||
for (const urlText of content.match(/https?:\/\/[a-zA-Z0-9._~:/?#[\]@!$&'()*+,;=%-]+/g) ?? []) {
|
||||
if (SPEC_NAMESPACE_IDENTIFIERS.includes(urlText)) continue; // adjudication 3
|
||||
const host = noteHost(urlText);
|
||||
if (host === null) continue;
|
||||
if (ALLOWED_FETCH_HOSTS.includes(host)) continue;
|
||||
if (host in DOCUMENTED_STATIC_HOSTS) continue; // adjudication 2
|
||||
violations.push(`${path.relative(dist, rel)} — unapproved host in an absolute URL: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of jsFiles) {
|
||||
const rel = path.relative(dist, file);
|
||||
const content = readFile(file);
|
||||
for (const token of ANALYTICS_TOKENS) {
|
||||
if (content.includes(token)) violations.push(`${rel} — analytics/beacon token: ${token}`);
|
||||
}
|
||||
for (const token of CRYPTO_TOKENS) {
|
||||
if (content.includes(token)) violations.push(`${rel} — deep-link crypto token (D-11): ${token}`);
|
||||
}
|
||||
for (const token of STORAGE_TOKENS) {
|
||||
if (content.includes(token)) violations.push(`${rel} — storage token in the shipped bundle: ${token}`);
|
||||
}
|
||||
checkHosts(file, content);
|
||||
}
|
||||
for (const file of htmlFiles) {
|
||||
checkHosts(file, readFile(file));
|
||||
}
|
||||
|
||||
const pass = violations.length === 0;
|
||||
const inventory = [...hostInventory.entries()].map(([h, n]) => `${h}×${n}`).join(", ") || "none";
|
||||
const evidence = pass
|
||||
? `${jsFiles.length} JS + ${htmlFiles.length} HTML emitted file(s) scanned (PRODUCTION web/dist only — dist-harness forbidden); host inventory (printed, not assumed): ${inventory}; every absolute-URL host is in the allowlist [${ALLOWED_FETCH_HOSTS.join(", ")}] or the documented static-text class; ZERO analytics/beacon/crypto/storage tokens`
|
||||
: `${jsFiles.length} JS + ${htmlFiles.length} HTML emitted file(s) scanned; host inventory: ${inventory}; ${violations.length} violation(s): ${violations.join("; ")}`;
|
||||
return { id, pass, evidence, violations: violations.length };
|
||||
}
|
||||
|
||||
/* readFileSync wrapper so a decode error is a loud violation, not a crash */
|
||||
function readFile(file) {
|
||||
try {
|
||||
return readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* cloudflare-probes.mjs — the LIVE probes (kv-expiry, asset-headers)
|
||||
* (07-03, D-01, T-07-01/T-07-16). No in-repo analog existed — the only live-KV
|
||||
* precedent is the manual command recorded in 06-DEVICE-GATE.md step 7.
|
||||
* ---------------------------------------------------------------------------
|
||||
* A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src,
|
||||
* web/src, routing/src, or search/src imports it. GPL-3.0: forks rerun the
|
||||
* probes against their OWN namespace (read from their own bot/wrangler.jsonc).
|
||||
*
|
||||
* LIVE ONLY: both probes need credentials + a deployed origin. Without them
|
||||
* each probe degrades to `probeUnavailable(...)` — { pass:false, notRun:true }
|
||||
* — and the CLI maps notRun to exit code 2 (environment drift). A skipped
|
||||
* probe is NEVER mistaken for a pass (the no-silent-passes contract).
|
||||
*
|
||||
* Category-only output law: the probe's evidence NEVER prints a raw KV key
|
||||
* name (session:<token> / loc:<chat_id> carry identifiers) — only family
|
||||
* counts, expiry bounds, and header lines.
|
||||
*
|
||||
* Pagination (07-RESEARCH Pitfall 7 — key presence alone does not prove
|
||||
* TTL-only): the wrangler envelope shape carries `list_complete` + `cursor`
|
||||
* and the cursor is FOLLOWED until list_complete; a bare-array shape means
|
||||
* the CLI paginated internally and returned the complete set (wrangler's
|
||||
* documented behavior for `kv key list`), and the evidence says which shape
|
||||
* was seen.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const REPO_ROOT = process.env.PRIVACY_AUDIT_ROOT ?? MODULE_ROOT;
|
||||
const WRANGLER_JSONC = path.join(REPO_ROOT, "bot", "wrangler.jsonc");
|
||||
|
||||
/** The four TTL-only key families (the KV storage contract). An unexpected
|
||||
* family is REPORTED, never assumed absent (07-RESEARCH A5). */
|
||||
export const KEY_FAMILIES = ["session:", "chat:", "loc:", "guidance:"];
|
||||
|
||||
/** A missing environment is the exit-2 class: pass:false + notRun:true. */
|
||||
export function probeUnavailable(reason) {
|
||||
return { id: "probe", pass: false, notRun: true, evidence: `NOT RUN — ${reason}` };
|
||||
}
|
||||
|
||||
function notRun(id, reason) {
|
||||
return Object.assign(probeUnavailable(reason), { id });
|
||||
}
|
||||
|
||||
/** Read the KV namespace id from bot/wrangler.jsonc AT RUNTIME (never
|
||||
* hard-coded twice). jsonc: strip line comments without eating `https://`
|
||||
* inside values — the `(^|[^:])//` guard (the repo stripper's line rule). */
|
||||
function readNamespaceId() {
|
||||
if (!existsSync(WRANGLER_JSONC)) return null;
|
||||
const stripped = readFileSync(WRANGLER_JSONC, "utf8").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
|
||||
return stripped.match(/kv_namespaces[\s\S]*?"id"\s*:\s*"([0-9a-f]+)"/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** Read the deployed Mini App origin from bot/wrangler.jsonc vars (public
|
||||
* configuration — never a secret). */
|
||||
function readMiniAppUrl() {
|
||||
if (!existsSync(WRANGLER_JSONC)) return null;
|
||||
const stripped = readFileSync(WRANGLER_JSONC, "utf8").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
|
||||
return stripped.match(/"MINI_APP_URL"\s*:\s*"([^"]+)"/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function wranglerAvailable() {
|
||||
if (existsSync(path.join(REPO_ROOT, "bot", "node_modules", ".bin", "wrangler"))) return true;
|
||||
try {
|
||||
execFileSync("wrangler", ["--version"], { encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "pipe"] });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live KV listing probe: `npx wrangler kv key list --namespace-id=<id>`
|
||||
* (the CURRENT wrangler syntax — the deprecated `kv:key` colon form is
|
||||
* forbidden). Parses each key's `name`, optional `expiration`, optional
|
||||
* `metadata`, the `list_complete` flag and the `cursor`; follows the cursor
|
||||
* until list_complete; asserts (1) every key's family prefix is one of the
|
||||
* four TTL-only families, (2) every listed key carries an `expiration`,
|
||||
* (3) reports the OLDEST and NEWEST expiry it saw.
|
||||
*/
|
||||
export function runKvExpiryProbe(opts = {}) {
|
||||
const id = "kv-expiry";
|
||||
const missing = [];
|
||||
if (!process.env.CLOUDFLARE_API_TOKEN || !process.env.CLOUDFLARE_ACCOUNT_ID) missing.push("Cloudflare credentials");
|
||||
const namespaceId = opts.namespaceId ?? readNamespaceId();
|
||||
if (!namespaceId) missing.push("KV namespace id (bot/wrangler.jsonc)");
|
||||
if (!wranglerAvailable()) missing.push("wrangler CLI");
|
||||
if (missing.length > 0) return notRun(id, `missing ${missing.join(" + ")}`);
|
||||
|
||||
/** @type {Array<{name: string, expiration?: number, metadata?: unknown}>} */
|
||||
const keys = [];
|
||||
let cursor;
|
||||
let listComplete = null; // null = the shape carried no list_complete metadata
|
||||
try {
|
||||
do {
|
||||
const args = ["wrangler", "kv", "key", "list", `--namespace-id=${namespaceId}`];
|
||||
if (cursor !== undefined) args.push(`--cursor=${cursor}`);
|
||||
const out = execFileSync("npx", args, {
|
||||
cwd: path.join(REPO_ROOT, "bot"),
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
maxBuffer: 1 << 26,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
const parsed = JSON.parse(extractJson(out));
|
||||
if (Array.isArray(parsed)) {
|
||||
keys.push(...parsed); // bare array — wrangler paginated internally (documented CLI behavior)
|
||||
} else if (Array.isArray(parsed?.keys)) {
|
||||
keys.push(...parsed.keys);
|
||||
listComplete = parsed.list_complete ?? null;
|
||||
cursor = parsed.cursor ?? undefined;
|
||||
} else {
|
||||
return notRun(id, "unrecognized wrangler kv key list output shape");
|
||||
}
|
||||
} while (cursor !== undefined && listComplete === false);
|
||||
} catch (err) {
|
||||
return notRun(id, `wrangler kv key list failed (${categoryOf(err)})`);
|
||||
}
|
||||
|
||||
const familyCounts = new Map();
|
||||
const violations = [];
|
||||
const expirations = [];
|
||||
for (const key of keys) {
|
||||
const name = typeof key?.name === "string" ? key.name : "";
|
||||
const family = name.slice(0, name.indexOf(":") + 1);
|
||||
const familyLabel = family || "(no family prefix)";
|
||||
if (!KEY_FAMILIES.includes(family)) {
|
||||
// A5: report the unexpected FAMILY (the prefix only — never the raw
|
||||
// key name, which carries a token or a chat id).
|
||||
if (!familyCounts.has(familyLabel)) familyCounts.set(familyLabel, 0);
|
||||
familyCounts.set(familyLabel, familyCounts.get(familyLabel) + 1);
|
||||
violations.push(`unexpected key family: ${familyLabel}`);
|
||||
continue;
|
||||
}
|
||||
familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
|
||||
if (typeof key.expiration !== "number" || key.expiration <= 0) {
|
||||
violations.push(`a key carries NO expiration (TTL-less live key)`);
|
||||
} else {
|
||||
expirations.push(key.expiration);
|
||||
}
|
||||
}
|
||||
|
||||
const oldest = expirations.length > 0 ? new Date(Math.min(...expirations) * 1000).toISOString() : "n/a";
|
||||
const newest = expirations.length > 0 ? new Date(Math.max(...expirations) * 1000).toISOString() : "n/a";
|
||||
const shape =
|
||||
listComplete === null
|
||||
? "wrangler returned the complete set (it paginates internally; list_complete/cursor metadata not exposed by this CLI shape)"
|
||||
: `list_complete=${listComplete}${cursor !== undefined ? " (cursor followed)" : ""}`;
|
||||
const counts = [...familyCounts.entries()].map(([f, n]) => `${f}${n}`).join(", ") || "no keys";
|
||||
const pass = violations.length === 0;
|
||||
const evidence = pass
|
||||
? `${keys.length} live key(s) [${counts}]; every key carries an expiration; oldest expiry ${oldest}, newest ${newest}; ${shape}`
|
||||
: `${keys.length} live key(s) [${counts}]; ${shape}; ${violations.length} violation(s): ${violations.join("; ")}`;
|
||||
return { id, pass, evidence, violations: violations.length };
|
||||
}
|
||||
|
||||
/** Pull the outermost JSON value out of wrangler's stdout (it may interleave
|
||||
* non-JSON banner lines around the payload). */
|
||||
function extractJson(out) {
|
||||
const start = out.search(/[[{]/);
|
||||
const end = Math.max(out.lastIndexOf("]"), out.lastIndexOf("}"));
|
||||
return out.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function categoryOf(err) {
|
||||
if (err?.code === "ETIMEDOUT" || /timed? ?out/i.test(String(err?.message))) return "timed out";
|
||||
return "non-zero exit / network error"; // category only — never err text (it can echo env/paths)
|
||||
}
|
||||
|
||||
/**
|
||||
* The live static-asset header probe: `curl -sSI` the HTML entry and one
|
||||
* hashed /assets/* file, reporting the observed `cache-control` and
|
||||
* `cf-cache-status` headers VERBATIM. CF-Cache-Status is probabilistic per
|
||||
* Cloudflare's own footnote — the evidence prints it, it does not interpret
|
||||
* it as a guarantee (07-RESEARCH Pitfall 10).
|
||||
*/
|
||||
export function runAssetHeaderProbe(opts = {}) {
|
||||
const id = "asset-headers";
|
||||
const baseUrl = opts.baseUrl ?? process.env.MINI_APP_URL ?? readMiniAppUrl();
|
||||
if (!baseUrl) return notRun(id, "no deployed origin (MINI_APP_URL / bot/wrangler.jsonc vars)");
|
||||
if (!curlAvailable()) return notRun(id, "curl CLI not available");
|
||||
|
||||
const dist = path.join(REPO_ROOT, "web", "dist");
|
||||
let assetPath = null;
|
||||
if (existsSync(path.join(dist, "assets"))) {
|
||||
const asset = readdirSync(path.join(dist, "assets")).find((f) => f.endsWith(".js"));
|
||||
if (asset) assetPath = `/assets/${asset}`;
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
let violations = 0;
|
||||
const targets = [["HTML entry /", `${baseUrl}/`]];
|
||||
if (assetPath !== null) targets.push([`hashed asset ${assetPath}`, `${baseUrl}${assetPath}`]);
|
||||
try {
|
||||
for (const [label, url] of targets) {
|
||||
const out = execFileSync("curl", ["-sSI", "--max-time", "20", url], {
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const cacheControl = out.match(/^cache-control:.*$/im)?.[0]?.trim() ?? null;
|
||||
const cfStatus = out.match(/^cf-cache-status:.*$/im)?.[0]?.trim() ?? null;
|
||||
if (cacheControl === null) {
|
||||
violations += 1;
|
||||
lines.push(`${label}: no cache-control header observed`);
|
||||
} else {
|
||||
lines.push(`${label}: ${cacheControl}${cfStatus !== null ? ` | ${cfStatus}` : " | cf-cache-status: not reported by this response"}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return notRun(id, `origin unreachable (${categoryOf(err)})`);
|
||||
}
|
||||
|
||||
const pass = violations === 0;
|
||||
const evidence = pass
|
||||
? `${targets.length} response header(s), VERBATIM (cf-cache-status is probabilistic — reported, not interpreted as a guarantee): ${lines.join(" || ")}`
|
||||
: `${violations.length} violation(s): ${lines.join(" || ")}`;
|
||||
return { id, pass, evidence, violations };
|
||||
}
|
||||
|
||||
function curlAvailable() {
|
||||
try {
|
||||
execFileSync("curl", ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user