AnythingLLM Chrome Extension ()

* initial commit for chrome extension

* wip browser extension backend

* wip frontend browser extension settings

* fix typo for browserExtension route

* implement verification codes + frontend panel for browser extension keys

* reorganize + state management for all connection states

* implement embed to workspace

* add send page to anythingllm extension option + refactor

* refactor connection string auth + update context menus + organize background.js into models

* popup extension from main app and save if successful

* fix hebrew translation misspelling

* fetch custom logo inside chrome extension

* delete api keys on disconnect of extension

* use correct apiUrl constant in frontend + remove unneeded comments

* remove upload-link endpoint and send inner text html to raw text collector endpoint

* update readme

* fix readme link

* fix readme typo

* update readme

* handle deletion of browser keys with key id and DELETE endpoint

* move event string to constant

* remove tablename and writable fields from BrowserExtensionApiKey backend model

* add border-none to all buttons and inputs for desktop compatibility

* patch prisma injections

* update delete endpoints to delete keys by id

* remove unused prop

* add button to attempt browser extension connection + remove max active keys

* wip multi user mode support

* multi user mode support

* clean up backend + show created by in frotend browser extension page

* show multi user warning message on key creation + hide context menus when no workspaces

* show browser extension options to managers

* small backend changes and refactors

* extension cleanup

* rename submodule

* extension updates & docs

* dev docker build

---------

Co-authored-by: shatfield4 <seanhatfield5@gmail.com>
This commit is contained in:
Timothy Carambat 2024-08-27 14:58:47 -07:00 committed by GitHub
parent fc6d7359b6
commit 29df483a27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 918 additions and 3 deletions
.github/workflows
.gitmodulesREADME.mdbrowser-extensionembed
frontend/src
App.jsx
components/SettingsSidebar
locales
models
pages/GeneralSettings/BrowserExtensionApiKey
BrowserExtensionApiKeyRow
NewBrowserExtensionApiKeyModal
index.jsx
utils
server

View file

@ -6,7 +6,7 @@ concurrency:
on:
push:
branches: ['encrypt-jwt-value'] # put your current branch to create a build. Core team only.
branches: ['chrome-extension'] # put your current branch to create a build. Core team only.
paths-ignore:
- '**.md'
- 'cloud-deployments/*'

3
.gitmodules vendored
View file

@ -2,3 +2,6 @@
branch = main
path = embed
url = git@github.com:Mintplex-Labs/anythingllm-embed.git
[submodule "browser-extension"]
path = browser-extension
url = git@github.com:Mintplex-Labs/anythingllm-extension.git

View file

