Repository navigation
Release v1.0.8 #102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Release v1.0.8 #102
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -2,11 +2,12 @@ | |||||
|
|
||||||
| import { useState, useEffect } from "react"; | ||||||
| import { Card, Button, Input, Toggle } from "@/shared/components"; | ||||||
| import { AI_PROVIDERS } from "@/shared/constants/providers"; | ||||||
| import IPFilterSection from "./IPFilterSection"; | ||||||
| import SessionInfoCard from "./SessionInfoCard"; | ||||||
|
|
||||||
| export default function SecurityTab() { | ||||||
| const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false }); | ||||||
| const [settings, setSettings] = useState<any>({ requireLogin: false, hasPassword: false }); | ||||||
| const [loading, setLoading] = useState(true); | ||||||
| const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); | ||||||
| const [passStatus, setPassStatus] = useState({ type: "", message: "" }); | ||||||
|
|
@@ -37,6 +38,29 @@ export default function SecurityTab() { | |||||
| } | ||||||
| }; | ||||||
|
|
||||||
| const updateSetting = async (key: string, value: any) => { | ||||||
| try { | ||||||
| const res = await fetch("/api/settings", { | ||||||
| method: "PATCH", | ||||||
| headers: { "Content-Type": "application/json" }, | ||||||
| body: JSON.stringify({ [key]: value }), | ||||||
| }); | ||||||
| if (res.ok) { | ||||||
| setSettings((prev) => ({ ...prev, [key]: value })); | ||||||
| } | ||||||
| } catch (err) { | ||||||
| console.error(`Failed to update ${key}:`, err); | ||||||
| } | ||||||
| }; | ||||||
|
|
||||||
| const toggleBlockedProvider = (providerId: string) => { | ||||||
| const current: string[] = settings.blockedProviders || []; | ||||||
| const updated = current.includes(providerId) | ||||||
| ? current.filter((p) => p !== providerId) | ||||||
| : [...current, providerId]; | ||||||
| updateSetting("blockedProviders", updated); | ||||||
| }; | ||||||
|
Comment on lines
+41
to
+62
|
||||||
|
|
||||||
| const handlePasswordChange = async (e) => { | ||||||
| e.preventDefault(); | ||||||
| if (passwords.new !== passwords.confirm) { | ||||||
|
|
@@ -70,6 +94,8 @@ export default function SecurityTab() { | |||||
| } | ||||||
| }; | ||||||
|
|
||||||
| const blockedProviders: string[] = settings.blockedProviders || []; | ||||||
|
|
||||||
| return ( | ||||||
| <div className="flex flex-col gap-6"> | ||||||
| <Card> | ||||||
|
|
@@ -146,6 +172,92 @@ export default function SecurityTab() { | |||||
| )} | ||||||
| </div> | ||||||
| </Card> | ||||||
|
|
||||||
| {/* API Endpoint Protection */} | ||||||
| <Card> | ||||||
| <div className="flex items-center gap-3 mb-4"> | ||||||
| <div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400"> | ||||||
| <span className="material-symbols-outlined text-[20px]" aria-hidden="true"> | ||||||
| api | ||||||
| </span> | ||||||
| </div> | ||||||
| <h3 className="text-lg font-semibold">API Endpoint Protection</h3> | ||||||
| </div> | ||||||
| <div className="flex flex-col gap-4"> | ||||||
| {/* Require auth for /models */} | ||||||
| <div className="flex items-center justify-between"> | ||||||
| <div> | ||||||
| <p className="font-medium">Require API key for /models</p> | ||||||
| <p className="text-sm text-text-muted"> | ||||||
| When ON, the{" "} | ||||||
| <code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded"> | ||||||
| /v1/models | ||||||
| </code>{" "} | ||||||
| endpoint returns 404 for unauthenticated requests. Prevents model discovery by | ||||||
| unauthorized users. | ||||||
| </p> | ||||||
| </div> | ||||||
| <Toggle | ||||||
| checked={settings.requireAuthForModels === true} | ||||||
| onChange={() => updateSetting("requireAuthForModels", !settings.requireAuthForModels)} | ||||||
| disabled={loading} | ||||||
| /> | ||||||
| </div> | ||||||
|
|
||||||
| {/* Blocked Providers */} | ||||||
| <div className="pt-4 border-t border-border/50"> | ||||||
| <div className="mb-3"> | ||||||
| <p className="font-medium">Blocked Providers</p> | ||||||
| <p className="text-sm text-text-muted"> | ||||||
| Hide specific providers from the{" "} | ||||||
| <code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded"> | ||||||
| /v1/models | ||||||
| </code>{" "} | ||||||
| response. Blocked providers will not appear in model listings. | ||||||
| </p> | ||||||
| </div> | ||||||
| <div className="flex flex-wrap gap-2"> | ||||||
| {Object.values(AI_PROVIDERS).map((provider: any) => { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using Based on its usage and definition in interface AiProvider {
id: string;
name: string;
icon: string;
color?: string;
}And use it in the map function.
Suggested change
|
||||||
| const isBlocked = blockedProviders.includes(provider.id); | ||||||
| return ( | ||||||
| <button | ||||||
| key={provider.id} | ||||||
| onClick={() => toggleBlockedProvider(provider.id)} | ||||||
| disabled={loading} | ||||||
| className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all border ${ | ||||||
| isBlocked | ||||||
| ? "bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400" | ||||||
| : "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]" | ||||||
| }`} | ||||||
| title={isBlocked ? `Unblock ${provider.name}` : `Block ${provider.name}`} | ||||||
| > | ||||||
| <span | ||||||
| className="material-symbols-outlined text-[14px]" | ||||||
| style={{ color: isBlocked ? undefined : provider.color }} | ||||||
| > | ||||||
| {isBlocked ? "block" : provider.icon} | ||||||
| </span> | ||||||
| {provider.name} | ||||||
| {isBlocked && ( | ||||||
| <span className="material-symbols-outlined text-[12px] text-red-500"> | ||||||
| close | ||||||
| </span> | ||||||
| )} | ||||||
| </button> | ||||||
| ); | ||||||
| })} | ||||||
| </div> | ||||||
| {blockedProviders.length > 0 && ( | ||||||
| <p className="text-xs text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1"> | ||||||
| <span className="material-symbols-outlined text-[14px]">warning</span> | ||||||
| {blockedProviders.length} provider{blockedProviders.length !== 1 ? "s" : ""} blocked | ||||||
| from /models | ||||||
| </p> | ||||||
| )} | ||||||
| </div> | ||||||
| </div> | ||||||
| </Card> | ||||||
|
|
||||||
| <SessionInfoCard /> | ||||||
| <IPFilterSection /> | ||||||
| </div> | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,8 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| import { CORS_ORIGIN } from "@/shared/utils/cors"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { AI_PROVIDERS } from "@/shared/constants/providers"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { getProviderConnections, getCombos, getAllCustomModels } from "@/lib/localDb"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { getProviderConnections, getCombos, getAllCustomModels, getSettings } from "@/lib/localDb"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -94,10 +95,28 @@ export async function OPTIONS() { | |||||||||||||||||||||||||||||||||||||||||||
| * GET /v1/models - OpenAI compatible models list | ||||||||||||||||||||||||||||||||||||||||||||
| * Returns models from all active providers, combos, embeddings, and image models in OpenAI format | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
| export async function GET() { | ||||||||||||||||||||||||||||||||||||||||||||
| export async function GET(request: Request) { | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| // Issue #100: Optionally require API key for /models (security hardening) | ||||||||||||||||||||||||||||||||||||||||||||
| // When enabled, unauthenticated requests get 404 to hide endpoint existence | ||||||||||||||||||||||||||||||||||||||||||||
| let settings: Record<string, any> = {}; | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| settings = await getSettings(); | ||||||||||||||||||||||||||||||||||||||||||||
| } catch {} | ||||||||||||||||||||||||||||||||||||||||||||
| if (settings.requireAuthForModels === true) { | ||||||||||||||||||||||||||||||||||||||||||||
| const apiKey = extractApiKey(request); | ||||||||||||||||||||||||||||||||||||||||||||
| if (!apiKey || !(await isValidApiKey(apiKey))) { | ||||||||||||||||||||||||||||||||||||||||||||
| return new Response("Not Found", { status: 404 }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+103
to
+111
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The authentication check for the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const { aliasToProviderId, providerIdToAlias } = buildAliasMaps(); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Issue #96: Allow blocking specific providers from the models list | ||||||||||||||||||||||||||||||||||||||||||||
| const blockedProviders: Set<string> = new Set( | ||||||||||||||||||||||||||||||||||||||||||||
| Array.isArray(settings.blockedProviders) ? settings.blockedProviders : [] | ||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+100
to
+118
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Get active provider connections | ||||||||||||||||||||||||||||||||||||||||||||
| let connections = []; | ||||||||||||||||||||||||||||||||||||||||||||
| let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -150,6 +169,9 @@ export async function GET() { | |||||||||||||||||||||||||||||||||||||||||||
| const providerId = aliasToProviderId[alias] || alias; | ||||||||||||||||||||||||||||||||||||||||||||
| const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Skip blocked providers (Issue #96) | ||||||||||||||||||||||||||||||||||||||||||||
| if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Only include models from providers with active connections | ||||||||||||||||||||||||||||||||||||||||||||
| if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) { | ||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,9 +14,26 @@ import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | ||
| import type Database from "better-sqlite3"; | ||
|
|
||
| const __filename = fileURLToPath(import.meta.url); | ||
| const __dirname = path.dirname(__filename); | ||
| const MIGRATIONS_DIR = path.join(__dirname, "migrations"); | ||
| /** | ||
| * Resolve the migrations directory path safely across platforms. | ||
| * On Windows with global npm installs, `import.meta.url` may not be a valid | ||
| * `file://` URL, causing `fileURLToPath` to throw `ERR_INVALID_FILE_URL_PATH`. | ||
| */ | ||
| function resolveMigrationsDir(): string { | ||
| try { | ||
| const metaUrl = import.meta.url; | ||
| if (metaUrl && metaUrl.startsWith("file://")) { | ||
| const __filename = fileURLToPath(metaUrl); | ||
| return path.join(path.dirname(__filename), "migrations"); | ||
| } | ||
| } catch { | ||
| // fileURLToPath failed (e.g. Windows global install) — use fallback | ||
| } | ||
| // Fallback: resolve relative to cwd (works for both dev and global installs) | ||
| return path.join(process.cwd(), "src", "lib", "db", "migrations"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using |
||
| } | ||
|
|
||
| const MIGRATIONS_DIR = resolveMigrationsDir(); | ||
|
|
||
| /** | ||
| * Ensure the schema_migrations tracking table exists. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
anyfor the state type bypasses TypeScript's type checking, reducing code safety and maintainability. It's better to define a specific interface for the settings object to ensure type safety, especially since new properties likerequireAuthForModelsandblockedProvidersare being used in this component.You can define an interface at the top of the file or in a shared types file:
Then, use this interface with
Partialfor the state, as the full object is fetched asynchronously.