From 91f5f94200e3eb7ca808e9e8098594fec52b3d8a Mon Sep 17 00:00:00 2001
From: Timothy Carambat
Date: Tue, 25 Jul 2023 10:37:04 -0700
Subject: [PATCH] [FEATURE] Enable the ability to have multi user instances
(#158)
* multi user wip
* WIP MUM features
* invitation mgmt
* suspend or unsuspend users
* workspace mangement
* manage chats
* manage chats
* add Support for admin system settings for users to delete workspaces and limit chats per user
* fix issue ith system var
update app to lazy load invite page
* cleanup and bug fixes
* wrong method
* update readme
* update readme
* update readme
* bump version to 0.1.0
---
README.md | 37 +-
frontend/package.json | 3 +-
frontend/src/App.jsx | 35 +-
frontend/src/AuthContext.jsx | 13 +-
.../src/components/AdminSidebar/index.jsx | 263 +++++++++++++
frontend/src/components/Modals/Password.jsx | 121 ------
.../Modals/Password/MultiUserAuth.jsx | 92 +++++
.../Modals/Password/SingleUserAuth.jsx | 76 ++++
.../src/components/Modals/Password/index.jsx | 97 +++++
.../components/Modals/Settings/Keys/index.jsx | 172 ++++-----
.../Modals/Settings/MultiUserMode/index.jsx | 153 ++++++++
.../Settings/PasswordProtection/index.jsx | 121 +++---
.../src/components/Modals/Settings/index.jsx | 127 +++++--
frontend/src/components/Preloader.jsx | 16 +
.../src/components/PrivateRoute/index.jsx | 63 ++++
frontend/src/components/Sidebar/index.jsx | 19 +-
.../ChatHistory/HistoricalMessage/index.jsx | 3 +-
frontend/src/hooks/usePrefersDarkMode.js | 9 +
frontend/src/hooks/useQuery.js | 3 +
frontend/src/hooks/useUser.js | 18 +
frontend/src/index.css | 55 +--
frontend/src/models/admin.js | 193 ++++++++++
frontend/src/models/invite.js | 27 ++
frontend/src/models/system.js | 12 +
frontend/src/models/workspace.js | 6 +-
.../src/pages/Admin/Chats/ChatRow/index.jsx | 95 +++++
frontend/src/pages/Admin/Chats/index.jsx | 142 +++++++
.../Admin/Invitations/InviteRow/index.jsx | 80 ++++
.../Invitations/NewInviteModal/index.jsx | 113 ++++++
.../src/pages/Admin/Invitations/index.jsx | 104 ++++++
frontend/src/pages/Admin/System/index.jsx | 155 ++++++++
.../pages/Admin/Users/NewUserModal/index.jsx | 126 +++++++
.../Users/UserRow/EditUserModal/index.jsx | 123 +++++++
.../src/pages/Admin/Users/UserRow/index.jsx | 71 ++++
frontend/src/pages/Admin/Users/index.jsx | 104 ++++++
.../Workspaces/NewWorkspaceModal/index.jsx | 90 +++++
.../EditWorkspaceUsersModal/index.jsx | 155 ++++++++
.../Admin/Workspaces/WorkspaceRow/index.jsx | 63 ++++
frontend/src/pages/Admin/Workspaces/index.jsx | 112 ++++++
.../src/pages/Invite/NewUserModal/index.jsx | 90 +++++
frontend/src/pages/Invite/index.jsx | 53 +++
frontend/src/pages/Main/index.jsx | 4 +-
frontend/src/pages/WorkspaceChat/index.jsx | 4 +-
frontend/src/utils/constants.js | 3 +
frontend/src/utils/paths.js | 17 +
frontend/src/utils/request.js | 14 +-
frontend/src/utils/session.js | 15 +
package.json | 4 +-
server/.env.example | 3 +-
server/endpoints/admin.js | 348 ++++++++++++++++++
server/endpoints/chat.js | 83 +++--
server/endpoints/invite.js | 63 ++++
server/endpoints/system.js | 307 ++++++++++-----
server/endpoints/workspaces.js | 223 ++++++-----
server/index.js | 11 +-
server/models/documents.js | 2 +-
server/models/invite.js | 191 ++++++++++
server/models/systemSettings.js | 122 ++++++
server/models/user.js | 170 +++++++++
server/models/vectors.js | 2 +-
server/models/workspace.js | 70 +++-
server/models/workspaceChats.js | 77 +++-
server/models/workspaceUsers.js | 132 +++++++
server/package.json | 2 +
server/utils/chats/commands/reset.js | 4 +-
server/utils/chats/index.js | 14 +-
server/utils/database/index.js | 10 +-
server/utils/http/index.js | 30 +-
server/utils/middleware/validatedRequest.js | 40 +-
.../utils/vectorDbProviders/chroma/index.js | 5 +
server/utils/vectorDbProviders/lance/index.js | 8 +
.../utils/vectorDbProviders/pinecone/index.js | 5 +
server/yarn.lock | 45 ++-
73 files changed, 4834 insertions(+), 604 deletions(-)
create mode 100644 frontend/src/components/AdminSidebar/index.jsx
delete mode 100644 frontend/src/components/Modals/Password.jsx
create mode 100644 frontend/src/components/Modals/Password/MultiUserAuth.jsx
create mode 100644 frontend/src/components/Modals/Password/SingleUserAuth.jsx
create mode 100644 frontend/src/components/Modals/Password/index.jsx
create mode 100644 frontend/src/components/Modals/Settings/MultiUserMode/index.jsx
create mode 100644 frontend/src/components/Preloader.jsx
create mode 100644 frontend/src/components/PrivateRoute/index.jsx
create mode 100644 frontend/src/hooks/usePrefersDarkMode.js
create mode 100644 frontend/src/hooks/useQuery.js
create mode 100644 frontend/src/hooks/useUser.js
create mode 100644 frontend/src/models/admin.js
create mode 100644 frontend/src/models/invite.js
create mode 100644 frontend/src/pages/Admin/Chats/ChatRow/index.jsx
create mode 100644 frontend/src/pages/Admin/Chats/index.jsx
create mode 100644 frontend/src/pages/Admin/Invitations/InviteRow/index.jsx
create mode 100644 frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx
create mode 100644 frontend/src/pages/Admin/Invitations/index.jsx
create mode 100644 frontend/src/pages/Admin/System/index.jsx
create mode 100644 frontend/src/pages/Admin/Users/NewUserModal/index.jsx
create mode 100644 frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
create mode 100644 frontend/src/pages/Admin/Users/UserRow/index.jsx
create mode 100644 frontend/src/pages/Admin/Users/index.jsx
create mode 100644 frontend/src/pages/Admin/Workspaces/NewWorkspaceModal/index.jsx
create mode 100644 frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx
create mode 100644 frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx
create mode 100644 frontend/src/pages/Admin/Workspaces/index.jsx
create mode 100644 frontend/src/pages/Invite/NewUserModal/index.jsx
create mode 100644 frontend/src/pages/Invite/index.jsx
create mode 100644 frontend/src/utils/session.js
create mode 100644 server/endpoints/admin.js
create mode 100644 server/endpoints/invite.js
create mode 100644 server/models/invite.js
create mode 100644 server/models/systemSettings.js
create mode 100644 server/models/user.js
create mode 100644 server/models/workspaceUsers.js
diff --git a/README.md b/README.md
index ceca8dbe..899b96f3 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,24 @@
-# 🤖 AnythingLLM: A full-stack personalized AI assistant
+
+ 🤖 AnythingLLM: A full-stack personalized AI assistant .
+ A hyper-efficient and open-source document chatbot solution for all.
+
-[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/tim.svg?style=social&label=Follow%20%40Timothy%20Carambat)](https://twitter.com/tcarambat) [![](https://dcbadge.vercel.app/api/server/6UyHPeGZAC?compact=true&style=flat)](https://discord.gg/6UyHPeGZAC)
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+ Docs
+
+
-A full-stack application and tool suite that enables you to turn any document, resource, or piece of content into a piece of data that any LLM can use as reference during chatting. This application runs with very minimal overhead as by default the LLM and vectorDB are hosted remotely, but can be swapped for local instances. Currently this project supports [Pinecone](https://pinecone.io), [ChromaDB](https://trychroma.com) & more for vector storage and [OpenAI](https://openai.com) for LLM/chatting.
+A full-stack application that enables you to turn any document, resource, or piece of content into context that any LLM can use as references during chatting. This application allows you to pick and choose which LLM or Vector Database you want to use. Currently this project supports [Pinecone](https://pinecone.io), [ChromaDB](https://trychroma.com) & more for vector storage and [OpenAI](https://openai.com) for LLM/chatting.
![Chatting](/images/screenshots/chat.png)
@@ -14,20 +30,21 @@ A full-stack application and tool suite that enables you to turn any document, r
### Product Overview
-AnythingLLM aims to be a full-stack application where you can use commercial off-the-shelf LLMs with Long-term-memory solutions or use popular open source LLM and vectorDB solutions.
+AnythingLLM aims to be a full-stack application where you can use commercial off-the-shelf LLMs or popular open source LLMs and vectorDB solutions.
Anything LLM is a full-stack product that you can run locally as well as host remotely and be able to chat intelligently with any documents you provide it.
AnythingLLM divides your documents into objects called `workspaces`. A Workspace functions a lot like a thread, but with the addition of containerization of your documents. Workspaces can share documents, but they do not talk to each other so you can keep your context for each workspace clean.
Some cool features of AnythingLLM
-- Atomically manage documents to be used in long-term-memory from a simple UI
+- Multi-user instance support and oversight
+- Atomically manage documents in your vector database from a simple UI
- Two chat modes `conversation` and `query`. Conversation retains previous questions and amendments. Query is simple QA against your documents
- Each chat response contains a citation that is linked to the original content
- Simple technology stack for fast iteration
-- Fully capable of being hosted remotely
-- "Bring your own LLM" model and vector solution. _still in progress_
-- Extremely efficient cost-saving measures for managing very large documents. you'll never pay to embed a massive document or transcript more than once. 90% more cost effective than other LTM chatbots
+- 100% Cloud deployment ready.
+- "Bring your own LLM" model. _still in progress - openai support only currently_
+- Extremely efficient cost-saving measures for managing very large documents. You'll never pay to embed a massive document or transcript more than once. 90% more cost effective than other document chatbot solutions.
### Technical Overview
This monorepo consists of three main sections:
@@ -37,8 +54,8 @@ This monorepo consists of three main sections:
### Requirements
- `yarn` and `node` on your machine
-- `python` 3.8+ for running scripts in `collector/`.
-- access to an LLM like `GPT-3.5`, `GPT-4`*.
+- `python` 3.9+ for running scripts in `collector/`.
+- access to an LLM like `GPT-3.5`, `GPT-4`.
- a [Pinecone.io](https://pinecone.io) free account*.
*you can use drop in replacements for these. This is just the easiest to get up and running fast. We support multiple vector database providers.
diff --git a/frontend/package.json b/frontend/package.json
index a5aad549..41ef5032 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,6 @@
{
"name": "anything-llm-frontend",
"private": false,
- "version": "0.0.1-beta",
"type": "module",
"license": "MIT",
"scripts": {
@@ -44,4 +43,4 @@
"tailwindcss": "^3.3.1",
"vite": "^4.3.0"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c29c6693..ca66bdc9 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,9 +1,16 @@
import React, { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { ContextWrapper } from "./AuthContext";
+import PrivateRoute, { AdminRoute } from "./components/PrivateRoute";
const Main = lazy(() => import("./pages/Main"));
+const InvitePage = lazy(() => import("./pages/Invite"));
const WorkspaceChat = lazy(() => import("./pages/WorkspaceChat"));
+const AdminUsers = lazy(() => import("./pages/Admin/Users"));
+const AdminInvites = lazy(() => import("./pages/Admin/Invitations"));
+const AdminWorkspaces = lazy(() => import("./pages/Admin/Workspaces"));
+const AdminChats = lazy(() => import("./pages/Admin/Chats"));
+const AdminSystem = lazy(() => import("./pages/Admin/System"));
export default function App() {
return (
@@ -11,7 +18,33 @@ export default function App() {
} />
- } />
+ }
+ />
+ } />
+
+ {/* Admin Routes */}
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx
index 4b7f820a..219fbea4 100644
--- a/frontend/src/AuthContext.jsx
+++ b/frontend/src/AuthContext.jsx
@@ -1,9 +1,10 @@
import React, { useState, createContext } from "react";
+import { AUTH_TOKEN, AUTH_USER } from "./utils/constants";
export const AuthContext = createContext(null);
export function ContextWrapper(props) {
- const localUser = localStorage.getItem("anythingllm_user");
- const localAuthToken = localStorage.getItem("anythingllm_authToken");
+ const localUser = localStorage.getItem(AUTH_USER);
+ const localAuthToken = localStorage.getItem(AUTH_TOKEN);
const [store, setStore] = useState({
user: localUser ? JSON.parse(localUser) : null,
authToken: localAuthToken ? localAuthToken : null,
@@ -11,13 +12,13 @@ export function ContextWrapper(props) {
const [actions] = useState({
updateUser: (user, authToken = "") => {
- localStorage.setItem("anythingllm_user", JSON.stringify(user));
- localStorage.setItem("anythingllm_authToken", authToken);
+ localStorage.setItem(AUTH_USER, JSON.stringify(user));
+ localStorage.setItem(AUTH_TOKEN, authToken);
setStore({ user, authToken });
},
unsetUser: () => {
- localStorage.removeItem("anythingllm_user");
- localStorage.removeItem("anythingllm_authToken");
+ localStorage.removeItem(AUTH_USER);
+ localStorage.removeItem(AUTH_TOKEN);
setStore({ user: null, authToken: null });
},
});
diff --git a/frontend/src/components/AdminSidebar/index.jsx b/frontend/src/components/AdminSidebar/index.jsx
new file mode 100644
index 00000000..6c8b8f8c
--- /dev/null
+++ b/frontend/src/components/AdminSidebar/index.jsx
@@ -0,0 +1,263 @@
+import React, { useEffect, useRef, useState } from "react";
+import {
+ BookOpen,
+ Database,
+ GitHub,
+ Mail,
+ Menu,
+ MessageSquare,
+ Settings,
+ Users,
+ X,
+} from "react-feather";
+import IndexCount from "../Sidebar/IndexCount";
+import LLMStatus from "../Sidebar/LLMStatus";
+import paths from "../../utils/paths";
+import Discord from "../Icons/Discord";
+
+export default function AdminSidebar() {
+ const sidebarRef = useRef(null);
+ return (
+ <>
+
+
+ {/* Header Information */}
+
+
+ AnythingLLM Admin
+
+
+
+
+ {/* Primary Body */}
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+ >
+ );
+}
+
+export function SidebarMobileHeader() {
+ const sidebarRef = useRef(null);
+ const [showSidebar, setShowSidebar] = useState(false);
+ const [showBgOverlay, setShowBgOverlay] = useState(false);
+
+ useEffect(() => {
+ function handleBg() {
+ if (showSidebar) {
+ setTimeout(() => {
+ setShowBgOverlay(true);
+ }, 300);
+ } else {
+ setShowBgOverlay(false);
+ }
+ }
+ handleBg();
+ }, [showSidebar]);
+
+ return (
+ <>
+
+
setShowSidebar(true)}
+ className="rounded-md bg-stone-200 p-2 flex items-center justify-center text-slate-800 hover:bg-stone-300 group dark:bg-stone-800 dark:text-slate-200 dark:hover:bg-stone-900 dark:border dark:border-stone-800"
+ >
+
+
+
+ AnythingLLM
+
+
+
+
setShowSidebar(false)}
+ />
+
+
+ {/* Header Information */}
+
+
+ AnythingLLM Admin
+
+
+
+
+ {/* Primary Body */}
+
+
+
+
+ >
+ );
+}
+
+const Option = ({ btnText, icon, href }) => {
+ const isActive = window.location.pathname === href;
+ return (
+
+ );
+};
diff --git a/frontend/src/components/Modals/Password.jsx b/frontend/src/components/Modals/Password.jsx
deleted file mode 100644
index 30c628fb..00000000
--- a/frontend/src/components/Modals/Password.jsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import React, { useState, useEffect, useRef } from "react";
-import System from "../../models/system";
-
-export default function PasswordModal() {
- const [loading, setLoading] = useState(false);
- const formEl = useRef(null);
- const [error, setError] = useState(null);
- const handleLogin = async (e) => {
- setError(null);
- setLoading(true);
- e.preventDefault();
- const data = {};
-
- const form = new FormData(formEl.current);
- for (var [key, value] of form.entries()) data[key] = value;
- const { valid, token, message } = await System.requestToken(data);
- if (valid && !!token) {
- window.localStorage.setItem("anythingllm_authtoken", token);
- window.location.reload();
- } else {
- setError(message);
- setLoading(false);
- }
- setLoading(false);
- };
-
- return (
-
- );
-}
-
-export function usePasswordModal() {
- const [requiresAuth, setRequiresAuth] = useState(null);
- useEffect(() => {
- async function checkAuthReq() {
- if (!window) return;
- if (import.meta.env.DEV) {
- setRequiresAuth(false);
- } else {
- const currentToken = window.localStorage.getItem(
- "anythingllm_authtoken"
- );
- const settings = await System.keys();
- const requiresAuth = settings?.RequiresAuth || false;
-
- // If Auth is disabled - skip check
- if (!requiresAuth) {
- setRequiresAuth(requiresAuth);
- return;
- }
-
- if (!!currentToken) {
- const valid = await System.checkAuth(currentToken);
- if (!valid) {
- setRequiresAuth(true);
- window.localStorage.removeItem("anythingllm_authtoken");
- return;
- } else {
- setRequiresAuth(false);
- return;
- }
- }
- setRequiresAuth(true);
- }
- }
- checkAuthReq();
- }, []);
-
- return { requiresAuth };
-}
diff --git a/frontend/src/components/Modals/Password/MultiUserAuth.jsx b/frontend/src/components/Modals/Password/MultiUserAuth.jsx
new file mode 100644
index 00000000..630c01ca
--- /dev/null
+++ b/frontend/src/components/Modals/Password/MultiUserAuth.jsx
@@ -0,0 +1,92 @@
+import React, { useState } from "react";
+import System from "../../../models/system";
+import { AUTH_TOKEN, AUTH_USER } from "../../../utils/constants";
+
+export default function MultiUserAuth() {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const handleLogin = async (e) => {
+ setError(null);
+ setLoading(true);
+ e.preventDefault();
+ const data = {};
+
+ const form = new FormData(e.target);
+ for (var [key, value] of form.entries()) data[key] = value;
+ const { valid, user, token, message } = await System.requestToken(data);
+ if (valid && !!token && !!user) {
+ window.localStorage.setItem(AUTH_USER, JSON.stringify(user));
+ window.localStorage.setItem(AUTH_TOKEN, token);
+ window.location.reload();
+ } else {
+ setError(message);
+ setLoading(false);
+ }
+ setLoading(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Modals/Password/SingleUserAuth.jsx b/frontend/src/components/Modals/Password/SingleUserAuth.jsx
new file mode 100644
index 00000000..c930289a
--- /dev/null
+++ b/frontend/src/components/Modals/Password/SingleUserAuth.jsx
@@ -0,0 +1,76 @@
+import React, { useState } from "react";
+import System from "../../../models/system";
+import { AUTH_TOKEN } from "../../../utils/constants";
+
+export default function SingleUserAuth() {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const handleLogin = async (e) => {
+ setError(null);
+ setLoading(true);
+ e.preventDefault();
+ const data = {};
+
+ const form = new FormData(e.target);
+ for (var [key, value] of form.entries()) data[key] = value;
+ const { valid, token, message } = await System.requestToken(data);
+ if (valid && !!token) {
+ window.localStorage.setItem(AUTH_TOKEN, token);
+ window.location.reload();
+ } else {
+ setError(message);
+ setLoading(false);
+ }
+ setLoading(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Modals/Password/index.jsx b/frontend/src/components/Modals/Password/index.jsx
new file mode 100644
index 00000000..ec5a0b44
--- /dev/null
+++ b/frontend/src/components/Modals/Password/index.jsx
@@ -0,0 +1,97 @@
+import React, { useState, useEffect } from "react";
+import System from "../../../models/system";
+import SingleUserAuth from "./SingleUserAuth";
+import MultiUserAuth from "./MultiUserAuth";
+import { AUTH_TOKEN, AUTH_USER } from "../../../utils/constants";
+
+export default function PasswordModal({ mode = "single" }) {
+ return (
+
+
+
+ {mode === "single" ? : }
+
+
+ );
+}
+
+export function usePasswordModal() {
+ const [auth, setAuth] = useState({
+ required: false,
+ mode: "single",
+ });
+
+ useEffect(() => {
+ async function checkAuthReq() {
+ if (!window) return;
+ const settings = await System.keys();
+
+ if (settings?.MultiUserMode) {
+ const currentToken = window.localStorage.getItem(AUTH_TOKEN);
+ if (!!currentToken) {
+ const valid = await System.checkAuth(currentToken);
+ if (!valid) {
+ setAuth({
+ requiresAuth: true,
+ mode: "multi",
+ });
+ window.localStorage.removeItem(AUTH_USER);
+ window.localStorage.removeItem(AUTH_TOKEN);
+ return;
+ } else {
+ setAuth({
+ requiresAuth: false,
+ mode: "multi",
+ });
+ return;
+ }
+ } else {
+ setAuth({
+ requiresAuth: true,
+ mode: "multi",
+ });
+ return;
+ }
+ } else {
+ // Running token check in single user Auth mode.
+ // If Single user Auth is disabled - skip check
+ const requiresAuth = settings?.RequiresAuth || false;
+ if (!requiresAuth) {
+ setAuth({
+ requiresAuth: false,
+ mode: "single",
+ });
+ return;
+ }
+
+ const currentToken = window.localStorage.getItem(AUTH_TOKEN);
+ if (!!currentToken) {
+ const valid = await System.checkAuth(currentToken);
+ if (!valid) {
+ setAuth({
+ requiresAuth: true,
+ mode: "single",
+ });
+ window.localStorage.removeItem(AUTH_TOKEN);
+ return;
+ } else {
+ setAuth({
+ requiresAuth: false,
+ mode: "single",
+ });
+ return;
+ }
+ } else {
+ setAuth({
+ requiresAuth: true,
+ mode: "single",
+ });
+ return;
+ }
+ }
+ }
+ checkAuthReq();
+ }, []);
+
+ return auth;
+}
diff --git a/frontend/src/components/Modals/Settings/Keys/index.jsx b/frontend/src/components/Modals/Settings/Keys/index.jsx
index 77e1a2f4..36709c39 100644
--- a/frontend/src/components/Modals/Settings/Keys/index.jsx
+++ b/frontend/src/components/Modals/Settings/Keys/index.jsx
@@ -1,12 +1,12 @@
-import React, { useState, useEffect } from "react";
-import { AlertCircle, Loader, X } from "react-feather";
+import React, { useState } from "react";
+import { AlertCircle, Loader } from "react-feather";
import System from "../../../../models/system";
const noop = () => false;
-export default function SystemKeys({ hideModal = noop }) {
- const [loading, setLoading] = useState(true);
- const [settings, setSettings] = useState({});
-
+export default function SystemKeys({ hideModal = noop, user, settings = {} }) {
+ const canDebug = settings.MultiUserMode
+ ? settings?.CanDebug && user?.role === "admin"
+ : settings?.CanDebug;
function validSettings(settings) {
return (
settings?.OpenAiKey &&
@@ -20,14 +20,6 @@ export default function SystemKeys({ hideModal = noop }) {
: true)
);
}
- useEffect(() => {
- async function fetchKeys() {
- const settings = await System.keys();
- setSettings(settings);
- setLoading(false);
- }
- fetchKeys();
- }, []);
return (
@@ -40,83 +32,75 @@ export default function SystemKeys({ hideModal = noop }) {
- {loading ? (
-
-
- loading system settings
-
-
- ) : (
-
- {!validSettings(settings) && (
-
-
-
- Ensure all fields are green before attempting to use
- AnythingLLM or it may not function as expected!
-
-
- )}
-
-
-
-
- {settings?.VectorDB === "pinecone" && (
- <>
-
-
-
- >
- )}
- {settings?.VectorDB === "chroma" && (
- <>
-
- >
- )}
-
- )}
+
+ {!validSettings(settings) && (
+
+
+
+ Ensure all fields are green before attempting to use
+ AnythingLLM or it may not function as expected!
+
+
+ )}
+
+
+
+
+ {settings?.VectorDB === "pinecone" && (
+ <>
+
+
+
+ >
+ )}
+ {settings?.VectorDB === "chroma" && (
+ <>
+
+ >
+ )}
+
setDebug(true)}
className="mt-2 text-xs text-slate-300 dark:text-slate-500"
>
- Debug
+ Change
)}
>
@@ -269,7 +253,7 @@ function ShowKey({ name, env, value, valid, allowDebug = true }) {
onClick={() => setDebug(true)}
className="mt-2 text-xs text-slate-300 dark:text-slate-500"
>
- Debug
+ Change
)}
diff --git a/frontend/src/components/Modals/Settings/MultiUserMode/index.jsx b/frontend/src/components/Modals/Settings/MultiUserMode/index.jsx
new file mode 100644
index 00000000..6a8b96e9
--- /dev/null
+++ b/frontend/src/components/Modals/Settings/MultiUserMode/index.jsx
@@ -0,0 +1,153 @@
+import React, { useState } from "react";
+import System from "../../../../models/system";
+import { AUTH_TOKEN, AUTH_USER } from "../../../../utils/constants";
+import paths from "../../../../utils/paths";
+
+const noop = () => false;
+export default function MultiUserMode({ hideModal = noop }) {
+ const [saving, setSaving] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [error, setError] = useState(null);
+ const [useMultiUserMode, setUseMultiUserMode] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSaving(true);
+ setSuccess(false);
+ setError(null);
+
+ const form = new FormData(e.target);
+ const data = {
+ username: form.get("username"),
+ password: form.get("password"),
+ };
+
+ const { success, error } = await System.setupMultiUser(data);
+ if (success) {
+ setSuccess(true);
+ setSaving(false);
+ setTimeout(() => {
+ window.localStorage.removeItem(AUTH_USER);
+ window.localStorage.removeItem(AUTH_TOKEN);
+ window.location = paths.admin.users();
+ }, 2_000);
+ return;
+ }
+
+ setError(error);
+ setSaving(false);
+ };
+
+ return (
+
+
+
+
+ Update your AnythingLLM instance to support multiple concurrent
+ users with their own workspaces. As the admin you can view all
+ workspaces and add people into workspaces as well. This change is
+ not reversible and will permanently alter your AnythingLLM
+ installation.
+
+
+ {(error || success) && (
+
+ {error && (
+
+ {error}
+
+ )}
+ {success && (
+
+ Your page will refresh in a few seconds.
+
+ )}
+
+ )}
+
+
+
+ Close
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx b/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx
index 2b6444ed..387c44bc 100644
--- a/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx
+++ b/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx
@@ -1,13 +1,16 @@
-import React, { useState, useEffect } from "react";
+import React, { useState } from "react";
import System from "../../../../models/system";
+import { AUTH_TOKEN, AUTH_USER } from "../../../../utils/constants";
const noop = () => false;
-export default function PasswordProtection({ hideModal = noop }) {
- const [loading, setLoading] = useState(true);
+export default function PasswordProtection({
+ hideModal = noop,
+ settings = {},
+}) {
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(null);
- const [usePassword, setUsePassword] = useState(false);
+ const [usePassword, setUsePassword] = useState(settings?.RequiresAuth);
const handleSubmit = async (e) => {
e.preventDefault();
@@ -26,7 +29,8 @@ export default function PasswordProtection({ hideModal = noop }) {
setSuccess(true);
setSaving(false);
setTimeout(() => {
- window.localStorage.removeItem("anythingllm_authToken");
+ window.localStorage.removeItem(AUTH_USER);
+ window.localStorage.removeItem(AUTH_TOKEN);
window.location.reload();
}, 2_000);
return;
@@ -36,15 +40,6 @@ export default function PasswordProtection({ hideModal = noop }) {
setSaving(false);
};
- useEffect(() => {
- async function fetchKeys() {
- const settings = await System.keys();
- setUsePassword(settings?.RequiresAuth);
- setLoading(false);
- }
- fetchKeys();
- }, []);
-
return (
@@ -69,62 +64,54 @@ export default function PasswordProtection({ hideModal = noop }) {
)}
- {loading ? (
-
-
- loading system settings
-
-
- ) : (
-
false;
export default function SystemSettingsModal({ hideModal = noop }) {
+ const { user } = useUser();
+ const [loading, setLoading] = useState(true);
const [selectedTab, setSelectedTab] = useState("keys");
+ const [settings, setSettings] = useState(null);
const Component = TABS[selectedTab || "keys"];
+ useEffect(() => {
+ async function fetchKeys() {
+ const _settings = await System.keys();
+ setSettings(_settings);
+ setLoading(false);
+ }
+ fetchKeys();
+ }, []);
+
return (
-
+
-
+ {loading ? (
+
+ ) : (
+
+ )}
);
}
-function SettingTabs({ selectedTab, changeTab }) {
+function SettingTabs({ selectedTab, changeTab, settings, user }) {
+ if (!settings) {
+ return (
+
+ );
+ }
+
return (
-
-
- }
- onClick={changeTab}
- />
- }
- onClick={changeTab}
- />
- }
- onClick={changeTab}
- />
-
-
+
+ }
+ onClick={changeTab}
+ />
+ }
+ onClick={changeTab}
+ />
+ {!settings?.MultiUserMode ? (
+ <>
+ }
+ onClick={changeTab}
+ />
+ }
+ onClick={changeTab}
+ />
+ >
+ ) : (
+
+ )}
+
);
}
@@ -102,6 +150,25 @@ function SettingTab({
);
}
+function LogoutTab({ user }) {
+ if (!user) return null;
+
+ return (
+
+ {
+ window.localStorage.removeItem(AUTH_USER);
+ window.localStorage.removeItem(AUTH_TOKEN);
+ window.location.replace(paths.home());
+ }}
+ className="flex items-center gap-x-1 p-4 border-b-2 rounded-t-lg group whitespace-nowrap border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300"
+ >
+ Log out of {user.username}
+
+
+ );
+}
+
export function useSystemSettingsModal() {
const [showing, setShowing] = useState(false);
const showModal = () => {
diff --git a/frontend/src/components/Preloader.jsx b/frontend/src/components/Preloader.jsx
new file mode 100644
index 00000000..728f41bf
--- /dev/null
+++ b/frontend/src/components/Preloader.jsx
@@ -0,0 +1,16 @@
+export default function PreLoader() {
+ return (
+
+ );
+}
+
+export function FullScreenLoader() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/PrivateRoute/index.jsx b/frontend/src/components/PrivateRoute/index.jsx
new file mode 100644
index 00000000..33f5c633
--- /dev/null
+++ b/frontend/src/components/PrivateRoute/index.jsx
@@ -0,0 +1,63 @@
+import { useEffect, useState } from "react";
+import { Navigate } from "react-router-dom";
+import { FullScreenLoader } from "../Preloader";
+import validateSessionTokenForUser from "../../utils/session";
+import paths from "../../utils/paths";
+import { AUTH_TOKEN, AUTH_USER } from "../../utils/constants";
+import { userFromStorage } from "../../utils/request";
+import System from "../../models/system";
+
+// Used only for Multi-user mode only as we permission specific pages based on auth role.
+// When in single user mode we just bypass any authchecks.
+function useIsAuthenticated() {
+ const [isAuthd, setIsAuthed] = useState(null);
+
+ useEffect(() => {
+ const validateSession = async () => {
+ const multiUserMode = (await System.keys()).MultiUserMode;
+ if (!multiUserMode) {
+ setIsAuthed(true);
+ return;
+ }
+
+ const localUser = localStorage.getItem(AUTH_USER);
+ const localAuthToken = localStorage.getItem(AUTH_TOKEN);
+ if (!localUser || !localAuthToken) {
+ setIsAuthed(false);
+ return;
+ }
+
+ const isValid = await validateSessionTokenForUser();
+ if (!isValid) {
+ localStorage.removeItem(AUTH_USER);
+ localStorage.removeItem(AUTH_TOKEN);
+ setIsAuthed(false);
+ return;
+ }
+
+ setIsAuthed(true);
+ };
+ validateSession();
+ }, []);
+
+ return isAuthd;
+}
+
+export function AdminRoute({ Component }) {
+ const authed = useIsAuthenticated();
+ if (authed === null) return ;
+
+ const user = userFromStorage();
+ return authed && user?.role === "admin" ? (
+
+ ) : (
+
+ );
+}
+
+export default function PrivateRoute({ Component }) {
+ const authed = useIsAuthenticated();
+ if (authed === null) return ;
+
+ return authed ? : ;
+}
diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx
index 6abdd51d..b1287282 100644
--- a/frontend/src/components/Sidebar/index.jsx
+++ b/frontend/src/components/Sidebar/index.jsx
@@ -6,6 +6,7 @@ import {
GitHub,
Menu,
Plus,
+ Shield,
Tool,
} from "react-feather";
import IndexCount from "./IndexCount";
@@ -19,6 +20,7 @@ import NewWorkspaceModal, {
import ActiveWorkspaces from "./ActiveWorkspaces";
import paths from "../../utils/paths";
import Discord from "../Icons/Discord";
+import useUser from "../../hooks/useUser";
export default function Sidebar() {
const sidebarRef = useRef(null);
@@ -47,6 +49,7 @@ export default function Sidebar() {
AnythingLLM
{!isMobile &&
}
diff --git a/frontend/src/pages/WorkspaceChat/index.jsx b/frontend/src/pages/WorkspaceChat/index.jsx
index 1eec9041..612b51a0 100644
--- a/frontend/src/pages/WorkspaceChat/index.jsx
+++ b/frontend/src/pages/WorkspaceChat/index.jsx
@@ -11,11 +11,11 @@ import PasswordModal, {
import { isMobile } from "react-device-detect";
export default function WorkspaceChat() {
- const { requiresAuth } = usePasswordModal();
+ const { requiresAuth, mode } = usePasswordModal();
if (requiresAuth === null || requiresAuth) {
return (
<>
- {requiresAuth &&
}
+ {requiresAuth &&
}
{!isMobile && }
diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js
index 53995e6a..e96e6836 100644
--- a/frontend/src/utils/constants.js
+++ b/frontend/src/utils/constants.js
@@ -1,2 +1,5 @@
export const API_BASE =
import.meta.env.VITE_API_BASE || "http://localhost:3001/api";
+
+export const AUTH_USER = "anythingllm_user";
+export const AUTH_TOKEN = "anythingllm_authToken";
diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js
index 03ff7b54..57eee6e1 100644
--- a/frontend/src/utils/paths.js
+++ b/frontend/src/utils/paths.js
@@ -27,4 +27,21 @@ export default {
exports: () => {
return `${API_BASE.replace("/api", "")}/system/data-exports`;
},
+ admin: {
+ system: () => {
+ return `/admin/system-preferences`;
+ },
+ users: () => {
+ return `/admin/users`;
+ },
+ invites: () => {
+ return `/admin/invites`;
+ },
+ workspaces: () => {
+ return `/admin/workspaces`;
+ },
+ chats: () => {
+ return "/admin/workspace-chats";
+ },
+ },
};
diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js
index 4de7681e..271c97b2 100644
--- a/frontend/src/utils/request.js
+++ b/frontend/src/utils/request.js
@@ -1,8 +1,18 @@
+import { AUTH_TOKEN, AUTH_USER } from "./constants";
+
// Sets up the base headers for all authenticated requests so that we are able to prevent
// basic spoofing since a valid token is required and that cannot be spoofed
+export function userFromStorage() {
+ try {
+ const userString = window.localStorage.getItem(AUTH_USER);
+ if (!userString) return null;
+ return JSON.parse(userString);
+ } catch {}
+ return {};
+}
+
export function baseHeaders(providedToken = null) {
- const token =
- providedToken || window.localStorage.getItem("anythingllm_authtoken");
+ const token = providedToken || window.localStorage.getItem(AUTH_TOKEN);
return {
Authorization: token ? `Bearer ${token}` : null,
};
diff --git a/frontend/src/utils/session.js b/frontend/src/utils/session.js
new file mode 100644
index 00000000..27228e48
--- /dev/null
+++ b/frontend/src/utils/session.js
@@ -0,0 +1,15 @@
+import { API_BASE } from "./constants";
+import { baseHeaders } from "./request";
+
+// Checks current localstorage and validates the session based on that.
+export default async function validateSessionTokenForUser() {
+ const isValidSession = await fetch(`${API_BASE}/system/check-token`, {
+ method: "GET",
+ cache: "default",
+ headers: baseHeaders(),
+ })
+ .then((res) => res.status === 200)
+ .catch(() => false);
+
+ return isValidSession;
+}
diff --git a/package.json b/package.json
index 7df9953f..12d46fe3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "anything-llm",
- "version": "0.0.1-beta",
- "description": "Turn anything into a chattable document through a simple UI",
+ "version": "0.1.0",
+ "description": "The best solution for turning private documents into a chat bot using off-the-shelf tools and commercially viable AI technologies.",
"main": "index.js",
"author": "Timothy Carambat (Mintplex Labs)",
"license": "MIT",
diff --git a/server/.env.example b/server/.env.example
index a74cec57..f671f78d 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -16,8 +16,9 @@ PINECONE_INDEX=
# Enable all below if you are using vector database: LanceDB.
# VECTOR_DB="lancedb"
+JWT_SECRET="my-random-string-for-seeding" # Please generate random string at least 12 chars long.
+
# CLOUD DEPLOYMENT VARIRABLES ONLY
# AUTH_TOKEN="hunter2" # This is the password to your application if remote hosting.
-# JWT_SECRET="my-random-string-for-seeding" # Only needed if AUTH_TOKEN is set. Please generate random string at least 12 chars long.
# STORAGE_DIR= # absolute filesystem path with no trailing slash
# NO_DEBUG="true"
\ No newline at end of file
diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js
new file mode 100644
index 00000000..b58dc0c1
--- /dev/null
+++ b/server/endpoints/admin.js
@@ -0,0 +1,348 @@
+const { Document } = require("../models/documents");
+const { Invite } = require("../models/invite");
+const { SystemSettings } = require("../models/systemSettings");
+const { User } = require("../models/user");
+const { DocumentVectors } = require("../models/vectors");
+const { Workspace } = require("../models/workspace");
+const { WorkspaceChats } = require("../models/workspaceChats");
+const { getVectorDbClass } = require("../utils/helpers");
+const { userFromSession, reqBody } = require("../utils/http");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+
+function adminEndpoints(app) {
+ if (!app) return;
+
+ app.get("/admin/users", [validatedRequest], async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+ const users = (await User.where()).map((user) => {
+ const { password, ...rest } = user;
+ return rest;
+ });
+ response.status(200).json({ users });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post(
+ "/admin/users/new",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const newUserParams = reqBody(request);
+ const { user: newUser, error } = await User.create(newUserParams);
+ response.status(200).json({ user: newUser, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post("/admin/user/:id", [validatedRequest], async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { id } = request.params;
+ const updates = reqBody(request);
+ const { success, error } = await User.update(id, updates);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.delete(
+ "/admin/user/:id",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+ const { id } = request.params;
+ await User.delete(`id = ${id}`);
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get("/admin/invites", [validatedRequest], async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const invites = await Invite.whereWithUsers();
+ response.status(200).json({ invites });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get(
+ "/admin/invite/new",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { invite, error } = await Invite.create(user.id);
+ response.status(200).json({ invite, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/admin/invite/:id",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { id } = request.params;
+ const { success, error } = await Invite.deactivate(id);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/admin/workspaces",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+ const workspaces = await Workspace.whereWithUsers();
+ response.status(200).json({ workspaces });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/workspaces/new",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+ const { name } = reqBody(request);
+ const { workspace, message: error } = await Workspace.new(
+ name,
+ user.id
+ );
+ response.status(200).json({ workspace, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/workspaces/:workspaceId/update-users",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { workspaceId } = request.params;
+ const { userIds } = reqBody(request);
+ const { success, error } = await Workspace.updateUsers(
+ workspaceId,
+ userIds
+ );
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/admin/workspaces/:id",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { id } = request.params;
+ const VectorDb = getVectorDbClass();
+ const workspace = Workspace.get(`id = ${id}`);
+ if (!workspace) {
+ response.sendStatus(404).end();
+ return;
+ }
+
+ await Workspace.delete(`id = ${id}`);
+ await DocumentVectors.deleteForWorkspace(id);
+ await Document.delete(`workspaceId = ${Number(id)}`);
+ await WorkspaceChats.delete(`workspaceId = ${Number(id)}`);
+ try {
+ await VectorDb["delete-namespace"]({ namespace: workspace.slug });
+ } catch (e) {
+ console.error(e.message);
+ }
+
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/workspace-chats",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+ const { offset = 0 } = reqBody(request);
+ const chats = await WorkspaceChats.whereWithData(`id >= ${offset}`, 20);
+ const hasPages = (await WorkspaceChats.count()) > 20;
+ response.status(200).json({ chats: chats.reverse(), hasPages });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/admin/workspace-chats/:id",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { id } = request.params;
+ await WorkspaceChats.delete(`id = ${id}`);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/admin/system-preferences",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const settings = {
+ users_can_delete_workspaces:
+ (await SystemSettings.get(`label = 'users_can_delete_workspaces'`))
+ ?.value === "true",
+ limit_user_messages:
+ (await SystemSettings.get(`label = 'limit_user_messages'`))
+ ?.value === "true",
+ message_limit:
+ Number(
+ (await SystemSettings.get(`label = 'message_limit'`))?.value
+ ) || 10,
+ };
+ response.status(200).json({ settings });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/system-preferences",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ if (!user || user?.role !== "admin") {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const updates = reqBody(request);
+ await SystemSettings.updateSettings(updates);
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { adminEndpoints };
diff --git a/server/endpoints/chat.js b/server/endpoints/chat.js
index 7c303643..0a9544d5 100644
--- a/server/endpoints/chat.js
+++ b/server/endpoints/chat.js
@@ -1,34 +1,73 @@
const { v4: uuidv4 } = require("uuid");
-const { reqBody } = require("../utils/http");
+const { reqBody, userFromSession, multiUserMode } = require("../utils/http");
const { Workspace } = require("../models/workspace");
const { chatWithWorkspace } = require("../utils/chats");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const { WorkspaceChats } = require("../models/workspaceChats");
+const { SystemSettings } = require("../models/systemSettings");
function chatEndpoints(app) {
if (!app) return;
- app.post("/workspace/:slug/chat", async (request, response) => {
- try {
- const { slug } = request.params;
- const { message, mode = "query" } = reqBody(request);
- const workspace = await Workspace.get(`slug = '${slug}'`);
- if (!workspace) {
- response.sendStatus(400).end();
- return;
- }
+ app.post(
+ "/workspace/:slug/chat",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { slug } = request.params;
+ const { message, mode = "query" } = reqBody(request);
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, `slug = '${slug}'`)
+ : await Workspace.get(`slug = '${slug}'`);
- const result = await chatWithWorkspace(workspace, message, mode);
- response.status(200).json({ ...result });
- } catch (e) {
- response.status(500).json({
- id: uuidv4(),
- type: "abort",
- textResponse: null,
- sources: [],
- close: true,
- error: e.message,
- });
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ if (multiUserMode(response) && user.role !== "admin") {
+ const limitMessages =
+ (await SystemSettings.get(`label = 'limit_user_messages'`))
+ ?.value === "true";
+
+ if (limitMessages) {
+ const systemLimit = Number(
+ (await SystemSettings.get(`label = 'message_limit'`))?.value
+ );
+ if (!!systemLimit) {
+ const currentChatCount = await WorkspaceChats.count(
+ `user_id = ${user.id} AND createdAt > datetime(CURRENT_TIMESTAMP, '-1 days')`
+ );
+ if (currentChatCount >= systemLimit) {
+ response.status(500).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: `You have met your maximum 24 hour chat quota of ${systemLimit} chats set by the instance administrators. Try again later.`,
+ });
+ return;
+ }
+ }
+ }
+ }
+
+ const result = await chatWithWorkspace(workspace, message, mode, user);
+ response.status(200).json({ ...result });
+ } catch (e) {
+ response.status(500).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ }
}
- });
+ );
}
module.exports = { chatEndpoints };
diff --git a/server/endpoints/invite.js b/server/endpoints/invite.js
new file mode 100644
index 00000000..3f3b1452
--- /dev/null
+++ b/server/endpoints/invite.js
@@ -0,0 +1,63 @@
+const { Invite } = require("../models/invite");
+const { User } = require("../models/user");
+const { reqBody } = require("../utils/http");
+
+function inviteEndpoints(app) {
+ if (!app) return;
+
+ app.get("/invite/:code", async (request, response) => {
+ try {
+ const { code } = request.params;
+ const invite = await Invite.get(`code = '${code}'`);
+ if (!invite) {
+ response.status(200).json({ invite: null, error: "Invite not found." });
+ return;
+ }
+
+ if (invite.status !== "pending") {
+ response
+ .status(200)
+ .json({ invite: null, error: "Invite is no longer valid." });
+ return;
+ }
+
+ response
+ .status(200)
+ .json({ invite: { code, status: invite.status }, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post("/invite/:code", async (request, response) => {
+ try {
+ const { code } = request.params;
+ const userParams = reqBody(request);
+ const invite = await Invite.get(`code = '${code}'`);
+ if (!invite || invite.status !== "pending") {
+ response
+ .status(200)
+ .json({ success: false, error: "Invite not found or is invalid." });
+ return;
+ }
+
+ const { user, error } = await User.create(userParams);
+ if (!user) {
+ console.error("Accepting invite:", error);
+ response
+ .status(200)
+ .json({ success: false, error: "Could not create user." });
+ return;
+ }
+
+ await Invite.markClaimed(invite.id, user);
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+}
+
+module.exports = { inviteEndpoints };
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
index a39ef3a3..e78af6ad 100644
--- a/server/endpoints/system.js
+++ b/server/endpoints/system.js
@@ -11,9 +11,17 @@ const {
const { purgeDocument } = require("../utils/files/purgeDocument");
const { getVectorDbClass } = require("../utils/helpers");
const { updateENV } = require("../utils/helpers/updateENV");
-const { reqBody, makeJWT } = require("../utils/http");
+const {
+ reqBody,
+ makeJWT,
+ userFromSession,
+ multiUserMode,
+} = require("../utils/http");
const { setupDataImports } = require("../utils/files/multer");
const { v4 } = require("uuid");
+const { SystemSettings } = require("../models/systemSettings");
+const { User } = require("../models/user");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { handleImports } = setupDataImports();
function systemEndpoints(app) {
@@ -28,7 +36,7 @@ function systemEndpoints(app) {
response.sendStatus(200);
});
- app.get("/setup-complete", (_, response) => {
+ app.get("/setup-complete", async (_, response) => {
try {
const vectorDB = process.env.VECTOR_DB || "pinecone";
const results = {
@@ -40,6 +48,7 @@ function systemEndpoints(app) {
AuthToken: !!process.env.AUTH_TOKEN,
JWTSecret: !!process.env.JWT_SECRET,
StorageDir: process.env.STORAGE_DIR,
+ MultiUserMode: await SystemSettings.isMultiUserMode(),
...(vectorDB === "pinecone"
? {
PineConeEnvironment: process.env.PINECONE_ENVIRONMENT,
@@ -60,35 +69,101 @@ function systemEndpoints(app) {
}
});
- app.get("/system/check-token", (_, response) => {
- response.sendStatus(200).end();
- });
+ app.get(
+ "/system/check-token",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ if (multiUserMode(response)) {
+ const user = await userFromSession(request, response);
+ if (!user || user.suspended) {
+ response.sendStatus(403).end();
+ return;
+ }
- app.post("/request-token", (request, response) => {
+ response.sendStatus(200).end();
+ return;
+ }
+
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post("/request-token", async (request, response) => {
try {
- const { password } = reqBody(request);
- if (password !== process.env.AUTH_TOKEN) {
- response.status(402).json({
- valid: false,
- token: null,
- message: "Invalid password provided",
+ if (await SystemSettings.isMultiUserMode()) {
+ const { username, password } = reqBody(request);
+ const existingUser = await User.get(`username = '${username}'`);
+
+ if (!existingUser) {
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[001] Invalid login credentials.",
+ });
+ return;
+ }
+
+ const bcrypt = require("bcrypt");
+ if (!bcrypt.compareSync(password, existingUser.password)) {
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[002] Invalid login credentials.",
+ });
+ return;
+ }
+
+ if (existingUser.suspended) {
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[004] Account suspended by admin.",
+ });
+ return;
+ }
+
+ response.status(200).json({
+ valid: true,
+ user: existingUser,
+ token: makeJWT(
+ { id: existingUser.id, username: existingUser.username },
+ "30d"
+ ),
+ message: null,
});
return;
- }
+ } else {
+ const { password } = reqBody(request);
+ if (password !== process.env.AUTH_TOKEN) {
+ response.status(401).json({
+ valid: false,
+ token: null,
+ message: "[003] Invalid password provided",
+ });
+ return;
+ }
- response.status(200).json({
- valid: true,
- token: makeJWT({ p: password }, "30d"),
- message: null,
- });
- return;
+ response.status(200).json({
+ valid: true,
+ token: makeJWT({ p: password }, "30d"),
+ message: null,
+ });
+ }
} catch (e) {
console.log(e.message, e);
response.sendStatus(500).end();
}
});
- app.get("/system/system-vectors", async (_, response) => {
+ app.get("/system/system-vectors", [validatedRequest], async (_, response) => {
try {
const VectorDb = getVectorDbClass();
const vectorCount = await VectorDb.totalIndicies();
@@ -99,18 +174,22 @@ function systemEndpoints(app) {
}
});
- app.delete("/system/remove-document", async (request, response) => {
- try {
- const { name, meta } = reqBody(request);
- await purgeDocument(name, meta);
- response.sendStatus(200).end();
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
+ app.delete(
+ "/system/remove-document",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const { name, meta } = reqBody(request);
+ await purgeDocument(name, meta);
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
}
- });
+ );
- app.get("/system/local-files", async (_, response) => {
+ app.get("/system/local-files", [validatedRequest], async (_, response) => {
try {
const localFiles = await viewLocalFiles();
response.status(200).json({ localFiles });
@@ -120,57 +199,109 @@ function systemEndpoints(app) {
}
});
- app.get("/system/document-processing-status", async (_, response) => {
- try {
- const online = await checkPythonAppAlive();
- response.sendStatus(online ? 200 : 503);
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
- }
- });
-
- app.get("/system/accepted-document-types", async (_, response) => {
- try {
- const types = await acceptedFileTypes();
- if (!types) {
- response.sendStatus(404).end();
- return;
+ app.get(
+ "/system/document-processing-status",
+ [validatedRequest],
+ async (_, response) => {
+ try {
+ const online = await checkPythonAppAlive();
+ response.sendStatus(online ? 200 : 503);
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
}
-
- response.status(200).json({ types });
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
}
- });
+ );
- app.post("/system/update-env", async (request, response) => {
- try {
- const body = reqBody(request);
- const { newValues, error } = updateENV(body);
- response.status(200).json({ newValues, error });
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
+ app.get(
+ "/system/accepted-document-types",
+ [validatedRequest],
+ async (_, response) => {
+ try {
+ const types = await acceptedFileTypes();
+ if (!types) {
+ response.sendStatus(404).end();
+ return;
+ }
+
+ response.status(200).json({ types });
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
}
- });
+ );
- app.post("/system/update-password", async (request, response) => {
- try {
- const { usePassword, newPassword } = reqBody(request);
- const { error } = updateENV({
- AuthToken: usePassword ? newPassword : "",
- JWTSecret: usePassword ? v4() : "",
- });
- response.status(200).json({ success: !error, error });
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
+ app.post(
+ "/system/update-env",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const body = reqBody(request);
+ const { newValues, error } = updateENV(body);
+ response.status(200).json({ newValues, error });
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
}
- });
+ );
- app.get("/system/data-export", async (_, response) => {
+ app.post(
+ "/system/update-password",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const { usePassword, newPassword } = reqBody(request);
+ const { error } = updateENV({
+ AuthToken: usePassword ? newPassword : "",
+ JWTSecret: usePassword ? v4() : "",
+ });
+ response.status(200).json({ success: !error, error });
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/system/enable-multi-user",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const { username, password } = reqBody(request);
+ const multiUserModeEnabled = await SystemSettings.isMultiUserMode();
+ if (multiUserModeEnabled) {
+ response.status(200).json({
+ success: false,
+ error: "Multi-user mode is already enabled.",
+ });
+ return;
+ }
+
+ const { user, error } = await User.create({
+ username,
+ password,
+ role: "admin",
+ });
+ await SystemSettings.updateSettings({
+ multi_user_mode: true,
+ users_can_delete_workspaces: false,
+ limit_user_messages: false,
+ message_limit: 25,
+ });
+ process.env.AUTH_TOKEN = null;
+ process.env.JWT_SECRET = process.env.JWT_SECRET ?? v4(); // Make sure JWT_SECRET is set for JWT issuance.
+ response.status(200).json({ success: !!user, error });
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get("/system/data-export", [validatedRequest], async (_, response) => {
try {
const { filename, error } = await exportData();
response.status(200).json({ filename, error });
@@ -180,18 +311,22 @@ function systemEndpoints(app) {
}
});
- app.get("/system/data-exports/:filename", (request, response) => {
- const filePath =
- __dirname + "/../storage/exports/" + request.params.filename;
- response.download(filePath, request.params.filename, (err) => {
- if (err) {
- response.send({
- error: err,
- msg: "Problem downloading the file",
- });
- }
- });
- });
+ app.get(
+ "/system/data-exports/:filename",
+ [validatedRequest],
+ (request, response) => {
+ const filePath =
+ __dirname + "/../storage/exports/" + request.params.filename;
+ response.download(filePath, request.params.filename, (err) => {
+ if (err) {
+ response.send({
+ error: err,
+ msg: "Problem downloading the file",
+ });
+ }
+ });
+ }
+ );
app.post(
"/system/data-import",
diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js
index 3473c6d3..f103a1c5 100644
--- a/server/endpoints/workspaces.js
+++ b/server/endpoints/workspaces.js
@@ -1,4 +1,4 @@
-const { reqBody } = require("../utils/http");
+const { reqBody, multiUserMode, userFromSession } = require("../utils/http");
const { Workspace } = require("../models/workspace");
const { Document } = require("../models/documents");
const { DocumentVectors } = require("../models/vectors");
@@ -13,15 +13,18 @@ const {
checkPythonAppAlive,
processDocument,
} = require("../utils/files/documentProcessor");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const { SystemSettings } = require("../models/systemSettings");
const { handleUploads } = setupMulter();
function workspaceEndpoints(app) {
if (!app) return;
- app.post("/workspace/new", async (request, response) => {
+ app.post("/workspace/new", [validatedRequest], async (request, response) => {
try {
+ const user = await userFromSession(request, response);
const { name = null } = reqBody(request);
- const { workspace, message } = await Workspace.new(name);
+ const { workspace, message } = await Workspace.new(name, user?.id);
response.status(200).json({ workspace, message });
} catch (e) {
console.log(e.message, e);
@@ -29,27 +32,34 @@ function workspaceEndpoints(app) {
}
});
- app.post("/workspace/:slug/update", async (request, response) => {
- try {
- const { slug = null } = request.params;
- const data = reqBody(request);
- const currWorkspace = await Workspace.get(`slug = '${slug}'`);
+ app.post(
+ "/workspace/:slug/update",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { slug = null } = request.params;
+ const data = reqBody(request);
+ const currWorkspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, `slug = '${slug}'`)
+ : await Workspace.get(`slug = '${slug}'`);
- if (!currWorkspace) {
- response.sendStatus(400).end();
- return;
+ if (!currWorkspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const { workspace, message } = await Workspace.update(
+ currWorkspace.id,
+ data
+ );
+ response.status(200).json({ workspace, message });
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
}
-
- const { workspace, message } = await Workspace.update(
- currWorkspace.id,
- data
- );
- response.status(200).json({ workspace, message });
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
}
- });
+ );
app.post(
"/workspace/:slug/upload",
@@ -81,57 +91,85 @@ function workspaceEndpoints(app) {
}
);
- app.post("/workspace/:slug/update-embeddings", async (request, response) => {
- try {
- const { slug = null } = request.params;
- const { adds = [], deletes = [] } = reqBody(request);
- const currWorkspace = await Workspace.get(`slug = '${slug}'`);
-
- if (!currWorkspace) {
- response.sendStatus(400).end();
- return;
- }
-
- await Document.removeDocuments(currWorkspace, deletes);
- await Document.addDocuments(currWorkspace, adds);
- const updatedWorkspace = await Workspace.get(`slug = '${slug}'`);
- response.status(200).json({ workspace: updatedWorkspace });
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
- }
- });
-
- app.delete("/workspace/:slug", async (request, response) => {
- try {
- const VectorDb = getVectorDbClass();
- const { slug = "" } = request.params;
- const workspace = await Workspace.get(`slug = '${slug}'`);
-
- if (!workspace) {
- response.sendStatus(400).end();
- return;
- }
-
- await Workspace.delete(`slug = '${slug.toLowerCase()}'`);
- await DocumentVectors.deleteForWorkspace(workspace.id);
- await Document.delete(`workspaceId = ${Number(workspace.id)}`);
- await WorkspaceChats.delete(`workspaceId = ${Number(workspace.id)}`);
+ app.post(
+ "/workspace/:slug/update-embeddings",
+ [validatedRequest],
+ async (request, response) => {
try {
- await VectorDb["delete-namespace"]({ namespace: slug });
- } catch (e) {
- console.error(e.message);
- }
- response.sendStatus(200).end();
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
- }
- });
+ const user = await userFromSession(request, response);
+ const { slug = null } = request.params;
+ const { adds = [], deletes = [] } = reqBody(request);
+ const currWorkspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, `slug = '${slug}'`)
+ : await Workspace.get(`slug = '${slug}'`);
- app.get("/workspaces", async (_, response) => {
+ if (!currWorkspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ await Document.removeDocuments(currWorkspace, deletes);
+ await Document.addDocuments(currWorkspace, adds);
+ const updatedWorkspace = await Workspace.get(`slug = '${slug}'`);
+ response.status(200).json({ workspace: updatedWorkspace });
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const { slug = "" } = request.params;
+ const user = await userFromSession(request, response);
+ const VectorDb = getVectorDbClass();
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, `slug = '${slug}'`)
+ : await Workspace.get(`slug = '${slug}'`);
+
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ if (multiUserMode(response) && user.role !== "admin") {
+ const canDelete =
+ (await SystemSettings.get(`label = 'users_can_delete_workspaces'`))
+ ?.value === "true";
+ if (!canDelete) {
+ response.sendStatus(500).end();
+ return;
+ }
+ }
+
+ await Workspace.delete(`slug = '${slug.toLowerCase()}'`);
+ await DocumentVectors.deleteForWorkspace(workspace.id);
+ await Document.delete(`workspaceId = ${Number(workspace.id)}`);
+ await WorkspaceChats.delete(`workspaceId = ${Number(workspace.id)}`);
+ try {
+ await VectorDb["delete-namespace"]({ namespace: slug });
+ } catch (e) {
+ console.error(e.message);
+ }
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get("/workspaces", [validatedRequest], async (request, response) => {
try {
- const workspaces = await Workspace.where();
+ const user = await userFromSession(request, response);
+ const workspaces = multiUserMode(response)
+ ? await Workspace.whereWithUser(user)
+ : await Workspace.where();
+
response.status(200).json({ workspaces });
} catch (e) {
console.log(e.message, e);
@@ -139,10 +177,14 @@ function workspaceEndpoints(app) {
}
});
- app.get("/workspace/:slug", async (request, response) => {
+ app.get("/workspace/:slug", [validatedRequest], async (request, response) => {
try {
const { slug } = request.params;
- const workspace = await Workspace.get(`slug = '${slug}'`);
+ const user = await userFromSession(request, response);
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, `slug = '${slug}'`)
+ : await Workspace.get(`slug = '${slug}'`);
+
response.status(200).json({ workspace });
} catch (e) {
console.log(e.message, e);
@@ -150,22 +192,33 @@ function workspaceEndpoints(app) {
}
});
- app.get("/workspace/:slug/chats", async (request, response) => {
- try {
- const { slug } = request.params;
- const workspace = await Workspace.get(`slug = '${slug}'`);
- if (!workspace) {
- response.sendStatus(400).end();
- return;
- }
+ app.get(
+ "/workspace/:slug/chats",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ const { slug } = request.params;
+ const user = await userFromSession(request, response);
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, `slug = '${slug}'`)
+ : await Workspace.get(`slug = '${slug}'`);
- const history = await WorkspaceChats.forWorkspace(workspace.id);
- response.status(200).json({ history: convertToChatHistory(history) });
- } catch (e) {
- console.log(e.message, e);
- response.sendStatus(500).end();
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const history = multiUserMode(response)
+ ? await WorkspaceChats.forWorkspaceByUser(workspace.id, user.id)
+ : await WorkspaceChats.forWorkspace(workspace.id);
+
+ response.status(200).json({ history: convertToChatHistory(history) });
+ } catch (e) {
+ console.log(e.message, e);
+ response.sendStatus(500).end();
+ }
}
- });
+ );
}
module.exports = { workspaceEndpoints };
diff --git a/server/index.js b/server/index.js
index 83ff994d..0164a03e 100644
--- a/server/index.js
+++ b/server/index.js
@@ -7,13 +7,14 @@ const bodyParser = require("body-parser");
const serveIndex = require("serve-index");
const cors = require("cors");
const path = require("path");
-const { validatedRequest } = require("./utils/middleware/validatedRequest");
const { reqBody } = require("./utils/http");
const { systemEndpoints } = require("./endpoints/system");
const { workspaceEndpoints } = require("./endpoints/workspaces");
const { chatEndpoints } = require("./endpoints/chat");
const { getVectorDbClass } = require("./utils/helpers");
const { validateTablePragmas } = require("./utils/database");
+const { adminEndpoints } = require("./endpoints/admin");
+const { inviteEndpoints } = require("./endpoints/invite");
const app = express();
const apiRouter = express.Router();
@@ -26,12 +27,12 @@ app.use(
})
);
-apiRouter.use("/system/*", validatedRequest);
+app.use("/api", apiRouter);
systemEndpoints(apiRouter);
-
-apiRouter.use("/workspace/*", validatedRequest);
workspaceEndpoints(apiRouter);
chatEndpoints(apiRouter);
+adminEndpoints(apiRouter);
+inviteEndpoints(apiRouter);
apiRouter.post("/v/:command", async (request, response) => {
try {
@@ -61,8 +62,6 @@ apiRouter.post("/v/:command", async (request, response) => {
}
});
-app.use("/api", apiRouter);
-
if (process.env.NODE_ENV !== "development") {
app.use(
express.static(path.resolve(__dirname, "public"), { extensions: ["js"] })
diff --git a/server/models/documents.js b/server/models/documents.js
index 777bd717..0de83bcd 100644
--- a/server/models/documents.js
+++ b/server/models/documents.js
@@ -35,7 +35,7 @@ const Document = {
});
await db.exec(
- `CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
+ `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
);
if (tracing) db.on("trace", (sql) => console.log(sql));
diff --git a/server/models/invite.js b/server/models/invite.js
new file mode 100644
index 00000000..0a945c4f
--- /dev/null
+++ b/server/models/invite.js
@@ -0,0 +1,191 @@
+const Invite = {
+ tablename: "invites",
+ writable: [],
+ colsInit: `
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT UNIQUE NOT NULL,
+ status TEXT NOT NULL DEFAULT "pending",
+ claimedBy INTEGER DEFAULT NULL,
+ createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
+ createdBy INTEGER NOT NULL,
+ lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP
+ `,
+ migrateTable: async function () {
+ const { checkForMigrations } = require("../utils/database");
+ console.log(`\x1b[34m[MIGRATING]\x1b[0m Checking for Invites migrations`);
+ const db = await this.db(false);
+ await checkForMigrations(this, db);
+ },
+ migrations: function () {
+ return [];
+ },
+ makeCode: () => {
+ const uuidAPIKey = require("uuid-apikey");
+ return uuidAPIKey.create().apiKey;
+ },
+ db: async function (tracing = true) {
+ const sqlite3 = require("sqlite3").verbose();
+ const { open } = require("sqlite");
+
+ const db = await open({
+ filename: `${
+ !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "storage/"
+ }anythingllm.db`,
+ driver: sqlite3.Database,
+ });
+
+ await db.exec(
+ `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
+ );
+
+ if (tracing) db.on("trace", (sql) => console.log(sql));
+ return db;
+ },
+ create: async function (createdByUserId = 0) {
+ const db = await this.db();
+ const { id, success, message } = await db
+ .run(`INSERT INTO ${this.tablename} (code, createdBy) VALUES(?, ?)`, [
+ this.makeCode(),
+ createdByUserId,
+ ])
+ .then((res) => {
+ return { id: res.lastID, success: true, message: null };
+ })
+ .catch((error) => {
+ return { id: null, success: false, message: error.message };
+ });
+
+ if (!success) {
+ db.close();
+ console.error("FAILED TO CREATE USER.", message);
+ return { invite: null, error: message };
+ }
+
+ const invite = await db.get(
+ `SELECT * FROM ${this.tablename} WHERE id = ${id} `
+ );
+ db.close();
+
+ return { invite, error: null };
+ },
+ deactivate: async function (inviteId = null) {
+ const invite = await this.get(`id = ${inviteId}`);
+ if (!invite) return { success: false, error: "Invite does not exist." };
+ if (invite.status !== "pending")
+ return { success: false, error: "Invite is not in pending status." };
+
+ const db = await this.db();
+ const { success, message } = await db
+ .run(`UPDATE ${this.tablename} SET status=? WHERE id=?`, [
+ "disabled",
+ inviteId,
+ ])
+ .then(() => {
+ return { success: true, message: null };
+ })
+ .catch((error) => {
+ return { success: false, message: error.message };
+ });
+
+ db.close();
+ if (!success) {
+ console.error(message);
+ return { success: false, error: message };
+ }
+
+ return { success: true, error: null };
+ },
+ markClaimed: async function (inviteId = null, user) {
+ const invite = await this.get(`id = ${inviteId}`);
+ if (!invite) return { success: false, error: "Invite does not exist." };
+ if (invite.status !== "pending")
+ return { success: false, error: "Invite is not in pending status." };
+
+ const db = await this.db();
+ const { success, message } = await db
+ .run(`UPDATE ${this.tablename} SET status=?,claimedBy=? WHERE id=?`, [
+ "claimed",
+ user.id,
+ inviteId,
+ ])
+ .then(() => {
+ return { success: true, message: null };
+ })
+ .catch((error) => {
+ return { success: false, message: error.message };
+ });
+
+ db.close();
+ if (!success) {
+ console.error(message);
+ return { success: false, error: message };
+ }
+
+ return { success: true, error: null };
+ },
+ get: async function (clause = "") {
+ const db = await this.db();
+ const result = await db
+ .get(
+ `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : clause}`
+ )
+ .then((res) => res || null);
+ if (!result) return null;
+ db.close();
+ return { ...result };
+ },
+ count: async function (clause = null) {
+ const db = await this.db();
+ const { count } = await db.get(
+ `SELECT COUNT(*) as count FROM ${this.tablename} ${
+ clause ? `WHERE ${clause}` : ""
+ } `
+ );
+ db.close();
+
+ return count;
+ },
+ delete: async function (clause = "") {
+ const db = await this.db();
+ await db.get(`DELETE FROM ${this.tablename} WHERE ${clause}`);
+ db.close();
+
+ return true;
+ },
+ where: async function (clause = "", limit = null) {
+ const db = await this.db();
+ const results = await db.all(
+ `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${
+ !!limit ? `LIMIT ${limit}` : ""
+ }`
+ );
+ db.close();
+
+ return results;
+ },
+ whereWithUsers: async function (clause = "", limit = null) {
+ const { User } = require("./user");
+ const results = await this.where(clause, limit);
+ for (const invite of results) {
+ console.log(invite);
+ if (!!invite.claimedBy) {
+ const acceptedUser = await User.get(`id = ${invite.claimedBy}`);
+ invite.claimedBy = {
+ id: acceptedUser?.id,
+ username: acceptedUser?.username,
+ };
+ }
+
+ if (!!invite.createdBy) {
+ const createdUser = await User.get(`id = ${invite.createdBy}`);
+ invite.createdBy = {
+ id: createdUser?.id,
+ username: createdUser?.username,
+ };
+ }
+ }
+ return results;
+ },
+};
+
+module.exports.Invite = Invite;
diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js
new file mode 100644
index 00000000..1df81204
--- /dev/null
+++ b/server/models/systemSettings.js
@@ -0,0 +1,122 @@
+const SystemSettings = {
+ supportedFields: [
+ "multi_user_mode",
+ "users_can_delete_workspaces",
+ "limit_user_messages",
+ "message_limit",
+ ],
+ privateField: [],
+ tablename: "system_settings",
+ colsInit: `
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT UNIQUE NOT NULL,
+ value TEXT,
+ createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
+ lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP
+ `,
+ migrateTable: async function () {
+ const { checkForMigrations } = require("../utils/database");
+ console.log(
+ `\x1b[34m[MIGRATING]\x1b[0m Checking for System Setting migrations`
+ );
+ const db = await this.db(false);
+ await checkForMigrations(this, db);
+ },
+ migrations: function () {
+ return [];
+ },
+ db: async function (tracing = true) {
+ const sqlite3 = require("sqlite3").verbose();
+ const { open } = require("sqlite");
+
+ const db = await open({
+ filename: `${
+ !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "storage/"
+ }anythingllm.db`,
+ driver: sqlite3.Database,
+ });
+
+ await db.exec(
+ `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
+ );
+
+ if (tracing) db.on("trace", (sql) => console.log(sql));
+ return db;
+ },
+ get: async function (clause = "") {
+ const db = await this.db();
+ const result = await db
+ .get(`SELECT * FROM ${this.tablename} WHERE ${clause}`)
+ .then((res) => res || null);
+ if (!result) return null;
+ db.close();
+
+ return result;
+ },
+ where: async function (clause = null, limit = null) {
+ const db = await this.db();
+ const results = await db.all(
+ `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${
+ !!limit ? `LIMIT ${limit}` : ""
+ }`
+ );
+ db.close();
+
+ return results;
+ },
+ updateSettings: async function (updates = {}) {
+ const validConfigKeys = Object.keys(updates).filter((key) =>
+ this.supportedFields.includes(key)
+ );
+ for (const key of validConfigKeys) {
+ const existingRecord = await this.get(`label = '${key}'`);
+ if (!existingRecord) {
+ const db = await this.db();
+ const value = updates[key] === null ? null : String(updates[key]);
+ const { success, message } = await db
+ .run(`INSERT INTO ${this.tablename} (label, value) VALUES (?, ?)`, [
+ key,
+ value,
+ ])
+ .then((res) => {
+ return { id: res.lastID, success: true, message: null };
+ })
+ .catch((error) => {
+ return { id: null, success: false, message: error.message };
+ });
+ db.close();
+ if (!success) {
+ console.error("FAILED TO ADD SYSTEM CONFIG OPTION", message);
+ return { success: false, error: message };
+ }
+ } else {
+ const db = await this.db();
+ const value = updates[key] === null ? null : String(updates[key]);
+ const { success, message } = await db
+ .run(`UPDATE ${this.tablename} SET label=?,value=? WHERE id = ?`, [
+ key,
+ value,
+ existingRecord.id,
+ ])
+ .then(() => {
+ return { success: true, message: null };
+ })
+ .catch((error) => {
+ return { success: false, message: error.message };
+ });
+
+ db.close();
+ if (!success) {
+ console.error("FAILED TO UPDATE SYSTEM CONFIG OPTION", message);
+ return { success: false, error: message };
+ }
+ }
+ }
+ return { success: true, error: null };
+ },
+ isMultiUserMode: async function () {
+ return (await this.get(`label = 'multi_user_mode'`))?.value === "true";
+ },
+};
+
+module.exports.SystemSettings = SystemSettings;
diff --git a/server/models/user.js b/server/models/user.js
new file mode 100644
index 00000000..9bfee350
--- /dev/null
+++ b/server/models/user.js
@@ -0,0 +1,170 @@
+const User = {
+ tablename: "users",
+ writable: [],
+ colsInit: `
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE,
+ password TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT "default",
+ suspended INTEGER NOT NULL DEFAULT 0,
+ createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
+ lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP
+ `,
+ migrateTable: async function () {
+ const { checkForMigrations } = require("../utils/database");
+ console.log(`\x1b[34m[MIGRATING]\x1b[0m Checking for User migrations`);
+ const db = await this.db(false);
+ await checkForMigrations(this, db);
+ },
+ migrations: function () {
+ return [];
+ },
+ db: async function (tracing = true) {
+ const sqlite3 = require("sqlite3").verbose();
+ const { open } = require("sqlite");
+
+ const db = await open({
+ filename: `${
+ !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "storage/"
+ }anythingllm.db`,
+ driver: sqlite3.Database,
+ });
+
+ await db.exec(
+ `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
+ );
+
+ if (tracing) db.on("trace", (sql) => console.log(sql));
+ return db;
+ },
+ create: async function ({ username, password, role = null }) {
+ const bcrypt = require("bcrypt");
+ const db = await this.db();
+ const { id, success, message } = await db
+ .run(
+ `INSERT INTO ${this.tablename} (username, password, role) VALUES(?, ?, ?)`,
+ [username, bcrypt.hashSync(password, 10), role ?? "default"]
+ )
+ .then((res) => {
+ return { id: res.lastID, success: true, message: null };
+ })
+ .catch((error) => {
+ return { id: null, success: false, message: error.message };
+ });
+
+ if (!success) {
+ db.close();
+ console.error("FAILED TO CREATE USER.", message);
+ return { user: null, error: message };
+ }
+
+ const user = await db.get(
+ `SELECT * FROM ${this.tablename} WHERE id = ${id} `
+ );
+ db.close();
+
+ return { user, error: null };
+ },
+ update: async function (userId, updates = {}) {
+ const user = await this.get(`id = ${userId}`);
+ if (!user) return { success: false, error: "User does not exist." };
+ const { username, password, role, suspended = 0 } = updates;
+ const toUpdate = { suspended };
+
+ if (user.username !== username && username?.length > 0) {
+ const usedUsername = !!(await this.get(`username = '${username}'`));
+ if (usedUsername)
+ return { success: false, error: `${username} is already in use.` };
+ toUpdate.username = username;
+ }
+
+ if (!!password) {
+ const bcrypt = require("bcrypt");
+ toUpdate.password = bcrypt.hashSync(password, 10);
+ }
+
+ if (user.role !== role && ["admin", "default"].includes(role)) {
+ // If was existing admin and that has been changed
+ // make sure at least one admin exists
+ if (user.role === "admin") {
+ const validAdminCount = (await this.count(`role = 'admin'`)) > 1;
+ if (!validAdminCount)
+ return {
+ success: false,
+ error: `There would be no admins if this action was completed. There must be at least one admin.`,
+ };
+ }
+
+ toUpdate.role = role;
+ }
+
+ if (Object.keys(toUpdate).length !== 0) {
+ const values = Object.values(toUpdate);
+ const template = `UPDATE ${this.tablename} SET ${Object.keys(
+ toUpdate
+ ).map((key) => {
+ return `${key}=?`;
+ })} WHERE id = ?`;
+
+ const db = await this.db();
+ const { success, message } = await db
+ .run(template, [...values, userId])
+ .then(() => {
+ return { success: true, message: null };
+ })
+ .catch((error) => {
+ return { success: false, message: error.message };
+ });
+
+ db.close();
+ if (!success) {
+ console.error(message);
+ return { success: false, error: message };
+ }
+ }
+
+ return { success: true, error: null };
+ },
+ get: async function (clause = "") {
+ const db = await this.db();
+ const result = await db
+ .get(
+ `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : clause}`
+ )
+ .then((res) => res || null);
+ if (!result) return null;
+ db.close();
+ return { ...result };
+ },
+ count: async function (clause = null) {
+ const db = await this.db();
+ const { count } = await db.get(
+ `SELECT COUNT(*) as count FROM ${this.tablename} ${
+ clause ? `WHERE ${clause}` : ""
+ } `
+ );
+ db.close();
+
+ return count;
+ },
+ delete: async function (clause = "") {
+ const db = await this.db();
+ await db.get(`DELETE FROM ${this.tablename} WHERE ${clause}`);
+ db.close();
+
+ return true;
+ },
+ where: async function (clause = "", limit = null) {
+ const db = await this.db();
+ const results = await db.all(
+ `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${
+ !!limit ? `LIMIT ${limit}` : ""
+ }`
+ );
+ db.close();
+
+ return results;
+ },
+};
+
+module.exports = { User };
diff --git a/server/models/vectors.js b/server/models/vectors.js
index 9e1a8dd4..e568097b 100644
--- a/server/models/vectors.js
+++ b/server/models/vectors.js
@@ -34,7 +34,7 @@ const DocumentVectors = {
});
await db.exec(
- `CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
+ `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
);
if (tracing) db.on("trace", (sql) => console.log(sql));
diff --git a/server/models/workspace.js b/server/models/workspace.js
index fed92434..5049a2ba 100644
--- a/server/models/workspace.js
+++ b/server/models/workspace.js
@@ -1,6 +1,7 @@
const slugify = require("slugify");
const { Document } = require("./documents");
const { checkForMigrations } = require("../utils/database");
+const { WorkspaceUser } = require("./workspaceUsers");
const Workspace = {
tablename: "workspaces",
@@ -70,13 +71,13 @@ const Workspace = {
});
await db.exec(
- `CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
+ `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
);
if (tracing) db.on("trace", (sql) => console.log(sql));
return db;
},
- new: async function (name = null) {
+ new: async function (name = null, creatorId = null) {
if (!name) return { result: null, message: "name cannot be null" };
var slug = slugify(name, { lower: true });
@@ -109,6 +110,10 @@ const Workspace = {
);
db.close();
+ // If created with a user then we need to create the relationship as well.
+ // If creating with an admin User it wont change anything because admins can
+ // view all workspaces anyway.
+ if (!!creatorId) await WorkspaceUser.create(creatorId, workspace.id);
return { workspace, message: null };
},
update: async function (id = null, data = {}) {
@@ -142,6 +147,25 @@ const Workspace = {
const updatedWorkspace = await this.get(`id = ${id}`);
return { workspace: updatedWorkspace, message: null };
},
+ getWithUser: async function (user = null, clause = "") {
+ if (user.role === "admin") return this.get(clause);
+
+ const db = await this.db();
+ const result = await db
+ .get(
+ `SELECT * FROM ${this.tablename} as workspace
+ LEFT JOIN workspace_users as ws_users
+ ON ws_users.workspace_id = workspace.id
+ WHERE ws_users.user_id = ${user?.id} AND ${clause}`
+ )
+ .then((res) => res || null);
+ if (!result) return null;
+ db.close();
+
+ const workspace = { ...result, id: result.workspace_id };
+ const documents = await Document.forWorkspace(workspace.id);
+ return { ...workspace, documents };
+ },
get: async function (clause = "") {
const db = await this.db();
const result = await db
@@ -160,17 +184,55 @@ const Workspace = {
return true;
},
- where: async function (clause = "", limit = null) {
+ where: async function (clause = "", limit = null, orderBy = null) {
const db = await this.db();
const results = await db.all(
`SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${
!!limit ? `LIMIT ${limit}` : ""
- }`
+ } ${!!orderBy ? orderBy : ""}`
);
db.close();
return results;
},
+ whereWithUser: async function (
+ user,
+ clause = null,
+ limit = null,
+ orderBy = null
+ ) {
+ if (user.role === "admin") return await this.where(clause, limit);
+ const db = await this.db();
+ const results = await db.all(
+ `SELECT * FROM ${this.tablename} as workspace
+ LEFT JOIN workspace_users as ws_users
+ ON ws_users.workspace_id = workspace.id
+ WHERE ws_users.user_id = ${user.id} ${clause ? `AND ${clause}` : ""} ${
+ !!limit ? `LIMIT ${limit}` : ""
+ } ${!!orderBy ? orderBy : ""}`
+ );
+ db.close();
+ const workspaces = results.map((ws) => {
+ return { ...ws, id: ws.workspace_id };
+ });
+
+ return workspaces;
+ },
+ whereWithUsers: async function (clause = "", limit = null, orderBy = null) {
+ const workspaces = await this.where(clause, limit, orderBy);
+ for (const workspace of workspaces) {
+ const userIds = (
+ await WorkspaceUser.where(`workspace_id = ${workspace.id}`)
+ ).map((rel) => rel.user_id);
+ workspace.userIds = userIds;
+ }
+ return workspaces;
+ },
+ updateUsers: async function (workspaceId, userIds = []) {
+ await WorkspaceUser.delete(`workspace_id = ${workspaceId}`);
+ await WorkspaceUser.createManyUsers(userIds, workspaceId);
+ return { success: true, error: null };
+ },
};
module.exports = { Workspace };
diff --git a/server/models/workspaceChats.js b/server/models/workspaceChats.js
index 7a2aafb8..027448bf 100644
--- a/server/models/workspaceChats.js
+++ b/server/models/workspaceChats.js
@@ -8,8 +8,11 @@ const WorkspaceChats = {
prompt TEXT NOT NULL,
response TEXT NOT NULL,
include BOOL DEFAULT true,
+ user_id INTEGER DEFAULT NULL,
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
- lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP
+ lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP,
+
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
`,
migrateTable: async function () {
console.log(
@@ -19,7 +22,13 @@ const WorkspaceChats = {
await checkForMigrations(this, db);
},
migrations: function () {
- return [];
+ return [
+ {
+ colName: "user_id",
+ execCmd: `ALTER TABLE ${this.tablename} ADD COLUMN user_id INTEGER DEFAULT NULL`,
+ doif: false,
+ },
+ ];
},
db: async function (tracing = true) {
const sqlite3 = require("sqlite3").verbose();
@@ -33,18 +42,18 @@ const WorkspaceChats = {
});
await db.exec(
- `CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
+ `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
);
if (tracing) db.on("trace", (sql) => console.log(sql));
return db;
},
- new: async function ({ workspaceId, prompt, response = {} }) {
+ new: async function ({ workspaceId, prompt, response = {}, user = null }) {
const db = await this.db();
const { id, success, message } = await db
.run(
- `INSERT INTO ${this.tablename} (workspaceId, prompt, response) VALUES (?, ?, ?)`,
- [workspaceId, prompt, JSON.stringify(response)]
+ `INSERT INTO ${this.tablename} (workspaceId, prompt, response, user_id) VALUES (?, ?, ?, ?)`,
+ [workspaceId, prompt, JSON.stringify(response), user?.id || null]
)
.then((res) => {
return { id: res.lastID, success: true, message: null };
@@ -64,6 +73,18 @@ const WorkspaceChats = {
return { chat, message: null };
},
+ forWorkspaceByUser: async function (
+ workspaceId = null,
+ userId = null,
+ limit = null
+ ) {
+ if (!workspaceId || !userId) return [];
+ return await this.where(
+ `workspaceId = ${workspaceId} AND include = true AND user_id = ${userId}`,
+ limit,
+ "ORDER BY id ASC"
+ );
+ },
forWorkspace: async function (workspaceId = null, limit = null) {
if (!workspaceId) return [];
return await this.where(
@@ -72,21 +93,27 @@ const WorkspaceChats = {
"ORDER BY id ASC"
);
},
- markHistoryInvalid: async function (workspaceId = null) {
+ markHistoryInvalid: async function (workspaceId = null, user = null) {
if (!workspaceId) return;
const db = await this.db();
await db.run(
- `UPDATE ${this.tablename} SET include = false WHERE workspaceId = ?`,
+ `UPDATE ${this.tablename} SET include = false WHERE workspaceId = ? ${
+ user ? `AND user_id = ${user.id}` : ""
+ }`,
[workspaceId]
);
db.close();
return;
},
- get: async function (clause = "") {
+ get: async function (clause = "", limit = null, order = null) {
const db = await this.db();
const result = await db
- .get(`SELECT * FROM ${this.tablename} WHERE ${clause}`)
+ .get(
+ `SELECT * FROM ${this.tablename} WHERE ${clause} ${
+ !!order ? order : ""
+ } ${!!limit ? `LIMIT ${limit}` : ""}`
+ )
.then((res) => res || null);
db.close();
@@ -105,12 +132,40 @@ const WorkspaceChats = {
const results = await db.all(
`SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${
!!order ? order : ""
- } ${!!limit ? `LIMIT ${limit}` : ""} `
+ } ${!!limit ? `LIMIT ${limit}` : ""}`
);
db.close();
return results;
},
+ count: async function (clause = null) {
+ const db = await this.db();
+ const { count } = await db.get(
+ `SELECT COUNT(*) as count FROM ${this.tablename} ${
+ clause ? `WHERE ${clause}` : ""
+ } `
+ );
+ db.close();
+
+ return count;
+ },
+ whereWithData: async function (clause = "", limit = null, order = null) {
+ const { Workspace } = require("./workspace");
+ const { User } = require("./user");
+ const results = await this.where(clause, limit, order);
+ for (const res of results) {
+ const workspace = await Workspace.get(`id = ${res.workspaceId}`);
+ res.workspace = workspace
+ ? { name: workspace.name, slug: workspace.slug }
+ : { name: "deleted workspace", slug: null };
+
+ const user = await User.get(`id = ${res.user_id}`);
+ res.user = user
+ ? { username: user.username }
+ : { username: "deleted user" };
+ }
+ return results;
+ },
};
module.exports = { WorkspaceChats };
diff --git a/server/models/workspaceUsers.js b/server/models/workspaceUsers.js
new file mode 100644
index 00000000..8dacbac1
--- /dev/null
+++ b/server/models/workspaceUsers.js
@@ -0,0 +1,132 @@
+const WorkspaceUser = {
+ tablename: "workspace_users",
+ colsInit: `
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ workspace_id INTEGER NOT NULL,
+ createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
+ lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP,
+
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
+ `,
+ migrateTable: async function () {
+ const { checkForMigrations } = require("../utils/database");
+ console.log(
+ `\x1b[34m[MIGRATING]\x1b[0m Checking for Workspace User migrations`
+ );
+ const db = await this.db(false);
+ await checkForMigrations(this, db);
+ },
+ migrations: function () {
+ return [];
+ },
+ db: async function (tracing = true) {
+ const sqlite3 = require("sqlite3").verbose();
+ const { open } = require("sqlite");
+
+ const db = await open({
+ filename: `${
+ !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "storage/"
+ }anythingllm.db`,
+ driver: sqlite3.Database,
+ });
+
+ await db.exec(
+ `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})`
+ );
+
+ if (tracing) db.on("trace", (sql) => console.log(sql));
+ return db;
+ },
+ createMany: async function (userId, workspaceIds = []) {
+ if (workspaceIds.length === 0) return;
+ const db = await this.db();
+ const stmt = await db.prepare(
+ `INSERT INTO ${this.tablename} (user_id, workspace_id) VALUES (?,?)`
+ );
+
+ for (const workspaceId of workspaceIds) {
+ stmt.run([userId, workspaceId]);
+ }
+
+ stmt.finalize();
+ db.close();
+ return;
+ },
+ createManyUsers: async function (userIds = [], workspaceId) {
+ if (userIds.length === 0) return;
+ const db = await this.db();
+ const stmt = await db.prepare(
+ `INSERT INTO ${this.tablename} (user_id, workspace_id) VALUES (?,?)`
+ );
+
+ for (const userId of userIds) {
+ stmt.run([userId, workspaceId]);
+ }
+
+ stmt.finalize();
+ db.close();
+ return;
+ },
+ create: async function (userId = 0, workspaceId = 0) {
+ const db = await this.db();
+ const { success, message } = await db
+ .run(
+ `INSERT INTO ${this.tablename} (user_id, workspace_id) VALUES (?, ?)`,
+ [userId, workspaceId]
+ )
+ .then((res) => {
+ return { id: res.lastID, success: true, message: null };
+ })
+ .catch((error) => {
+ return { id: null, success: false, message: error.message };
+ });
+
+ if (!success) {
+ db.close();
+ console.error("FAILED TO CREATE WORKSPACE_USER RELATIONSHIP.", message);
+ return false;
+ }
+ return true;
+ },
+ get: async function (clause = "") {
+ const db = await this.db();
+ const result = await db
+ .get(`SELECT * FROM ${this.tablename} WHERE ${clause}`)
+ .then((res) => res || null);
+ if (!result) return null;
+ db.close();
+
+ return result;
+ },
+ where: async function (clause = null, limit = null) {
+ const db = await this.db();
+ const results = await db.all(
+ `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${
+ !!limit ? `LIMIT ${limit}` : ""
+ }`
+ );
+ db.close();
+
+ return results;
+ },
+ count: async function (clause = null) {
+ const db = await this.db();
+ const { count } = await db.get(
+ `SELECT COUNT(*) as count FROM ${this.tablename} ${
+ clause ? `WHERE ${clause}` : ""
+ }`
+ );
+ db.close();
+
+ return count;
+ },
+ delete: async function (clause = null) {
+ const db = await this.db();
+ await db.get(`DELETE FROM ${this.tablename} WHERE ${clause}`);
+ return;
+ },
+};
+
+module.exports.WorkspaceUser = WorkspaceUser;
diff --git a/server/package.json b/server/package.json
index c9cc4d20..24b84210 100644
--- a/server/package.json
+++ b/server/package.json
@@ -18,6 +18,7 @@
"@googleapis/youtube": "^9.0.0",
"@pinecone-database/pinecone": "^0.1.6",
"archiver": "^5.3.1",
+ "bcrypt": "^5.1.0",
"body-parser": "^1.20.2",
"chromadb": "^1.5.2",
"cors": "^2.8.5",
@@ -35,6 +36,7 @@
"sqlite": "^4.2.1",
"sqlite3": "^5.1.6",
"uuid": "^9.0.0",
+ "uuid-apikey": "^1.5.3",
"vectordb": "0.1.12"
},
"devDependencies": {
diff --git a/server/utils/chats/commands/reset.js b/server/utils/chats/commands/reset.js
index 59f9448e..8851efdf 100644
--- a/server/utils/chats/commands/reset.js
+++ b/server/utils/chats/commands/reset.js
@@ -1,7 +1,7 @@
const { WorkspaceChats } = require("../../../models/workspaceChats");
-async function resetMemory(workspace, _message, msgUUID) {
- await WorkspaceChats.markHistoryInvalid(workspace.id);
+async function resetMemory(workspace, _message, msgUUID, user = null) {
+ await WorkspaceChats.markHistoryInvalid(workspace.id, user);
return {
uuid: msgUUID,
type: "textResponse",
diff --git a/server/utils/chats/index.js b/server/utils/chats/index.js
index 800003e2..5e099dad 100644
--- a/server/utils/chats/index.js
+++ b/server/utils/chats/index.js
@@ -59,14 +59,19 @@ function grepCommand(message) {
return null;
}
-async function chatWithWorkspace(workspace, message, chatMode = "chat") {
+async function chatWithWorkspace(
+ workspace,
+ message,
+ chatMode = "chat",
+ user = null
+) {
const uuid = uuidv4();
const openai = new OpenAi();
const VectorDb = getVectorDbClass();
const command = grepCommand(message);
if (!!command && Object.keys(VALID_COMMANDS).includes(command)) {
- return await VALID_COMMANDS[command](workspace, message, uuid);
+ return await VALID_COMMANDS[command](workspace, message, uuid, user);
}
const { safe, reasons = [] } = await openai.isSafe(message);
@@ -84,7 +89,8 @@ async function chatWithWorkspace(workspace, message, chatMode = "chat") {
}
const hasVectorizedSpace = await VectorDb.hasNamespace(workspace.slug);
- if (!hasVectorizedSpace) {
+ const embeddingsCount = await VectorDb.namespaceCount(workspace.slug);
+ if (!hasVectorizedSpace || embeddingsCount === 0) {
const rawHistory = await WorkspaceChats.forWorkspace(workspace.id);
const chatHistory = convertToPromptHistory(rawHistory);
const response = await openai.sendChat(chatHistory, message, workspace);
@@ -94,6 +100,7 @@ async function chatWithWorkspace(workspace, message, chatMode = "chat") {
workspaceId: workspace.id,
prompt: message,
response: data,
+ user,
});
return {
id: uuid,
@@ -137,6 +144,7 @@ async function chatWithWorkspace(workspace, message, chatMode = "chat") {
workspaceId: workspace.id,
prompt: message,
response: data,
+ user,
});
return {
id: uuid,
diff --git a/server/utils/database/index.js b/server/utils/database/index.js
index 0b1f42bd..e3b658a5 100644
--- a/server/utils/database/index.js
+++ b/server/utils/database/index.js
@@ -50,15 +50,23 @@ async function validateTablePragmas(force = false) {
);
return;
}
-
+ const { SystemSettings } = require("../../models/systemSettings");
+ const { User } = require("../../models/user");
const { Workspace } = require("../../models/workspace");
+ const { WorkspaceUser } = require("../../models/workspaceUsers");
const { Document } = require("../../models/documents");
const { DocumentVectors } = require("../../models/vectors");
const { WorkspaceChats } = require("../../models/workspaceChats");
+ const { Invite } = require("../../models/invite");
+
+ await SystemSettings.migrateTable();
+ await User.migrateTable();
await Workspace.migrateTable();
+ await WorkspaceUser.migrateTable();
await Document.migrateTable();
await DocumentVectors.migrateTable();
await WorkspaceChats.migrateTable();
+ await Invite.migrateTable();
} catch (e) {
console.error(`validateTablePragmas: Migrations failed`, e);
}
diff --git a/server/utils/http/index.js b/server/utils/http/index.js
index 9fd643b7..3543a36b 100644
--- a/server/utils/http/index.js
+++ b/server/utils/http/index.js
@@ -2,6 +2,7 @@ process.env.NODE_ENV === "development"
? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` })
: require("dotenv").config();
const JWT = require("jsonwebtoken");
+const { User } = require("../../models/user");
function reqBody(request) {
return typeof request.body === "string"
@@ -19,16 +20,43 @@ function makeJWT(info = {}, expiry = "30d") {
return JWT.sign(info, process.env.JWT_SECRET, { expiresIn: expiry });
}
+async function userFromSession(request, response = null) {
+ if (!!response && !!response.locals?.user) {
+ return response.locals.user;
+ }
+
+ const auth = request.header("Authorization");
+ const token = auth ? auth.split(" ")[1] : null;
+
+ if (!token) {
+ return null;
+ }
+
+ const valid = decodeJWT(token);
+ if (!valid || !valid.id) {
+ return null;
+ }
+
+ const user = await User.get(`id = ${valid.id}`);
+ return user;
+}
+
function decodeJWT(jwtToken) {
try {
return JWT.verify(jwtToken, process.env.JWT_SECRET);
} catch {}
- return { p: null };
+ return { p: null, id: null, username: null };
+}
+
+function multiUserMode(response) {
+ return response?.locals?.multiUserMode;
}
module.exports = {
reqBody,
+ multiUserMode,
queryParams,
makeJWT,
decodeJWT,
+ userFromSession,
};
diff --git a/server/utils/middleware/validatedRequest.js b/server/utils/middleware/validatedRequest.js
index 4e7c519a..1ff13f3d 100644
--- a/server/utils/middleware/validatedRequest.js
+++ b/server/utils/middleware/validatedRequest.js
@@ -1,6 +1,13 @@
+const { SystemSettings } = require("../../models/systemSettings");
+const { User } = require("../../models/user");
const { decodeJWT } = require("../http");
-function validatedRequest(request, response, next) {
+async function validatedRequest(request, response, next) {
+ const multiUserMode = await SystemSettings.isMultiUserMode();
+ response.locals.multiUserMode = multiUserMode;
+ if (multiUserMode)
+ return await validateMultiUserRequest(request, response, next);
+
// When in development passthrough auth token for ease of development.
// Or if the user simply did not set an Auth token or JWT Secret
if (
@@ -40,6 +47,37 @@ function validatedRequest(request, response, next) {
next();
}
+async function validateMultiUserRequest(request, response, next) {
+ const auth = request.header("Authorization");
+ const token = auth ? auth.split(" ")[1] : null;
+
+ if (!token) {
+ response.status(403).json({
+ error: "No auth token found.",
+ });
+ return;
+ }
+
+ const valid = decodeJWT(token);
+ if (!valid || !valid.id) {
+ response.status(403).json({
+ error: "Invalid auth token.",
+ });
+ return;
+ }
+
+ const user = await User.get(`id = ${valid.id}`);
+ if (!user) {
+ response.status(403).json({
+ error: "Invalid auth for user.",
+ });
+ return;
+ }
+
+ response.locals.user = user;
+ next();
+}
+
module.exports = {
validatedRequest,
};
diff --git a/server/utils/vectorDbProviders/chroma/index.js b/server/utils/vectorDbProviders/chroma/index.js
index 863b6f13..801a41db 100644
--- a/server/utils/vectorDbProviders/chroma/index.js
+++ b/server/utils/vectorDbProviders/chroma/index.js
@@ -44,6 +44,11 @@ const Chroma = {
}
return totalVectors;
},
+ namespaceCount: async function (_namespace = null) {
+ const { client } = await this.connect();
+ const namespace = await this.namespace(client, _namespace);
+ return namespace?.vectorCount || 0;
+ },
embeddingFunc: function () {
return new OpenAIEmbeddingFunction({
openai_api_key: process.env.OPEN_AI_KEY,
diff --git a/server/utils/vectorDbProviders/lance/index.js b/server/utils/vectorDbProviders/lance/index.js
index 3315028a..ddc18469 100644
--- a/server/utils/vectorDbProviders/lance/index.js
+++ b/server/utils/vectorDbProviders/lance/index.js
@@ -58,6 +58,14 @@ const LanceDb = {
}
return count;
},
+ namespaceCount: async function (_namespace = null) {
+ const { client } = await this.connect();
+ const exists = await this.namespaceExists(client, _namespace);
+ if (!exists) return 0;
+
+ const table = await client.openTable(_namespace);
+ return (await table.countRows()) || 0;
+ },
embeddingFunc: function () {
return new lancedb.OpenAIEmbeddingFunction(
"context",
diff --git a/server/utils/vectorDbProviders/pinecone/index.js b/server/utils/vectorDbProviders/pinecone/index.js
index 0c03e75b..e34391b1 100644
--- a/server/utils/vectorDbProviders/pinecone/index.js
+++ b/server/utils/vectorDbProviders/pinecone/index.js
@@ -86,6 +86,11 @@ const Pinecone = {
0
);
},
+ namespaceCount: async function (_namespace = null) {
+ const { pineconeIndex } = await this.connect();
+ const namespace = await this.namespace(pineconeIndex, _namespace);
+ return namespace?.vectorCount || 0;
+ },
similarityResponse: async function (index, namespace, queryVector) {
const result = {
contextTexts: [],
diff --git a/server/yarn.lock b/server/yarn.lock
index d5cef639..1a82497e 100644
--- a/server/yarn.lock
+++ b/server/yarn.lock
@@ -43,7 +43,7 @@
dependencies:
googleapis-common "^6.0.3"
-"@mapbox/node-pre-gyp@^1.0.0":
+"@mapbox/node-pre-gyp@^1.0.0", "@mapbox/node-pre-gyp@^1.0.10":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa"
integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==
@@ -308,6 +308,14 @@ batch@0.6.1:
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==
+bcrypt@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.0.tgz#bbb27665dbc400480a524d8991ac7434e8529e17"
+ integrity sha512-RHBS7HI5N5tEnGTmtR/pppX0mmDSBpQ4aCBsj7CEQfYXDcO74A8sIBYcJMuCsis2E81zDxeENYhv66oZwLiA+Q==
+ dependencies:
+ "@mapbox/node-pre-gyp" "^1.0.10"
+ node-addon-api "^5.0.0"
+
bignumber.js@^9.0.0:
version "9.1.1"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6"
@@ -515,6 +523,11 @@ color-support@^1.1.2, color-support@^1.1.3:
resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
+colors@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
+ integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
+
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@@ -542,6 +555,11 @@ command-line-usage@6.1.3:
table-layout "^1.0.2"
typical "^5.2.0"
+commander@^8.0.0:
+ version "8.3.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
+ integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==
+
compress-commons@^4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.1.tgz#df2a09a7ed17447642bad10a85cc9a19e5c42a7d"
@@ -705,6 +723,11 @@ emoji-regex@^8.0.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+encode32@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/encode32/-/encode32-1.1.0.tgz#0c54b45fb314ad5502e3c230cb95acdc5e5cd1dd"
+ integrity sha512-BCmijZ4lWec5+fuGHclf7HSZf+mos2ncQkBjtvomvRWVEGAMI/tw56fuN2x4e+FTgQuTPbZjODPwX80lFy958w==
+
encodeurl@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
@@ -1654,6 +1677,11 @@ node-addon-api@^4.2.0:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f"
integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==
+node-addon-api@^5.0.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762"
+ integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==
+
node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7, node-fetch@^2.6.9:
version "2.6.12"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba"
@@ -2340,6 +2368,21 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
+uuid-apikey@^1.5.3:
+ version "1.5.3"
+ resolved "https://registry.yarnpkg.com/uuid-apikey/-/uuid-apikey-1.5.3.tgz#2e5d648dce93d2909018d7b73ec26ecb9fd2cdbd"
+ integrity sha512-v28vGJ1hRDzqLm6ufZ7b098Kmk159PInIHYWXfB47r3xOACZ5nRIAWe9VxFjvSW0MwckQYAnS1ucWUAXGKo95w==
+ dependencies:
+ colors "^1.4.0"
+ commander "^8.0.0"
+ encode32 "^1.1.0"
+ uuid "^8.3.1"
+
+uuid@^8.3.1:
+ version "8.3.2"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
+ integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
+
uuid@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"