@ -137,7 +137,8 @@ This monorepo consists of three main sections:
- `server`: A NodeJS express server to handle all the interactions and do all the vectorDB management and LLM interactions.
- `collector`: NodeJS express server that process and parses documents from the UI.
- `docker`: Docker instructions and build process + information for building from source.
- `embed`: Submodule specifically for generation & creation of the [web embed widget](https://github.com/Mintplex-Labs/anythingllm-embed).
- `embed`: Submodule for generation & creation of the [web embed widget](https://github.com/Mintplex-Labs/anythingllm-embed).
- `browser-extension`: Submodule for the [chrome browser extension](https://github.com/Mintplex-Labs/anythingllm-extension).
## 🛳 Self Hosting

1
browser-extension Submodule

@ -0,0 +1 @@
Subproject commit d9b28cc1e23b64fdb4e666d5b5b49cc8e583aabd

1
embed

@ -1 +0,0 @@
Subproject commit 22a0848d58e3a758d85d93d9204a72a65854ea94

View file

@ -49,6 +49,9 @@ const GeneralVectorDatabase = lazy(
() => import("@/pages/GeneralSettings/VectorDatabase")
);
const GeneralSecurity = lazy(() => import("@/pages/GeneralSettings/Security"));
const GeneralBrowserExtension = lazy(
() => import("@/pages/GeneralSettings/BrowserExtensionApiKey")
);
const WorkspaceSettings = lazy(() => import("@/pages/WorkspaceSettings"));
const EmbedConfigSetup = lazy(
() => import("@/pages/GeneralSettings/EmbedConfigs")
@ -157,6 +160,10 @@ export default function App() {
path="/settings/api-keys"
element={<AdminRoute Component={GeneralApiKeys} />}
/>
<Route
path="/settings/browser-extension"
element={<ManagerRoute Component={GeneralBrowserExtension} />}
/>
<Route
path="/settings/workspace-chats"
element={<ManagerRoute Component={GeneralChats} />}

View file

@ -332,6 +332,12 @@ const SidebarOptions = ({ user = null, t }) => (
flex: true,
roles: ["admin"],
},
{
btnText: t("settings.browser-extension"),
href: paths.settings.browserExtension(),
flex: true,
roles: ["admin", "manager"],
},
]}
/>
<Option

View file

@ -37,6 +37,7 @@ const TRANSLATIONS = {
tools: "Werkzeuge",
"experimental-features": "Experimentelle Funktionen",
contact: "Support kontaktieren",
"browser-extension": "Browser-Erweiterung",
},
login: {

View file

@ -38,6 +38,7 @@ const TRANSLATIONS = {
tools: "Tools",
"experimental-features": "Experimental Features",
contact: "Contact Support",
"browser-extension": "Browser Extension",
},
// Page Definitions

View file

@ -37,6 +37,7 @@ const TRANSLATIONS = {
tools: "Herramientas",
"experimental-features": "Funciones Experimentales",
contact: "Contactar Soporte",
"browser-extension": "Extensión del navegador",
},
login: {

View file

@ -38,6 +38,7 @@ const TRANSLATIONS = {
tools: "Outils",
"experimental-features": "Fonctionnalités Expérimentales",
contact: "Contacter le Support",
"browser-extension": "Extension de navigateur",
},
// Page Definitions

View file

@ -38,6 +38,7 @@ const TRANSLATIONS = {
tools: "כלים",
"experimental-features": "תכונות ניסיוניות",
contact: "צור קשר עם התמיכה",
"browser-extension": "תוסף דפדפן",
},
// Page Definitions

View file

@ -38,6 +38,7 @@ const TRANSLATIONS = {
tools: "Strumenti",
"experimental-features": "Caratteristiche sperimentali",
contact: "Contatta il Supporto",
"browser-extension": "Estensione del browser",
},
// Page Definitions

View file

@ -38,6 +38,7 @@ const TRANSLATIONS = {
tools: "도구",
"experimental-features": "실험적 기능",
contact: "지원팀 연락",
"browser-extension": "브라우저 확장 프로그램",
},
// Page Definitions

View file

@ -38,6 +38,7 @@ const TRANSLATIONS = {
tools: "Ferramentas",
"experimental-features": "Recursos Experimentais",
contact: "Contato com Suporte",
"browser-extension": "Extensão do navegador",
},
// Page Definitions

View file

@ -37,6 +37,7 @@ const TRANSLATIONS = {
tools: "Инструменты",
"experimental-features": "Экспериментальные функции",
contact: "联系支持Связаться с Поддержкой",
"browser-extension": "Расширение браузера",
},
login: {

View file

@ -39,6 +39,7 @@ const TRANSLATIONS = {
tools: "工具",
"experimental-features": "实验功能",
contact: "联系支持",
"browser-extension": "浏览器扩展",
},
// Page Definitions

View file

@ -0,0 +1,42 @@
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";
const BrowserExtensionApiKey = {
getAll: async () => {
return await fetch(`${API_BASE}/browser-extension/api-keys`, {
method: "GET",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message, apiKeys: [] };
});
},
generateKey: async () => {
return await fetch(`${API_BASE}/browser-extension/api-keys/new`, {
method: "POST",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message };
});
},
revoke: async (id) => {
return await fetch(`${API_BASE}/browser-extension/api-keys/${id}`, {
method: "DELETE",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message };
});
},
};
export default BrowserExtensionApiKey;

View file

@ -0,0 +1,120 @@
import { useRef, useState } from "react";
import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
import showToast from "@/utils/toast";
import { Trash, Copy, Check, Plug } from "@phosphor-icons/react";
import { POPUP_BROWSER_EXTENSION_EVENT } from "@/utils/constants";
import { Tooltip } from "react-tooltip";
export default function BrowserExtensionApiKeyRow({
apiKey,
removeApiKey,
connectionString,
isMultiUser,
}) {
const rowRef = useRef(null);
const [copied, setCopied] = useState(false);
const handleRevoke = async () => {
if (
!window.confirm(
`Are you sure you want to revoke this browser extension API key?\nAfter you do this it will no longer be useable.\n\nThis action is irreversible.`
)
)
return false;
const result = await BrowserExtensionApiKey.revoke(apiKey.id);
if (result.success) {
removeApiKey(apiKey.id);
showToast("Browser Extension API Key permanently revoked", "info", {
clear: true,
});
} else {
showToast("Failed to revoke API Key", "error", {
clear: true,
});
}
};
const handleCopy = () => {
navigator.clipboard.writeText(connectionString);
showToast("Connection string copied to clipboard", "success", {
clear: true,
});
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleConnect = () => {
// Sending a message to Chrome extension to pop up the extension window
// This will open the extension window and attempt to connect with the API key
window.postMessage(
{ type: POPUP_BROWSER_EXTENSION_EVENT, apiKey: connectionString },
"*"
);
showToast("Attempting to connect to browser extension...", "info", {
clear: true,
});
};
return (
<tr
ref={rowRef}
className="bg-transparent text-white text-opacity-80 text-sm font-medium"
>
<td scope="row" className="px-6 py-4 whitespace-nowrap flex items-center">
<span className="mr-2 font-mono">{connectionString}</span>
<div className="flex items-center space-x-2">
<button
onClick={handleCopy}
data-tooltip-id={`copy-connection-text-${apiKey.id}`}
data-tooltip-content="Copy connection string"
className="text-white hover:text-white/80 transition-colors duration-200 p-1 rounded"
>
{copied ? (
<Check className="h-5 w-5 text-green-500" />
) : (
<Copy className="h-5 w-5" />
)}
<Tooltip
id={`copy-connection-text-${apiKey.id}`}
place="bottom"
delayShow={300}
className="allm-tooltip !allm-text-xs"
/>
</button>
<button
onClick={handleConnect}
data-tooltip-id={`auto-connection-${apiKey.id}`}
data-tooltip-content="Automatically connect to extension"
className="text-white hover:text-white/80 transition-colors duration-200 p-1 rounded"
>
<Plug className="h-5 w-5" />
<Tooltip
id={`auto-connection-${apiKey.id}`}
place="bottom"
delayShow={300}
className="allm-tooltip !allm-text-xs"
/>
</button>
</div>
</td>
{isMultiUser && (
<td className="px-6 py-4">
{apiKey.user ? apiKey.user.username : "N/A"}
</td>
)}
<td className="px-6 py-4">
{new Date(apiKey.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4">
<button
onClick={handleRevoke}
className="font-medium px-2 py-1 rounded-lg hover:bg-sidebar-gradient text-white hover:text-white/80 hover:bg-opacity-20"
>
<Trash className="h-5 w-5" />
</button>
</td>
</tr>
);
}

View file

@ -0,0 +1,127 @@
import React, { useEffect, useState } from "react";
import { X } from "@phosphor-icons/react";
import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
import { fullApiUrl, POPUP_BROWSER_EXTENSION_EVENT } from "@/utils/constants";
export default function NewBrowserExtensionApiKeyModal({
closeModal,
onSuccess,
isMultiUser,
}) {
const [apiKey, setApiKey] = useState(null);
const [error, setError] = useState(null);
const [copied, setCopied] = useState(false);
const handleCreate = async (e) => {
setError(null);
e.preventDefault();
const { apiKey: newApiKey, error } =
await BrowserExtensionApiKey.generateKey();
if (!!newApiKey) {
const fullApiKey = `${fullApiUrl()}|${newApiKey}`;
setApiKey(fullApiKey);
onSuccess();
window.postMessage(
{ type: POPUP_BROWSER_EXTENSION_EVENT, apiKey: fullApiKey },
"*"
);
}
setError(error);
};
const copyApiKey = () => {
if (!apiKey) return false;
window.navigator.clipboard.writeText(apiKey);
setCopied(true);
};
useEffect(() => {
function resetStatus() {
if (!copied) return false;
setTimeout(() => {
setCopied(false);
}, 3000);
}
resetStatus();
}, [copied]);
return (
<div className="relative w-[500px] max-w-2xl max-h-full">
<div className="relative bg-main-gradient rounded-lg shadow">
<div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
<h3 className="text-xl font-semibold text-white">
New Browser Extension API Key
</h3>
<button
onClick={closeModal}
type="button"
className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border border-none cursor-pointer"
>
<X className="text-gray-300 text-lg" />
</button>
</div>
<form onSubmit={handleCreate}>
<div className="p-6 space-y-6 flex h-full w-full">
<div className="w-full flex flex-col gap-y-4">
{error && <p className="text-red-400 text-sm">Error: {error}</p>}
{apiKey && (
<input
type="text"
defaultValue={apiKey}
disabled={true}
className="rounded-lg px-4 py-2 text-white bg-zinc-900 border border-gray-500/50 border-none"
/>
)}
{isMultiUser && (
<p className="text-yellow-300 text-xs md:text-sm font-semibold">
Warning: You are in multi-user mode, this API key will allow
access to all workspaces associated with your account. Please
share it cautiously.
</p>
)}
<p className="text-white text-xs md:text-sm">
After clicking "Create API Key", AnythingLLM will attempt to
connect to your browser extension automatically.
</p>
<p className="text-white text-xs md:text-sm">
If you see "Connected to AnythingLLM" in the extension, the
connection was successful. If not, please copy the connection
string and paste it into the extension manually.
</p>
</div>
</div>
<div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
{!apiKey ? (
<>
<button
onClick={closeModal}
type="button"
className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300 border-none"
>
Cancel
</button>
<button
type="submit"
className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800 border-none"
>
Create API Key
</button>
</>
) : (
<button
onClick={copyApiKey}
type="button"
disabled={copied}
className="w-full transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800 text-center justify-center border-none cursor-pointer"
>
{copied ? "API Key Copied!" : "Copy API Key"}
</button>
)}
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import * as Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { PlusCircle } from "@phosphor-icons/react";
import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
import BrowserExtensionApiKeyRow from "./BrowserExtensionApiKeyRow";
import CTAButton from "@/components/lib/CTAButton";
import NewBrowserExtensionApiKeyModal from "./NewBrowserExtensionApiKeyModal";
import ModalWrapper from "@/components/ModalWrapper";
import { useModal } from "@/hooks/useModal";
import { fullApiUrl } from "@/utils/constants";
export default function BrowserExtensionApiKeys() {
const [loading, setLoading] = useState(true);
const [apiKeys, setApiKeys] = useState([]);
const [error, setError] = useState(null);
const { isOpen, openModal, closeModal } = useModal();
const [isMultiUser, setIsMultiUser] = useState(false);
useEffect(() => {
fetchExistingKeys();
}, []);
const fetchExistingKeys = async () => {
const result = await BrowserExtensionApiKey.getAll();
if (result.success) {
setApiKeys(result.apiKeys);
setIsMultiUser(result.apiKeys.some((key) => key.user !== null));
} else {
setError(result.error || "Failed to fetch API keys");
}
setLoading(false);
};
const removeApiKey = (id) => {
setApiKeys((prevKeys) => prevKeys.filter((apiKey) => apiKey.id !== id));
};
return (
<div className="w-screen h-screen overflow-hidden bg-sidebar flex">
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-main-gradient w-full h-full overflow-y-scroll"
>
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px] md:py-6 py-16">
<div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
<div className="items-center flex gap-x-4">
<p className="text-lg leading-6 font-bold text-white">
Browser Extension API Keys
</p>
</div>
<p className="text-xs leading-[18px] font-base text-white text-opacity-60">
Manage API keys for browser extensions connecting to your
AnythingLLM instance.
</p>
</div>
<div className="w-full justify-end flex">
<CTAButton onClick={openModal} className="mt-3 mr-0 -mb-6 z-10">
<PlusCircle className="h-4 w-4" weight="bold" />
Generate New API Key
</CTAButton>
</div>
{loading ? (
<Skeleton.default
height="80vh"
width="100%"
highlightColor="#3D4147"
baseColor="#2C2F35"
count={1}
className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6"
containerClassName="flex w-full"
/>
) : error ? (
<div className="text-red-500 mt-6">Error: {error}</div>
) : (
<table className="w-full text-sm text-left rounded-lg mt-6">
<thead className="text-white text-opacity-80 text-xs leading-[18px] font-bold uppercase border-white border-b border-opacity-60">
<tr>
<th scope="col" className="px-6 py-3 rounded-tl-lg">
Extension Connection String
</th>
{isMultiUser && (
<th scope="col" className="px-6 py-3">
Created By
</th>
)}
<th scope="col" className="px-6 py-3">
Created At
</th>
<th scope="col" className="px-6 py-3 rounded-tr-lg">
Actions
</th>
</tr>
</thead>
<tbody>
{apiKeys.length === 0 ? (
<tr className="bg-transparent text-white text-opacity-80 text-sm font-medium">
<td
colSpan={isMultiUser ? "4" : "3"}
className="px-6 py-4 text-center"
>
No API keys found
</td>
</tr>
) : (
apiKeys.map((apiKey) => (
<BrowserExtensionApiKeyRow
key={apiKey.id}
apiKey={apiKey}
removeApiKey={removeApiKey}
connectionString={`${fullApiUrl()}|${apiKey.key}`}
isMultiUser={isMultiUser}
/>
))
)}
</tbody>
</table>
)}
</div>
</div>
<ModalWrapper isOpen={isOpen}>
<NewBrowserExtensionApiKeyModal
closeModal={closeModal}
onSuccess={fetchExistingKeys}
isMultiUser={isMultiUser}
/>
</ModalWrapper>
</div>
);
}

View file

@ -41,3 +41,5 @@ export function fullApiUrl() {
if (API_BASE !== "/api") return API_BASE;
return `${window.location.origin}/api`;
}
export const POPUP_BROWSER_EXTENSION_EVENT = "NEW_BROWSER_EXTENSION_CONNECTION";

View file

@ -138,6 +138,9 @@ export default {
embedChats: () => {
return `/settings/embed-chats`;
},
browserExtension: () => {
return `/settings/browser-extension`;
},
experimental: () => {
return `/settings/beta-features`;
},

View file

@ -0,0 +1,224 @@
const { Workspace } = require("../models/workspace");
const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
const { Document } = require("../models/documents");
const {
validBrowserExtensionApiKey,
} = require("../utils/middleware/validBrowserExtensionApiKey");
const { CollectorApi } = require("../utils/collectorApi");
const { reqBody, multiUserMode, userFromSession } = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
const { Telemetry } = require("../models/telemetry");
function browserExtensionEndpoints(app) {
if (!app) return;
app.get(
"/browser-extension/check",
[validBrowserExtensionApiKey],
async (request, response) => {
try {
const user = await userFromSession(request, response);
const workspaces = multiUserMode(response)
? await Workspace.whereWithUser(user)
: await Workspace.where();
const apiKeyId = response.locals.apiKey.id;
response.status(200).json({
connected: true,
workspaces,
apiKeyId,
});
} catch (error) {
console.error(error);
response
.status(500)
.json({ connected: false, error: "Failed to fetch workspaces" });
}
}
);
app.delete(
"/browser-extension/disconnect",
[validBrowserExtensionApiKey],
async (_request, response) => {
try {
const apiKeyId = response.locals.apiKey.id;
const { success, error } =
await BrowserExtensionApiKey.delete(apiKeyId);
if (!success) throw new Error(error);
response.status(200).json({ success: true });
} catch (error) {
console.error(error);
response
.status(500)
.json({ error: "Failed to disconnect and revoke API key" });
}
}
);
app.get(
"/browser-extension/workspaces",
[validBrowserExtensionApiKey],
async (request, response) => {
try {
const user = await userFromSession(request, response);
const workspaces = multiUserMode(response)
? await Workspace.whereWithUser(user)
: await Workspace.where();
response.status(200).json({ workspaces });
} catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to fetch workspaces" });
}
}
);
app.post(
"/browser-extension/embed-content",
[validBrowserExtensionApiKey],
async (request, response) => {
try {
const { workspaceId, textContent, metadata } = reqBody(request);
const user = await userFromSession(request, response);
const workspace = multiUserMode(response)
? await Workspace.getWithUser(user, { id: parseInt(workspaceId) })
: await Workspace.get({ id: parseInt(workspaceId) });
if (!workspace) {
response.status(404).json({ error: "Workspace not found" });
return;
}
const Collector = new CollectorApi();
const { success, reason, documents } = await Collector.processRawText(
textContent,
metadata
);
if (!success) {
response.status(500).json({ success: false, error: reason });
return;
}
const { failedToEmbed = [], errors = [] } = await Document.addDocuments(
workspace,
[documents[0].location],
user?.id
);
if (failedToEmbed.length > 0) {
response.status(500).json({ success: false, error: errors[0] });
return;
}
await Telemetry.sendTelemetry("browser_extension_embed_content");
response.status(200).json({ success: true });
} catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to embed content" });
}
}
);
app.post(
"/browser-extension/upload-content",
[validBrowserExtensionApiKey],
async (request, response) => {
try {
const { textContent, metadata } = reqBody(request);
const Collector = new CollectorApi();
const { success, reason } = await Collector.processRawText(
textContent,
metadata
);
if (!success) {
response.status(500).json({ success: false, error: reason });
return;
}
await Telemetry.sendTelemetry("browser_extension_upload_content");
response.status(200).json({ success: true });
} catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to embed content" });
}
}
);
// Internal endpoints for managing API keys
app.get(
"/browser-extension/api-keys",
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
async (request, response) => {
try {
const user = await userFromSession(request, response);
const apiKeys = multiUserMode(response)
? await BrowserExtensionApiKey.whereWithUser(user)
: await BrowserExtensionApiKey.where();
response.status(200).json({ success: true, apiKeys });
} catch (error) {
console.error(error);
response
.status(500)
.json({ success: false, error: "Failed to fetch API keys" });
}
}
);
app.post(
"/browser-extension/api-keys/new",
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
async (request, response) => {
try {
const user = await userFromSession(request, response);
const { apiKey, error } = await BrowserExtensionApiKey.create(
user?.id || null
);
if (error) throw new Error(error);
response.status(200).json({
apiKey: apiKey.key,
});
} catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to create API key" });
}
}
);
app.delete(
"/browser-extension/api-keys/:id",
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
async (request, response) => {
try {
const { id } = request.params;
const user = await userFromSession(request, response);
if (multiUserMode(response) && user.role !== ROLES.admin) {
const apiKey = await BrowserExtensionApiKey.get({
id: parseInt(id),
user_id: user?.id,
});
if (!apiKey) {
return response.status(403).json({ error: "Unauthorized" });
}
}
const { success, error } = await BrowserExtensionApiKey.delete(id);
if (!success) throw new Error(error);
response.status(200).json({ success: true });
} catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to revoke API key" });
}
}
);
}
module.exports = { browserExtensionEndpoints };

View file

@ -52,6 +52,7 @@ const {
} = require("../utils/PasswordRecovery");
const { SlashCommandPresets } = require("../models/slashCommandsPresets");
const { EncryptionManager } = require("../utils/EncryptionManager");
const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
function systemEndpoints(app) {
if (!app) return;
@ -495,6 +496,7 @@ function systemEndpoints(app) {
limit_user_messages: false,
message_limit: 25,
});
await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id);
await updateENV(
{

View file

@ -24,6 +24,7 @@ const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads");
const { documentEndpoints } = require("./endpoints/document");
const { agentWebsocket } = require("./endpoints/agentWebsocket");
const { experimentalEndpoints } = require("./endpoints/experimental");
const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
const app = express();
const apiRouter = express.Router();
const FILE_LIMIT = "3GB";
@ -62,6 +63,9 @@ developerEndpoints(app, apiRouter);
// Externally facing embedder endpoints
embeddedEndpoints(apiRouter);
// Externally facing browser extension endpoints
browserExtensionEndpoints(apiRouter);
if (process.env.NODE_ENV !== "development") {
const { MetaGenerator } = require("./utils/boot/MetaGenerator");
const IndexPage = new MetaGenerator();

View file

@ -0,0 +1,168 @@
const prisma = require("../utils/prisma");
const { SystemSettings } = require("./systemSettings");
const { ROLES } = require("../utils/middleware/multiUserProtected");
const BrowserExtensionApiKey = {
/**
* Creates a new secret for a browser extension API key.
* @returns {string} brx-*** API key to use with extension
*/
makeSecret: () => {
const uuidAPIKey = require("uuid-apikey");
return `brx-${uuidAPIKey.create().apiKey}`;
},
/**
* Creates a new api key for the browser Extension
* @param {number|null} userId - User id to associate creation of key with.
* @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|null, error:string|null}>}
*/
create: async function (userId = null) {
try {
const apiKey = await prisma.browser_extension_api_keys.create({
data: {
key: this.makeSecret(),
user_id: userId,
},
});
return { apiKey, error: null };
} catch (error) {
console.error("Failed to create browser extension API key", error);
return { apiKey: null, error: error.message };
}
},
/**
* Validated existing API key
* @param {string} key
* @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|boolean}>}
*/
validate: async function (key) {
if (!key.startsWith("brx-")) return false;
const apiKey = await prisma.browser_extension_api_keys.findUnique({
where: { key: key.toString() },
include: { user: true },
});
if (!apiKey) return false;
const multiUserMode = await SystemSettings.isMultiUserMode();
if (!multiUserMode) return apiKey; // In single-user mode, all keys are valid
// In multi-user mode, check if the key is associated with a user
return apiKey.user_id ? apiKey : false;
},
/**
* Fetches browser api key by params.
* @param {object} clause - Prisma props for search
* @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|boolean}>}
*/
get: async function (clause = {}) {
try {
const apiKey = await prisma.browser_extension_api_keys.findFirst({
where: clause,
});
return apiKey;
} catch (error) {
console.error("FAILED TO GET BROWSER EXTENSION API KEY.", error.message);
return null;
}
},
/**
* Deletes browser api key by db id.
* @param {number} id - database id of browser key
* @returns {Promise<{success: boolean, error:string|null}>}
*/
delete: async function (id) {
try {
await prisma.browser_extension_api_keys.delete({
where: { id: parseInt(id) },
});
return { success: true, error: null };
} catch (error) {
console.error("Failed to delete browser extension API key", error);
return { success: false, error: error.message };
}
},
/**
* Gets browser keys by params
* @param {object} clause
* @param {number|null} limit
* @param {object|null} orderBy
* @returns {Promise<import("@prisma/client").browser_extension_api_keys[]>}
*/
where: async function (clause = {}, limit = null, orderBy = null) {
try {
const apiKeys = await prisma.browser_extension_api_keys.findMany({
where: clause,
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null ? { orderBy } : {}),
include: { user: true },
});
return apiKeys;
} catch (error) {
console.error("FAILED TO GET BROWSER EXTENSION API KEYS.", error.message);
return [];
}
},
/**
* Get browser API keys for user
* @param {import("@prisma/client").users} user
* @param {object} clause
* @param {number|null} limit
* @param {object|null} orderBy
* @returns {Promise<import("@prisma/client").browser_extension_api_keys[]>}
*/
whereWithUser: async function (
user,
clause = {},
limit = null,
orderBy = null
) {
// Admin can view and use any keys
if ([ROLES.admin].includes(user.role))
return await this.where(clause, limit, orderBy);
try {
const apiKeys = await prisma.browser_extension_api_keys.findMany({
where: {
...clause,
user_id: user.id,
},
include: { user: true },
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null ? { orderBy } : {}),
});
return apiKeys;
} catch (error) {
console.error(error.message);
return [];
}
},
/**
* Updates owner of all DB ids to new admin.
* @param {number} userId
* @returns {Promise<void>}
*/
migrateApiKeysToMultiUser: async function (userId) {
try {
await prisma.browser_extension_api_keys.updateMany({
where: {
user_id: null,
},
data: {
user_id: userId,
},
});
console.log("Successfully migrated API keys to multi-user mode");
} catch (error) {
console.error("Error migrating API keys to multi-user mode:", error);
}
},
};
module.exports = { BrowserExtensionApiKey };

View file

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "browser_extension_api_keys" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"key" TEXT NOT NULL,
"user_id" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUpdatedAt" DATETIME NOT NULL,
CONSTRAINT "browser_extension_api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "browser_extension_api_keys_key_key" ON "browser_extension_api_keys"("key");
-- CreateIndex
CREATE INDEX "browser_extension_api_keys_user_id_idx" ON "browser_extension_api_keys"("user_id");

View file

@ -76,6 +76,7 @@ model users {
password_reset_tokens password_reset_tokens[]
workspace_agent_invocations workspace_agent_invocations[]
slash_command_presets slash_command_presets[]
browser_extension_api_keys browser_extension_api_keys[]
}
model recovery_codes {
@ -298,3 +299,14 @@ model document_sync_executions {
createdAt DateTime @default(now())
queue document_sync_queues @relation(fields: [queueId], references: [id], onDelete: Cascade)
}
model browser_extension_api_keys {
id Int @id @default(autoincrement())
key String @unique
user_id Int?
createdAt DateTime @default(now())
lastUpdatedAt DateTime @updatedAt
user users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
}

View file

@ -0,0 +1,36 @@
const {
BrowserExtensionApiKey,
} = require("../../models/browserExtensionApiKey");
const { SystemSettings } = require("../../models/systemSettings");
const { User } = require("../../models/user");
async function validBrowserExtensionApiKey(request, response, next) {
const multiUserMode = await SystemSettings.isMultiUserMode();
response.locals.multiUserMode = multiUserMode;
const auth = request.header("Authorization");
const bearerKey = auth ? auth.split(" ")[1] : null;
if (!bearerKey) {
response.status(403).json({
error: "No valid API key found.",
});
return;
}
const apiKey = await BrowserExtensionApiKey.validate(bearerKey);
if (!apiKey) {
response.status(403).json({
error: "No valid API key found.",
});
return;
}
if (multiUserMode) {
response.locals.user = await User.get({ id: apiKey.user_id });
}
response.locals.apiKey = apiKey;
next();
}
module.exports = { validBrowserExtensionApiKey };