mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-02 02:00:31 +08:00
Enable localhost connections in radicle-explorer
Allow radicle-explorer to connect to a locally running radicle-httpd via https://app.radicle.xyz/nodes/localhost. This was previously disabled to encourage use of Radicle Desktop, but is now useful for testing the new always-on node in local development environments. Remove support for /nodes/radicle.local, an undocumented and unused convention. Fix remote node loading when Cloudflare URL-encodes port separator (: → %3A).
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"fallbackPublicExplorer": "FALLBACK_PUBLIC_EXPLORER",
|
||||
"requiredApiVersion": "REQUIRED_API_VERSION",
|
||||
"defaultHttpdPort": "DEFAULT_HTTPD_PORT",
|
||||
"defaultLocalHttpdPort": "DEFAULT_LOCAL_HTTPD_PORT",
|
||||
"defaultHttpdScheme": "DEFAULT_HTTPD_SCHEME"
|
||||
},
|
||||
"source": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"fallbackPublicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
|
||||
"requiredApiVersion": "~0.18.0",
|
||||
"defaultHttpdPort": 443,
|
||||
"defaultLocalHttpdPort": 8080,
|
||||
"defaultHttpdScheme": "https"
|
||||
},
|
||||
"source": {
|
||||
|
||||
Vendored
+2
@@ -3,6 +3,8 @@ import type { Config } from "./module.d.ts";
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var, @typescript-eslint/naming-convention
|
||||
var __CONFIG__: Config;
|
||||
// eslint-disable-next-line no-var, @typescript-eslint/naming-convention
|
||||
var __PLAYWRIGHT__: boolean;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { HttpdClient } from "@http-client";
|
||||
import { defaultHttpdPort } from "@tests/support/fixtures";
|
||||
import config from "@app/lib/config";
|
||||
|
||||
const api = new HttpdClient({
|
||||
hostname: "127.0.0.1",
|
||||
port: defaultHttpdPort,
|
||||
port: config.nodes.defaultHttpdPort,
|
||||
scheme: "http",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { HttpdClient } from "@http-client";
|
||||
import config from "@app/lib/config";
|
||||
import {
|
||||
aliceMainHead,
|
||||
aliceRemote,
|
||||
cobRid,
|
||||
defaultHttpdPort,
|
||||
sourceBrowsingRid,
|
||||
} from "@tests/support/fixtures.js";
|
||||
|
||||
describe("repo", () => {
|
||||
const api = new HttpdClient({
|
||||
hostname: "127.0.0.1",
|
||||
port: defaultHttpdPort,
|
||||
port: config.nodes.defaultHttpdPort,
|
||||
scheme: "http",
|
||||
});
|
||||
|
||||
|
||||
+23
-29
@@ -145,37 +145,31 @@ export async function replace(newRoute: Route): Promise<void> {
|
||||
await navigate("replace", newRoute);
|
||||
}
|
||||
|
||||
function extractBaseUrl(hostAndPort: string): BaseUrl {
|
||||
if (
|
||||
hostAndPort === "radicle.local" ||
|
||||
hostAndPort === `radicle.local:${config.nodes.defaultHttpdPort}` ||
|
||||
hostAndPort === "0.0.0.0" ||
|
||||
hostAndPort === `0.0.0.0:${config.nodes.defaultHttpdPort}` ||
|
||||
hostAndPort === "127.0.0.1" ||
|
||||
hostAndPort === `127.0.0.1:${config.nodes.defaultHttpdPort}`
|
||||
) {
|
||||
return {
|
||||
hostname: "127.0.0.1",
|
||||
port: config.nodes.defaultHttpdPort,
|
||||
scheme: "http",
|
||||
};
|
||||
} else if (hostAndPort.includes(":")) {
|
||||
const [hostname, port] = hostAndPort.split(":");
|
||||
return {
|
||||
hostname,
|
||||
port: Number(port),
|
||||
scheme:
|
||||
utils.isLocal(hostname) || utils.isOnion(hostname)
|
||||
? "http"
|
||||
: config.nodes.defaultHttpdScheme,
|
||||
};
|
||||
export function extractBaseUrl(hostAndPort: string): BaseUrl {
|
||||
const [hostname, portString] = decodeURIComponent(hostAndPort).split(":");
|
||||
|
||||
let port;
|
||||
|
||||
if (portString !== undefined) {
|
||||
port = Number(portString);
|
||||
} else if (globalThis.__PLAYWRIGHT__ === true) {
|
||||
port = config.nodes.defaultHttpdPort;
|
||||
} else {
|
||||
return {
|
||||
hostname: hostAndPort,
|
||||
port: config.nodes.defaultHttpdPort,
|
||||
scheme: config.nodes.defaultHttpdScheme,
|
||||
};
|
||||
port = utils.isLocal(hostname)
|
||||
? config.nodes.defaultLocalHttpdPort
|
||||
: config.nodes.defaultHttpdPort;
|
||||
}
|
||||
|
||||
const scheme =
|
||||
utils.isLocal(hostname) || utils.isOnion(hostname)
|
||||
? "http"
|
||||
: config.nodes.defaultHttpdScheme;
|
||||
|
||||
return {
|
||||
hostname,
|
||||
port,
|
||||
scheme,
|
||||
};
|
||||
}
|
||||
|
||||
function urlToRoute(url: URL): Route | null {
|
||||
|
||||
+1
-5
@@ -205,11 +205,7 @@ export function isMarkdownPath(path: string): boolean {
|
||||
|
||||
// Check whether the given address is a localhost address.
|
||||
export function isLocal(addr: string): boolean {
|
||||
return (
|
||||
addr.startsWith("127.0.0.1") ||
|
||||
addr.startsWith("0.0.0.0") ||
|
||||
addr.startsWith("radicle.local")
|
||||
);
|
||||
return addr.startsWith("127.0.0.1") || addr.startsWith("localhost");
|
||||
}
|
||||
|
||||
// Check whether the given domain name is an onion domain name.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
selectedSeed,
|
||||
} from "./SeedSelector";
|
||||
import { closeFocused } from "@app/components/Popover.svelte";
|
||||
import { push } from "@app/lib/router";
|
||||
import { extractBaseUrl, push } from "@app/lib/router";
|
||||
|
||||
import DropdownList from "@app/components/DropdownList.svelte";
|
||||
import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
|
||||
@@ -45,11 +45,7 @@
|
||||
|
||||
async function navigateToSeed() {
|
||||
loading = true;
|
||||
const seed = {
|
||||
hostname: seedAddressInput.trim(),
|
||||
port: config.nodes.defaultHttpdPort,
|
||||
scheme: config.nodes.defaultHttpdScheme,
|
||||
};
|
||||
const seed = extractBaseUrl(seedAddressInput.trim());
|
||||
validationMessage = await validateInput(seed);
|
||||
if (validationMessage === undefined) {
|
||||
closeFocused();
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
|
||||
import config from "@app/lib/config";
|
||||
import { HttpdClient } from "@http-client";
|
||||
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
|
||||
import { baseUrlToString, isLocal } from "@app/lib/utils";
|
||||
import { baseUrlToString } from "@app/lib/utils";
|
||||
import { handleError } from "@app/views/nodes/error";
|
||||
import { unreachableError } from "@app/views/repos/error";
|
||||
import { determineSeed } from "./SeedSelector";
|
||||
@@ -53,17 +53,6 @@ export async function loadNodeRoute(
|
||||
|
||||
const api = new HttpdClient(baseUrl);
|
||||
|
||||
if (import.meta.env.PROD && isLocal(`${baseUrl.hostname}:${baseUrl.port}`)) {
|
||||
return {
|
||||
resource: "error",
|
||||
params: {
|
||||
icon: "device",
|
||||
title: "Local node browsing not supported",
|
||||
description: `You're trying to access a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [node, stats] = await Promise.all([api.getNode(), api.getStats()]);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import { HttpdClient } from "@http-client";
|
||||
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
|
||||
import { cached } from "@app/lib/cache";
|
||||
import { handleError, unreachableError } from "@app/views/repos/error";
|
||||
import { isLocal, unreachable } from "@app/lib/utils";
|
||||
import { unreachable } from "@app/lib/utils";
|
||||
import { nodePath } from "@app/views/nodes/router";
|
||||
|
||||
export const PATCHES_PER_PAGE = 10;
|
||||
@@ -262,19 +262,6 @@ export async function loadRepoRoute(
|
||||
route: RepoRoute,
|
||||
previousLoaded: LoadedRoute,
|
||||
): Promise<RepoLoadedRoute | ErrorRoute | NotFoundRoute> {
|
||||
if (
|
||||
import.meta.env.PROD &&
|
||||
isLocal(`${route.node.hostname}:${route.node.port}`)
|
||||
) {
|
||||
return {
|
||||
resource: "error",
|
||||
params: {
|
||||
icon: "device",
|
||||
title: "Local node browsing not supported",
|
||||
description: `You're trying to access a repository on a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const api = new HttpdClient(route.node);
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { BaseUrl, NodeIdentity, NodeStats } from "@http-client";
|
||||
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
|
||||
|
||||
import * as utils from "@app/lib/utils";
|
||||
import config from "@app/lib/config";
|
||||
import { HttpdClient } from "@http-client";
|
||||
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
|
||||
import { handleError } from "@app/views/nodes/error";
|
||||
@@ -30,19 +29,6 @@ export async function loadUserRoute({
|
||||
did,
|
||||
baseUrl,
|
||||
}: UserRoute): Promise<UserLoadedRoute | NotFoundRoute | ErrorRoute> {
|
||||
if (
|
||||
import.meta.env.PROD &&
|
||||
utils.isLocal(`${baseUrl.hostname}:${baseUrl.port}`)
|
||||
) {
|
||||
return {
|
||||
resource: "error",
|
||||
params: {
|
||||
icon: "device",
|
||||
title: "Local node browsing not supported",
|
||||
description: `You're trying to access a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const parsedDid = utils.parseNodeId(decodeURIComponent(did));
|
||||
if (!parsedDid) {
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,5 @@ test("exceptions in production build", async ({ page }) => {
|
||||
// Wait for scripts to finish executing, there might be exceptions that
|
||||
// happen after the page has been painted.
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(
|
||||
page.getByText("Local node browsing not supported"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Node not found")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ test("copy to clipboard", async ({ page, browserName, context }) => {
|
||||
await page.getByRole("button", { name: "Git" }).click();
|
||||
await page.getByText("git clone").locator(".clipboard").first().click();
|
||||
await expectClipboard(
|
||||
`git clone http://127.0.0.1/${sourceBrowsingRid.replace(
|
||||
`git clone http://localhost/${sourceBrowsingRid.replace(
|
||||
"rad:",
|
||||
"",
|
||||
)}.git source-browsing`,
|
||||
|
||||
@@ -26,7 +26,7 @@ test("node metadata", async ({ page, peerManager }) => {
|
||||
});
|
||||
|
||||
test("node repos", async ({ page }) => {
|
||||
await page.goto("/nodes/radicle.local");
|
||||
await page.goto("/nodes/localhost");
|
||||
const repo = page
|
||||
.locator(".repo-card", { hasText: "source-browsing" })
|
||||
.nth(0);
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ test("navigate between landing and repo page", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("navigation between node and repo pages", async ({ page }) => {
|
||||
await page.goto("/nodes/radicle.local");
|
||||
await page.goto("/nodes/localhost");
|
||||
|
||||
const repo = page
|
||||
.locator(".repo-card", { hasText: "source-browsing" })
|
||||
@@ -32,7 +32,7 @@ test("navigation between node and repo pages", async ({ page }) => {
|
||||
await repo.click();
|
||||
await expect(page).toHaveURL(sourceBrowsingUrl);
|
||||
|
||||
await expectBackAndForwardNavigationWorks("/nodes/radicle.local", page);
|
||||
await expectBackAndForwardNavigationWorks("/nodes/localhost", page);
|
||||
await expectUrlPersistsReload(page);
|
||||
|
||||
await page.getByRole("link", { name: "Local Node" }).click();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Test adapter for configuration in Node.js environment (Playwright tests).
|
||||
//
|
||||
// The app uses "virtual:config" (defined in vite.config.ts via
|
||||
// vite-plugin-virtual), which is populated at build time with
|
||||
// config.util.toObject() from the "config" npm package. Since Vite's virtual
|
||||
// module system doesn't work in Node.js, this file provides the same
|
||||
// configuration data by directly importing and converting the "config" package.
|
||||
import nodeConfig from "config";
|
||||
|
||||
const config = nodeConfig.util.toObject();
|
||||
|
||||
export default config;
|
||||
@@ -30,6 +30,13 @@ export const test = base.extend<{
|
||||
}>({
|
||||
forAllTests: [
|
||||
async ({ outputLog, page }, use) => {
|
||||
// Flag that tests are running so the app uses the test httpd port
|
||||
// (8081 from config.test.json) instead of the production default (8080)
|
||||
// for /nodes/localhost requests.
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__PLAYWRIGHT__ = true;
|
||||
});
|
||||
|
||||
const browserLabel = logLabel.logPrefix("browser");
|
||||
page.on("console", msg => {
|
||||
// Ignore common console logs that we don't care about.
|
||||
@@ -602,11 +609,10 @@ export const shortBobHead = formatCommit(bobHead);
|
||||
export const sourceBrowsingRid = "rad:z4BwwjPCFNVP27FwVbDFgwVwkjcir";
|
||||
export const cobRid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe";
|
||||
export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg";
|
||||
export const sourceBrowsingUrl = `/nodes/127.0.0.1/${sourceBrowsingRid}`;
|
||||
export const cobUrl = `/nodes/127.0.0.1/${cobRid}`;
|
||||
export const markdownUrl = `/nodes/127.0.0.1/${markdownRid}`;
|
||||
export const sourceBrowsingUrl = `/nodes/localhost/${sourceBrowsingRid}`;
|
||||
export const cobUrl = `/nodes/localhost/${cobRid}`;
|
||||
export const markdownUrl = `/nodes/localhost/${markdownRid}`;
|
||||
export const shortNodeRemote = "z6MktU…1xB22S";
|
||||
export const defaultHttpdPort = 8081;
|
||||
export const gitOptions = {
|
||||
alice: {
|
||||
GIT_AUTHOR_NAME: "Alice Liddell",
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
createCobsFixture,
|
||||
createMarkdownFixture,
|
||||
createSourceBrowsingFixture,
|
||||
defaultHttpdPort,
|
||||
gitOptions,
|
||||
} from "@tests/support/fixtures.js";
|
||||
import config from "@tests/support/config.js";
|
||||
import { createPeerManager } from "@tests/support/peerManager.js";
|
||||
|
||||
const heartwoodBinaryPath = Path.join(
|
||||
@@ -87,7 +87,7 @@ export default async function globalSetup(): Promise<() => void> {
|
||||
alias: "palm",
|
||||
},
|
||||
});
|
||||
await palm.startHttpd(defaultHttpdPort);
|
||||
await palm.startHttpd(config.nodes.defaultHttpdPort);
|
||||
|
||||
try {
|
||||
console.log("Creating source-browsing fixture");
|
||||
@@ -108,7 +108,7 @@ export default async function globalSetup(): Promise<() => void> {
|
||||
}
|
||||
await palm.stopNode();
|
||||
} else {
|
||||
await palm.startHttpd(defaultHttpdPort);
|
||||
await palm.startHttpd(config.nodes.defaultHttpdPort);
|
||||
}
|
||||
|
||||
return async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defaultHttpdPort } from "@tests/support/fixtures.js";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { testExports, type Route } from "@app/lib/router";
|
||||
import config from "@tests/support/config.js";
|
||||
|
||||
// Defining the window.origin value, since vitest doesn't provide one.
|
||||
window.origin = "http://localhost:3000";
|
||||
@@ -217,7 +217,7 @@ describe("pathToRoute", () => {
|
||||
baseUrl: {
|
||||
hostname: "example.node.tld",
|
||||
scheme: "http",
|
||||
port: defaultHttpdPort,
|
||||
port: config.nodes.defaultHttpdPort,
|
||||
},
|
||||
repoPageIndex: 0,
|
||||
},
|
||||
@@ -232,7 +232,7 @@ describe("pathToRoute", () => {
|
||||
node: {
|
||||
hostname: "example.node.tld",
|
||||
scheme: "http",
|
||||
port: defaultHttpdPort,
|
||||
port: config.nodes.defaultHttpdPort,
|
||||
},
|
||||
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
|
||||
route: "",
|
||||
@@ -248,7 +248,7 @@ describe("pathToRoute", () => {
|
||||
node: {
|
||||
hostname: "example.node.tld",
|
||||
scheme: "http",
|
||||
port: defaultHttpdPort,
|
||||
port: config.nodes.defaultHttpdPort,
|
||||
},
|
||||
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
|
||||
route: "",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from "@tests/support/fixtures.js";
|
||||
|
||||
test("node page", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("November 24 2022 12:00:00"));
|
||||
await page.goto("/nodes/radicle.local", { waitUntil: "networkidle" });
|
||||
await page.goto("/nodes/localhost", { waitUntil: "networkidle" });
|
||||
await expect(page).toHaveScreenshot();
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ test("response parse error", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/nodes/radicle.local", {
|
||||
await page.goto("/nodes/localhost", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
@@ -51,7 +51,7 @@ test("response error", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/nodes/radicle.local", {
|
||||
await page.goto("/nodes/localhost", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
|
||||
@@ -7,14 +7,14 @@ import {
|
||||
|
||||
test("user page", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("November 24 2022 12:00:00"));
|
||||
await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, {
|
||||
await page.goto(`/nodes/localhost/users/${aliceRemote}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("empty pinned repos", async ({ page }) => {
|
||||
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
|
||||
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
@@ -27,7 +27,7 @@ test("response parse error", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
|
||||
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
@@ -40,7 +40,7 @@ test("response error", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
|
||||
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
|
||||
@@ -7,14 +7,14 @@ import {
|
||||
|
||||
test("user page", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("November 24 2022 12:00:00"));
|
||||
await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, {
|
||||
await page.goto(`/nodes/localhost/users/${aliceRemote}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("empty pinned repos", async ({ page }) => {
|
||||
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
|
||||
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
@@ -27,7 +27,7 @@ test("response parse error", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
|
||||
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
@@ -40,7 +40,7 @@ test("response error", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
|
||||
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await expect(page).toHaveScreenshot();
|
||||
|
||||
Reference in New Issue
Block a user