diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 558e8ae3..2d1eeb7d 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 (
}>
-
- } />
- } />
- }
- />
- } />
+
+
+
+ } />
+ } />
+ }
+ />
+ } />
- {/* Admin */}
- }
- />
- }
- />
- }
- />
- {/* Manager */}
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- {/* Onboarding Flow */}
- } />
-
-
+ {/* Admin */}
+ }
+ />
+ }
+ />
+ }
+ />
+ {/* Manager */}
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ {/* Onboarding Flow */}
+ } />
+
+
+
+
);
diff --git a/frontend/src/LogoContext.jsx b/frontend/src/LogoContext.jsx
new file mode 100644
index 00000000..6818967b
--- /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 (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/PfpContext.jsx b/frontend/src/PfpContext.jsx
new file mode 100644
index 00000000..3d60d559
--- /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 (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/UserIcon/index.jsx b/frontend/src/components/UserIcon/index.jsx
index 40694606..6cc9b57d 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 (
-
+
+
+ {role === "user" && pfp && (
+
+ )}
+
);
}
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 8bfc1b93..61e111da 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 (
+
+
+
+ );
+ }
+
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"
>
+ {mode === "multi" && !!user && (
+
+ )}
)}
+ {user && showAccountSettings && (
+ setShowAccountSettings(false)}
+ />
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
Edit Account
+
+
+
+
);
}
diff --git a/frontend/src/hooks/useLogo.js b/frontend/src/hooks/useLogo.js
index f03d2098..4834b7a8 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 00000000..36c54497
--- /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 2ef4eebb..79c203d9 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 e823dd0b..7a992e91 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 496a4ff4..30e87b0a 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 22ce8ef1..024bdd99 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 00000000..7e37f7e8
--- /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 b2661e38..e9aa8a8a 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 14e8032f..eb4738b0 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 cc12ac9f..9c2967e0 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 00000000..30c42a51
--- /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,
+};