diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ca66bdc95ac987003efd4dbc8517cddfc907ce60..30168aafca6a6311a1785f1cdb88fd891fab500d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,8 @@ 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")); +const AdminAppearance = lazy(() => import("./pages/Admin/Appearance")); +const Appearance = lazy(() => import("./pages/System/Appearance")); export default function App() { return ( @@ -18,6 +20,7 @@ export default function App() { <ContextWrapper> <Routes> <Route path="/" element={<Main />} /> + <Route path="/system/appearance" element={<Appearance />} /> <Route path="/workspace/:slug" element={<PrivateRoute Component={WorkspaceChat} />} @@ -45,6 +48,10 @@ export default function App() { path="/admin/workspace-chats" element={<AdminRoute Component={AdminChats} />} /> + <Route + path="/admin/appearance" + element={<AdminRoute Component={AdminAppearance} />} + /> </Routes> </ContextWrapper> </Suspense> diff --git a/frontend/src/components/AdminSidebar/index.jsx b/frontend/src/components/AdminSidebar/index.jsx index 6c8b8f8c8fda53fadf667d33e3da71a0c9ec72c5..61402d66cf298056969d643a8206bdb559aa7c79 100644 --- a/frontend/src/components/AdminSidebar/index.jsx +++ b/frontend/src/components/AdminSidebar/index.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { BookOpen, - Database, + Eye, GitHub, Mail, Menu, @@ -14,9 +14,12 @@ import IndexCount from "../Sidebar/IndexCount"; import LLMStatus from "../Sidebar/LLMStatus"; import paths from "../../utils/paths"; import Discord from "../Icons/Discord"; +import useLogo from "../../hooks/useLogo"; export default function AdminSidebar() { + const { logo } = useLogo(); const sidebarRef = useRef(null); + return ( <> <div @@ -27,9 +30,14 @@ export default function AdminSidebar() { <div className="w-full h-full flex flex-col overflow-x-hidden items-between"> {/* Header Information */} <div className="flex w-full items-center justify-between"> - <p className="text-xl font-base text-slate-600 dark:text-slate-200"> - AnythingLLM Admin - </p> + <div className="flex shrink-0 max-w-[50%] items-center justify-start"> + <img + src={logo} + alt="Logo" + className="rounded max-h-[40px]" + style={{ objectFit: "contain" }} + /> + </div> <div className="flex gap-x-2 items-center text-slate-500"> <a href={paths.home()} @@ -69,6 +77,11 @@ export default function AdminSidebar() { btnText="Workspace Chat Management" icon={<MessageSquare className="h-4 w-4 flex-shrink-0" />} /> + <Option + href={paths.admin.appearance()} + btnText="Appearance" + icon={<Eye className="h-4 w-4 flex-shrink-0" />} + /> </div> </div> <div> @@ -117,6 +130,7 @@ export default function AdminSidebar() { } export function SidebarMobileHeader() { + const { logo } = useLogo(); const sidebarRef = useRef(null); const [showSidebar, setShowSidebar] = useState(false); const [showBgOverlay, setShowBgOverlay] = useState(false); @@ -143,9 +157,14 @@ export function SidebarMobileHeader() { > <Menu className="h-6 w-6" /> </button> - <p className="text-xl font-base text-slate-600 dark:text-slate-200"> - AnythingLLM - </p> + <div className="flex shrink-0 w-fit items-center justify-start"> + <img + src={logo} + alt="Logo" + className="rounded w-full max-h-[40px]" + style={{ objectFit: "contain" }} + /> + </div> </div> <div style={{ @@ -168,9 +187,14 @@ export function SidebarMobileHeader() { <div className="w-full h-full flex flex-col overflow-x-hidden items-between"> {/* Header Information */} <div className="flex w-full items-center justify-between"> - <p className="text-xl font-base text-slate-600 dark:text-slate-200"> - AnythingLLM Admin - </p> + <div className="flex shrink-0 w-fit items-center justify-start"> + <img + src={logo} + alt="Logo" + className="rounded w-full max-h-[40px]" + style={{ objectFit: "contain" }} + /> + </div> <div className="flex gap-x-2 items-center text-slate-500"> <a href={paths.home()} @@ -188,11 +212,36 @@ export function SidebarMobileHeader() { style={{ height: "calc(100vw - -3rem)" }} className=" flex flex-col gap-y-4 pb-8 overflow-y-scroll no-scroll" > + <Option + href={paths.admin.system()} + btnText="System Preferences" + icon={<Settings className="h-4 w-4 flex-shrink-0" />} + /> + <Option + href={paths.admin.invites()} + btnText="Invitation Management" + icon={<Mail className="h-4 w-4 flex-shrink-0" />} + /> <Option href={paths.admin.users()} btnText="User Management" icon={<Users className="h-4 w-4 flex-shrink-0" />} /> + <Option + href={paths.admin.workspaces()} + btnText="Workspace Management" + icon={<BookOpen className="h-4 w-4 flex-shrink-0" />} + /> + <Option + href={paths.admin.chats()} + btnText="Workspace Chat Management" + icon={<MessageSquare className="h-4 w-4 flex-shrink-0" />} + /> + <Option + href={paths.admin.appearance()} + btnText="Appearance" + icon={<Eye className="h-4 w-4 flex-shrink-0" />} + /> </div> </div> <div> diff --git a/frontend/src/components/Modals/Settings/index.jsx b/frontend/src/components/Modals/Settings/index.jsx index f644c5e19e1e474b1ba0a61c20f6436dd843cfc9..0814b1d40118431806c87becb86fb4c186ca75f0 100644 --- a/frontend/src/components/Modals/Settings/index.jsx +++ b/frontend/src/components/Modals/Settings/index.jsx @@ -6,6 +6,7 @@ import { Users, Database, MessageSquare, + Eye, } from "react-feather"; import ExportOrImportData from "./ExportImport"; import PasswordProtection from "./PasswordProtection"; @@ -14,6 +15,7 @@ import MultiUserMode from "./MultiUserMode"; import useUser from "../../../hooks/useUser"; import VectorDBSelection from "./VectorDbs"; import LLMSelection from "./LLMSelection"; +import paths from "../../../utils/paths"; const TABS = { llm: LLMSelection, @@ -130,6 +132,12 @@ function SettingTabs({ selectedTab, changeTab, settings, user }) { icon={<Lock className="h-4 w-4 flex-shrink-0" />} onClick={changeTab} /> + <SettingTab + displayName="Appearance" + tabName="appearance" + icon={<Eye className="h-4 w-4 flex-shrink-0" />} + onClick={() => window.open(paths.appearance())} + /> </> )} </ul> diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx index 4a074a345dd7a316f90d106744c429c7d8ad125c..9b3068ddf8fea7722b437d79ab36f9aefacef5e0 100644 --- a/frontend/src/components/Sidebar/index.jsx +++ b/frontend/src/components/Sidebar/index.jsx @@ -26,8 +26,10 @@ import Discord from "../Icons/Discord"; import useUser from "../../hooks/useUser"; import { userFromStorage } from "../../utils/request"; import { AUTH_TOKEN, AUTH_USER } from "../../utils/constants"; +import useLogo from "../../hooks/useLogo"; export default function Sidebar() { + const { logo } = useLogo(); const sidebarRef = useRef(null); const { showing: showingSystemSettingsModal, @@ -50,9 +52,14 @@ export default function Sidebar() { <div className="w-full h-full flex flex-col overflow-x-hidden items-between"> {/* Header Information */} <div className="flex w-full items-center justify-between"> - <p className="text-xl font-base text-slate-600 dark:text-slate-200"> - AnythingLLM - </p> + <div className="flex shrink-0 max-w-[50%] items-center justify-start"> + <img + src={logo} + alt="Logo" + className="rounded max-h-[40px]" + style={{ objectFit: "contain" }} + /> + </div> <div className="flex gap-x-2 items-center text-slate-500"> <AdminHome /> <button @@ -144,6 +151,7 @@ export default function Sidebar() { } export function SidebarMobileHeader() { + const { logo } = useLogo(); const sidebarRef = useRef(null); const [showSidebar, setShowSidebar] = useState(false); const [showBgOverlay, setShowBgOverlay] = useState(false); @@ -180,9 +188,14 @@ export function SidebarMobileHeader() { > <Menu className="h-6 w-6" /> </button> - <p className="text-xl font-base text-slate-600 dark:text-slate-200"> - AnythingLLM - </p> + <div className="flex shrink-0 w-fit items-center justify-start"> + <img + src={logo} + alt="Logo" + className="rounded w-full max-h-[40px]" + style={{ objectFit: "contain" }} + /> + </div> </div> <div style={{ @@ -205,9 +218,14 @@ export function SidebarMobileHeader() { <div className="w-full h-full flex flex-col overflow-x-hidden items-between"> {/* Header Information */} <div className="flex w-full items-center justify-between"> - <p className="text-xl font-base text-slate-600 dark:text-slate-200"> - AnythingLLM - </p> + <div className="flex shrink-0 w-fit items-center justify-start"> + <img + src={logo} + alt="Logo" + className="rounded w-full max-h-[40px]" + style={{ objectFit: "contain" }} + /> + </div> <div className="flex gap-x-2 items-center text-slate-500"> <AdminHome /> <button diff --git a/frontend/src/hooks/useLogo.js b/frontend/src/hooks/useLogo.js new file mode 100644 index 0000000000000000000000000000000000000000..36cd64d67f7690debe9f138fd75588fd0b6816a9 --- /dev/null +++ b/frontend/src/hooks/useLogo.js @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import usePrefersDarkMode from "./usePrefersDarkMode"; +import System from "../models/system"; +import AnythingLLMDark from "../media/logo/anything-llm-dark.png"; +import AnythingLLMLight from "../media/logo/anything-llm-light.png"; + +export default function useLogo() { + const [logo, setLogo] = useState(""); + const prefersDarkMode = usePrefersDarkMode(); + + useEffect(() => { + async function fetchInstanceLogo() { + try { + const logoURL = await System.fetchLogo(!prefersDarkMode); + setLogo(logoURL); + } catch (err) { + setLogo(prefersDarkMode ? AnythingLLMLight : AnythingLLMDark); + console.error("Failed to fetch logo:", err); + } + } + fetchInstanceLogo(); + }, [prefersDarkMode]); + + return { logo }; +} diff --git a/frontend/src/media/logo/anything-llm-dark.png b/frontend/src/media/logo/anything-llm-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a294843869eface3065ca61c413528b3bfca668d Binary files /dev/null and b/frontend/src/media/logo/anything-llm-dark.png differ diff --git a/frontend/src/media/logo/anything-llm-light.png b/frontend/src/media/logo/anything-llm-light.png new file mode 100644 index 0000000000000000000000000000000000000000..341d21b6cea9a1b7b8ed431920931c55f4d1c0c2 Binary files /dev/null and b/frontend/src/media/logo/anything-llm-light.png differ diff --git a/frontend/src/models/admin.js b/frontend/src/models/admin.js index 8aedf810bc2729f7e0173dd9370a0e6b89d8e86f..21d40a3e909fe87c0aa6c7129a8f52f1ec752983 100644 --- a/frontend/src/models/admin.js +++ b/frontend/src/models/admin.js @@ -188,6 +188,34 @@ const Admin = { return { success: false, error: e.message }; }); }, + uploadLogo: async function (formData) { + return await fetch(`${API_BASE}/system/upload-logo`, { + method: "POST", + body: formData, + headers: baseHeaders(), + }) + .then((res) => { + if (!res.ok) throw new Error("Error uploading logo."); + return { success: true, error: null }; + }) + .catch((e) => { + console.log(e); + return { success: false, error: e.message }; + }); + }, + removeCustomLogo: async function () { + return await fetch(`${API_BASE}/system/remove-logo`, { + headers: baseHeaders(), + }) + .then((res) => { + if (res.ok) return { success: true, error: null }; + throw new Error("Error removing logo!"); + }) + .catch((e) => { + console.log(e); + return { success: false, error: e.message }; + }); + }, }; export default Admin; diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 43c0013f2aef3373a4a08446c07508b5272aeff5..9dc84001c6619c5ff085e5e51c1b8c1804e2c970 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -147,6 +147,46 @@ const System = { return { success: false, error: e.message }; }); }, + uploadLogo: async function (formData) { + return await fetch(`${API_BASE}/system/upload-logo`, { + method: "POST", + body: formData, + }) + .then((res) => { + if (!res.ok) throw new Error("Error uploading logo."); + return { success: true, error: null }; + }) + .catch((e) => { + console.log(e); + return { success: false, error: e.message }; + }); + }, + fetchLogo: async function (light = false) { + return await fetch(`${API_BASE}/system/logo${light ? "/light" : ""}`, { + method: "GET", + cache: "no-cache", + }) + .then((res) => { + if (res.ok) return res.blob(); + throw new Error("Failed to fetch logo!"); + }) + .then((blob) => URL.createObjectURL(blob)) + .catch((e) => { + console.log(e); + return null; + }); + }, + removeCustomLogo: async function () { + return await fetch(`${API_BASE}/system/remove-logo`) + .then((res) => { + if (res.ok) return { success: true, error: null }; + throw new Error("Error removing logo!"); + }) + .catch((e) => { + console.log(e); + return { success: false, error: e.message }; + }); + }, }; export default System; diff --git a/frontend/src/pages/Admin/Appearance/index.jsx b/frontend/src/pages/Admin/Appearance/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e9dc51486202dd32b21b5eca5975e985890a4a4b --- /dev/null +++ b/frontend/src/pages/Admin/Appearance/index.jsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from "react"; +import Sidebar, { SidebarMobileHeader } from "../../../components/AdminSidebar"; +import { isMobile } from "react-device-detect"; +import Admin from "../../../models/admin"; +import AnythingLLMLight from "../../../media/logo/anything-llm-light.png"; +import AnythingLLMDark from "../../../media/logo/anything-llm-dark.png"; +import usePrefersDarkMode from "../../../hooks/usePrefersDarkMode"; +import useLogo from "../../../hooks/useLogo"; +import System from "../../../models/system"; + +export default function Appearance() { + const { logo: _initLogo } = useLogo(); + const [logo, setLogo] = useState(""); + const prefersDarkMode = usePrefersDarkMode(); + const [errorMsg, setErrorMsg] = useState(""); + + useEffect(() => { + async function setInitLogo() { + setLogo(_initLogo || ""); + } + setInitLogo(); + }, [_initLogo]); + + useEffect(() => { + if (!!errorMsg) { + setTimeout(() => { + setErrorMsg(""); + }, 3_500); + } + }, [errorMsg]); + + const handleFileUpload = async (event) => { + const file = event.target.files[0]; + if (!file) return false; + + const formData = new FormData(); + formData.append("logo", file); + const { success, error } = await Admin.uploadLogo(formData); + if (!success) { + setErrorMsg(error); + return; + } + + const logoURL = await System.fetchLogo(); + setLogo(logoURL); + setErrorMsg(""); + window.location.reload(); + }; + + const handleRemoveLogo = async () => { + const { success, error } = await Admin.removeCustomLogo(); + if (!success) { + console.error("Failed to remove logo:", error); + setErrorMsg(error); + return; + } + + const logoURL = await System.fetchLogo(); + setLogo(logoURL); + setErrorMsg(""); + + window.location.reload(); + }; + + return ( + <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> + {!isMobile && <Sidebar />} + <div + style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }} + className="transition-all duration-500 relative md:ml-[2px] md:mr-[8px] md:my-[16px] md:rounded-[26px] bg-white dark:bg-black-900 md:min-w-[82%] p-[18px] h-full overflow-y-scroll" + > + {isMobile && <SidebarMobileHeader />} + <div className="px-1 md:px-8"> + <div className="mb-6"> + <p className="text-3xl font-semibold text-slate-600 dark:text-slate-200"> + Appearance Settings + </p> + <p className="mt-2 text-sm font-base text-slate-600 dark:text-slate-200"> + Customize the appearance settings of your platform. + </p> + </div> + + <div className="flex items-center"> + <img + src={logo} + alt="Uploaded Logo" + className="w-48 h-48 object-contain mr-6" + onError={(e) => + (e.target.src = prefersDarkMode + ? AnythingLLMLight + : AnythingLLMDark) + } + /> + + <div className="flex flex-col"> + <div className="mb-4"> + <label className="cursor-pointer text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"> + Upload Image + <input + type="file" + accept="image/*" + className="hidden" + onChange={handleFileUpload} + /> + </label> + <button + onClick={handleRemoveLogo} + className="ml-4 cursor-pointer text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" + > + Remove Custom Logo + </button> + </div> + <div className="text-sm text-gray-600 dark:text-gray-300"> + Upload your logo. Recommended size: 800x200. + </div> + </div> + </div> + + {errorMsg && ( + <div className="mt-4 text-sm text-red-600 dark:text-red-400 text-center"> + {errorMsg} + </div> + )} + </div> + </div> + </div> + ); +} diff --git a/frontend/src/pages/System/Appearance.jsx b/frontend/src/pages/System/Appearance.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f840e41432c8d1e19a6a9859f205e8b3641b9203 --- /dev/null +++ b/frontend/src/pages/System/Appearance.jsx @@ -0,0 +1,134 @@ +import React, { useState, useEffect } from "react"; +import AnythingLLMLight from "../../media/logo/anything-llm-light.png"; +import AnythingLLMDark from "../../media/logo/anything-llm-dark.png"; +import System from "../../models/system"; +import usePrefersDarkMode from "../../hooks/usePrefersDarkMode"; +import useLogo from "../../hooks/useLogo"; + +export default function Appearance() { + const { logo: _initLogo } = useLogo(); + const prefersDarkMode = usePrefersDarkMode(); + const [logo, setLogo] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const [successMsg, setSuccessMsg] = useState(""); + + useEffect(() => { + async function setInitLogo() { + setLogo(_initLogo || ""); + } + setInitLogo(); + }, [_initLogo]); + + useEffect(() => { + if (!!successMsg) { + setTimeout(() => { + setSuccessMsg(""); + }, 3_500); + } + + if (!!errorMsg) { + setTimeout(() => { + setErrorMsg(""); + }, 3_500); + } + }, [successMsg, errorMsg]); + + const handleFileUpload = async (event) => { + const file = event.target.files[0]; + if (!file) return false; + + const formData = new FormData(); + formData.append("logo", file); + const { success, error } = await System.uploadLogo(formData); + if (!success) { + console.error("Failed to upload logo:", error); + setErrorMsg(error); + setSuccessMsg(""); + return; + } + + const logoURL = await System.fetchLogo(); + setLogo(logoURL); + setSuccessMsg("Image uploaded successfully"); + setErrorMsg(""); + }; + + const handleRemoveLogo = async () => { + const { success, error } = await System.removeCustomLogo(); + if (!success) { + console.error("Failed to remove logo:", error); + setErrorMsg(error); + setSuccessMsg(""); + return; + } + + const logoURL = await System.fetchLogo(); + setLogo(logoURL); + setSuccessMsg("Image successfully removed"); + setErrorMsg(""); + }; + + return ( + <div className="min-h-screen flex items-center justify-center bg-orange-100 dark:bg-black-900"> + <div className="p-6 w-full max-w-xl bg-white dark:bg-stone-600 rounded-xl shadow-md space-y-4"> + <h2 className="text-2xl font-bold text-center text-black dark:text-white"> + Customize Appearance + </h2> + <p className="text-center text-xs font-light text-black dark:text-white"> + Customize the logo you see on the sidebar + </p> + + <div className="flex flex-col items-center border border-slate-200 dark:border-black-900 p-6 rounded-xl"> + <img + src={logo} + alt="Uploaded Logo" + className="w-48 h-48 object-contain" + onError={(e) => + (e.target.src = prefersDarkMode + ? AnythingLLMLight + : AnythingLLMDark) + } + /> + <div className="flex gap-2 p-2 flex-col items-center"> + <div className="text-sm text-gray-600 dark:text-gray-300"> + Upload your logo + </div> + <div className="text-sm text-gray-600 dark:text-gray-300"> + Recommended size at least 800x200 + </div> + </div> + </div> + + <div className="flex justify-center mt-4 gap-2"> + <label className="cursor-pointer text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"> + Upload Image + <input + type="file" + accept="image/*" + className="hidden" + onChange={handleFileUpload} + /> + </label> + <button + onClick={handleRemoveLogo} + className="cursor-pointer text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" + > + Remove Custom Logo + </button> + </div> + + {errorMsg && ( + <div className="text-sm text-red-600 dark:text-red-400 text-center"> + {errorMsg} + </div> + )} + + {successMsg && ( + <div className="text-sm text-green-600 dark:text-green-400 text-center"> + {successMsg} + </div> + )} + </div> + </div> + ); +} diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js index d9748a3bf4c40873e3d5a2700d285b43022da8bc..7e729bb61738ac257fa159850ce3087e9383c0e7 100644 --- a/frontend/src/utils/paths.js +++ b/frontend/src/utils/paths.js @@ -22,6 +22,9 @@ export default { feedback: () => { return "https://mintplexlabs.typeform.com/to/i0KE3aEW"; }, + appearance: () => { + return "/system/appearance"; + }, workspace: { chat: (slug) => { return `/workspace/${slug}`; @@ -46,5 +49,8 @@ export default { chats: () => { return "/admin/workspace-chats"; }, + appearance: () => { + return "/admin/appearance"; + }, }, }; diff --git a/server/.gitignore b/server/.gitignore index bfff4bb267870136759cb7501fee3d09c0f3e45d..5f304ff2a77387c8b0aaf8dfce21088e82dcdb79 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,5 +1,8 @@ .env.production .env.development +storage/assets/* +!storage/assets/anything-llm-dark.png +!storage/assets/anything-llm-light.png storage/documents/* storage/vector-cache/*.json storage/exports diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index b58dc0c1589bc919a1eb54ef17e3aa67f205ec6d..d27ccf230f43540c93e26ae695e0e838f49ca775 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -8,6 +8,8 @@ const { WorkspaceChats } = require("../models/workspaceChats"); const { getVectorDbClass } = require("../utils/helpers"); const { userFromSession, reqBody } = require("../utils/http"); const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { setupLogoUploads } = require("../utils/files/multer"); +const { handleLogoUploads } = setupLogoUploads(); function adminEndpoints(app) { if (!app) return; diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 01a367c7eb3ee46bb73396a63c772ecaef492405..9cb0ee92bfe6df0c3807f95f32228909d20b070f 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -17,12 +17,23 @@ const { userFromSession, multiUserMode, } = require("../utils/http"); -const { setupDataImports } = require("../utils/files/multer"); +const { setupDataImports, setupLogoUploads } = 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(); +const { handleLogoUploads } = setupLogoUploads(); +const path = require("path"); +const { + getDefaultFilename, + determineLogoFilepath, + fetchLogo, + validFilename, + renameLogoFile, + removeCustomLogo, + DARK_LOGO_FILENAME, +} = require("../utils/files/logo"); function systemEndpoints(app) { if (!app) return; @@ -358,6 +369,99 @@ function systemEndpoints(app) { response.status(200).json({ success, error }); } ); + + app.get("/system/logo/:mode?", async function (request, response) { + try { + const defaultFilename = getDefaultFilename(request.params.mode); + const logoPath = await determineLogoFilepath(defaultFilename); + const { buffer, size, mime } = fetchLogo(logoPath); + response.writeHead(200, { + "Content-Type": mime || "image/png", + "Content-Disposition": `attachment; filename=${path.basename( + logoPath + )}`, + "Content-Length": size, + }); + response.end(Buffer.from(buffer, "base64")); + return; + } catch (error) { + console.error("Error processing the logo request:", error); + response.status(500).json({ message: "Internal server error" }); + } + }); + + app.post( + "/system/upload-logo", + [validatedRequest], + handleLogoUploads.single("logo"), + async (request, response) => { + if (!request.file || !request.file.originalname) { + return response.status(400).json({ message: "No logo file provided." }); + } + + if (!validFilename(request.file.originalname)) { + return response.status(400).json({ + message: "Invalid file name. Please choose a different file.", + }); + } + + try { + if ( + response.locals.multiUserMode && + response.locals.user?.role !== "admin" + ) { + return response.sendStatus(401).end(); + } + + const newFilename = await renameLogoFile(request.file.originalname); + const existingLogoFilename = await SystemSettings.currentLogoFilename(); + await removeCustomLogo(existingLogoFilename); + + const { success, error } = await SystemSettings.updateSettings({ + logo_filename: newFilename, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Logo uploaded successfully." + : error || "Failed to update with new logo.", + }); + } catch (error) { + console.error("Error processing the logo upload:", error); + response.status(500).json({ message: "Error uploading the logo." }); + } + } + ); + + app.get( + "/system/remove-logo", + [validatedRequest], + async (request, response) => { + try { + if ( + response.locals.multiUserMode && + response.locals.user?.role !== "admin" + ) { + return response.sendStatus(401).end(); + } + + const currentLogoFilename = await SystemSettings.currentLogoFilename(); + await removeCustomLogo(currentLogoFilename); + const { success, error } = await SystemSettings.updateSettings({ + logo_filename: DARK_LOGO_FILENAME, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Logo removed successfully." + : error || "Failed to update with new logo.", + }); + } catch (error) { + console.error("Error processing the logo removal:", error); + response.status(500).json({ message: "Error removing the logo." }); + } + } + ); } module.exports = { systemEndpoints }; diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index 1df81204f05217e244032cdb3fe9e0872563668e..e0d58d4511cec7e6ffe0498438e7b63eff789d40 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -4,6 +4,7 @@ const SystemSettings = { "users_can_delete_workspaces", "limit_user_messages", "message_limit", + "logo_filename", ], privateField: [], tablename: "system_settings", @@ -117,6 +118,10 @@ const SystemSettings = { isMultiUserMode: async function () { return (await this.get(`label = 'multi_user_mode'`))?.value === "true"; }, + currentLogoFilename: async function () { + const result = await this.get(`label = 'logo_filename'`); + return result ? result.value : null; + }, }; module.exports.SystemSettings = SystemSettings; diff --git a/server/package.json b/server/package.json index 24a04b9fb3952ac3061c61119c75acbfca298caf..f2714838e7f4edfe12c31c79aa32a2a1b6954b20 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ "graphql": "^16.7.1", "jsonwebtoken": "^8.5.1", "langchain": "^0.0.90", + "mime": "^3.0.0", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", "openai": "^3.2.1", diff --git a/server/storage/assets/anything-llm-dark.png b/server/storage/assets/anything-llm-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a294843869eface3065ca61c413528b3bfca668d Binary files /dev/null and b/server/storage/assets/anything-llm-dark.png differ diff --git a/server/storage/assets/anything-llm-light.png b/server/storage/assets/anything-llm-light.png new file mode 100644 index 0000000000000000000000000000000000000000..341d21b6cea9a1b7b8ed431920931c55f4d1c0c2 Binary files /dev/null and b/server/storage/assets/anything-llm-light.png differ diff --git a/server/utils/files/logo.js b/server/utils/files/logo.js new file mode 100644 index 0000000000000000000000000000000000000000..cf509d9ab7c0019df47ef5e7253ec3d970817310 --- /dev/null +++ b/server/utils/files/logo.js @@ -0,0 +1,72 @@ +const path = require("path"); +const fs = require("fs"); +const { getType } = require("mime"); +const { v4 } = require("uuid"); +const { SystemSettings } = require("../../models/systemSettings"); +const LIGHT_LOGO_FILENAME = "anything-llm-light.png"; +const DARK_LOGO_FILENAME = "anything-llm-dark.png"; + +function validFilename(newFilename = "") { + return ![DARK_LOGO_FILENAME, LIGHT_LOGO_FILENAME].includes(newFilename); +} + +function getDefaultFilename(mode = "dark") { + return mode === "light" ? DARK_LOGO_FILENAME : LIGHT_LOGO_FILENAME; +} + +async function determineLogoFilepath(defaultFilename = DARK_LOGO_FILENAME) { + const currentLogoFilename = await SystemSettings.currentLogoFilename(); + const basePath = path.join(__dirname, "../../storage/assets"); + const defaultFilepath = path.join(basePath, defaultFilename); + + if (currentLogoFilename && validFilename(currentLogoFilename)) { + customLogoPath = path.join(basePath, currentLogoFilename); + return fs.existsSync(customLogoPath) ? customLogoPath : defaultFilepath; + } + + return defaultFilepath; +} + +function fetchLogo(logoPath) { + const mime = getType(logoPath); + const buffer = fs.readFileSync(logoPath); + return { + buffer, + size: buffer.length, + mime, + }; +} + +async function renameLogoFile(originalFilename = null) { + const extname = path.extname(originalFilename) || ".png"; + const newFilename = `${v4()}${extname}`; + const originalFilepath = path.join( + __dirname, + `../../storage/assets/${originalFilename}` + ); + const outputFilepath = path.join( + __dirname, + `../../storage/assets/${newFilename}` + ); + + fs.renameSync(originalFilepath, outputFilepath); + return newFilename; +} + +async function removeCustomLogo(logoFilename = DARK_LOGO_FILENAME) { + if (!logoFilename || !validFilename(logoFilename)) return false; + const logoPath = path.join(__dirname, `../../storage/assets/${logoFilename}`); + if (fs.existsSync(logoPath)) fs.unlinkSync(logoPath); + return true; +} + +module.exports = { + fetchLogo, + renameLogoFile, + removeCustomLogo, + validFilename, + getDefaultFilename, + determineLogoFilepath, + LIGHT_LOGO_FILENAME, + DARK_LOGO_FILENAME, +}; diff --git a/server/utils/files/multer.js b/server/utils/files/multer.js index 49484dd7e81a3739c1942ff38636f24c3a816897..cc12ac9fd4017070788b03af3f06235459e996ae 100644 --- a/server/utils/files/multer.js +++ b/server/utils/files/multer.js @@ -1,9 +1,11 @@ +const multer = require("multer"); +const path = require("path"); +const fs = require("fs"); + function setupMulter() { - const multer = require("multer"); // Handle File uploads for auto-uploading. const storage = multer.diskStorage({ destination: function (_, _, cb) { - const path = require("path"); const uploadOutput = process.env.NODE_ENV === "development" ? path.resolve(__dirname, `../../../collector/hotdir`) @@ -14,19 +16,14 @@ function setupMulter() { cb(null, file.originalname); }, }); - const upload = multer({ - storage, - }); - return { handleUploads: upload }; + + return { handleUploads: multer({ storage }) }; } function setupDataImports() { - const multer = require("multer"); // Handle File uploads for auto-uploading. const storage = multer.diskStorage({ destination: function (_, _, cb) { - const path = require("path"); - const fs = require("fs"); const uploadOutput = path.resolve(__dirname, `../../storage/imports`); fs.mkdirSync(uploadOutput, { recursive: true }); return cb(null, uploadOutput); @@ -35,13 +32,28 @@ function setupDataImports() { cb(null, file.originalname); }, }); - const upload = multer({ - storage, + + return { handleImports: multer({ storage }) }; +} + +function setupLogoUploads() { + // Handle Logo uploads. + const storage = multer.diskStorage({ + destination: function (_, _, cb) { + const uploadOutput = path.resolve(__dirname, `../../storage/assets`); + fs.mkdirSync(uploadOutput, { recursive: true }); + return cb(null, uploadOutput); + }, + filename: function (_, file, cb) { + cb(null, file.originalname); + }, }); - return { handleImports: upload }; + + return { handleLogoUploads: multer({ storage }) }; } module.exports = { setupMulter, setupDataImports, + setupLogoUploads, }; diff --git a/server/yarn.lock b/server/yarn.lock index c5a6a75c9eab2fa82082fd3d3f52633762726648..51300358e15cf85fb9439100c68420f4968361c7 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1648,6 +1648,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"