From 29df483a2703edb3cb1009dc613ec12f97fb6290 Mon Sep 17 00:00:00 2001 From: Timothy Carambat <rambat1010@gmail.com> Date: Tue, 27 Aug 2024 14:58:47 -0700 Subject: [PATCH] AnythingLLM Chrome Extension (#2066) * 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> --- .github/workflows/dev-build.yaml | 2 +- .gitmodules | 3 + README.md | 3 +- browser-extension | 1 + embed | 1 - frontend/src/App.jsx | 7 + .../src/components/SettingsSidebar/index.jsx | 6 + frontend/src/locales/de/common.js | 1 + frontend/src/locales/en/common.js | 1 + frontend/src/locales/es/common.js | 1 + frontend/src/locales/fr/common.js | 1 + frontend/src/locales/he/common.js | 1 + frontend/src/locales/it/common.js | 1 + frontend/src/locales/ko/common.js | 1 + frontend/src/locales/pt_BR/common.js | 1 + frontend/src/locales/ru/common.js | 1 + frontend/src/locales/zh/common.js | 1 + frontend/src/models/browserExtensionApiKey.js | 42 ++++ .../BrowserExtensionApiKeyRow/index.jsx | 120 ++++++++++ .../NewBrowserExtensionApiKeyModal/index.jsx | 127 ++++++++++ .../BrowserExtensionApiKey/index.jsx | 133 +++++++++++ frontend/src/utils/constants.js | 2 + frontend/src/utils/paths.js | 3 + server/endpoints/browserExtension.js | 224 ++++++++++++++++++ server/endpoints/system.js | 2 + server/index.js | 4 + server/models/browserExtensionApiKey.js | 168 +++++++++++++ .../20240824005054_init/migration.sql | 15 ++ server/prisma/schema.prisma | 12 + .../middleware/validBrowserExtensionApiKey.js | 36 +++ 30 files changed, 918 insertions(+), 3 deletions(-) create mode 160000 browser-extension delete mode 160000 embed create mode 100644 frontend/src/models/browserExtensionApiKey.js create mode 100644 frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx create mode 100644 server/endpoints/browserExtension.js create mode 100644 server/models/browserExtensionApiKey.js create mode 100644 server/prisma/migrations/20240824005054_init/migration.sql create mode 100644 server/utils/middleware/validBrowserExtensionApiKey.js diff --git a/.github/workflows/dev-build.yaml b/.github/workflows/dev-build.yaml index aef9a6c35..6d938af8e 100644 --- a/.github/workflows/dev-build.yaml +++ b/.github/workflows/dev-build.yaml @@ -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/*' diff --git a/.gitmodules b/.gitmodules index dfb4bfcaa..a27e72e97 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/README.md b/README.md index 299ab9ea6..5c4e9cccb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/browser-extension b/browser-extension new file mode 160000 index 000000000..d9b28cc1e --- /dev/null +++ b/browser-extension @@ -0,0 +1 @@ +Subproject commit d9b28cc1e23b64fdb4e666d5b5b49cc8e583aabd diff --git a/embed b/embed deleted file mode 160000 index 22a0848d5..000000000 --- a/embed +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 22a0848d58e3a758d85d93d9204a72a65854ea94 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3737541f2..c6cac66db 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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} />} diff --git a/frontend/src/components/SettingsSidebar/index.jsx b/frontend/src/components/SettingsSidebar/index.jsx index 9fa6fd611..54cf9b4a1 100644 --- a/frontend/src/components/SettingsSidebar/index.jsx +++ b/frontend/src/components/SettingsSidebar/index.jsx @@ -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 diff --git a/frontend/src/locales/de/common.js b/frontend/src/locales/de/common.js index d82e9fbdb..5f0c64a98 100644 --- a/frontend/src/locales/de/common.js +++ b/frontend/src/locales/de/common.js @@ -37,6 +37,7 @@ const TRANSLATIONS = { tools: "Werkzeuge", "experimental-features": "Experimentelle Funktionen", contact: "Support kontaktieren", + "browser-extension": "Browser-Erweiterung", }, login: { diff --git a/frontend/src/locales/en/common.js b/frontend/src/locales/en/common.js index a64d37d84..3954e3f58 100644 --- a/frontend/src/locales/en/common.js +++ b/frontend/src/locales/en/common.js @@ -38,6 +38,7 @@ const TRANSLATIONS = { tools: "Tools", "experimental-features": "Experimental Features", contact: "Contact Support", + "browser-extension": "Browser Extension", }, // Page Definitions diff --git a/frontend/src/locales/es/common.js b/frontend/src/locales/es/common.js index f14f1e81b..ba9d6eab6 100644 --- a/frontend/src/locales/es/common.js +++ b/frontend/src/locales/es/common.js @@ -37,6 +37,7 @@ const TRANSLATIONS = { tools: "Herramientas", "experimental-features": "Funciones Experimentales", contact: "Contactar Soporte", + "browser-extension": "Extensión del navegador", }, login: { diff --git a/frontend/src/locales/fr/common.js b/frontend/src/locales/fr/common.js index 0a3473013..8c5d79f4f 100644 --- a/frontend/src/locales/fr/common.js +++ b/frontend/src/locales/fr/common.js @@ -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 diff --git a/frontend/src/locales/he/common.js b/frontend/src/locales/he/common.js index a6b57e58a..a985a33f2 100644 --- a/frontend/src/locales/he/common.js +++ b/frontend/src/locales/he/common.js @@ -38,6 +38,7 @@ const TRANSLATIONS = { tools: "כלי×", "experimental-features": "×ª×›×•× ×•×ª × ×™×¡×™×•× ×™×•×ª", contact: "צור קשר ×¢× ×”×ª×ž×™×›×”", + "browser-extension": "תוסף דפדפן", }, // Page Definitions diff --git a/frontend/src/locales/it/common.js b/frontend/src/locales/it/common.js index 8b74bd4fd..40da81ee2 100644 --- a/frontend/src/locales/it/common.js +++ b/frontend/src/locales/it/common.js @@ -38,6 +38,7 @@ const TRANSLATIONS = { tools: "Strumenti", "experimental-features": "Caratteristiche sperimentali", contact: "Contatta il Supporto", + "browser-extension": "Estensione del browser", }, // Page Definitions diff --git a/frontend/src/locales/ko/common.js b/frontend/src/locales/ko/common.js index eb5b34dfe..247ead951 100644 --- a/frontend/src/locales/ko/common.js +++ b/frontend/src/locales/ko/common.js @@ -38,6 +38,7 @@ const TRANSLATIONS = { tools: "ë„구", "experimental-features": "실험ì 기능", contact: "지ì›íŒ€ ì—°ë½", + "browser-extension": "브ë¼ìš°ì € 확장 프로그램", }, // Page Definitions diff --git a/frontend/src/locales/pt_BR/common.js b/frontend/src/locales/pt_BR/common.js index a0f8d495c..078105b92 100644 --- a/frontend/src/locales/pt_BR/common.js +++ b/frontend/src/locales/pt_BR/common.js @@ -38,6 +38,7 @@ const TRANSLATIONS = { tools: "Ferramentas", "experimental-features": "Recursos Experimentais", contact: "Contato com Suporte", + "browser-extension": "Extensão do navegador", }, // Page Definitions diff --git a/frontend/src/locales/ru/common.js b/frontend/src/locales/ru/common.js index b898c79b2..dc5284064 100644 --- a/frontend/src/locales/ru/common.js +++ b/frontend/src/locales/ru/common.js @@ -37,6 +37,7 @@ const TRANSLATIONS = { tools: "ИнÑтрументы", "experimental-features": "ÐкÑпериментальные функции", contact: "è”系支æŒÐ¡Ð²ÑзатьÑÑ Ñ ÐŸÐ¾Ð´Ð´ÐµÑ€Ð¶ÐºÐ¾Ð¹", + "browser-extension": "РаÑширение браузера", }, login: { diff --git a/frontend/src/locales/zh/common.js b/frontend/src/locales/zh/common.js index dd369a370..318757060 100644 --- a/frontend/src/locales/zh/common.js +++ b/frontend/src/locales/zh/common.js @@ -39,6 +39,7 @@ const TRANSLATIONS = { tools: "工具", "experimental-features": "实验功能", contact: "è”系支æŒ", + "browser-extension": "æµè§ˆå™¨æ‰©å±•", }, // Page Definitions diff --git a/frontend/src/models/browserExtensionApiKey.js b/frontend/src/models/browserExtensionApiKey.js new file mode 100644 index 000000000..d487ed06d --- /dev/null +++ b/frontend/src/models/browserExtensionApiKey.js @@ -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; diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx new file mode 100644 index 000000000..e43996a41 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx new file mode 100644 index 000000000..ee3f86786 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx @@ -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> + ); +} diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx new file mode 100644 index 000000000..1bcb7c13f --- /dev/null +++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx @@ -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> + ); +} diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 6a9773b9e..1fad7389f 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -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"; diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js index 00fce5117..28d36025c 100644 --- a/frontend/src/utils/paths.js +++ b/frontend/src/utils/paths.js @@ -138,6 +138,9 @@ export default { embedChats: () => { return `/settings/embed-chats`; }, + browserExtension: () => { + return `/settings/browser-extension`; + }, experimental: () => { return `/settings/beta-features`; }, diff --git a/server/endpoints/browserExtension.js b/server/endpoints/browserExtension.js new file mode 100644 index 000000000..844da00e7 --- /dev/null +++ b/server/endpoints/browserExtension.js @@ -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 }; diff --git a/server/endpoints/system.js b/server/endpoints/system.js index d49765d40..9e57b3c35 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -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( { diff --git a/server/index.js b/server/index.js index 757f35344..041d34c95 100644 --- a/server/index.js +++ b/server/index.js @@ -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(); diff --git a/server/models/browserExtensionApiKey.js b/server/models/browserExtensionApiKey.js new file mode 100644 index 000000000..21c1a3a01 --- /dev/null +++ b/server/models/browserExtensionApiKey.js @@ -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 }; diff --git a/server/prisma/migrations/20240824005054_init/migration.sql b/server/prisma/migrations/20240824005054_init/migration.sql new file mode 100644 index 000000000..7dc4632b6 --- /dev/null +++ b/server/prisma/migrations/20240824005054_init/migration.sql @@ -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"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b45e29119..276b9eaf9 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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]) +} \ No newline at end of file diff --git a/server/utils/middleware/validBrowserExtensionApiKey.js b/server/utils/middleware/validBrowserExtensionApiKey.js new file mode 100644 index 000000000..75485a778 --- /dev/null +++ b/server/utils/middleware/validBrowserExtensionApiKey.js @@ -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 }; -- GitLab