mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge branch 'fix/v147-review-wolfi' into release/v1.47.0
This commit is contained in:
commit
da48f1af9e
5 files changed
+456
-5
No files matched your search
+27
-1
@@ -106,7 +106,19 @@ FROM cgr.dev/chainguard/wolfi-base:latest@sha256:629d9acaceda7746cd9293365eb0d8e
|
||||
#
|
||||
# busybox, part of the base, is the shell the entrypoint runs under and
|
||||
# carries `tar`/`gzip` for the GeoLite2 extraction and `addgroup`/`adduser`.
|
||||
RUN apk add --no-cache nodejs-22 tzdata postgresql-18-client && \
|
||||
#
|
||||
# The two packages the app runs on are pinned to an exact package version,
|
||||
# so a rebuild of the same commit runs the same Node and the same pg_dump;
|
||||
# `apk upgrade` cannot move a pinned package, and moves only the libraries
|
||||
# under them. No updater reads these pins. What moves them is the image
|
||||
# scan: Wolfi records the fixed package version for every advisory, so a
|
||||
# pinned package with a published fix turns the HIGH/CRITICAL gate red and
|
||||
# names the version to pin. To bump, take the newest from
|
||||
# `apk policy nodejs-22` / `apk policy postgresql-18-client` in the pinned
|
||||
# base. `src/__tests__/runtime-package-pins.test.ts` holds the pin format,
|
||||
# and the client's minor to the bundled database's in docker-compose.yml.
|
||||
# tzdata stays unpinned on purpose: a zone rule change is a fix to take.
|
||||
RUN apk add --no-cache nodejs-22=22.23.2-r1 tzdata postgresql-18-client=18.6-r7 && \
|
||||
apk upgrade --no-cache && \
|
||||
mkdir -p /usr/local/bin
|
||||
COPY --from=builder /app/.native-abi /tmp/.native-abi
|
||||
@@ -401,6 +413,20 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 |
|
||||
RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2
|
||||
COPY --chown=nextjs:nodejs assets/geolite2/ /opt/geolite2/
|
||||
|
||||
# `wget` for health checks written outside the image. A compose file copied
|
||||
# from this repository up to v1.46.6 and the TrueNAS catalog app both call
|
||||
# `wget --spider` against /api/health, and their check replaces the
|
||||
# HEALTHCHECK below; the Wolfi base has no wget, so without this every such
|
||||
# container stayed unhealthy. `scripts/wget-shim.mjs` understands the flags
|
||||
# those checks use, exits like wget, and writes nowhere but stdout. The
|
||||
# image's own HEALTHCHECK and the bundled compose file use the Node probe.
|
||||
# Root-owned: the app user can run it but not replace it.
|
||||
COPY --from=builder /app/scripts/wget-shim.mjs /usr/local/lib/healthlog/wget-shim.mjs
|
||||
RUN printf '#!/bin/sh\nexec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\n' > /usr/local/bin/wget && \
|
||||
chmod 755 /usr/local/bin/wget && \
|
||||
wget 2>&1 | grep -q "health check shim" && \
|
||||
! wget --no-verbose --tries=1 --spider http://127.0.0.1:9/api/health
|
||||
|
||||
# ISO-8601 build timestamp — /api/version returns it as `builtAt`.
|
||||
# Declared this late in the stage on purpose: the value differs on
|
||||
# every CI run, and every instruction after an ARG shares its cache
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* `wget` in the image (`/usr/local/bin/wget` runs this file): enough of wget
|
||||
* for a container health check, and nothing more.
|
||||
*
|
||||
* The runtime base has no wget, but health checks written outside the image
|
||||
* call it: compose files copied from this repository up to v1.46.6 run
|
||||
* `wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health`,
|
||||
* and the TrueNAS catalog app renders `wget --quiet --spider <url>`. Such a
|
||||
* check replaces the image's own HEALTHCHECK, so without a `wget` the
|
||||
* container would never turn healthy. The image's HEALTHCHECK and the
|
||||
* bundled compose file use a `node -e` fetch instead; this file exists only
|
||||
* for those external callers.
|
||||
*
|
||||
* Understood: the URL, `--spider`, `-q`/`--quiet`, `-nv`/`--no-verbose`,
|
||||
* `-t N`/`--tries=N`, `-T N`/`--timeout=N`, `-O -` and `-O /dev/null`
|
||||
* (also `-O-`, `--output-document=…`), `-S`/`--server-response`. Any other
|
||||
* flag is ignored. It is not a download tool: it writes a body only to
|
||||
* standard output (`-O -`), refuses any other `-O` target before sending a
|
||||
* request, never follows a redirect, and speaks plain http/https only.
|
||||
*
|
||||
* Exit 0 on a 2xx or 3xx answer, 1 on any other status, a refused or
|
||||
* failed connection, or the timeout, and 2 on a command line it will not
|
||||
* run. Without `-T` a request gives up after 30 s; Docker's own health
|
||||
* check timeout usually ends it sooner.
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
|
||||
/** Flags whose value may follow as the next argument. */
|
||||
const VALUE_FLAGS = new Set(["-O", "-T", "-t", "-o", "-U", "-P", "--header"]);
|
||||
|
||||
/**
|
||||
* @param {string[]} argv
|
||||
* @returns {{ url: string | null; spider: boolean; quiet: boolean;
|
||||
* verbose: boolean; tries: number; timeout: number; output: string | null;
|
||||
* serverResponse: boolean }}
|
||||
*/
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
url: /** @type {string | null} */ (null),
|
||||
spider: false,
|
||||
quiet: false,
|
||||
verbose: true,
|
||||
tries: 1,
|
||||
timeout: DEFAULT_TIMEOUT_SECONDS,
|
||||
output: /** @type {string | null} */ (null),
|
||||
serverResponse: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i] ?? "";
|
||||
/** @param {string} name */
|
||||
const inline = (name) =>
|
||||
arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : null;
|
||||
/** The value of a short flag, attached (`-T4`) or as the next argument. */
|
||||
const shortValue = () => {
|
||||
if (arg.length > 2) return arg.slice(2).replace(/^=/, "");
|
||||
i += 1;
|
||||
return argv[i] ?? "";
|
||||
};
|
||||
if (/^https?:\/\//i.test(arg)) {
|
||||
opts.url ??= arg;
|
||||
} else if (arg === "--spider") {
|
||||
opts.spider = true;
|
||||
} else if (arg === "-q" || arg === "--quiet") {
|
||||
opts.quiet = true;
|
||||
} else if (arg === "-nv" || arg === "--no-verbose") {
|
||||
opts.verbose = false;
|
||||
} else if (arg === "-S" || arg === "--server-response") {
|
||||
opts.serverResponse = true;
|
||||
} else if (arg.startsWith("--tries=")) {
|
||||
opts.tries = count(inline("--tries"), 1);
|
||||
} else if (arg.startsWith("-t")) {
|
||||
opts.tries = count(shortValue(), 1);
|
||||
} else if (arg.startsWith("--timeout=")) {
|
||||
opts.timeout = count(inline("--timeout"), DEFAULT_TIMEOUT_SECONDS);
|
||||
} else if (arg.startsWith("-T")) {
|
||||
opts.timeout = count(shortValue(), DEFAULT_TIMEOUT_SECONDS);
|
||||
} else if (arg.startsWith("--output-document=")) {
|
||||
opts.output = inline("--output-document");
|
||||
} else if (arg.startsWith("-O")) {
|
||||
opts.output = shortValue();
|
||||
} else if (VALUE_FLAGS.has(arg)) {
|
||||
// A flag this shim ignores, with its value as the next argument.
|
||||
i += 1;
|
||||
} else if (/^-q[a-zA-Z]*O/.test(arg)) {
|
||||
// `-qO-` / `-qO /dev/null`: quiet plus an output target, bundled.
|
||||
opts.quiet = true;
|
||||
const rest = arg.slice(arg.indexOf("O") + 1);
|
||||
opts.output = rest || (argv[++i] ?? "");
|
||||
}
|
||||
// Anything else is ignored.
|
||||
}
|
||||
// wget treats 0 tries as "retry forever"; a health check must not.
|
||||
if (opts.tries < 1) opts.tries = 1;
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} value
|
||||
* @param {number} fallback
|
||||
*/
|
||||
function count(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/** @param {string} message @param {boolean} quiet */
|
||||
function say(message, quiet) {
|
||||
if (!quiet) process.stderr.write(`wget: ${message}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} argv
|
||||
* @returns {Promise<number>} the exit code
|
||||
*/
|
||||
async function run(argv) {
|
||||
const opts = parseArgs(argv);
|
||||
if (!opts.url) {
|
||||
process.stderr.write("wget: missing URL (health check shim)\n");
|
||||
return 2;
|
||||
}
|
||||
const toStdout = opts.output === "-";
|
||||
if (opts.output !== null && !toStdout && opts.output !== "/dev/null") {
|
||||
process.stderr.write(
|
||||
`wget: refusing to write ${opts.output}: this image's wget is a health check shim (-O - and -O /dev/null only)\n`,
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= opts.tries; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(opts.url, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(Math.max(opts.timeout, 0.001) * 1000),
|
||||
});
|
||||
if (opts.serverResponse) {
|
||||
process.stderr.write(
|
||||
` HTTP/1.1 ${response.status} ${response.statusText}\n`,
|
||||
);
|
||||
for (const [name, value] of response.headers) {
|
||||
process.stderr.write(` ${name}: ${value}\n`);
|
||||
}
|
||||
}
|
||||
if (toStdout && !opts.spider && response.body) {
|
||||
for await (const chunk of response.body) process.stdout.write(chunk);
|
||||
} else {
|
||||
await response.body?.cancel();
|
||||
}
|
||||
const ok = response.status >= 200 && response.status < 400;
|
||||
if (ok) {
|
||||
if (opts.verbose) say(`${opts.url}: ${response.status}`, opts.quiet);
|
||||
return 0;
|
||||
}
|
||||
say(`server returned error: HTTP ${response.status}`, opts.quiet);
|
||||
return 1;
|
||||
} catch (err) {
|
||||
lastError =
|
||||
err instanceof Error && err.name === "TimeoutError"
|
||||
? "download timed out"
|
||||
: err instanceof Error
|
||||
? err.cause instanceof Error
|
||||
? err.cause.message
|
||||
: err.message
|
||||
: String(err);
|
||||
}
|
||||
}
|
||||
say(`${opts.url}: ${lastError}`, opts.quiet);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] !== undefined &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
run(process.argv.slice(2)).then(
|
||||
(code) => process.exit(code),
|
||||
() => process.exit(1),
|
||||
);
|
||||
}
|
||||
@@ -80,12 +80,13 @@ function imageCheck(): Check {
|
||||
}
|
||||
|
||||
/**
|
||||
* The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The
|
||||
* image carries no wget or curl, so a probe that names one would fail every
|
||||
* time and mark a healthy container unhealthy.
|
||||
* The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The image
|
||||
* carries no curl, and its `wget` is a shim kept for health checks written
|
||||
* outside the repository (`wget-shim.test.ts`); the image's own check and the
|
||||
* bundled compose file do not lean on it, so neither names one.
|
||||
*/
|
||||
function probeTimeout(command: string): number {
|
||||
expect(command, "the probe needs a binary the image has").not.toMatch(
|
||||
expect(command, "the image's own probe is the Node fetch").not.toMatch(
|
||||
/\b(wget|curl)\b/,
|
||||
);
|
||||
const m =
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The runtime packages the image installs from apk are pinned, and the
|
||||
* PostgreSQL client moves with the bundled database.
|
||||
*
|
||||
* The runner installs Node and the PostgreSQL client from the Wolfi
|
||||
* repository. Unpinned, a rebuild of the same commit could ship another
|
||||
* Node or another pg_dump than the one the release was checked with. No
|
||||
* updater reads an apk pin; the image scan is what flags a pinned package
|
||||
* once Wolfi publishes a fix (the Dockerfile comment explains the bump), so
|
||||
* this file only holds the pins' shape and their agreement with the rest of
|
||||
* the repository.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
|
||||
const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
|
||||
|
||||
function runner(): string {
|
||||
const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
|
||||
expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
|
||||
return DOCKERFILE.slice(start);
|
||||
}
|
||||
|
||||
function apkAdds(): string[] {
|
||||
return [...runner().matchAll(/apk add --no-cache ([^&\n\\]+)/g)].flatMap(
|
||||
(m) => m[1]!.trim().split(/\s+/),
|
||||
);
|
||||
}
|
||||
|
||||
describe("runtime package pins", () => {
|
||||
it("pins nodejs-22 to one exact Node 22 package version", () => {
|
||||
const pins = apkAdds().filter((p) => p.startsWith("nodejs"));
|
||||
expect(pins).toHaveLength(1);
|
||||
expect(pins[0]).toMatch(/^nodejs-22=22\.\d+\.\d+-r\d+$/);
|
||||
});
|
||||
|
||||
it("pins postgresql-18-client to the bundled database's minor", () => {
|
||||
const pins = apkAdds().filter((p) => p.startsWith("postgresql"));
|
||||
expect(pins).toHaveLength(1);
|
||||
const client = /^postgresql-18-client=(18\.\d+)-r\d+$/.exec(pins[0]!);
|
||||
expect(client, `an exact pin, not ${pins[0]}`).not.toBeNull();
|
||||
const db = /^\s*image:\s*postgres:(\d+\.\d+)-alpine$/m.exec(COMPOSE);
|
||||
expect(db, "the compose file pins the database").not.toBeNull();
|
||||
// One decision: a database minor bump moves the client with it.
|
||||
expect(client![1]).toBe(db![1]);
|
||||
});
|
||||
|
||||
it("leaves no other package floating except tzdata and the build-only npm", () => {
|
||||
const unpinned = apkAdds().filter((p) => !p.includes("="));
|
||||
expect(new Set(unpinned)).toEqual(new Set(["tzdata", "npm"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* The image's `wget` answers the health checks written outside this
|
||||
* repository the way wget would.
|
||||
*
|
||||
* The runtime base carries no wget. A compose file copied from this
|
||||
* repository up to v1.46.6 checks health with
|
||||
* `wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health`,
|
||||
* and the TrueNAS catalog app renders `wget --quiet --spider <url>`; either
|
||||
* replaces the image's own HEALTHCHECK. Without a `wget` such a container is
|
||||
* unhealthy forever and a proxy that routes on health drops its traffic. So
|
||||
* the image installs `scripts/wget-shim.mjs` as `/usr/local/bin/wget`, and
|
||||
* this file runs that script as the health check would: exit 0 on a healthy
|
||||
* answer, 1 on a 503, a refused connection or a timeout, and no file
|
||||
* written anywhere.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SHIM = join(ROOT, "scripts", "wget-shim.mjs");
|
||||
|
||||
let server: Server;
|
||||
let base = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
if (req.url === "/ok") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end('{"status":"ok"}');
|
||||
} else if (req.url === "/down") {
|
||||
res.writeHead(503, { "content-type": "application/json" });
|
||||
res.end('{"status":"degraded"}');
|
||||
} else if (req.url === "/moved") {
|
||||
res.writeHead(302, { location: "http://192.0.2.1/elsewhere" });
|
||||
res.end();
|
||||
} else if (req.url === "/slow") {
|
||||
// Never answers; the shim's timeout has to end the request.
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function wget(
|
||||
args: string[],
|
||||
cwd = ROOT,
|
||||
): Promise<{
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
ms: number;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const child = spawn(process.execPath, [SHIM, ...args], { cwd });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
|
||||
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
||||
child.on("close", (code) =>
|
||||
resolve({ code, stdout, stderr, ms: Date.now() - started }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** A port nothing listens on: bind one, note it, close it again. */
|
||||
async function closedPort(): Promise<number> {
|
||||
const probe = createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = probe.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
return port;
|
||||
}
|
||||
|
||||
// The two command lines found in the wild, with the URL left to the case.
|
||||
const LINES: Array<[string, (url: string) => string[]]> = [
|
||||
[
|
||||
"compose up to v1.46.6",
|
||||
(url) => ["--no-verbose", "--tries=1", "--timeout=4", "--spider", url],
|
||||
],
|
||||
[
|
||||
"compose without a timeout",
|
||||
(url) => ["--no-verbose", "--tries=1", "--spider", url],
|
||||
],
|
||||
["TrueNAS catalog app", (url) => ["--quiet", "--spider", url]],
|
||||
];
|
||||
|
||||
describe.each(LINES)("the %s health check", (_name, line) => {
|
||||
it("passes on a healthy server", async () => {
|
||||
expect((await wget(line(`${base}/ok`))).code).toBe(0);
|
||||
});
|
||||
|
||||
it("fails on a 503", async () => {
|
||||
expect((await wget(line(`${base}/down`))).code).toBe(1);
|
||||
});
|
||||
|
||||
it("fails on a refused connection", async () => {
|
||||
const port = await closedPort();
|
||||
expect((await wget(line(`http://127.0.0.1:${port}/api/health`))).code).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wget shim", () => {
|
||||
it("gives up at its timeout instead of hanging", async () => {
|
||||
const result = await wget(["-q", "-T", "1", "--spider", `${base}/slow`]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.ms).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
it("counts a redirect as an answer and does not follow it", async () => {
|
||||
expect((await wget(["-q", "--spider", `${base}/moved`])).code).toBe(0);
|
||||
});
|
||||
|
||||
it("prints the body for -O - and stays quiet with -q", async () => {
|
||||
const result = await wget(["-qO-", `${base}/ok`]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe('{"status":"ok"}');
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("accepts -O /dev/null and -S", async () => {
|
||||
const result = await wget(["-S", "-O", "/dev/null", `${base}/ok`]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toMatch(/HTTP\/1\.1 200/);
|
||||
});
|
||||
|
||||
it("ignores flags it does not know", async () => {
|
||||
const result = await wget([
|
||||
"--no-check-certificate",
|
||||
"--header",
|
||||
"X-Probe: 1",
|
||||
"-c",
|
||||
"--spider",
|
||||
`${base}/ok`,
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it("is not a download tool: it refuses any other output file and writes none", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wget-shim-"));
|
||||
try {
|
||||
const target = join(dir, "health.json");
|
||||
const refused = await wget(["-O", target, `${base}/ok`], dir);
|
||||
expect(refused.code).toBe(2);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
// Without -O real wget saves `index.html`; the shim discards the body.
|
||||
const bare = await wget([`${base}/ok`], dir);
|
||||
expect(bare.code).toBe(0);
|
||||
expect(existsSync(join(dir, "ok"))).toBe(false);
|
||||
expect(existsSync(join(dir, "index.html"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a command line without a URL", async () => {
|
||||
expect((await wget(["--spider"])).code).toBe(2);
|
||||
});
|
||||
|
||||
it("is what the image installs as wget", () => {
|
||||
const dockerfile = readFileSync(join(ROOT, "Dockerfile"), "utf8");
|
||||
const runner = dockerfile.slice(dockerfile.search(/^FROM .* AS runner$/m));
|
||||
expect(runner).toContain(
|
||||
"COPY --from=builder /app/scripts/wget-shim.mjs /usr/local/lib/healthlog/wget-shim.mjs",
|
||||
);
|
||||
expect(runner).toContain(
|
||||
`exec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\\n' > /usr/local/bin/wget`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user