mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): pin Studio bundle, signature and runtime file reads (#3728)
* fix(cli): read Studio bundle files through checked descriptors * fix(cli): pin Studio signature and runtime artifact reads
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { openSync, fstatSync, closeSync, statSync, readFileSync, constants } from "node:fs";
|
||||
|
||||
export function readBundleFile(filePath: string): Buffer<ArrayBuffer> | null {
|
||||
let fd: number;
|
||||
try {
|
||||
// Check named pipes without waiting for a writer to connect.
|
||||
fd = openSync(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
||||
)
|
||||
return null;
|
||||
// Classify platform-specific directory/socket errors after a failed open.
|
||||
// No pathname read follows this check.
|
||||
if (!statSync(filePath, { throwIfNoEntry: false })?.isFile()) return null;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (!fstatSync(fd).isFile()) return null;
|
||||
return readFileSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { loadRuntimeSource } from "./runtimeSource.js";
|
||||
|
||||
const hooks = vi.hoisted(() => ({
|
||||
dir: "",
|
||||
source: "",
|
||||
inlined: "",
|
||||
checked: () => {},
|
||||
beforeRead: () => {},
|
||||
active: new Set<number>(),
|
||||
opened: 0,
|
||||
}));
|
||||
vi.mock("@hyperframes/core", () => ({
|
||||
loadHyperframeRuntimeSource: () => hooks.source,
|
||||
getHyperframeRuntimeScript: () => hooks.inlined || null,
|
||||
}));
|
||||
vi.mock("node:path", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof path>();
|
||||
return {
|
||||
...actual,
|
||||
resolve: (...parts: string[]) => {
|
||||
const name = parts.at(-1) ?? "";
|
||||
if (hooks.dir && ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"].includes(name))
|
||||
return actual.join(hooks.dir, name);
|
||||
return actual.resolve(...parts);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (file: fs.PathLike) => {
|
||||
if (String(file).endsWith("entry.ts")) return true;
|
||||
const exists = actual.existsSync(file);
|
||||
if (exists) hooks.checked();
|
||||
return exists;
|
||||
},
|
||||
openSync: (file: fs.PathLike, flags: string | number) => {
|
||||
const fd = actual.openSync(file, flags);
|
||||
hooks.active.add(fd);
|
||||
hooks.opened++;
|
||||
return fd;
|
||||
},
|
||||
fstatSync: (fd: number) => {
|
||||
const stat = actual.fstatSync(fd);
|
||||
hooks.checked();
|
||||
return stat;
|
||||
},
|
||||
closeSync: (fd: number) => {
|
||||
actual.closeSync(fd);
|
||||
hooks.active.delete(fd);
|
||||
},
|
||||
readFileSync: (file: fs.PathOrFileDescriptor, encoding?: BufferEncoding) => {
|
||||
hooks.beforeRead();
|
||||
return encoding ? actual.readFileSync(file, encoding) : actual.readFileSync(file);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("prebuilt runtime file reads", () => {
|
||||
beforeEach(() => {
|
||||
hooks.dir = fs.mkdtempSync(path.join(tmpdir(), "hf-runtime-read-"));
|
||||
hooks.opened = 0;
|
||||
hooks.active.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
hooks.checked = () => {};
|
||||
hooks.beforeRead = () => {};
|
||||
hooks.source = "";
|
||||
hooks.inlined = "";
|
||||
fs.rmSync(hooks.dir, { recursive: true, force: true });
|
||||
hooks.dir = "";
|
||||
});
|
||||
function expectClosed() {
|
||||
expect(hooks.opened).toBeGreaterThan(0);
|
||||
expect(hooks.active.size).toBe(0);
|
||||
}
|
||||
|
||||
it.each(["hyperframe-runtime.js", "hyperframe.runtime.iife.js"])(
|
||||
"reads checked %s despite replacement",
|
||||
async (name) => {
|
||||
const file = path.join(hooks.dir, name);
|
||||
fs.writeFileSync(file, "checked runtime");
|
||||
hooks.checked = () => {
|
||||
hooks.checked = () => {};
|
||||
fs.renameSync(file, path.join(hooks.dir, "original"));
|
||||
fs.writeFileSync(file, "replacement runtime");
|
||||
};
|
||||
expect(await loadRuntimeSource()).toBe("checked runtime");
|
||||
expectClosed();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves source, inline and artifact priority", async () => {
|
||||
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "first artifact");
|
||||
fs.writeFileSync(path.join(hooks.dir, "hyperframe.runtime.iife.js"), "second artifact");
|
||||
hooks.source = "source";
|
||||
hooks.inlined = "inline";
|
||||
expect(await loadRuntimeSource()).toBe("source");
|
||||
hooks.source = "";
|
||||
expect(await loadRuntimeSource()).toBe("inline");
|
||||
expect(hooks.opened).toBe(0);
|
||||
hooks.inlined = "";
|
||||
expect(await loadRuntimeSource()).toBe("first artifact");
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it("preserves an empty first artifact", async () => {
|
||||
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "");
|
||||
fs.writeFileSync(path.join(hooks.dir, "hyperframe.runtime.iife.js"), "second artifact");
|
||||
expect(await loadRuntimeSource()).toBe("");
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it("returns null when artifacts are absent", async () => {
|
||||
expect(await loadRuntimeSource()).toBeNull();
|
||||
expect(hooks.active.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["stat", "read"])("closes an artifact after %s failure", async (step) => {
|
||||
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "bytes");
|
||||
const fail = () => {
|
||||
throw new Error("Injected artifact failure");
|
||||
};
|
||||
if (step === "stat") hooks.checked = fail;
|
||||
else hooks.beforeRead = fail;
|
||||
await expect(loadRuntimeSource()).rejects.toThrow("Injected artifact failure");
|
||||
expectClosed();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readBundleFile } from "./readBundleFile.js";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
const ARTIFACT_NAMES = ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"];
|
||||
@@ -73,7 +74,8 @@ function readPrebuiltArtifact(): string | null {
|
||||
function readFromDir(dir: string): string | null {
|
||||
for (const name of ARTIFACT_NAMES) {
|
||||
const path = resolve(dir, name);
|
||||
if (existsSync(path)) return readFileSync(path, "utf-8");
|
||||
const content = readBundleFile(path);
|
||||
if (content !== null) return content.toString("utf-8");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
createStudioServer,
|
||||
loadPreviewServerBuildSignature,
|
||||
type StudioServer,
|
||||
} from "./studioServer.js";
|
||||
|
||||
const hooks = vi.hoisted(() => ({
|
||||
studioDir: "",
|
||||
runtimeDir: "",
|
||||
ignoreNextExists: "",
|
||||
useRuntimeFallback: false,
|
||||
checked: (_path: fs.PathLike) => {},
|
||||
beforeRead: () => {},
|
||||
opened: new Map<number, fs.PathLike>(),
|
||||
active: new Set<number>(),
|
||||
}));
|
||||
|
||||
vi.mock("./runtimeSource.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./runtimeSource.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadRuntimeSource: () =>
|
||||
hooks.useRuntimeFallback ? Promise.resolve(null) : actual.loadRuntimeSource(),
|
||||
loadRuntimeSourceSignature: () => Promise.resolve("stable-runtime-signature"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:path", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof path>();
|
||||
return {
|
||||
...actual,
|
||||
resolve: (...parts: string[]) => {
|
||||
if (
|
||||
hooks.studioDir &&
|
||||
parts.length === 2 &&
|
||||
parts[0]?.endsWith("server") &&
|
||||
parts[1] === "studio"
|
||||
)
|
||||
return hooks.studioDir;
|
||||
if (
|
||||
hooks.runtimeDir &&
|
||||
["hyperframe-runtime.js", "hyperframe.runtime.iife.js"].includes(parts.at(-1) ?? "")
|
||||
) {
|
||||
return actual.resolve(hooks.runtimeDir, parts.at(-1) ?? "");
|
||||
}
|
||||
return actual.resolve(...parts);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (file: fs.PathLike) => {
|
||||
const exists = actual.existsSync(file);
|
||||
if (file === hooks.ignoreNextExists) hooks.ignoreNextExists = "";
|
||||
else if (exists) hooks.checked(file);
|
||||
return exists;
|
||||
},
|
||||
openSync: (file: fs.PathLike, flags: number | string) => {
|
||||
const fd = actual.openSync(file, flags);
|
||||
hooks.opened.set(fd, file);
|
||||
hooks.active.add(fd);
|
||||
return fd;
|
||||
},
|
||||
closeSync: (fd: number) => {
|
||||
actual.closeSync(fd);
|
||||
hooks.active.delete(fd);
|
||||
},
|
||||
statSync: (file: fs.PathLike) => {
|
||||
const stat = actual.statSync(file);
|
||||
hooks.checked(file);
|
||||
return stat;
|
||||
},
|
||||
fstatSync: (fd: number) => {
|
||||
const stat = actual.fstatSync(fd);
|
||||
const file = hooks.opened.get(fd);
|
||||
if (file) hooks.checked(file);
|
||||
return stat;
|
||||
},
|
||||
readFileSync: (file: fs.PathOrFileDescriptor, encoding?: BufferEncoding) => {
|
||||
hooks.beforeRead();
|
||||
return encoding ? actual.readFileSync(file, encoding) : actual.readFileSync(file);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("Studio bundle file reads", () => {
|
||||
let root: string;
|
||||
let server: StudioServer;
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(tmpdir(), "hf-studio-static-"));
|
||||
hooks.studioDir = path.join(root, "studio");
|
||||
hooks.runtimeDir = path.join(root, "runtime");
|
||||
fs.mkdirSync(hooks.runtimeDir);
|
||||
fs.writeFileSync(path.join(hooks.runtimeDir, "hyperframe-runtime.js"), "checked runtime");
|
||||
const projectDir = path.join(root, "project");
|
||||
fs.mkdirSync(projectDir);
|
||||
fs.mkdirSync(hooks.studioDir);
|
||||
fs.mkdirSync(path.join(hooks.studioDir, "assets"));
|
||||
fs.mkdirSync(path.join(hooks.studioDir, "icons"));
|
||||
fs.writeFileSync(
|
||||
path.join(hooks.studioDir, "index.html"),
|
||||
"<html><head></head><body>Studio</body></html>",
|
||||
);
|
||||
server = createStudioServer({ projectDir });
|
||||
hooks.opened.clear();
|
||||
hooks.active.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
hooks.checked = () => {};
|
||||
hooks.beforeRead = () => {};
|
||||
server.watcher.close();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
hooks.studioDir = "";
|
||||
hooks.runtimeDir = "";
|
||||
hooks.ignoreNextExists = "";
|
||||
hooks.useRuntimeFallback = false;
|
||||
});
|
||||
function expectClosed() {
|
||||
expect(hooks.opened.size).toBeGreaterThan(0);
|
||||
expect(hooks.active.size).toBe(0);
|
||||
}
|
||||
|
||||
it.each(["assets/main.js", "icons/logo.svg", "favicon.svg", "index.html"])(
|
||||
"reads the checked %s despite replacement",
|
||||
async (name) => {
|
||||
const file = path.join(hooks.studioDir, name);
|
||||
fs.writeFileSync(file, "checked bytes");
|
||||
hooks.checked = (checked) => {
|
||||
if (checked !== file) return;
|
||||
hooks.checked = () => {};
|
||||
fs.renameSync(file, path.join(root, "original"));
|
||||
fs.writeFileSync(file, "replacement bytes");
|
||||
};
|
||||
const response = await server.app.request(name === "index.html" ? "/" : `/${name}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("checked bytes");
|
||||
expectClosed();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["assets/main.js", "text/javascript"],
|
||||
["icons/logo.svg", "image/svg+xml"],
|
||||
["favicon.svg", "image/svg+xml"],
|
||||
])("preserves %s MIME and cache headers", async (name, mime) => {
|
||||
fs.writeFileSync(path.join(hooks.studioDir, name), "");
|
||||
const response = await server.app.request(`/${name}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(mime);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.text()).toBe("");
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it("hashes the checked Studio index despite replacement", async () => {
|
||||
const file = path.join(hooks.studioDir, "index.html");
|
||||
const expected = await loadPreviewServerBuildSignature();
|
||||
hooks.ignoreNextExists = file; // bundle selection precedes the content read
|
||||
hooks.checked = (checked) => {
|
||||
if (checked !== file) return;
|
||||
hooks.checked = () => {};
|
||||
fs.renameSync(file, path.join(root, "original-index"));
|
||||
fs.writeFileSync(file, "replacement index");
|
||||
};
|
||||
expect(await loadPreviewServerBuildSignature()).toBe(expected);
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it("serves the checked runtime fallback despite replacement", async () => {
|
||||
hooks.useRuntimeFallback = true;
|
||||
const file = path.join(hooks.runtimeDir, "hyperframe-runtime.js");
|
||||
hooks.checked = (checked) => {
|
||||
if (checked !== file) return;
|
||||
hooks.checked = () => {};
|
||||
fs.renameSync(file, path.join(root, "original-runtime"));
|
||||
fs.writeFileSync(file, "replacement runtime");
|
||||
};
|
||||
const response = await server.app.request("/api/runtime.js");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.text()).toBe("checked runtime");
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it.each(["signature", "runtime"])("closes a failed %s read", async (kind) => {
|
||||
hooks.useRuntimeFallback = true;
|
||||
hooks.beforeRead = () => {
|
||||
throw new Error("Injected read failure");
|
||||
};
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
if (kind === "signature")
|
||||
await expect(loadPreviewServerBuildSignature()).rejects.toThrow("Injected read failure");
|
||||
else expect((await server.app.request("/api/runtime.js")).status).toBe(500);
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it("returns 404 for absent assets and directories", async () => {
|
||||
fs.mkdirSync(path.join(hooks.studioDir, "assets", "folder"));
|
||||
for (const route of ["/assets/missing", "/assets/folder", "/icons/missing", "/favicon.svg"]) {
|
||||
const response = await server.app.request(route);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("not found");
|
||||
}
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("rejects an asset FIFO without blocking", async () => {
|
||||
execFileSync("mkfifo", [path.join(hooks.studioDir, "assets", "pipe")]);
|
||||
expect((await server.app.request("/assets/pipe")).status).toBe(404);
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it("continues serving symlinked bundle assets", async () => {
|
||||
const shared = path.join(root, "shared");
|
||||
fs.mkdirSync(shared);
|
||||
fs.writeFileSync(path.join(shared, "file.js"), "shared asset");
|
||||
fs.symlinkSync(
|
||||
shared,
|
||||
path.join(hooks.studioDir, "assets", "shared"),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const response = await server.app.request("/assets/shared/file.js");
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("shared asset");
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it("preserves the unavailable-bundle diagnostic", async () => {
|
||||
fs.unlinkSync(path.join(hooks.studioDir, "index.html"));
|
||||
const response = await server.app.request("/");
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.text()).toContain("HyperFrames Studio unavailable");
|
||||
});
|
||||
|
||||
it("still injects runtime environment into the SPA fallback", async () => {
|
||||
vi.stubEnv("VITE_STUDIO_STATIC_TEST", "runtime-value");
|
||||
const response = await server.app.request("/editor");
|
||||
expect(response.status).toBe(200);
|
||||
const text = await response.text();
|
||||
expect(text).toContain("Studio");
|
||||
expect(text).toContain('"VITE_STUDIO_STATIC_TEST":"runtime-value"');
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it.each(["stat", "read"])("closes a file when %s fails", async (step) => {
|
||||
fs.writeFileSync(path.join(hooks.studioDir, "assets", "file.js"), "bytes");
|
||||
const fail = () => {
|
||||
throw new Error("Injected file failure");
|
||||
};
|
||||
if (step === "stat") hooks.checked = fail;
|
||||
else hooks.beforeRead = fail;
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect((await server.app.request("/assets/file.js")).status).toBe(500);
|
||||
expectClosed();
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import { Hono, type Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from "node:fs";
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { resolve, join, basename } from "node:path";
|
||||
import { readBundleFile } from "./readBundleFile.js";
|
||||
import {
|
||||
createProjectWatcher,
|
||||
shouldWatchProjectFile,
|
||||
@@ -314,10 +315,9 @@ export interface StudioServer {
|
||||
export async function loadPreviewServerBuildSignature(): Promise<string> {
|
||||
const runtimeSignature = await loadRuntimeSourceSignature();
|
||||
const studioBundle = resolveStudioBundle();
|
||||
const studioIndex =
|
||||
studioBundle.available && existsSync(studioBundle.indexPath)
|
||||
? readFileSync(studioBundle.indexPath, "utf-8")
|
||||
: "";
|
||||
const studioIndex = studioBundle.available
|
||||
? (readBundleFile(studioBundle.indexPath)?.toString("utf-8") ?? "")
|
||||
: "";
|
||||
return hashSignatureParts([
|
||||
version,
|
||||
runtimeSignature,
|
||||
@@ -734,8 +734,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
app.get("/api/runtime.js", (c) => {
|
||||
const serve = async () => {
|
||||
const runtimeSource =
|
||||
(await loadRuntimeSource()) ??
|
||||
(existsSync(runtimePath) ? readFileSync(runtimePath, "utf-8") : null);
|
||||
(await loadRuntimeSource()) ?? readBundleFile(runtimePath)?.toString("utf-8") ?? null;
|
||||
if (!runtimeSource) return c.text("runtime not available", 404);
|
||||
return c.body(runtimeSource, 200, {
|
||||
"Content-Type": "text/javascript",
|
||||
@@ -877,8 +876,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// Studio SPA static files
|
||||
const serveStudioStaticFile = (c: Context) => {
|
||||
const filePath = resolve(studioDir, c.req.path.slice(1));
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) return c.text("not found", 404);
|
||||
const content = readFileSync(filePath);
|
||||
const content = readBundleFile(filePath);
|
||||
if (content === null) return c.text("not found", 404);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" },
|
||||
});
|
||||
@@ -906,7 +905,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// SPA fallback
|
||||
app.get("*", (c) => {
|
||||
const indexPath = resolve(studioDir, "index.html");
|
||||
if (!existsSync(indexPath)) {
|
||||
const indexContent = readBundleFile(indexPath);
|
||||
if (indexContent === null) {
|
||||
return c.html(
|
||||
`<!doctype html>
|
||||
<html>
|
||||
@@ -962,7 +962,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
500,
|
||||
);
|
||||
}
|
||||
let html = readFileSync(indexPath, "utf-8");
|
||||
let html = indexContent.toString("utf-8");
|
||||
// Inject before the studio bundle runs. Identity script first (see
|
||||
// buildStudioHeadScripts) so the CLI distinct id is on `window` by the time
|
||||
// telemetry init reads it.
|
||||
|
||||
Reference in New Issue
Block a user