Skip to content

Commit d457057

Browse files
committed
feat: employ file strategies for upload/delete files
1 parent a505082 commit d457057

File tree

10 files changed

+273
-75
lines changed

10 files changed

+273
-75
lines changed

api/server/routes/files/files.js

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,14 @@
11
const { z } = require('zod');
2-
const path = require('path');
3-
const fs = require('fs').promises;
42
const express = require('express');
3+
const { FileSources } = require('librechat-data-provider');
4+
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
55
const { deleteFiles, getFiles } = require('~/models');
66
const { logger } = require('~/config');
77

88
const router = express.Router();
99

1010
const isUUID = z.string().uuid();
1111

12-
const isValidPath = (req, base, subfolder, filepath) => {
13-
const normalizedBase = path.resolve(base, subfolder, req.user.id);
14-
const normalizedFilepath = path.resolve(filepath);
15-
return normalizedFilepath.startsWith(normalizedBase);
16-
};
17-
18-
const deleteFile = async (req, file) => {
19-
const { publicPath } = req.app.locals.paths;
20-
const parts = file.filepath.split(path.sep);
21-
const subfolder = parts[1];
22-
const filepath = path.join(publicPath, file.filepath);
23-
24-
if (!isValidPath(req, publicPath, subfolder, filepath)) {
25-
throw new Error('Invalid file path');
26-
}
27-
28-
await fs.unlink(filepath);
29-
};
30-
3112
router.get('/', async (req, res) => {
3213
try {
3314
const files = await getFiles({ user: req.user.id });
@@ -41,6 +22,8 @@ router.get('/', async (req, res) => {
4122
router.delete('/', async (req, res) => {
4223
try {
4324
const { files: _files } = req.body;
25+
26+
/** @type {MongoFile[]} */
4427
const files = _files.filter((file) => {
4528
if (!file.file_id) {
4629
return false;
@@ -57,9 +40,24 @@ router.delete('/', async (req, res) => {
5740
}
5841

5942
const file_ids = files.map((file) => file.file_id);
43+
const deletionMethods = {};
6044
const promises = [];
6145
promises.push(await deleteFiles(file_ids));
46+
6247
for (const file of files) {
48+
const source = file.source ?? FileSources.local;
49+
50+
if (deletionMethods[source]) {
51+
promises.push(deletionMethods[source](req, file));
52+
continue;
53+
}
54+
55+
const { deleteFile } = getStrategyFunctions(source);
56+
if (!deleteFile) {
57+
throw new Error(`Delete function not implemented for ${source}`);
58+
}
59+
60+
deletionMethods[source] = deleteFile;
6361
promises.push(deleteFile(req, file));
6462
}
6563

api/server/routes/files/images.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const { z } = require('zod');
22
const fs = require('fs').promises;
33
const express = require('express');
44
const upload = require('./multer');
5-
const { localStrategy } = require('~/server/services/Files');
5+
const { processImageUpload } = require('~/server/services/Files/process');
66
const { logger } = require('~/config');
77

88
const router = express.Router();
@@ -35,7 +35,7 @@ router.post('/', upload.single('file'), async (req, res) => {
3535
metadata.temp_file_id = metadata.file_id;
3636
metadata.file_id = req.file_id;
3737

38-
await localStrategy({ req, res, file, metadata });
38+
await processImageUpload({ req, res, file, metadata });
3939
} catch (error) {
4040
logger.error('[/files/images] Error processing file:', error);
4141
try {

api/server/services/Files/Firebase/crud.js

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const { getFirebaseStorage } = require('./initialize');
88
* @param {string} fileName - The name of the file to delete.
99
* @returns {Promise<void>} A promise that resolves when the file is deleted.
1010
*/
11-
async function deleteFileFromFirebase(basePath, fileName) {
11+
async function deleteFile(basePath, fileName) {
1212
const storage = getFirebaseStorage();
1313
if (!storage) {
1414
console.error('Firebase is not initialized. Cannot delete file from Firebase Storage.');
@@ -27,39 +27,38 @@ async function deleteFileFromFirebase(basePath, fileName) {
2727
}
2828

2929
/**
30-
* Saves an image from a given URL to Firebase Storage. The function first initializes the Firebase Storage
31-
* reference, then uploads the image to a specified basePath in the Firebase Storage. It handles initialization
30+
* Saves an file from a given URL to Firebase Storage. The function first initializes the Firebase Storage
31+
* reference, then uploads the file to a specified basePath in the Firebase Storage. It handles initialization
3232
* errors and upload errors, logging them to the console. If the upload is successful, the file name is returned.
3333
*
3434
* @param {Object} params - The parameters object.
3535
* @param {string} params.userId - The user's unique identifier. This is used to create a user-specific basePath
3636
* in Firebase Storage.
37-
* @param {string} params.URL - The URL of the image to be uploaded. The image at this URL will be fetched
37+
* @param {string} params.URL - The URL of the file to be uploaded. The file at this URL will be fetched
3838
* and uploaded to Firebase Storage.
39-
* @param {string} params.fileName - The name that will be used to save the image in Firebase Storage. This
39+
* @param {string} params.fileName - The name that will be used to save the file in Firebase Storage. This
4040
* should include the file extension.
41-
* @param {string} [params.basePath='images'] - Optional. The base basePath in Firebase Storage where the image will
41+
* @param {string} [params.basePath='images'] - Optional. The base basePath in Firebase Storage where the file will
4242
* be stored. Defaults to 'images' if not specified.
4343
*
4444
* @returns {Promise<string|null>}
45-
* A promise that resolves to the file name if the image is successfully uploaded, or null if there
45+
* A promise that resolves to the file name if the file is successfully uploaded, or null if there
4646
* is an error in initialization or upload.
4747
*/
4848
async function saveURLToFirebase({ userId, URL, fileName, basePath = 'images' }) {
4949
const storage = getFirebaseStorage();
5050
if (!storage) {
51-
console.error('Firebase is not initialized. Cannot save image to Firebase Storage.');
51+
console.error('Firebase is not initialized. Cannot save file to Firebase Storage.');
5252
return null;
5353
}
5454

5555
const storageRef = ref(storage, `${basePath}/${userId.toString()}/${fileName}`);
5656

5757
try {
58-
// Upload image to Firebase Storage using the image URL
5958
await uploadBytes(storageRef, await fetch(URL).then((response) => response.buffer()));
6059
return fileName;
6160
} catch (error) {
62-
console.error('Error uploading image to Firebase Storage:', error.message);
61+
console.error('Error uploading file to Firebase Storage:', error.message);
6362
return null;
6463
}
6564
}
@@ -97,8 +96,78 @@ async function getFirebaseURL({ fileName, basePath = 'images' }) {
9796
}
9897
}
9998

99+
/**
100+
* Uploads a buffer to Firebase Storage.
101+
*
102+
* @param {Object} params - The parameters object.
103+
* @param {string} params.userId - The user's unique identifier. This is used to create a user-specific basePath
104+
* in Firebase Storage.
105+
* @param {string} params.fileName - The name of the file to be saved in Firebase Storage.
106+
* @param {string} params.buffer - The buffer to be uploaded.
107+
* @param {string} [params.basePath='images'] - Optional. The base basePath in Firebase Storage where the file will
108+
* be stored. Defaults to 'images' if not specified.
109+
*
110+
* @returns {Promise<string>} - A promise that resolves to the download URL of the uploaded file.
111+
*/
112+
async function saveBufferToFirebase({ userId, buffer, fileName, basePath = 'images' }) {
113+
const storage = getFirebaseStorage();
114+
if (!storage) {
115+
throw new Error('Firebase is not initialized');
116+
}
117+
118+
const storageRef = ref(storage, `${basePath}/${userId}/${fileName}`);
119+
await uploadBytes(storageRef, buffer);
120+
121+
// Assuming you have a function to get the download URL
122+
return await getFirebaseURL({ fileName, basePath: `${basePath}/${userId}` });
123+
}
124+
125+
/**
126+
* Extracts and decodes the file path from a Firebase Storage URL.
127+
*
128+
* @param {string} urlString - The Firebase Storage URL.
129+
* @returns {string} The decoded file path.
130+
*/
131+
function extractFirebaseFilePath(urlString) {
132+
try {
133+
const url = new URL(urlString);
134+
const pathRegex = /\/o\/(.+?)(\?|$)/;
135+
const match = url.pathname.match(pathRegex);
136+
137+
if (match && match[1]) {
138+
return decodeURIComponent(match[1]);
139+
}
140+
141+
return '';
142+
} catch (error) {
143+
// If URL parsing fails, return an empty string
144+
return '';
145+
}
146+
}
147+
148+
/**
149+
* Deletes a file from Firebase storage. This function determines the subfolder (either 'images' or 'documents')
150+
* based on the file type and constructs a Firebase storage path using the user's ID and the file name.
151+
* It then deletes the file from Firebase storage.
152+
*
153+
* @param {Object} req - The request object from Express. It should contain a `user` object with an `id` property.
154+
* @param {Object} file - The file object to be deleted. It should have `filepath` and `type` properties.
155+
* `filepath` is a string representing the file's name, and `type` is used to determine
156+
* the file's storage subfolder in Firebase.
157+
*
158+
* @returns {Promise<void>}
159+
* A promise that resolves when the file has been successfully deleted from Firebase storage.
160+
* Throws an error if there is an issue with deletion.
161+
*/
162+
const deleteFirebaseFile = async (req, file) => {
163+
const fileName = extractFirebaseFilePath(file.filepath);
164+
await deleteFile('', fileName);
165+
};
166+
100167
module.exports = {
101-
saveURLToFirebase,
168+
deleteFile,
102169
getFirebaseURL,
103-
deleteFileFromFirebase,
170+
saveURLToFirebase,
171+
deleteFirebaseFile,
172+
saveBufferToFirebase,
104173
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const sharp = require('sharp');
4+
const { saveBufferToFirebase } = require('./crud');
5+
const { resizeImage } = require('../images/resize');
6+
7+
/**
8+
* Converts an image file to the WebP format. The function first resizes the image based on the specified
9+
* resolution.
10+
*
11+
*
12+
* @param {Object} req - The request object from Express. It should have a `user` property with an `id`
13+
* representing the user, and an `app.locals.paths` object with an `imageOutput` path.
14+
* @param {Express.Multer.File} file - The file object, which is part of the request. The file object should
15+
* have a `path` property that points to the location of the uploaded file.
16+
* @param {string} [resolution='high'] - Optional. The desired resolution for the image resizing. Default is 'high'.
17+
*
18+
* @returns {Promise<{ filepath: string, bytes: number, width: number, height: number}>}
19+
* A promise that resolves to an object containing:
20+
* - filepath: The path where the converted WebP image is saved.
21+
* - bytes: The size of the converted image in bytes.
22+
* - width: The width of the converted image.
23+
* - height: The height of the converted image.
24+
*/
25+
async function uploadImageToFirebase(req, file, resolution = 'high') {
26+
const inputFilePath = file.path;
27+
const { buffer: resizedBuffer, width, height } = await resizeImage(inputFilePath, resolution);
28+
const extension = path.extname(inputFilePath);
29+
const userId = req.user.id;
30+
31+
let webPBuffer;
32+
let fileName = path.basename(inputFilePath);
33+
if (extension.toLowerCase() === '.webp') {
34+
webPBuffer = resizedBuffer;
35+
} else {
36+
webPBuffer = await sharp(resizedBuffer).toFormat('webp').toBuffer();
37+
// Replace or append the correct extension
38+
const extRegExp = new RegExp(path.extname(fileName) + '$');
39+
fileName = fileName.replace(extRegExp, '.webp');
40+
if (!path.extname(fileName)) {
41+
fileName += '.webp';
42+
}
43+
}
44+
45+
const downloadURL = await saveBufferToFirebase({ userId, buffer: webPBuffer, fileName });
46+
47+
await fs.promises.unlink(inputFilePath);
48+
49+
const bytes = Buffer.byteLength(webPBuffer);
50+
return { filepath: downloadURL, bytes, width, height };
51+
}
52+
53+
module.exports = { uploadImageToFirebase };
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
const crud = require('./crud');
2+
const images = require('./images');
23
const initialize = require('./initialize');
34

45
module.exports = {
56
...crud,
7+
...images,
68
...initialize,
79
};

api/server/services/Files/Local/crud.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,49 @@ async function getLocalFileURL({ fileName, basePath = 'images' }) {
126126
return path.posix.join('/', basePath, fileName);
127127
}
128128

129-
module.exports = { saveFile, saveLocalImage, saveFileFromURL, getLocalFileURL };
129+
/**
130+
* Validates if a given filepath is within a specified subdirectory under a base path. This function constructs
131+
* the expected base path using the base, subfolder, and user id from the request, and then checks if the
132+
* provided filepath starts with this constructed base path.
133+
*
134+
* @param {Express.Request} req - The request object from Express. It should contain a `user` property with an `id`.
135+
* @param {string} base - The base directory path.
136+
* @param {string} subfolder - The subdirectory under the base path.
137+
* @param {string} filepath - The complete file path to be validated.
138+
*
139+
* @returns {boolean}
140+
* Returns true if the filepath is within the specified base and subfolder, false otherwise.
141+
*/
142+
const isValidPath = (req, base, subfolder, filepath) => {
143+
const normalizedBase = path.resolve(base, subfolder, req.user.id);
144+
const normalizedFilepath = path.resolve(filepath);
145+
return normalizedFilepath.startsWith(normalizedBase);
146+
};
147+
148+
/**
149+
* Deletes a file from the filesystem. This function takes a file object, constructs the full path, and
150+
* verifies the path's validity before deleting the file. If the path is invalid, an error is thrown.
151+
*
152+
* @param {Express.Request} req - The request object from Express. It should have an `app.locals.paths` object with
153+
* a `publicPath` property.
154+
* @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is
155+
* a string representing the path of the file relative to the publicPath.
156+
*
157+
* @returns {Promise<void>}
158+
* A promise that resolves when the file has been successfully deleted, or throws an error if the
159+
* file path is invalid or if there is an error in deletion.
160+
*/
161+
const deleteLocalFile = async (req, file) => {
162+
const { publicPath } = req.app.locals.paths;
163+
const parts = file.filepath.split(path.sep);
164+
const subfolder = parts[1];
165+
const filepath = path.join(publicPath, file.filepath);
166+
167+
if (!isValidPath(req, publicPath, subfolder, filepath)) {
168+
throw new Error('Invalid file path');
169+
}
170+
171+
await fs.promises.unlink(filepath);
172+
};
173+
174+
module.exports = { saveFile, saveLocalImage, saveFileFromURL, getLocalFileURL, deleteLocalFile };

api/server/services/Files/Local/images.js

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
const fs = require('fs');
22
const path = require('path');
33
const sharp = require('sharp');
4-
const { updateFile, createFile } = require('~/models');
54
const { resizeImage } = require('../images/resize');
5+
const { updateFile } = require('~/models');
66

77
/**
88
* Converts an image file to the WebP format. The function first resizes the image based on the specified
@@ -26,7 +26,7 @@ const { resizeImage } = require('../images/resize');
2626
* - width: The width of the converted image.
2727
* - height: The height of the converted image.
2828
*/
29-
async function convertToWebP(req, file, resolution = 'high') {
29+
async function uploadLocalImage(req, file, resolution = 'high') {
3030
const inputFilePath = file.path;
3131
const { buffer: resizedBuffer, width, height } = await resizeImage(inputFilePath, resolution);
3232
const extension = path.extname(inputFilePath);
@@ -94,36 +94,4 @@ async function encodeLocal(req, file) {
9494
return await Promise.all(promises);
9595
}
9696

97-
/**
98-
* Applies the local strategy for image uploads.
99-
* Saves file metadata to the database with an expiry TTL.
100-
* Files must be deleted from the server filesystem manually.
101-
*
102-
* @param {Object} params - The parameters object.
103-
* @param {Express.Request} params.req - The Express request object.
104-
* @param {Express.Response} params.res - The Express response object.
105-
* @param {Express.Multer.File} params.file - The uploaded file.
106-
* @param {ImageMetadata} params.metadata - Additional metadata for the file.
107-
* @returns {Promise<void>}
108-
*/
109-
const saveLocalImage = async ({ req, res, file, metadata }) => {
110-
const { file_id, temp_file_id } = metadata;
111-
const { filepath, bytes, width, height } = await convertToWebP(req, file);
112-
const result = await createFile(
113-
{
114-
user: req.user.id,
115-
file_id,
116-
temp_file_id,
117-
bytes,
118-
filepath,
119-
filename: file.originalname,
120-
type: 'image/webp',
121-
width,
122-
height,
123-
},
124-
true,
125-
);
126-
res.status(200).json({ message: 'File uploaded and processed successfully', ...result });
127-
};
128-
129-
module.exports = { convertToWebP, encodeImage, encodeLocal, saveLocalImage };
97+
module.exports = { uploadLocalImage, encodeImage, encodeLocal };

0 commit comments

Comments
 (0)