From fcb591d364193b09cada5a53667c5c5d6919cff7 Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Thu, 7 Dec 2023 14:11:51 -0800 Subject: [PATCH] Add user PFP support and context to logo (#408) * fix sizing of onboarding modals & lint * fix extra scrolling on mobile onboarding flow * added message to use desktop for onboarding * linting * add arrow to scroll to bottom (debounced) and fix chat scrolling to always scroll to very bottom on message history change * fix for empty chat * change mobile alert copy * WIP adding PFP upload support * WIP pfp for users * edit account menu complete with change username/password and upload profile picture * add pfp context to update all instances of usePfp hook on update * linting * add context for logo change to immediately update logo * fix div with bullet points to use list-disc instead * fix: small changes * update multer file storage locations * fix: use STORAGE_DIR for filepathing --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- frontend/src/App.jsx | 130 +++++------ frontend/src/LogoContext.jsx | 28 +++ frontend/src/PfpContext.jsx | 30 +++ frontend/src/components/UserIcon/index.jsx | 29 +-- frontend/src/components/UserMenu/index.jsx | 207 +++++++++++++++++- frontend/src/hooks/useLogo.js | 23 +- frontend/src/hooks/usePfp.js | 7 + frontend/src/models/system.js | 59 ++++- .../GeneralSettings/Appearance/index.jsx | 8 +- .../Steps/AppearanceSetup/index.jsx | 8 +- server/endpoints/system.js | 153 ++++++++++++- .../20231129012019_add/migration.sql | 2 + server/prisma/schema.prisma | 1 + server/utils/files/logo.js | 1 + server/utils/files/multer.js | 27 ++- server/utils/files/pfp.js | 44 ++++ 16 files changed, 656 insertions(+), 101 deletions(-) create mode 100644 frontend/src/LogoContext.jsx create mode 100644 frontend/src/PfpContext.jsx create mode 100644 frontend/src/hooks/usePfp.js create mode 100644 server/prisma/migrations/20231129012019_add/migration.sql create mode 100644 server/utils/files/pfp.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 558e8ae3f..2d1eeb7d1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,8 @@ import PrivateRoute, { import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import Login from "@/pages/Login"; +import { PfpProvider } from "./PfpContext"; +import { LogoProvider } from "./LogoContext"; const Main = lazy(() => import("@/pages/Main")); const InvitePage = lazy(() => import("@/pages/Invite")); @@ -40,69 +42,73 @@ export default function App() { return ( <Suspense fallback={<div />}> <ContextWrapper> - <Routes> - <Route path="/" element={<PrivateRoute Component={Main} />} /> - <Route path="/login" element={<Login />} /> - <Route - path="/workspace/:slug" - element={<PrivateRoute Component={WorkspaceChat} />} - /> - <Route path="/accept-invite/:code" element={<InvitePage />} /> + <LogoProvider> + <PfpProvider> + <Routes> + <Route path="/" element={<PrivateRoute Component={Main} />} /> + <Route path="/login" element={<Login />} /> + <Route + path="/workspace/:slug" + element={<PrivateRoute Component={WorkspaceChat} />} + /> + <Route path="/accept-invite/:code" element={<InvitePage />} /> - {/* Admin */} - <Route - path="/settings/llm-preference" - element={<AdminRoute Component={GeneralLLMPreference} />} - /> - <Route - path="/settings/embedding-preference" - element={<AdminRoute Component={GeneralEmbeddingPreference} />} - /> - <Route - path="/settings/vector-database" - element={<AdminRoute Component={GeneralVectorDatabase} />} - /> - {/* Manager */} - <Route - path="/settings/export-import" - element={<ManagerRoute Component={GeneralExportImport} />} - /> - <Route - path="/settings/security" - element={<ManagerRoute Component={GeneralSecurity} />} - /> - <Route - path="/settings/appearance" - element={<ManagerRoute Component={GeneralAppearance} />} - /> - <Route - path="/settings/api-keys" - element={<ManagerRoute Component={GeneralApiKeys} />} - /> - <Route - path="/settings/workspace-chats" - element={<ManagerRoute Component={GeneralChats} />} - /> - <Route - path="/settings/system-preferences" - element={<ManagerRoute Component={AdminSystem} />} - /> - <Route - path="/settings/invites" - element={<ManagerRoute Component={AdminInvites} />} - /> - <Route - path="/settings/users" - element={<ManagerRoute Component={AdminUsers} />} - /> - <Route - path="/settings/workspaces" - element={<ManagerRoute Component={AdminWorkspaces} />} - /> - {/* Onboarding Flow */} - <Route path="/onboarding" element={<OnboardingFlow />} /> - </Routes> - <ToastContainer /> + {/* Admin */} + <Route + path="/settings/llm-preference" + element={<AdminRoute Component={GeneralLLMPreference} />} + /> + <Route + path="/settings/embedding-preference" + element={<AdminRoute Component={GeneralEmbeddingPreference} />} + /> + <Route + path="/settings/vector-database" + element={<AdminRoute Component={GeneralVectorDatabase} />} + /> + {/* Manager */} + <Route + path="/settings/export-import" + element={<ManagerRoute Component={GeneralExportImport} />} + /> + <Route + path="/settings/security" + element={<ManagerRoute Component={GeneralSecurity} />} + /> + <Route + path="/settings/appearance" + element={<ManagerRoute Component={GeneralAppearance} />} + /> + <Route + path="/settings/api-keys" + element={<ManagerRoute Component={GeneralApiKeys} />} + /> + <Route + path="/settings/workspace-chats" + element={<ManagerRoute Component={GeneralChats} />} + /> + <Route + path="/settings/system-preferences" + element={<ManagerRoute Component={AdminSystem} />} + /> + <Route + path="/settings/invites" + element={<ManagerRoute Component={AdminInvites} />} + /> + <Route + path="/settings/users" + element={<ManagerRoute Component={AdminUsers} />} + /> + <Route + path="/settings/workspaces" + element={<ManagerRoute Component={AdminWorkspaces} />} + /> + {/* Onboarding Flow */} + <Route path="/onboarding" element={<OnboardingFlow />} /> + </Routes> + <ToastContainer /> + </PfpProvider> + </LogoProvider> </ContextWrapper> </Suspense> ); diff --git a/frontend/src/LogoContext.jsx b/frontend/src/LogoContext.jsx new file mode 100644 index 000000000..6818967b8 --- /dev/null +++ b/frontend/src/LogoContext.jsx @@ -0,0 +1,28 @@ +import { createContext, useEffect, useState } from "react"; +import AnythingLLM from "./media/logo/anything-llm.png"; +import System from "./models/system"; + +export const LogoContext = createContext(); + +export function LogoProvider({ children }) { + const [logo, setLogo] = useState(""); + + useEffect(() => { + async function fetchInstanceLogo() { + try { + const logoURL = await System.fetchLogo(); + logoURL ? setLogo(logoURL) : setLogo(AnythingLLM); + } catch (err) { + setLogo(AnythingLLM); + console.error("Failed to fetch logo:", err); + } + } + fetchInstanceLogo(); + }, []); + + return ( + <LogoContext.Provider value={{ logo, setLogo }}> + {children} + </LogoContext.Provider> + ); +} diff --git a/frontend/src/PfpContext.jsx b/frontend/src/PfpContext.jsx new file mode 100644 index 000000000..3d60d559d --- /dev/null +++ b/frontend/src/PfpContext.jsx @@ -0,0 +1,30 @@ +import React, { createContext, useState, useEffect } from "react"; +import useUser from "./hooks/useUser"; +import System from "./models/system"; + +export const PfpContext = createContext(); + +export function PfpProvider({ children }) { + const [pfp, setPfp] = useState(null); + const { user } = useUser(); + + useEffect(() => { + async function fetchPfp() { + if (!user?.id) return; + try { + const pfpUrl = await System.fetchPfp(user.id); + setPfp(pfpUrl); + } catch (err) { + setPfp(null); + console.error("Failed to fetch pfp:", err); + } + } + fetchPfp(); + }, [user?.id]); + + return ( + <PfpContext.Provider value={{ pfp, setPfp }}> + {children} + </PfpContext.Provider> + ); +} diff --git a/frontend/src/components/UserIcon/index.jsx b/frontend/src/components/UserIcon/index.jsx index 40694606d..6cc9b57d0 100644 --- a/frontend/src/components/UserIcon/index.jsx +++ b/frontend/src/components/UserIcon/index.jsx @@ -1,32 +1,35 @@ import React, { useRef, useEffect } from "react"; import JAZZ from "@metamask/jazzicon"; +import usePfp from "../../hooks/usePfp"; export default function Jazzicon({ size = 10, user, role }) { + const { pfp } = usePfp(); const divRef = useRef(null); const seed = user?.uid ? toPseudoRandomInteger(user.uid) : Math.floor(100000 + Math.random() * 900000); - const result = JAZZ(size, seed); useEffect(() => { - if (!divRef || !divRef.current) return null; + if (!divRef.current || (role === "user" && pfp)) return; + const result = JAZZ(size, seed); divRef.current.appendChild(result); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [pfp, role, seed, size]); return ( - <div - className={`flex ${role === "user" ? "user-reply" : ""}`} - ref={divRef} - /> + <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden"> + <div ref={divRef} /> + {role === "user" && pfp && ( + <img + src={pfp} + alt="User profile picture" + className="absolute top-0 left-0 w-full h-full object-cover rounded-full bg-white" + /> + )} + </div> ); } function toPseudoRandomInteger(uidString = "") { - var numberArray = [uidString.length]; - for (var i = 0; i < uidString.length; i++) { - numberArray[i] = uidString.charCodeAt(i); - } - - return numberArray.reduce((a, b) => a + b, 0); + return uidString.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0); } diff --git a/frontend/src/components/UserMenu/index.jsx b/frontend/src/components/UserMenu/index.jsx index 8bfc1b934..61e111da4 100644 --- a/frontend/src/components/UserMenu/index.jsx +++ b/frontend/src/components/UserMenu/index.jsx @@ -2,8 +2,12 @@ import React, { useState, useEffect, useRef } from "react"; import { isMobile } from "react-device-detect"; import paths from "@/utils/paths"; import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants"; -import { Person, SignOut } from "@phosphor-icons/react"; +import { Person, Plus, X } from "@phosphor-icons/react"; import { userFromStorage } from "@/utils/request"; +import useUser from "@/hooks/useUser"; +import System from "@/models/system"; +import showToast from "@/utils/toast"; +import usePfp from "@/hooks/usePfp"; export default function UserMenu({ children }) { if (isMobile) return <>{children}</>; @@ -26,12 +30,28 @@ function useLoginMode() { } function userDisplay() { + const { pfp } = usePfp(); const user = userFromStorage(); + + if (pfp) { + return ( + <div className="w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden transition-all duration-300 bg-gray-100 hover:border-slate-100 hover:border-opacity-50 border-transparent border hover:opacity-60"> + <img + src={pfp} + alt="User profile picture" + className="w-full h-full object-cover" + /> + </div> + ); + } + return user?.username?.slice(0, 2) || "AA"; } function UserButton() { + const { user } = useUser(); const [showMenu, setShowMenu] = useState(false); + const [showAccountSettings, setShowAccountSettings] = useState(false); const mode = useLoginMode(); const menuRef = useRef(); const buttonRef = useRef(); @@ -45,6 +65,11 @@ function UserButton() { } }; + const handleOpenAccountModal = () => { + setShowAccountSettings(true); + setShowMenu(false); + }; + useEffect(() => { if (showMenu) { document.addEventListener("mousedown", handleClose); @@ -71,6 +96,14 @@ function UserButton() { className="w-fit rounded-lg absolute top-12 right-0 bg-sidebar p-4 flex items-center-justify-center" > <div className="flex flex-col gap-y-2"> + {mode === "multi" && !!user && ( + <button + onClick={handleOpenAccountModal} + className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" + > + Account + </button> + )} <a href={paths.mailToMintplex()} className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" @@ -92,6 +125,178 @@ function UserButton() { </div> </div> )} + {user && showAccountSettings && ( + <AccountModal + user={user} + hideModal={() => setShowAccountSettings(false)} + /> + )} + </div> + ); +} + +function AccountModal({ user, hideModal }) { + const { pfp, setPfp } = usePfp(); + const handleFileUpload = async (event) => { + const file = event.target.files[0]; + if (!file) return false; + + const formData = new FormData(); + formData.append("file", file); + const { success, error } = await System.uploadPfp(formData); + if (!success) { + showToast(`Failed to upload profile picture: ${error}`, "error"); + return; + } + + const pfpUrl = await System.fetchPfp(user.id); + setPfp(pfpUrl); + + showToast("Profile picture uploaded successfully.", "success"); + }; + + const handleRemovePfp = async () => { + const { success, error } = await System.removePfp(); + if (!success) { + showToast(`Failed to remove profile picture: ${error}`, "error"); + return; + } + + setPfp(null); + showToast("Profile picture removed successfully.", "success"); + }; + + const handleUpdate = async (e) => { + e.preventDefault(); + + const data = {}; + const form = new FormData(e.target); + for (var [key, value] of form.entries()) { + if (!value || value === null) continue; + data[key] = value; + } + + const { success, error } = await System.updateUser(data); + if (success) { + let storedUser = JSON.parse(localStorage.getItem(AUTH_USER)); + + if (storedUser) { + storedUser.username = data.username; + localStorage.setItem(AUTH_USER, JSON.stringify(storedUser)); + } + window.location.reload(); + } else { + showToast(`Failed to update user: ${error}`, "error"); + } + }; + + return ( + <div + id="account-modal" + className="bg-black/20 fixed top-0 left-0 outline-none w-screen h-screen flex items-center justify-center" + > + <div className="relative w-[500px] max-w-2xl max-h-full bg-main-gradient rounded-lg shadow"> + <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50"> + <h3 className="text-xl font-semibold text-white">Edit Account</h3> + <button + onClick={hideModal} + type="button" + className="text-gray-400 bg-transparent hover:border-white/60 rounded-lg p-1.5 ml-auto inline-flex items-center hover:bg-menu-item-selected-gradient hover:border-slate-100 border-transparent" + > + <X className="text-lg" /> + </button> + </div> + <form onSubmit={handleUpdate} className="space-y-6"> + <div className="flex flex-col md:flex-row items-center justify-center gap-8"> + <div className="flex flex-col items-center"> + <label className="w-48 h-48 flex flex-col items-center justify-center bg-zinc-900/50 transition-all duration-300 rounded-full mt-8 border-2 border-dashed border-white border-opacity-60 cursor-pointer hover:opacity-60"> + <input + id="logo-upload" + type="file" + accept="image/*" + className="hidden" + onChange={handleFileUpload} + /> + {pfp ? ( + <img + src={pfp} + alt="User profile picture" + className="w-48 h-48 rounded-full object-cover bg-white" + /> + ) : ( + <div className="flex flex-col items-center justify-center p-3"> + <Plus className="w-8 h-8 text-white/80 m-2" /> + <span className="text-white text-opacity-80 text-sm font-semibold"> + Profile Picture + </span> + <span className="text-white text-opacity-60 text-xs"> + 800 x 800 + </span> + </div> + )} + </label> + {pfp && ( + <button + type="button" + onClick={handleRemovePfp} + className="mt-3 text-white text-opacity-60 text-sm font-medium hover:underline" + > + Remove Profile Picture + </button> + )} + </div> + </div> + <div className="flex flex-col gap-y-4 px-6"> + <div> + <label + htmlFor="username" + className="block mb-2 text-sm font-medium text-white" + > + Username + </label> + <input + name="username" + type="text" + className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5" + placeholder="User's username" + minLength={2} + defaultValue={user.username} + required + autoComplete="off" + /> + </div> + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-white" + > + New Password + </label> + <input + name="password" + type="password" + className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5" + placeholder={`${user.username}'s new password`} + /> + </div> + </div> + <div className="flex justify-between items-center border-t border-gray-500/50 pt-4 p-6"> + <button + onClick={hideModal} + type="button" + className="px-4 py-2 rounded-lg text-white bg-transparent hover:bg-stone-900" + > + Cancel + </button> + <button + type="submit" + className="px-4 py-2 rounded-lg text-white bg-transparent border border-slate-200 hover:bg-slate-200 hover:text-slate-800" + > + Update Account + </button> + </div> + </form> + </div> </div> ); } diff --git a/frontend/src/hooks/useLogo.js b/frontend/src/hooks/useLogo.js index f03d20981..4834b7a8e 100644 --- a/frontend/src/hooks/useLogo.js +++ b/frontend/src/hooks/useLogo.js @@ -1,22 +1,7 @@ -import { useEffect, useState } from "react"; -import System from "@/models/system"; -import AnythingLLM from "@/media/logo/anything-llm.png"; +import { useContext } from "react"; +import { LogoContext } from "../LogoContext"; export default function useLogo() { - const [logo, setLogo] = useState(""); - - useEffect(() => { - async function fetchInstanceLogo() { - try { - const logoURL = await System.fetchLogo(); - logoURL ? setLogo(logoURL) : setLogo(AnythingLLM); - } catch (err) { - setLogo(AnythingLLM); - console.error("Failed to fetch logo:", err); - } - } - fetchInstanceLogo(); - }, []); - - return { logo }; + const { logo, setLogo } = useContext(LogoContext); + return { logo, setLogo }; } diff --git a/frontend/src/hooks/usePfp.js b/frontend/src/hooks/usePfp.js new file mode 100644 index 000000000..36c54497f --- /dev/null +++ b/frontend/src/hooks/usePfp.js @@ -0,0 +1,7 @@ +import { useContext } from "react"; +import { PfpContext } from "../PfpContext"; + +export default function usePfp() { + const { pfp, setPfp } = useContext(PfpContext); + return { pfp, setPfp }; +} diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 2ef4eebbf..79c203d94 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -170,6 +170,21 @@ const System = { return { success: false, error: e.message }; }); }, + uploadPfp: async function (formData) { + return await fetch(`${API_BASE}/system/upload-pfp`, { + method: "POST", + body: formData, + headers: baseHeaders(), + }) + .then((res) => { + if (!res.ok) throw new Error("Error uploading pfp."); + return { success: true, error: null }; + }) + .catch((e) => { + console.log(e); + return { success: false, error: e.message }; + }); + }, uploadLogo: async function (formData) { return await fetch(`${API_BASE}/system/upload-logo`, { method: "POST", @@ -191,7 +206,7 @@ const System = { cache: "no-cache", }) .then((res) => { - if (res.ok) return res.blob(); + if (res.ok && res.status !== 204) return res.blob(); throw new Error("Failed to fetch logo!"); }) .then((blob) => URL.createObjectURL(blob)) @@ -200,6 +215,36 @@ const System = { return null; }); }, + fetchPfp: async function (id) { + return await fetch(`${API_BASE}/system/pfp/${id}`, { + method: "GET", + cache: "no-cache", + }) + .then((res) => { + if (res.ok && res.status !== 204) return res.blob(); + throw new Error("Failed to fetch pfp."); + }) + .then((blob) => (blob ? URL.createObjectURL(blob) : null)) + .catch((e) => { + console.log(e); + return null; + }); + }, + removePfp: async function (id) { + return await fetch(`${API_BASE}/system/remove-pfp`, { + method: "DELETE", + headers: baseHeaders(), + }) + .then((res) => { + if (res.ok) return { success: true, error: null }; + throw new Error("Failed to remove pfp."); + }) + .catch((e) => { + console.log(e); + return { success: false, error: e.message }; + }); + }, + isDefaultLogo: async function () { return await fetch(`${API_BASE}/system/is-default-logo`, { method: "GET", @@ -374,6 +419,18 @@ const System = { return null; }); }, + updateUser: async (data) => { + return await fetch(`${API_BASE}/system/user`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify(data), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, }; export default System; diff --git a/frontend/src/pages/GeneralSettings/Appearance/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/index.jsx index e823dd0b7..7a992e916 100644 --- a/frontend/src/pages/GeneralSettings/Appearance/index.jsx +++ b/frontend/src/pages/GeneralSettings/Appearance/index.jsx @@ -10,7 +10,7 @@ import showToast from "@/utils/toast"; import { Plus } from "@phosphor-icons/react"; export default function Appearance() { - const { logo: _initLogo } = useLogo(); + const { logo: _initLogo, setLogo: _setLogo } = useLogo(); const [logo, setLogo] = useState(""); const [hasChanges, setHasChanges] = useState(false); const [messages, setMessages] = useState([]); @@ -49,6 +49,9 @@ export default function Appearance() { return; } + const logoURL = await System.fetchLogo(); + _setLogo(logoURL); + showToast("Image uploaded successfully.", "success"); setIsDefaultLogo(false); }; @@ -67,6 +70,9 @@ export default function Appearance() { return; } + const logoURL = await System.fetchLogo(); + _setLogo(logoURL); + showToast("Image successfully removed.", "success"); }; diff --git a/frontend/src/pages/OnboardingFlow/OnboardingModal/Steps/AppearanceSetup/index.jsx b/frontend/src/pages/OnboardingFlow/OnboardingModal/Steps/AppearanceSetup/index.jsx index 496a4ff4f..30e87b0ac 100644 --- a/frontend/src/pages/OnboardingFlow/OnboardingModal/Steps/AppearanceSetup/index.jsx +++ b/frontend/src/pages/OnboardingFlow/OnboardingModal/Steps/AppearanceSetup/index.jsx @@ -6,7 +6,7 @@ import { Plus } from "@phosphor-icons/react"; import showToast from "@/utils/toast"; function AppearanceSetup({ prevStep, nextStep }) { - const { logo: _initLogo } = useLogo(); + const { logo: _initLogo, setLogo: _setLogo } = useLogo(); const [logo, setLogo] = useState(""); const [isDefaultLogo, setIsDefaultLogo] = useState(true); @@ -35,6 +35,9 @@ function AppearanceSetup({ prevStep, nextStep }) { return; } + const logoURL = await System.fetchLogo(); + _setLogo(logoURL); + showToast("Image uploaded successfully.", "success"); setIsDefaultLogo(false); }; @@ -53,6 +56,9 @@ function AppearanceSetup({ prevStep, nextStep }) { return; } + const logoURL = await System.fetchLogo(); + _setLogo(logoURL); + showToast("Image successfully removed.", "success"); }; diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 22ce8ef10..024bdd995 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -16,13 +16,18 @@ const { userFromSession, multiUserMode, } = require("../utils/http"); -const { setupDataImports, setupLogoUploads } = require("../utils/files/multer"); +const { + setupDataImports, + setupLogoUploads, + setupPfpUploads, +} = 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 { handlePfpUploads } = setupPfpUploads(); const fs = require("fs"); const path = require("path"); const { @@ -41,6 +46,7 @@ const { getCustomModels } = require("../utils/helpers/customModels"); const { WorkspaceChats } = require("../models/workspaceChats"); const { Workspace } = require("../models/workspace"); const { flexUserRoleValid } = require("../utils/middleware/multiUserProtected"); +const { fetchPfp, determinePfpFilepath } = require("../utils/files/pfp"); function systemEndpoints(app) { if (!app) return; @@ -399,7 +405,12 @@ function systemEndpoints(app) { try { const defaultFilename = getDefaultFilename(); const logoPath = await determineLogoFilepath(defaultFilename); - const { buffer, size, mime } = fetchLogo(logoPath); + const { found, buffer, size, mime } = fetchLogo(logoPath); + if (!found) { + response.sendStatus(204).end(); + return; + } + response.writeHead(200, { "Content-Type": mime || "image/png", "Content-Disposition": `attachment; filename=${path.basename( @@ -415,6 +426,110 @@ function systemEndpoints(app) { } }); + app.get("/system/pfp/:id", async function (request, response) { + try { + const { id } = request.params; + const pfpPath = await determinePfpFilepath(id); + + if (!pfpPath) { + response.sendStatus(204).end(); + return; + } + + const { found, buffer, size, mime } = fetchPfp(pfpPath); + if (!found) { + response.sendStatus(204).end(); + return; + } + + response.writeHead(200, { + "Content-Type": mime || "image/png", + "Content-Disposition": `attachment; filename=${path.basename(pfpPath)}`, + "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-pfp", + [validatedRequest, flexUserRoleValid], + handlePfpUploads.single("file"), + async function (request, response) { + try { + const user = await userFromSession(request, response); + const uploadedFileName = request.randomFileName; + + if (!uploadedFileName) { + return response.status(400).json({ message: "File upload failed." }); + } + + const userRecord = await User.get({ id: user.id }); + const oldPfpFilename = userRecord.pfpFilename; + console.log("oldPfpFilename", oldPfpFilename); + if (oldPfpFilename) { + const oldPfpPath = path.join( + __dirname, + `../storage/assets/pfp/${oldPfpFilename}` + ); + + if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath); + } + + const { success, error } = await User.update(user.id, { + pfpFilename: uploadedFileName, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Profile picture uploaded successfully." + : error || "Failed to update with new profile picture.", + }); + } catch (error) { + console.error("Error processing the profile picture upload:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.delete( + "/system/remove-pfp", + [validatedRequest, flexUserRoleValid], + async function (request, response) { + try { + const user = await userFromSession(request, response); + const userRecord = await User.get({ id: user.id }); + const oldPfpFilename = userRecord.pfpFilename; + console.log("oldPfpFilename", oldPfpFilename); + if (oldPfpFilename) { + const oldPfpPath = path.join( + __dirname, + `../storage/assets/pfp/${oldPfpFilename}` + ); + + if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath); + } + + const { success, error } = await User.update(user.id, { + pfpFilename: null, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Profile picture removed successfully." + : error || "Failed to remove profile picture.", + }); + } catch (error) { + console.error("Error processing the profile picture removal:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + app.post( "/system/upload-logo", [validatedRequest, flexUserRoleValid], @@ -738,6 +853,40 @@ function systemEndpoints(app) { } } ); + + app.post("/system/user", [validatedRequest], async (request, response) => { + try { + const sessionUser = await userFromSession(request, response); + const { username, password } = reqBody(request); + const id = Number(sessionUser.id); + + if (!id) { + response.status(400).json({ success: false, error: "Invalid user ID" }); + return; + } + + const updates = {}; + if (username) { + updates.username = username; + } + if (password) { + updates.password = password; + } + + if (Object.keys(updates).length === 0) { + response + .status(400) + .json({ success: false, error: "No updates provided" }); + return; + } + + const { success, error } = await User.update(id, updates); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); } module.exports = { systemEndpoints }; diff --git a/server/prisma/migrations/20231129012019_add/migration.sql b/server/prisma/migrations/20231129012019_add/migration.sql new file mode 100644 index 000000000..7e37f7e89 --- /dev/null +++ b/server/prisma/migrations/20231129012019_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "pfpFilename" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b2661e384..e9aa8a8a5 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -57,6 +57,7 @@ model users { id Int @id @default(autoincrement()) username String? @unique password String + pfpFilename String? role String @default("default") suspended Int @default(0) createdAt DateTime @default(now()) diff --git a/server/utils/files/logo.js b/server/utils/files/logo.js index 14e8032f9..eb4738b09 100644 --- a/server/utils/files/logo.js +++ b/server/utils/files/logo.js @@ -41,6 +41,7 @@ function fetchLogo(logoPath) { const mime = getType(logoPath); const buffer = fs.readFileSync(logoPath); return { + found: true, buffer, size: buffer.length, mime, diff --git a/server/utils/files/multer.js b/server/utils/files/multer.js index cc12ac9fd..9c2967e01 100644 --- a/server/utils/files/multer.js +++ b/server/utils/files/multer.js @@ -1,6 +1,7 @@ const multer = require("multer"); const path = require("path"); const fs = require("fs"); +const { v4 } = require("uuid"); function setupMulter() { // Handle File uploads for auto-uploading. @@ -40,7 +41,10 @@ function setupLogoUploads() { // Handle Logo uploads. const storage = multer.diskStorage({ destination: function (_, _, cb) { - const uploadOutput = path.resolve(__dirname, `../../storage/assets`); + const uploadOutput = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../storage/assets`) + : path.resolve(process.env.STORAGE_DIR, "assets"); fs.mkdirSync(uploadOutput, { recursive: true }); return cb(null, uploadOutput); }, @@ -52,8 +56,29 @@ function setupLogoUploads() { return { handleLogoUploads: multer({ storage }) }; } +function setupPfpUploads() { + const storage = multer.diskStorage({ + destination: function (_, _, cb) { + const uploadOutput = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../storage/assets/pfp`) + : path.resolve(process.env.STORAGE_DIR, "assets/pfp"); + fs.mkdirSync(uploadOutput, { recursive: true }); + return cb(null, uploadOutput); + }, + filename: function (req, file, cb) { + const randomFileName = `${v4()}${path.extname(file.originalname)}`; + req.randomFileName = randomFileName; + cb(null, randomFileName); + }, + }); + + return { handlePfpUploads: multer({ storage }) }; +} + module.exports = { setupMulter, setupDataImports, setupLogoUploads, + setupPfpUploads, }; diff --git a/server/utils/files/pfp.js b/server/utils/files/pfp.js new file mode 100644 index 000000000..30c42a519 --- /dev/null +++ b/server/utils/files/pfp.js @@ -0,0 +1,44 @@ +const path = require("path"); +const fs = require("fs"); +const { getType } = require("mime"); +const { User } = require("../../models/user"); + +function fetchPfp(pfpPath) { + if (!fs.existsSync(pfpPath)) { + return { + found: false, + buffer: null, + size: 0, + mime: "none/none", + }; + } + + const mime = getType(pfpPath); + const buffer = fs.readFileSync(pfpPath); + return { + found: true, + buffer, + size: buffer.length, + mime, + }; +} + +async function determinePfpFilepath(id) { + const numberId = Number(id); + const user = await User.get({ id: numberId }); + const pfpFilename = user.pfpFilename; + if (!pfpFilename) return null; + + const basePath = process.env.STORAGE_DIR + ? path.join(process.env.STORAGE_DIR, "assets/pfp") + : path.join(__dirname, "../../storage/assets/pfp"); + const pfpFilepath = path.join(basePath, pfpFilename); + + if (!fs.existsSync(pfpFilepath)) return null; + return pfpFilepath; +} + +module.exports = { + fetchPfp, + determinePfpFilepath, +}; -- GitLab