mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
Merge branch 'feat/appearance-tab-whitelabel-v2' into release/v3.4.2
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -10709,6 +10709,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function AppearanceTab() {
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const [settings, setSettings] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [customThemeColor, setCustomThemeColor] = useState(customColor || "#3b82f6");
|
||||
const isValidHex = /^#([0-9a-fA-F]{6})$/.test(
|
||||
customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}`
|
||||
@@ -282,6 +283,227 @@ export default function AppearanceTab() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
badge
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">{t("whitelabeling")}</h4>
|
||||
<p className="text-sm text-text-muted">{t("whitelabelingDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{t("appName")}</p>
|
||||
<p className="text-sm text-text-muted">{t("appNameDesc")}</p>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.instanceName || "OmniRoute"}
|
||||
onChange={(e) => updateSetting("instanceName", e.target.value)}
|
||||
placeholder="OmniRoute"
|
||||
maxLength={100}
|
||||
className="h-10 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{t("customLogo")}</p>
|
||||
<p className="text-sm text-text-muted">{t("customLogoDesc")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.customLogoUrl || ""}
|
||||
onChange={(e) => updateSetting("customLogoUrl", e.target.value)}
|
||||
className="flex-1 h-10 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary"
|
||||
placeholder="https://example.com/logo.png"
|
||||
maxLength={2000}
|
||||
/>
|
||||
{(settings.customLogoUrl || settings.customLogoBase64) && (
|
||||
<img
|
||||
src={settings.customLogoBase64 || settings.customLogoUrl}
|
||||
alt="Logo preview"
|
||||
className="h-10 w-10 rounded border border-border object-contain bg-surface"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-medium">{t("uploadLogo")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface border border-border text-sm text-text-main cursor-pointer hover:bg-surface/80 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/gif,image/webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 500 * 1024) {
|
||||
setUploadError("Logo file must be less than 500KB");
|
||||
return;
|
||||
}
|
||||
const validTypes = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError(
|
||||
"Invalid file type. Please upload PNG, JPG, SVG, GIF, or WebP."
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUploadError(null);
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => {
|
||||
setUploadError("Failed to read file");
|
||||
};
|
||||
reader.onload = (event) => {
|
||||
const base64 = event.target?.result as string;
|
||||
updateSetting("customLogoBase64", base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="material-symbols-outlined text-[18px]">upload</span>
|
||||
<span>{t("uploadLogo")}</span>
|
||||
</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
updateSetting("customLogoUrl", "");
|
||||
updateSetting("customLogoBase64", "");
|
||||
}}
|
||||
>
|
||||
{t("resetLogo")}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadError && <p className="text-sm text-red-500">{uploadError}</p>}
|
||||
{(settings.customLogoBase64 || settings.customLogoUrl) && (
|
||||
<div className="mt-2 p-3 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
<p className="text-xs text-text-muted mb-2">{t("logoPreview")}</p>
|
||||
<img
|
||||
src={settings.customLogoBase64 || settings.customLogoUrl}
|
||||
alt="Logo preview"
|
||||
className="h-12 w-auto max-w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-4 border-t border-border">
|
||||
<div>
|
||||
<p className="font-medium">{t("customFavicon")}</p>
|
||||
<p className="text-sm text-text-muted">{t("customFaviconDesc")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.customFaviconUrl || ""}
|
||||
onChange={(e) => updateSetting("customFaviconUrl", e.target.value)}
|
||||
className="flex-1 h-10 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary"
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
maxLength={2000}
|
||||
/>
|
||||
{(settings.customFaviconUrl || settings.customFaviconBase64) && (
|
||||
<img
|
||||
src={settings.customFaviconBase64 || settings.customFaviconUrl}
|
||||
alt="Favicon preview"
|
||||
className="h-10 w-10 rounded border border-border object-contain bg-surface"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-medium">{t("uploadFavicon")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface border border-border text-sm text-text-main cursor-pointer hover:bg-surface/80 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/x-icon,image/svg+xml,image/gif,image/webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 50 * 1024) {
|
||||
setUploadError("Favicon file must be less than 50KB");
|
||||
return;
|
||||
}
|
||||
const validTypes = [
|
||||
"image/png",
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError(
|
||||
"Invalid file type. Please upload PNG, ICO, SVG, GIF, or WebP."
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUploadError(null);
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => {
|
||||
setUploadError("Failed to read file");
|
||||
};
|
||||
reader.onload = (event) => {
|
||||
const base64 = event.target?.result as string;
|
||||
updateSetting("customFaviconBase64", base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="material-symbols-outlined text-[18px]">upload</span>
|
||||
<span>{t("uploadFavicon")}</span>
|
||||
</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
updateSetting("customFaviconUrl", "");
|
||||
updateSetting("customFaviconBase64", "");
|
||||
}}
|
||||
>
|
||||
{t("resetFavicon")}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadError && !uploadError.includes("Logo") && (
|
||||
<p className="text-sm text-red-500">{uploadError}</p>
|
||||
)}
|
||||
{(settings.customFaviconBase64 || settings.customFaviconUrl) && (
|
||||
<div className="mt-2 p-3 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
<p className="text-xs text-text-muted mb-2">{t("faviconPreview")}</p>
|
||||
<img
|
||||
src={settings.customFaviconBase64 || settings.customFaviconUrl}
|
||||
alt="Favicon preview"
|
||||
className="h-8 w-8 rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import ResilienceTab from "./components/ResilienceTab";
|
||||
|
||||
const tabs = [
|
||||
{ id: "general", labelKey: "general", icon: "settings" },
|
||||
{ id: "appearance", labelKey: "appearance", icon: "palette" },
|
||||
{ id: "ai", labelKey: "ai", icon: "smart_toy" },
|
||||
{ id: "security", labelKey: "security", icon: "shield" },
|
||||
{ id: "routing", labelKey: "routing", icon: "route" },
|
||||
@@ -75,14 +76,9 @@ export default function SettingsPage() {
|
||||
role="tabpanel"
|
||||
aria-label={t(tabs.find((t2) => t2.id === activeTab)?.labelKey || "general")}
|
||||
>
|
||||
{activeTab === "general" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SystemStorageTab />
|
||||
<AppearanceTab />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "general" && <SystemStorageTab />}
|
||||
|
||||
{activeTab === "appearance" && <AppearanceTab />}
|
||||
|
||||
{activeTab === "ai" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
141
src/app/api/settings/favicon/route.ts
Normal file
141
src/app/api/settings/favicon/route.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = [
|
||||
"image/png",
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/jpeg",
|
||||
];
|
||||
const MAX_FAVICON_SIZE = 50 * 1024; // 50KB
|
||||
const FETCH_TIMEOUT = 5000; // 5 seconds
|
||||
const CACHE_DURATION = 300; // 5 minutes
|
||||
|
||||
function isAllowedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
// Only allow https (or http for local development)
|
||||
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
|
||||
return false;
|
||||
}
|
||||
// Block private/internal IPs
|
||||
const hostname = parsedUrl.hostname;
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("172.") ||
|
||||
hostname.endsWith(".local") ||
|
||||
hostname === "localhost"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateImageData(base64Data: string, contentType: string): boolean {
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(contentType)) {
|
||||
console.error("Invalid content type:", contentType);
|
||||
return false;
|
||||
}
|
||||
// Check for obvious image magic bytes
|
||||
const matches = base64Data.match(/^data:[^;]+;base64,(.+)$/);
|
||||
if (!matches) return false;
|
||||
|
||||
const binaryData = Buffer.from(matches[1], "base64");
|
||||
if (binaryData.length > MAX_FAVICON_SIZE) {
|
||||
console.error("Favicon too large:", binaryData.length);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
const customFaviconBase64 = settings?.customFaviconBase64 as string | undefined;
|
||||
const customFaviconUrl = settings?.customFaviconUrl as string | undefined;
|
||||
|
||||
let faviconData: string | null = null;
|
||||
|
||||
if (customFaviconBase64) {
|
||||
// Validate stored Base64 data
|
||||
const match = customFaviconBase64.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (match && validateImageData(customFaviconBase64, match[1])) {
|
||||
faviconData = customFaviconBase64;
|
||||
}
|
||||
} else if (customFaviconUrl) {
|
||||
// Validate URL before fetching (SSRF protection)
|
||||
if (!isAllowedUrl(customFaviconUrl)) {
|
||||
console.error("Blocked invalid favicon URL:", customFaviconUrl);
|
||||
} else {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
|
||||
const response = await fetch(customFaviconUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "OmniRoute/1.0",
|
||||
},
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
// Validate size before processing
|
||||
if (uint8Array.length > MAX_FAVICON_SIZE) {
|
||||
console.error("Favicon exceeds max size:", uint8Array.length);
|
||||
} else {
|
||||
const base64 = Buffer.from(uint8Array).toString("base64");
|
||||
const fullData = `data:${contentType};base64,${base64}`;
|
||||
|
||||
if (validateImageData(fullData, contentType)) {
|
||||
faviconData = fullData;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch custom favicon:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!faviconData) {
|
||||
return NextResponse.redirect("/favicon.svg");
|
||||
}
|
||||
|
||||
const match = faviconData.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!match) {
|
||||
return NextResponse.redirect("/favicon.svg");
|
||||
}
|
||||
|
||||
const contentType = match[1];
|
||||
const base64Data = match[2];
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": `public, max-age=${CACHE_DURATION}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Favicon API error:", error);
|
||||
return NextResponse.redirect("/favicon.svg");
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,28 @@ import "@/lib/initCloudSync"; // Auto-initialize cloud sync
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, getLocale } from "next-intl/server";
|
||||
import { RTL_LOCALES } from "@/i18n/config";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "OmniRoute — AI Gateway for Multi-Provider LLMs",
|
||||
description:
|
||||
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
apple: "/apple-touch-icon.svg",
|
||||
},
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const settings = await getSettings();
|
||||
const instanceName = settings?.instanceName || "OmniRoute";
|
||||
const customFaviconUrl = settings?.customFaviconUrl || settings?.customFaviconBase64;
|
||||
|
||||
return {
|
||||
title: `${instanceName} — AI Gateway for Multi-Provider LLMs`,
|
||||
description:
|
||||
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
|
||||
icons: {
|
||||
icon: customFaviconUrl ? "/api/settings/favicon" : "/favicon.svg",
|
||||
apple: "/apple-touch-icon.svg",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }) {
|
||||
const locale = await getLocale();
|
||||
|
||||
@@ -1829,6 +1829,20 @@
|
||||
"themeViolet": "Violet",
|
||||
"themeOrange": "Orange",
|
||||
"themeCyan": "Cyan",
|
||||
"whitelabeling": "Branding",
|
||||
"whitelabelingDesc": "Customize the application name and logo",
|
||||
"appName": "Application Name",
|
||||
"appNameDesc": "Display name shown in sidebar and browser tab",
|
||||
"customLogo": "Custom Logo URL",
|
||||
"customLogoDesc": "URL to your custom logo image",
|
||||
"uploadLogo": "Upload Logo",
|
||||
"resetLogo": "Reset to Default",
|
||||
"logoPreview": "Preview",
|
||||
"customFavicon": "Browser Favicon",
|
||||
"customFaviconDesc": "URL to your custom favicon (shown in browser tab)",
|
||||
"uploadFavicon": "Upload Favicon",
|
||||
"resetFavicon": "Reset Favicon",
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"promptCache": "Prompt Cache",
|
||||
"flushCache": "Flush Cache",
|
||||
"flushing": "Flushing…",
|
||||
|
||||
@@ -37,11 +37,15 @@ export default function Sidebar({
|
||||
const [isDisconnected, setIsDisconnected] = useState(false);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<string[]>([]);
|
||||
const [customAppName, setCustomAppName] = useState<string | null>(null);
|
||||
const [customLogo, setCustomLogo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const applySettings = (data) => {
|
||||
setShowDebug(data?.debugMode === true);
|
||||
setHiddenSidebarItems(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]));
|
||||
setCustomAppName(data?.instanceName || null);
|
||||
setCustomLogo(data?.customLogoBase64 || data?.customLogoUrl || null);
|
||||
};
|
||||
|
||||
fetch("/api/settings")
|
||||
@@ -61,6 +65,16 @@ export default function Sidebar({
|
||||
normalizeHiddenSidebarItems(detail[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])
|
||||
);
|
||||
}
|
||||
|
||||
if ("instanceName" in detail) {
|
||||
setCustomAppName(detail.instanceName as string || null);
|
||||
}
|
||||
|
||||
if ("customLogoBase64" in detail) {
|
||||
setCustomLogo(detail.customLogoBase64 as string || null);
|
||||
} else if ("customLogoUrl" in detail) {
|
||||
setCustomLogo(detail.customLogoUrl as string || null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(SIDEBAR_SETTINGS_UPDATED_EVENT, handleSettingsUpdated as EventListener);
|
||||
@@ -221,12 +235,20 @@ export default function Sidebar({
|
||||
className={cn("flex items-center", collapsed ? "justify-center" : "gap-3")}
|
||||
>
|
||||
<div className="flex items-center justify-center size-9 rounded bg-linear-to-br from-[#E54D5E] to-[#C93D4E] shrink-0">
|
||||
<OmniRouteLogo size={20} className="text-white" />
|
||||
{customLogo ? (
|
||||
<img
|
||||
src={customLogo}
|
||||
alt={customAppName || APP_CONFIG.name}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<OmniRouteLogo size={20} className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-text-main">
|
||||
{APP_CONFIG.name}
|
||||
{customAppName || APP_CONFIG.name}
|
||||
</h1>
|
||||
<span className="text-xs text-text-muted">v{APP_CONFIG.version}</span>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,10 @@ export const updateSettingsSchema = z.object({
|
||||
requireLogin: z.boolean().optional(),
|
||||
enableSocks5Proxy: z.boolean().optional(),
|
||||
instanceName: z.string().max(100).optional(),
|
||||
customLogoUrl: z.string().max(2000).optional(),
|
||||
customLogoBase64: z.string().max(100000).optional(),
|
||||
customFaviconUrl: z.string().max(2000).optional(),
|
||||
customFaviconBase64: z.string().max(50000).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
|
||||
Reference in New Issue
Block a user