mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🔥🚀 feat: CDN (Firebase) & feat: account section (#1438)
* localization + api-endpoint * docs: added firebase documentation * chore: icons * chore: SettingsTabs * feat: account pannel; fix: gear icons * docs: position update * feat: firebase * feat: plugin support * route * fixed bugs with firebase and moved a lot of files * chore(DALLE3): using UUID v4 * feat: support for social strategies; moved '/images' path * fix: data ignored * gitignore update * docs: update firebase guide * refactor: Firebase - use singleton pattern for firebase initialization, initially on server start - reorganize imports, move firebase specific files to own service under Files - rename modules to remove 'avatar' redundancy - fix imports based on changes * ci(DALLE/DALLE3): fix tests to use logger and new expected outputs, add firebase tests * refactor(loadToolWithAuth): pass userId to tool as field * feat(images/parse): feat: Add URL Image Basename Extraction Implement a new module to extract the basename of an image from a given URL. This addition includes the function, which parses the URL and retrieves the basename using the Node.js 'url' and 'path' modules. The function is documented with JSDoc comments for better maintainability and understanding. This feature enhances the application's ability to handle and process image URLs efficiently. * refactor(addImages): function to use a more specific regular expression for observedImagePath based on the generated image markdown standard across the app * refactor(DALLE/DALLE3): utilize `getImageBasename` and `this.userId`; fix: pass correct image path to firebase url helper * fix(addImages): make more general to match any image markdown descriptor * fix(parse/getImageBasename): test result of this function for an actual image basename * ci(DALLE3): mock getImageBasename * refactor(AuthContext): use Recoil atom state for user * feat: useUploadAvatarMutation, react-query hook for avatar upload * fix(Toast): stack z-order of Toast over all components (1000) * refactor(showToast): add optional status field to avoid importing NotificationSeverity on each use of the function * refactor(routes/avatar): remove unnecessary get route, get userId from req.user.id, require auth on POST request * chore(uploadAvatar): TODO: remove direct use of Model, `User` * fix(client): fix Spinner imports * refactor(Avatar): use react-query hook, Toast, remove unnecessary states, add optimistic UI to upload * fix(avatar/localStrategy): correctly save local profile picture and cache bust for immediate rendering; fix: firebase init info message (only show once) * fix: use `includes` instead of `endsWith` for checking manual query of avatar image path in case more queries are appended (as is done in avatar/localStrategy) --------- Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
This commit is contained in:
parent
bd4d23d314
commit
f19f5dca8e
59 changed files with 1855 additions and 172 deletions
|
|
@ -60,12 +60,10 @@ function addImages(intermediateSteps, responseMessage) {
|
|||
if (!observation || !observation.includes('![')) {
|
||||
return;
|
||||
}
|
||||
const observedImagePath = observation.match(/\(\/images\/.*\.\w*\)/g);
|
||||
const observedImagePath = observation.match(/!\[.*\]\([^)]*\)/g);
|
||||
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
|
||||
responseMessage.text += '\n' + observation;
|
||||
if (process.env.DEBUG_PLUGINS) {
|
||||
logger.debug('[addImages] added image from intermediateSteps:', observation);
|
||||
}
|
||||
logger.debug('[addImages] added image from intermediateSteps:', observation);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,15 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const OpenAI = require('openai');
|
||||
// const { genAzureEndpoint } = require('~/utils/genAzureEndpoints');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { Tool } = require('langchain/tools');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const {
|
||||
saveImageToFirebaseStorage,
|
||||
getFirebaseStorageImageUrl,
|
||||
getFirebaseStorage,
|
||||
} = require('~/server/services/Files/Firebase');
|
||||
const { getImageBasename } = require('~/server/services/Files/images');
|
||||
const extractBaseURL = require('~/utils/extractBaseURL');
|
||||
const saveImageFromUrl = require('./saveImageFromUrl');
|
||||
const { logger } = require('~/config');
|
||||
|
|
@ -15,7 +22,9 @@ class OpenAICreateImage extends Tool {
|
|||
constructor(fields = {}) {
|
||||
super();
|
||||
|
||||
this.userId = fields.userId;
|
||||
let apiKey = fields.DALLE_API_KEY || this.getApiKey();
|
||||
|
||||
const config = { apiKey };
|
||||
if (DALLE_REVERSE_PROXY) {
|
||||
config.baseURL = extractBaseURL(DALLE_REVERSE_PROXY);
|
||||
|
|
@ -24,7 +33,6 @@ class OpenAICreateImage extends Tool {
|
|||
if (PROXY) {
|
||||
config.httpAgent = new HttpsProxyAgent(PROXY);
|
||||
}
|
||||
|
||||
// let azureKey = fields.AZURE_API_KEY || process.env.AZURE_API_KEY;
|
||||
|
||||
// if (azureKey) {
|
||||
|
|
@ -97,12 +105,11 @@ Guidelines:
|
|||
throw new Error('No image URL returned from OpenAI API.');
|
||||
}
|
||||
|
||||
const regex = /img-[\w\d]+.png/;
|
||||
const match = theImageUrl.match(regex);
|
||||
let imageName = '1.png';
|
||||
const imageBasename = getImageBasename(theImageUrl);
|
||||
let imageName = `image_${uuidv4()}.png`;
|
||||
|
||||
if (match) {
|
||||
imageName = match[0];
|
||||
if (imageBasename) {
|
||||
imageName = imageBasename;
|
||||
logger.debug('[DALL-E]', { imageName }); // Output: img-lgCf7ppcbhqQrz6a5ear6FOb.png
|
||||
} else {
|
||||
logger.debug('[DALL-E] No image name found in the string.', {
|
||||
|
|
@ -111,7 +118,18 @@ Guidelines:
|
|||
});
|
||||
}
|
||||
|
||||
this.outputPath = path.resolve(__dirname, '..', '..', '..', '..', 'client', 'public', 'images');
|
||||
this.outputPath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'client',
|
||||
'public',
|
||||
'images',
|
||||
this.userId,
|
||||
);
|
||||
|
||||
const appRoot = path.resolve(__dirname, '..', '..', '..', '..', 'client');
|
||||
this.relativeImageUrl = path.relative(appRoot, this.outputPath);
|
||||
|
||||
|
|
@ -120,14 +138,25 @@ Guidelines:
|
|||
fs.mkdirSync(this.outputPath, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await saveImageFromUrl(theImageUrl, this.outputPath, imageName);
|
||||
this.result = this.getMarkdownImageUrl(imageName);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the DALL-E image:', error);
|
||||
this.result = theImageUrl;
|
||||
const storage = getFirebaseStorage();
|
||||
if (storage) {
|
||||
try {
|
||||
await saveImageToFirebaseStorage(this.userId, theImageUrl, imageName);
|
||||
this.result = await getFirebaseStorageImageUrl(`${this.userId}/${imageName}`);
|
||||
logger.debug('[DALL-E] result: ' + this.result);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the image to Firebase Storage:', error);
|
||||
this.result = `Failed to save the image to Firebase Storage. ${error.message}`;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await saveImageFromUrl(theImageUrl, this.outputPath, imageName);
|
||||
this.result = this.getMarkdownImageUrl(imageName);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the image locally:', error);
|
||||
this.result = `Failed to save the image locally. ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,17 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const { z } = require('zod');
|
||||
const OpenAI = require('openai');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { Tool } = require('langchain/tools');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const saveImageFromUrl = require('../saveImageFromUrl');
|
||||
const {
|
||||
saveImageToFirebaseStorage,
|
||||
getFirebaseStorageImageUrl,
|
||||
getFirebaseStorage,
|
||||
} = require('~/server/services/Files/Firebase');
|
||||
const { getImageBasename } = require('~/server/services/Files/images');
|
||||
const extractBaseURL = require('~/utils/extractBaseURL');
|
||||
const saveImageFromUrl = require('../saveImageFromUrl');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const { DALLE3_SYSTEM_PROMPT, DALLE_REVERSE_PROXY, PROXY } = process.env;
|
||||
|
|
@ -15,6 +22,7 @@ class DALLE3 extends Tool {
|
|||
constructor(fields = {}) {
|
||||
super();
|
||||
|
||||
this.userId = fields.userId;
|
||||
let apiKey = fields.DALLE_API_KEY || this.getApiKey();
|
||||
const config = { apiKey };
|
||||
if (DALLE_REVERSE_PROXY) {
|
||||
|
|
@ -108,12 +116,12 @@ class DALLE3 extends Tool {
|
|||
n: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
return `Something went wrong when trying to generate the image. The DALL-E API may unavailable:
|
||||
return `Something went wrong when trying to generate the image. The DALL-E API may be unavailable:
|
||||
Error Message: ${error.message}`;
|
||||
}
|
||||
|
||||
if (!resp) {
|
||||
return 'Something went wrong when trying to generate the image. The DALL-E API may unavailable';
|
||||
return 'Something went wrong when trying to generate the image. The DALL-E API may be unavailable';
|
||||
}
|
||||
|
||||
const theImageUrl = resp.data[0].url;
|
||||
|
|
@ -122,12 +130,11 @@ Error Message: ${error.message}`;
|
|||
return 'No image URL returned from OpenAI API. There may be a problem with the API or your configuration.';
|
||||
}
|
||||
|
||||
const regex = /img-[\w\d]+.png/;
|
||||
const match = theImageUrl.match(regex);
|
||||
let imageName = '1.png';
|
||||
const imageBasename = getImageBasename(theImageUrl);
|
||||
let imageName = `image_${uuidv4()}.png`;
|
||||
|
||||
if (match) {
|
||||
imageName = match[0];
|
||||
if (imageBasename) {
|
||||
imageName = imageBasename;
|
||||
logger.debug('[DALL-E-3]', { imageName }); // Output: img-lgCf7ppcbhqQrz6a5ear6FOb.png
|
||||
} else {
|
||||
logger.debug('[DALL-E-3] No image name found in the string.', {
|
||||
|
|
@ -146,6 +153,7 @@ Error Message: ${error.message}`;
|
|||
'client',
|
||||
'public',
|
||||
'images',
|
||||
this.userId,
|
||||
);
|
||||
const appRoot = path.resolve(__dirname, '..', '..', '..', '..', '..', 'client');
|
||||
this.relativeImageUrl = path.relative(appRoot, this.outputPath);
|
||||
|
|
@ -154,13 +162,24 @@ Error Message: ${error.message}`;
|
|||
if (!fs.existsSync(this.outputPath)) {
|
||||
fs.mkdirSync(this.outputPath, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await saveImageFromUrl(theImageUrl, this.outputPath, imageName);
|
||||
this.result = this.getMarkdownImageUrl(imageName);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the image:', error);
|
||||
this.result = theImageUrl;
|
||||
const storage = getFirebaseStorage();
|
||||
if (storage) {
|
||||
try {
|
||||
await saveImageToFirebaseStorage(this.userId, theImageUrl, imageName);
|
||||
this.result = await getFirebaseStorageImageUrl(`${this.userId}/${imageName}`);
|
||||
logger.debug('[DALL-E-3] result: ' + this.result);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the image to Firebase Storage:', error);
|
||||
this.result = `Failed to save the image to Firebase Storage. ${error.message}`;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await saveImageFromUrl(theImageUrl, this.outputPath, imageName);
|
||||
this.result = this.getMarkdownImageUrl(imageName);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the image locally:', error);
|
||||
this.result = `Failed to save the image locally. ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return this.result;
|
||||
|
|
|
|||
|
|
@ -2,11 +2,40 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const OpenAI = require('openai');
|
||||
const DALLE3 = require('../DALLE3');
|
||||
const {
|
||||
getFirebaseStorage,
|
||||
saveImageToFirebaseStorage,
|
||||
} = require('~/server/services/Files/Firebase');
|
||||
const saveImageFromUrl = require('../../saveImageFromUrl');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
jest.mock('openai');
|
||||
|
||||
jest.mock('~/server/services/Files/Firebase', () => ({
|
||||
getFirebaseStorage: jest.fn(),
|
||||
saveImageToFirebaseStorage: jest.fn(),
|
||||
getFirebaseStorageImageUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/images', () => ({
|
||||
getImageBasename: jest.fn().mockImplementation((url) => {
|
||||
// Split the URL by '/'
|
||||
const parts = url.split('/');
|
||||
|
||||
// Get the last part of the URL
|
||||
const lastPart = parts.pop();
|
||||
|
||||
// Check if the last part of the URL matches the image extension regex
|
||||
const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg)$/i;
|
||||
if (imageExtensionRegex.test(lastPart)) {
|
||||
return lastPart;
|
||||
}
|
||||
|
||||
// If the regex test fails, return an empty string
|
||||
return '';
|
||||
}),
|
||||
}));
|
||||
|
||||
const generate = jest.fn();
|
||||
OpenAI.mockImplementation(() => ({
|
||||
images: {
|
||||
|
|
@ -187,7 +216,48 @@ describe('DALLE3', () => {
|
|||
generate.mockResolvedValue(mockResponse);
|
||||
saveImageFromUrl.mockRejectedValue(error);
|
||||
const result = await dalle._call(mockData);
|
||||
expect(logger.error).toHaveBeenCalledWith('Error while saving the image:', error);
|
||||
expect(result).toBe(mockResponse.data[0].url);
|
||||
expect(logger.error).toHaveBeenCalledWith('Error while saving the image locally:', error);
|
||||
expect(result).toBe('Failed to save the image locally. Error while saving the image');
|
||||
});
|
||||
|
||||
it('should save image to Firebase Storage if Firebase is initialized', async () => {
|
||||
const mockData = {
|
||||
prompt: 'A test prompt',
|
||||
};
|
||||
const mockImageUrl = 'http://example.com/img-test.png';
|
||||
const mockResponse = { data: [{ url: mockImageUrl }] };
|
||||
generate.mockResolvedValue(mockResponse);
|
||||
getFirebaseStorage.mockReturnValue({}); // Simulate Firebase being initialized
|
||||
|
||||
await dalle._call(mockData);
|
||||
|
||||
expect(getFirebaseStorage).toHaveBeenCalled();
|
||||
expect(saveImageToFirebaseStorage).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
mockImageUrl,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error when saving image to Firebase Storage fails', async () => {
|
||||
const mockData = {
|
||||
prompt: 'A test prompt',
|
||||
};
|
||||
const mockImageUrl = 'http://example.com/img-test.png';
|
||||
const mockResponse = { data: [{ url: mockImageUrl }] };
|
||||
const error = new Error('Error while saving to Firebase');
|
||||
generate.mockResolvedValue(mockResponse);
|
||||
getFirebaseStorage.mockReturnValue({}); // Simulate Firebase being initialized
|
||||
saveImageToFirebaseStorage.mockRejectedValue(error);
|
||||
|
||||
const result = await dalle._call(mockData);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error while saving the image to Firebase Storage:',
|
||||
error,
|
||||
);
|
||||
expect(result).toBe(
|
||||
'Failed to save the image to Firebase Storage. Error while saving to Firebase',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,19 +67,19 @@ const validateTools = async (user, tools = []) => {
|
|||
}
|
||||
};
|
||||
|
||||
const loadToolWithAuth = async (user, authFields, ToolConstructor, options = {}) => {
|
||||
const loadToolWithAuth = async (userId, authFields, ToolConstructor, options = {}) => {
|
||||
return async function () {
|
||||
let authValues = {};
|
||||
|
||||
for (const authField of authFields) {
|
||||
let authValue = process.env[authField];
|
||||
if (!authValue) {
|
||||
authValue = await getUserPluginAuthValue(user, authField);
|
||||
authValue = await getUserPluginAuthValue(userId, authField);
|
||||
}
|
||||
authValues[authField] = authValue;
|
||||
}
|
||||
|
||||
return new ToolConstructor({ ...options, ...authValues });
|
||||
return new ToolConstructor({ ...options, ...authValues, userId });
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"express-mongo-sanitize": "^2.2.0",
|
||||
"express-rate-limit": "^6.9.0",
|
||||
"express-session": "^1.17.3",
|
||||
"firebase": "^10.6.0",
|
||||
"googleapis": "^126.0.1",
|
||||
"handlebars": "^4.7.7",
|
||||
"html": "^1.0.0",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const cors = require('cors');
|
|||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { initializeFirebase } = require('~/server/services/Files/Firebase/initialize');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const { connectDb, indexSync } = require('~/lib/db');
|
||||
|
|
@ -23,6 +24,7 @@ const { jwtLogin, passportLogin } = require('~/strategies');
|
|||
const startServer = async () => {
|
||||
await connectDb();
|
||||
logger.info('Connected to MongoDB');
|
||||
initializeFirebase();
|
||||
await indexSync();
|
||||
|
||||
const app = express();
|
||||
|
|
|
|||
34
api/server/routes/files/avatar.js
Normal file
34
api/server/routes/files/avatar.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
|
||||
const uploadAvatar = require('~/server/services/Files/images/avatar/uploadAvatar');
|
||||
const { requireJwtAuth } = require('~/server/middleware/');
|
||||
const User = require('~/models/User');
|
||||
|
||||
const upload = multer();
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', requireJwtAuth, upload.single('input'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { manual } = req.body;
|
||||
const input = req.file.buffer;
|
||||
if (!userId) {
|
||||
throw new Error('User ID is undefined');
|
||||
}
|
||||
|
||||
// TODO: do not use Model directly, instead use a service method that uses the model
|
||||
const user = await User.findById(userId).lean();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
const url = await uploadAvatar(userId, input, manual);
|
||||
|
||||
res.json({ url });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'An error occurred while uploading the profile picture' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -18,5 +18,6 @@ router.use(uaParser);
|
|||
|
||||
router.use('/', files);
|
||||
router.use('/images', images);
|
||||
router.use('/images/avatar', require('./avatar'));
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
45
api/server/services/Files/Firebase/images.js
Normal file
45
api/server/services/Files/Firebase/images.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
const fetch = require('node-fetch');
|
||||
const { ref, uploadBytes, getDownloadURL } = require('firebase/storage');
|
||||
const { getFirebaseStorage } = require('./initialize');
|
||||
|
||||
async function saveImageToFirebaseStorage(userId, imageUrl, imageName) {
|
||||
const storage = getFirebaseStorage();
|
||||
if (!storage) {
|
||||
console.error('Firebase is not initialized. Cannot save image to Firebase Storage.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageRef = ref(storage, `images/${userId.toString()}/${imageName}`);
|
||||
|
||||
try {
|
||||
// Upload image to Firebase Storage using the image URL
|
||||
await uploadBytes(storageRef, await fetch(imageUrl).then((response) => response.buffer()));
|
||||
return imageName;
|
||||
} catch (error) {
|
||||
console.error('Error uploading image to Firebase Storage:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getFirebaseStorageImageUrl(imageName) {
|
||||
const storage = getFirebaseStorage();
|
||||
if (!storage) {
|
||||
console.error('Firebase is not initialized. Cannot get image URL from Firebase Storage.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageRef = ref(storage, `images/${imageName}`);
|
||||
|
||||
try {
|
||||
// Get the download URL for the image from Firebase Storage
|
||||
return `})`;
|
||||
} catch (error) {
|
||||
console.error('Error fetching image URL from Firebase Storage:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveImageToFirebaseStorage,
|
||||
getFirebaseStorageImageUrl,
|
||||
};
|
||||
7
api/server/services/Files/Firebase/index.js
Normal file
7
api/server/services/Files/Firebase/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const images = require('./images');
|
||||
const initialize = require('./initialize');
|
||||
|
||||
module.exports = {
|
||||
...images,
|
||||
...initialize,
|
||||
};
|
||||
42
api/server/services/Files/Firebase/initialize.js
Normal file
42
api/server/services/Files/Firebase/initialize.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const firebase = require('firebase/app');
|
||||
const { getStorage } = require('firebase/storage');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
let i = 0;
|
||||
let firebaseApp = null;
|
||||
|
||||
const initializeFirebase = () => {
|
||||
// Return existing instance if already initialized
|
||||
if (firebaseApp) {
|
||||
return firebaseApp;
|
||||
}
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.FIREBASE_API_KEY,
|
||||
authDomain: process.env.FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
if (Object.values(firebaseConfig).some((value) => !value)) {
|
||||
i === 0 &&
|
||||
logger.info(
|
||||
'[Optional] Firebase configuration missing or incomplete. Firebase will not be initialized.',
|
||||
);
|
||||
i++;
|
||||
return null;
|
||||
}
|
||||
|
||||
firebaseApp = firebase.initializeApp(firebaseConfig);
|
||||
logger.info('Firebase initialized');
|
||||
return firebaseApp;
|
||||
};
|
||||
|
||||
const getFirebaseStorage = () => {
|
||||
const app = initializeFirebase();
|
||||
return app ? getStorage(app) : null;
|
||||
};
|
||||
|
||||
module.exports = { initializeFirebase, getFirebaseStorage };
|
||||
29
api/server/services/Files/images/avatar/firebaseStrategy.js
Normal file
29
api/server/services/Files/images/avatar/firebaseStrategy.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const { ref, uploadBytes, getDownloadURL } = require('firebase/storage');
|
||||
const { getFirebaseStorage } = require('~/server/services/Files/Firebase/initialize');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
async function firebaseStrategy(userId, webPBuffer, oldUser, manual) {
|
||||
try {
|
||||
const storage = getFirebaseStorage();
|
||||
if (!storage) {
|
||||
throw new Error('Firebase is not initialized.');
|
||||
}
|
||||
const avatarRef = ref(storage, `images/${userId.toString()}/avatar`);
|
||||
|
||||
await uploadBytes(avatarRef, webPBuffer);
|
||||
const urlFirebase = await getDownloadURL(avatarRef);
|
||||
const isManual = manual === 'true';
|
||||
|
||||
const url = `${urlFirebase}?manual=${isManual}`;
|
||||
if (isManual) {
|
||||
oldUser.avatar = url;
|
||||
await oldUser.save();
|
||||
}
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error('Error uploading profile picture:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = firebaseStrategy;
|
||||
32
api/server/services/Files/images/avatar/localStrategy.js
Normal file
32
api/server/services/Files/images/avatar/localStrategy.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
async function localStrategy(userId, webPBuffer, oldUser, manual) {
|
||||
const userDir = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'client',
|
||||
'public',
|
||||
'images',
|
||||
userId,
|
||||
);
|
||||
let avatarPath = path.join(userDir, 'avatar.png');
|
||||
const urlRoute = `/images/${userId}/avatar.png`;
|
||||
await fs.mkdir(userDir, { recursive: true });
|
||||
await fs.writeFile(avatarPath, webPBuffer);
|
||||
const isManual = manual === 'true';
|
||||
let url = `${urlRoute}?manual=${isManual}×tamp=${new Date().getTime()}`;
|
||||
if (isManual) {
|
||||
oldUser.avatar = url;
|
||||
await oldUser.save();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
module.exports = localStrategy;
|
||||
63
api/server/services/Files/images/avatar/uploadAvatar.js
Normal file
63
api/server/services/Files/images/avatar/uploadAvatar.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
const sharp = require('sharp');
|
||||
const fetch = require('node-fetch');
|
||||
const fs = require('fs').promises;
|
||||
const User = require('~/models/User');
|
||||
const { getFirebaseStorage } = require('~/server/services/Files/Firebase/initialize');
|
||||
const firebaseStrategy = require('./firebaseStrategy');
|
||||
const localStrategy = require('./localStrategy');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
async function convertToWebP(inputBuffer) {
|
||||
return sharp(inputBuffer).resize({ width: 150 }).toFormat('webp').toBuffer();
|
||||
}
|
||||
|
||||
async function uploadAvatar(userId, input, manual) {
|
||||
try {
|
||||
if (userId === undefined) {
|
||||
throw new Error('User ID is undefined');
|
||||
}
|
||||
const _id = userId;
|
||||
// TODO: remove direct use of Model, `User`
|
||||
const oldUser = await User.findOne({ _id });
|
||||
let imageBuffer;
|
||||
if (typeof input === 'string') {
|
||||
const response = await fetch(input);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);
|
||||
}
|
||||
imageBuffer = await response.buffer();
|
||||
} else if (input instanceof Buffer) {
|
||||
imageBuffer = input;
|
||||
} else if (typeof input === 'object' && input instanceof File) {
|
||||
const fileContent = await fs.readFile(input.path);
|
||||
imageBuffer = Buffer.from(fileContent);
|
||||
} else {
|
||||
throw new Error('Invalid input type. Expected URL, Buffer, or File.');
|
||||
}
|
||||
const { width, height } = await sharp(imageBuffer).metadata();
|
||||
const minSize = Math.min(width, height);
|
||||
const squaredBuffer = await sharp(imageBuffer)
|
||||
.extract({
|
||||
left: Math.floor((width - minSize) / 2),
|
||||
top: Math.floor((height - minSize) / 2),
|
||||
width: minSize,
|
||||
height: minSize,
|
||||
})
|
||||
.toBuffer();
|
||||
const webPBuffer = await convertToWebP(squaredBuffer);
|
||||
const storage = getFirebaseStorage();
|
||||
if (storage) {
|
||||
const url = await firebaseStrategy(userId, webPBuffer, oldUser, manual);
|
||||
return url;
|
||||
}
|
||||
|
||||
const url = await localStrategy(userId, webPBuffer, oldUser, manual);
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error('Error uploading the avatar:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = uploadAvatar;
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
const convert = require('./convert');
|
||||
const encode = require('./encode');
|
||||
const parse = require('./parse');
|
||||
const resize = require('./resize');
|
||||
const validate = require('./validate');
|
||||
const uploadAvatar = require('./avatar/uploadAvatar');
|
||||
|
||||
module.exports = {
|
||||
...convert,
|
||||
...encode,
|
||||
...parse,
|
||||
...resize,
|
||||
...validate,
|
||||
uploadAvatar,
|
||||
};
|
||||
|
|
|
|||
27
api/server/services/Files/images/parse.js
Normal file
27
api/server/services/Files/images/parse.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
const URL = require('url').URL;
|
||||
const path = require('path');
|
||||
|
||||
const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg)$/i;
|
||||
|
||||
/**
|
||||
* Extracts the image basename from a given URL.
|
||||
*
|
||||
* @param {string} urlString - The URL string from which the image basename is to be extracted.
|
||||
* @returns {string} The basename of the image file from the URL.
|
||||
* Returns an empty string if the URL does not contain a valid image basename.
|
||||
*/
|
||||
function getImageBasename(urlString) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const basename = path.basename(url.pathname);
|
||||
|
||||
return imageExtensionRegex.test(basename) ? basename : '';
|
||||
} catch (error) {
|
||||
// If URL parsing fails, return an empty string
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getImageBasename,
|
||||
};
|
||||
|
|
@ -1,51 +1,72 @@
|
|||
const { Strategy: DiscordStrategy } = require('passport-discord');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
const { useFirebase, uploadAvatar } = require('~/server/services/Files/images');
|
||||
|
||||
const discordLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
const email = profile.email;
|
||||
const discordId = profile.id;
|
||||
const oldUser = await User.findOne({
|
||||
email,
|
||||
});
|
||||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
let avatarURL;
|
||||
let avatarUrl;
|
||||
|
||||
if (profile.avatar) {
|
||||
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
|
||||
avatarURL = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
|
||||
} else {
|
||||
const defaultAvatarNum = Number(profile.discriminator) % 5;
|
||||
avatarURL = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
|
||||
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
|
||||
}
|
||||
|
||||
if (oldUser) {
|
||||
oldUser.avatar = avatarURL;
|
||||
await oldUser.save();
|
||||
await handleExistingUser(oldUser, avatarUrl, useFirebase);
|
||||
return cb(null, oldUser);
|
||||
} else if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await new User({
|
||||
provider: 'discord',
|
||||
discordId,
|
||||
username: profile.username,
|
||||
email,
|
||||
name: profile.global_name,
|
||||
avatar: avatarURL,
|
||||
}).save();
|
||||
|
||||
return cb(null, newUser);
|
||||
}
|
||||
|
||||
return cb(null, false, {
|
||||
message: 'User not found.',
|
||||
});
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser(profile, discordId, email, avatarUrl, useFirebase);
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[discordLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => {
|
||||
if (!useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
const userId = oldUser._id;
|
||||
const newavatarUrl = await uploadAvatar(userId, avatarUrl);
|
||||
oldUser.avatar = newavatarUrl;
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
const createNewUser = async (profile, discordId, email, avatarUrl, useFirebase) => {
|
||||
const newUser = await new User({
|
||||
provider: 'discord',
|
||||
discordId,
|
||||
username: profile.username,
|
||||
email,
|
||||
name: profile.global_name,
|
||||
avatar: avatarUrl,
|
||||
}).save();
|
||||
|
||||
if (useFirebase) {
|
||||
const userId = newUser._id;
|
||||
const newavatarUrl = await uploadAvatar(userId, avatarUrl);
|
||||
newUser.avatar = newavatarUrl;
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
new DiscordStrategy(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,43 +1,64 @@
|
|||
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
const { useFirebase, uploadAvatar } = require('~/server/services/Files/images');
|
||||
|
||||
const facebookLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
const email = profile.emails[0]?.value;
|
||||
const facebookId = profile.id;
|
||||
const oldUser = await User.findOne({
|
||||
email,
|
||||
});
|
||||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0]?.value;
|
||||
|
||||
if (oldUser) {
|
||||
oldUser.avatar = profile.photo;
|
||||
await oldUser.save();
|
||||
await handleExistingUser(oldUser, avatarUrl, useFirebase);
|
||||
return cb(null, oldUser);
|
||||
} else if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await new User({
|
||||
provider: 'facebook',
|
||||
facebookId,
|
||||
username: profile.displayName,
|
||||
email,
|
||||
name: profile.name?.givenName + ' ' + profile.name?.familyName,
|
||||
avatar: profile.photos[0]?.value,
|
||||
}).save();
|
||||
|
||||
return cb(null, newUser);
|
||||
}
|
||||
|
||||
return cb(null, false, {
|
||||
message: 'User not found.',
|
||||
});
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser(profile, facebookId, email, avatarUrl, useFirebase);
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[facebookLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => {
|
||||
if (!useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
const userId = oldUser._id;
|
||||
const newavatarUrl = await uploadAvatar(userId, avatarUrl);
|
||||
oldUser.avatar = newavatarUrl;
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
const createNewUser = async (profile, facebookId, email, avatarUrl, useFirebase) => {
|
||||
const newUser = await new User({
|
||||
provider: 'facebook',
|
||||
facebookId,
|
||||
username: profile.displayName,
|
||||
email,
|
||||
name: profile.name?.givenName + ' ' + profile.name?.familyName,
|
||||
avatar: avatarUrl,
|
||||
}).save();
|
||||
|
||||
if (useFirebase) {
|
||||
const userId = newUser._id;
|
||||
const newavatarUrl = await uploadAvatar(userId, avatarUrl);
|
||||
newUser.avatar = newavatarUrl;
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
new FacebookStrategy(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const { Strategy: GitHubStrategy } = require('passport-github2');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
const { useFirebase, uploadAvatar } = require('~/server/services/Files/images');
|
||||
|
||||
const githubLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
|
|
@ -9,32 +10,56 @@ const githubLogin = async (accessToken, refreshToken, profile, cb) => {
|
|||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0].value;
|
||||
|
||||
if (oldUser) {
|
||||
oldUser.avatar = profile.photos[0].value;
|
||||
await oldUser.save();
|
||||
await handleExistingUser(oldUser, avatarUrl, useFirebase);
|
||||
return cb(null, oldUser);
|
||||
} else if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await new User({
|
||||
provider: 'github',
|
||||
githubId,
|
||||
username: profile.username,
|
||||
email,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
name: profile.displayName,
|
||||
avatar: profile.photos[0].value,
|
||||
}).save();
|
||||
|
||||
return cb(null, newUser);
|
||||
}
|
||||
|
||||
return cb(null, false, { message: 'User not found.' });
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser(profile, githubId, email, avatarUrl, useFirebase);
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[githubLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => {
|
||||
if (!useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
const userId = oldUser._id;
|
||||
const avatarURL = await uploadAvatar(userId, avatarUrl);
|
||||
oldUser.avatar = avatarURL;
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
const createNewUser = async (profile, githubId, email, avatarUrl, useFirebase) => {
|
||||
const newUser = await new User({
|
||||
provider: 'github',
|
||||
githubId,
|
||||
username: profile.username,
|
||||
email,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
name: profile.displayName,
|
||||
avatar: avatarUrl,
|
||||
}).save();
|
||||
|
||||
if (useFirebase) {
|
||||
const userId = newUser._id;
|
||||
const avatarURL = await uploadAvatar(userId, avatarUrl);
|
||||
newUser.avatar = avatarURL;
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
new GitHubStrategy(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
const { useFirebase, uploadAvatar } = require('~/server/services/Files/images');
|
||||
|
||||
const googleLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
|
|
@ -9,32 +10,56 @@ const googleLogin = async (accessToken, refreshToken, profile, cb) => {
|
|||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0].value;
|
||||
|
||||
if (oldUser) {
|
||||
oldUser.avatar = profile.photos[0].value;
|
||||
await oldUser.save();
|
||||
await handleExistingUser(oldUser, avatarUrl, useFirebase);
|
||||
return cb(null, oldUser);
|
||||
} else if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await new User({
|
||||
provider: 'google',
|
||||
googleId,
|
||||
username: profile.name.givenName,
|
||||
email,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
name: `${profile.name.givenName} ${profile.name.familyName}`,
|
||||
avatar: profile.photos[0].value,
|
||||
}).save();
|
||||
|
||||
return cb(null, newUser);
|
||||
}
|
||||
|
||||
return cb(null, false, { message: 'User not found.' });
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser(profile, googleId, email, avatarUrl, useFirebase);
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[googleLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => {
|
||||
if ((!useFirebase && !oldUser.avatar.includes('?manual=true')) || oldUser.avatar === null) {
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
const userId = oldUser._id;
|
||||
const avatarURL = await uploadAvatar(userId, avatarUrl);
|
||||
oldUser.avatar = avatarURL;
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
const createNewUser = async (profile, googleId, email, avatarUrl, useFirebase) => {
|
||||
const newUser = await new User({
|
||||
provider: 'google',
|
||||
googleId,
|
||||
username: profile.name.givenName,
|
||||
email,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
name: `${profile.name.givenName} ${profile.name.familyName}`,
|
||||
avatar: avatarUrl,
|
||||
}).save();
|
||||
|
||||
if (useFirebase) {
|
||||
const userId = newUser._id;
|
||||
const avatarURL = await uploadAvatar(userId, avatarUrl);
|
||||
newUser.avatar = avatarURL;
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
new GoogleStrategy(
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue