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>
This commit is contained in:
Sean Hatfield 2023-12-07 14:11:51 -08:00 committed by GitHub
parent f48e6b1a3e
commit fcb591d364
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 656 additions and 101 deletions

View File

@ -8,6 +8,8 @@ import PrivateRoute, {
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import Login from "@/pages/Login"; import Login from "@/pages/Login";
import { PfpProvider } from "./PfpContext";
import { LogoProvider } from "./LogoContext";
const Main = lazy(() => import("@/pages/Main")); const Main = lazy(() => import("@/pages/Main"));
const InvitePage = lazy(() => import("@/pages/Invite")); const InvitePage = lazy(() => import("@/pages/Invite"));
@ -40,69 +42,73 @@ export default function App() {
return ( return (
<Suspense fallback={<div />}> <Suspense fallback={<div />}>
<ContextWrapper> <ContextWrapper>
<Routes> <LogoProvider>
<Route path="/" element={<PrivateRoute Component={Main} />} /> <PfpProvider>
<Route path="/login" element={<Login />} /> <Routes>
<Route <Route path="/" element={<PrivateRoute Component={Main} />} />
path="/workspace/:slug" <Route path="/login" element={<Login />} />
element={<PrivateRoute Component={WorkspaceChat} />} <Route
/> path="/workspace/:slug"
<Route path="/accept-invite/:code" element={<InvitePage />} /> element={<PrivateRoute Component={WorkspaceChat} />}
/>
<Route path="/accept-invite/:code" element={<InvitePage />} />
{/* Admin */} {/* Admin */}
<Route <Route
path="/settings/llm-preference" path="/settings/llm-preference"
element={<AdminRoute Component={GeneralLLMPreference} />} element={<AdminRoute Component={GeneralLLMPreference} />}
/> />
<Route <Route
path="/settings/embedding-preference" path="/settings/embedding-preference"
element={<AdminRoute Component={GeneralEmbeddingPreference} />} element={<AdminRoute Component={GeneralEmbeddingPreference} />}
/> />
<Route <Route
path="/settings/vector-database" path="/settings/vector-database"
element={<AdminRoute Component={GeneralVectorDatabase} />} element={<AdminRoute Component={GeneralVectorDatabase} />}
/> />
{/* Manager */} {/* Manager */}
<Route <Route
path="/settings/export-import" path="/settings/export-import"
element={<ManagerRoute Component={GeneralExportImport} />} element={<ManagerRoute Component={GeneralExportImport} />}
/> />
<Route <Route
path="/settings/security" path="/settings/security"
element={<ManagerRoute Component={GeneralSecurity} />} element={<ManagerRoute Component={GeneralSecurity} />}
/> />
<Route <Route
path="/settings/appearance" path="/settings/appearance"
element={<ManagerRoute Component={GeneralAppearance} />} element={<ManagerRoute Component={GeneralAppearance} />}
/> />
<Route <Route
path="/settings/api-keys" path="/settings/api-keys"
element={<ManagerRoute Component={GeneralApiKeys} />} element={<ManagerRoute Component={GeneralApiKeys} />}
/> />
<Route <Route
path="/settings/workspace-chats" path="/settings/workspace-chats"
element={<ManagerRoute Component={GeneralChats} />} element={<ManagerRoute Component={GeneralChats} />}
/> />
<Route <Route
path="/settings/system-preferences" path="/settings/system-preferences"
element={<ManagerRoute Component={AdminSystem} />} element={<ManagerRoute Component={AdminSystem} />}
/> />
<Route <Route
path="/settings/invites" path="/settings/invites"
element={<ManagerRoute Component={AdminInvites} />} element={<ManagerRoute Component={AdminInvites} />}
/> />
<Route <Route
path="/settings/users" path="/settings/users"
element={<ManagerRoute Component={AdminUsers} />} element={<ManagerRoute Component={AdminUsers} />}
/> />
<Route <Route
path="/settings/workspaces" path="/settings/workspaces"
element={<ManagerRoute Component={AdminWorkspaces} />} element={<ManagerRoute Component={AdminWorkspaces} />}
/> />
{/* Onboarding Flow */} {/* Onboarding Flow */}
<Route path="/onboarding" element={<OnboardingFlow />} /> <Route path="/onboarding" element={<OnboardingFlow />} />
</Routes> </Routes>
<ToastContainer /> <ToastContainer />
</PfpProvider>
</LogoProvider>
</ContextWrapper> </ContextWrapper>
</Suspense> </Suspense>
); );

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,32 +1,35 @@
import React, { useRef, useEffect } from "react"; import React, { useRef, useEffect } from "react";
import JAZZ from "@metamask/jazzicon"; import JAZZ from "@metamask/jazzicon";
import usePfp from "../../hooks/usePfp";
export default function Jazzicon({ size = 10, user, role }) { export default function Jazzicon({ size = 10, user, role }) {
const { pfp } = usePfp();
const divRef = useRef(null); const divRef = useRef(null);
const seed = user?.uid const seed = user?.uid
? toPseudoRandomInteger(user.uid) ? toPseudoRandomInteger(user.uid)
: Math.floor(100000 + Math.random() * 900000); : Math.floor(100000 + Math.random() * 900000);
const result = JAZZ(size, seed);
useEffect(() => { useEffect(() => {
if (!divRef || !divRef.current) return null; if (!divRef.current || (role === "user" && pfp)) return;
const result = JAZZ(size, seed);
divRef.current.appendChild(result); divRef.current.appendChild(result);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [pfp, role, seed, size]);
return ( return (
<div <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden">
className={`flex ${role === "user" ? "user-reply" : ""}`} <div ref={divRef} />
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 = "") { function toPseudoRandomInteger(uidString = "") {
var numberArray = [uidString.length]; return uidString.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
for (var i = 0; i < uidString.length; i++) {
numberArray[i] = uidString.charCodeAt(i);
}
return numberArray.reduce((a, b) => a + b, 0);
} }

View File

@ -2,8 +2,12 @@ import React, { useState, useEffect, useRef } from "react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import paths from "@/utils/paths"; import paths from "@/utils/paths";
import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants"; 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 { 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 }) { export default function UserMenu({ children }) {
if (isMobile) return <>{children}</>; if (isMobile) return <>{children}</>;
@ -26,12 +30,28 @@ function useLoginMode() {
} }
function userDisplay() { function userDisplay() {
const { pfp } = usePfp();
const user = userFromStorage(); 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"; return user?.username?.slice(0, 2) || "AA";
} }
function UserButton() { function UserButton() {
const { user } = useUser();
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [showAccountSettings, setShowAccountSettings] = useState(false);
const mode = useLoginMode(); const mode = useLoginMode();
const menuRef = useRef(); const menuRef = useRef();
const buttonRef = useRef(); const buttonRef = useRef();
@ -45,6 +65,11 @@ function UserButton() {
} }
}; };
const handleOpenAccountModal = () => {
setShowAccountSettings(true);
setShowMenu(false);
};
useEffect(() => { useEffect(() => {
if (showMenu) { if (showMenu) {
document.addEventListener("mousedown", handleClose); 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" 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"> <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 <a
href={paths.mailToMintplex()} href={paths.mailToMintplex()}
className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" 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>
</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> </div>
); );
} }

View File

@ -1,22 +1,7 @@
import { useEffect, useState } from "react"; import { useContext } from "react";
import System from "@/models/system"; import { LogoContext } from "../LogoContext";
import AnythingLLM from "@/media/logo/anything-llm.png";
export default function useLogo() { export default function useLogo() {
const [logo, setLogo] = useState(""); const { logo, setLogo } = useContext(LogoContext);
return { logo, setLogo };
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 };
} }

View File

@ -0,0 +1,7 @@
import { useContext } from "react";
import { PfpContext } from "../PfpContext";
export default function usePfp() {
const { pfp, setPfp } = useContext(PfpContext);
return { pfp, setPfp };
}

View File

@ -170,6 +170,21 @@ const System = {
return { success: false, error: e.message }; 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) { uploadLogo: async function (formData) {
return await fetch(`${API_BASE}/system/upload-logo`, { return await fetch(`${API_BASE}/system/upload-logo`, {
method: "POST", method: "POST",
@ -191,7 +206,7 @@ const System = {
cache: "no-cache", cache: "no-cache",
}) })
.then((res) => { .then((res) => {
if (res.ok) return res.blob(); if (res.ok && res.status !== 204) return res.blob();
throw new Error("Failed to fetch logo!"); throw new Error("Failed to fetch logo!");
}) })
.then((blob) => URL.createObjectURL(blob)) .then((blob) => URL.createObjectURL(blob))
@ -200,6 +215,36 @@ const System = {
return null; 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 () { isDefaultLogo: async function () {
return await fetch(`${API_BASE}/system/is-default-logo`, { return await fetch(`${API_BASE}/system/is-default-logo`, {
method: "GET", method: "GET",
@ -374,6 +419,18 @@ const System = {
return null; 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; export default System;

View File

@ -10,7 +10,7 @@ import showToast from "@/utils/toast";
import { Plus } from "@phosphor-icons/react"; import { Plus } from "@phosphor-icons/react";
export default function Appearance() { export default function Appearance() {
const { logo: _initLogo } = useLogo(); const { logo: _initLogo, setLogo: _setLogo } = useLogo();
const [logo, setLogo] = useState(""); const [logo, setLogo] = useState("");
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
@ -49,6 +49,9 @@ export default function Appearance() {
return; return;
} }
const logoURL = await System.fetchLogo();
_setLogo(logoURL);
showToast("Image uploaded successfully.", "success"); showToast("Image uploaded successfully.", "success");
setIsDefaultLogo(false); setIsDefaultLogo(false);
}; };
@ -67,6 +70,9 @@ export default function Appearance() {
return; return;
} }
const logoURL = await System.fetchLogo();
_setLogo(logoURL);
showToast("Image successfully removed.", "success"); showToast("Image successfully removed.", "success");
}; };

View File

@ -6,7 +6,7 @@ import { Plus } from "@phosphor-icons/react";
import showToast from "@/utils/toast"; import showToast from "@/utils/toast";
function AppearanceSetup({ prevStep, nextStep }) { function AppearanceSetup({ prevStep, nextStep }) {
const { logo: _initLogo } = useLogo(); const { logo: _initLogo, setLogo: _setLogo } = useLogo();
const [logo, setLogo] = useState(""); const [logo, setLogo] = useState("");
const [isDefaultLogo, setIsDefaultLogo] = useState(true); const [isDefaultLogo, setIsDefaultLogo] = useState(true);
@ -35,6 +35,9 @@ function AppearanceSetup({ prevStep, nextStep }) {
return; return;
} }
const logoURL = await System.fetchLogo();
_setLogo(logoURL);
showToast("Image uploaded successfully.", "success"); showToast("Image uploaded successfully.", "success");
setIsDefaultLogo(false); setIsDefaultLogo(false);
}; };
@ -53,6 +56,9 @@ function AppearanceSetup({ prevStep, nextStep }) {
return; return;
} }
const logoURL = await System.fetchLogo();
_setLogo(logoURL);
showToast("Image successfully removed.", "success"); showToast("Image successfully removed.", "success");
}; };

View File

@ -16,13 +16,18 @@ const {
userFromSession, userFromSession,
multiUserMode, multiUserMode,
} = require("../utils/http"); } = require("../utils/http");
const { setupDataImports, setupLogoUploads } = require("../utils/files/multer"); const {
setupDataImports,
setupLogoUploads,
setupPfpUploads,
} = require("../utils/files/multer");
const { v4 } = require("uuid"); const { v4 } = require("uuid");
const { SystemSettings } = require("../models/systemSettings"); const { SystemSettings } = require("../models/systemSettings");
const { User } = require("../models/user"); const { User } = require("../models/user");
const { validatedRequest } = require("../utils/middleware/validatedRequest"); const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { handleImports } = setupDataImports(); const { handleImports } = setupDataImports();
const { handleLogoUploads } = setupLogoUploads(); const { handleLogoUploads } = setupLogoUploads();
const { handlePfpUploads } = setupPfpUploads();
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { const {
@ -41,6 +46,7 @@ const { getCustomModels } = require("../utils/helpers/customModels");
const { WorkspaceChats } = require("../models/workspaceChats"); const { WorkspaceChats } = require("../models/workspaceChats");
const { Workspace } = require("../models/workspace"); const { Workspace } = require("../models/workspace");
const { flexUserRoleValid } = require("../utils/middleware/multiUserProtected"); const { flexUserRoleValid } = require("../utils/middleware/multiUserProtected");
const { fetchPfp, determinePfpFilepath } = require("../utils/files/pfp");
function systemEndpoints(app) { function systemEndpoints(app) {
if (!app) return; if (!app) return;
@ -399,7 +405,12 @@ function systemEndpoints(app) {
try { try {
const defaultFilename = getDefaultFilename(); const defaultFilename = getDefaultFilename();
const logoPath = await determineLogoFilepath(defaultFilename); 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, { response.writeHead(200, {
"Content-Type": mime || "image/png", "Content-Type": mime || "image/png",
"Content-Disposition": `attachment; filename=${path.basename( "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( app.post(
"/system/upload-logo", "/system/upload-logo",
[validatedRequest, flexUserRoleValid], [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 }; module.exports = { systemEndpoints };

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "pfpFilename" TEXT;

View File

@ -57,6 +57,7 @@ model users {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String? @unique username String? @unique
password String password String
pfpFilename String?
role String @default("default") role String @default("default")
suspended Int @default(0) suspended Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -41,6 +41,7 @@ function fetchLogo(logoPath) {
const mime = getType(logoPath); const mime = getType(logoPath);
const buffer = fs.readFileSync(logoPath); const buffer = fs.readFileSync(logoPath);
return { return {
found: true,
buffer, buffer,
size: buffer.length, size: buffer.length,
mime, mime,

View File

@ -1,6 +1,7 @@
const multer = require("multer"); const multer = require("multer");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const { v4 } = require("uuid");
function setupMulter() { function setupMulter() {
// Handle File uploads for auto-uploading. // Handle File uploads for auto-uploading.
@ -40,7 +41,10 @@ function setupLogoUploads() {
// Handle Logo uploads. // Handle Logo uploads.
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: function (_, _, cb) { 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 }); fs.mkdirSync(uploadOutput, { recursive: true });
return cb(null, uploadOutput); return cb(null, uploadOutput);
}, },
@ -52,8 +56,29 @@ function setupLogoUploads() {
return { handleLogoUploads: multer({ storage }) }; 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 = { module.exports = {
setupMulter, setupMulter,
setupDataImports, setupDataImports,
setupLogoUploads, setupLogoUploads,
setupPfpUploads,
}; };

44
server/utils/files/pfp.js Normal file
View File

@ -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,
};