• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

NodeBB / NodeBB / 23305017021

19 Mar 2026 04:20PM UTC coverage: 85.425% (-0.1%) from 85.545%
23305017021

push

github

nodebb-misty
chore: update changelog for v4.10.0

13445 of 18442 branches covered (72.9%)

28384 of 33227 relevant lines covered (85.42%)

3333.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.39
/src/controllers/admin/uploads.js
1
'use strict';
2

3
const path = require('path');
4✔
4
const nconf = require('nconf');
4✔
5
const fs = require('fs');
4✔
6
const winston = require('winston');
4✔
7

8
const meta = require('../../meta');
4✔
9
const posts = require('../../posts');
4✔
10
const file = require('../../file');
4✔
11
const image = require('../../image');
4✔
12
const plugins = require('../../plugins');
4✔
13
const pagination = require('../../pagination');
4✔
14

15
const allowedImageTypes = [
4✔
16
        'image/png', 'image/jpeg', 'image/pjpeg', 'image/jpg', 'image/gif', 'image/svg+xml',
17
];
18

19
const uploadsController = module.exports;
4✔
20

21
uploadsController.get = async function (req, res, next) {
4✔
22
        const currentFolder = path.join(nconf.get('upload_path'), req.query.dir || '');
8✔
23
        if (!currentFolder.startsWith(nconf.get('upload_path'))) {
8!
24
                return next(new Error('[[error:invalid-path]]'));
×
25
        }
26
        const itemsPerPage = 20;
8✔
27
        const page = parseInt(req.query.page, 10) || 1;
8✔
28
        let files;
29
        try {
8✔
30
                await checkSymLinks(req.query.dir);
8✔
31
                files = await getFilesInFolder(currentFolder);
8✔
32
        } catch (err) {
33
                winston.error(err.stack);
×
34
                return next(new Error('[[error:invalid-path]]'));
×
35
        }
36
        try {
8✔
37
                const itemCount = files.length;
8✔
38
                const start = Math.max(0, (page - 1) * itemsPerPage);
8✔
39
                const stop = start + itemsPerPage;
8✔
40
                files = files.slice(start, stop);
8✔
41

42
                files = await filesToData(currentFolder, files);
8✔
43

44
                // Float directories to the top
45
                files.sort((a, b) => {
8✔
46
                        if (a.isDirectory && !b.isDirectory) {
24!
47
                                return -1;
×
48
                        } else if (!a.isDirectory && b.isDirectory) {
24!
49
                                return 1;
×
50
                        } else if (!a.isDirectory && !b.isDirectory) {
24!
51
                                return a.mtime < b.mtime ? -1 : 1;
×
52
                        }
53

54
                        return 0;
24✔
55
                });
56

57
                // Add post usage info if in /files
58
                if (['files', '/files', '/files/'].includes(req.query.dir)) {
8!
59
                        const usage = await posts.uploads.getUsage(files);
×
60
                        files.forEach((file, idx) => {
×
61
                                file.inPids = usage[idx].map(pid => parseInt(pid, 10));
×
62
                        });
63
                }
64
                res.render('admin/manage/uploads', {
8✔
65
                        currentFolder: currentFolder.replace(nconf.get('upload_path'), ''),
66
                        showPids: files.length && files[0].hasOwnProperty('inPids'),
16✔
67
                        files: files,
68
                        breadcrumbs: buildBreadcrumbs(currentFolder),
69
                        pagination: pagination.create(page, Math.ceil(itemCount / itemsPerPage), req.query),
70
                });
71
        } catch (err) {
72
                next(err);
×
73
        }
74
};
75

76
async function checkSymLinks(folder) {
77
        let dir = path.normalize(folder || '');
8✔
78
        while (dir.length && dir !== '.') {
8✔
79
                const nextPath = path.join(nconf.get('upload_path'), dir);
4✔
80
                // eslint-disable-next-line no-await-in-loop
81
                const stat = await fs.promises.lstat(nextPath);
4✔
82
                if (stat.isSymbolicLink()) {
4!
83
                        throw new Error('[[invalid-path]]');
×
84
                }
85
                const newDir = path.dirname(dir);
4✔
86
                if (newDir === dir) {
4!
87
                        break;
4✔
88
                }
89
                dir = newDir;
×
90
        }
91
}
92

93
async function getFilesInFolder(folder) {
94
        const dirents = await fs.promises.readdir(folder, { withFileTypes: true });
40✔
95
        const files = [];
40✔
96
        for await (const dirent of dirents) {
40✔
97
                if (!dirent.isSymbolicLink() && dirent.name !== '.gitignore') {
40!
98
                        files.push(dirent.name);
40✔
99
                }
100
        }
101
        return files;
40✔
102
}
103

104
function buildBreadcrumbs(currentFolder) {
105
        const crumbs = [];
8✔
106
        const parts = currentFolder.replace(nconf.get('upload_path'), '').split(path.sep);
8✔
107
        let currentPath = '';
8✔
108
        parts.forEach((part, i) => {
8✔
109
                const dir = path.join(currentPath, part);
12✔
110
                const crumb = {
12✔
111
                        text: part || 'Uploads',
24✔
112
                };
113
                if (i < parts.length - 1) {
12✔
114
                        crumb.url = part ?
4!
115
                                (`${nconf.get('relative_path')}/admin/manage/uploads?dir=${dir}`) :
116
                                `${nconf.get('relative_path')}/admin/manage/uploads`;
117
                }
118
                crumbs.push(crumb);
12✔
119
                currentPath = dir;
12✔
120
        });
121

122
        return crumbs;
8✔
123
}
124

125
async function filesToData(currentDir, files) {
126
        return await Promise.all(files.map(file => getFileData(currentDir, file)));
32✔
127
}
128

129
async function getFileData(currentDir, file) {
130
        const pathToFile = path.join(currentDir, file);
32✔
131
        const stat = await fs.promises.stat(pathToFile);
32✔
132
        let filesInDir = [];
32✔
133
        if (stat.isDirectory()) {
32!
134
                filesInDir = await getFilesInFolder(pathToFile);
32✔
135
        }
136
        const url = `${nconf.get('upload_url') + currentDir.replace(nconf.get('upload_path'), '')}/${file}`;
32✔
137
        return {
32✔
138
                name: file,
139
                path: pathToFile.replace(path.join(nconf.get('upload_path'), '/'), ''),
140
                url: url,
141
                fileCount: filesInDir.length,
142
                size: stat.size,
143
                sizeHumanReadable: `${(stat.size / 1024).toFixed(1)}KiB`,
144
                isDirectory: stat.isDirectory(),
145
                isFile: stat.isFile(),
146
                mtime: stat.mtimeMs,
147
        };
148
}
149

150
uploadsController.uploadCategoryPicture = async function (req, res, next) {
4✔
151
        const uploadedFile = req.files[0];
16✔
152
        let params;
153

154
        try {
16✔
155
                params = JSON.parse(req.body.params);
16✔
156
        } catch (e) {
157
                file.delete(uploadedFile.path);
4✔
158
                return next(new Error('[[error:invalid-json]]'));
4✔
159
        }
160

161
        await validateUpload(uploadedFile, allowedImageTypes);
12✔
162
        const filename = `category-${params.cid}${path.extname(uploadedFile.name)}`;
8✔
163
        await uploadImage(filename, 'category', uploadedFile, req, res, next);
8✔
164
};
165

166
uploadsController.uploadFavicon = async function (req, res, next) {
4✔
167
        const uploadedFile = req.files[0];
4✔
168
        const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon'];
4✔
169

170
        await validateUpload(uploadedFile, allowedTypes);
4✔
171
        const filename = 'favicon' + path.extname(uploadedFile.name);
4✔
172
        await uploadImage(filename, 'system', uploadedFile, req, res, next);
4✔
173
};
174

175
uploadsController.uploadTouchIcon = async function (req, res, next) {
4✔
176
        const uploadedFile = req.files[0];
4✔
177
        const allowedTypes = ['image/png'];
4✔
178
        const sizes = [36, 48, 72, 96, 144, 192, 512];
4✔
179

180
        await validateUpload(uploadedFile, allowedTypes);
4✔
181
        try {
4✔
182
                const imageObj = await file.saveFileToLocal('touchicon-orig.png', 'system', uploadedFile.path);
4✔
183
                // Resize the image into squares for use as touch icons at various DPIs
184
                for (const size of sizes) {
4✔
185
                        /* eslint-disable no-await-in-loop */
186
                        await image.resizeImage({
28✔
187
                                path: uploadedFile.path,
188
                                target: path.join(nconf.get('upload_path'), 'system', `touchicon-${size}.png`),
189
                                width: size,
190
                                height: size,
191
                        });
192
                }
193
                res.json([{ name: uploadedFile.name, url: imageObj.url }]);
4✔
194
        } catch (err) {
195
                next(err);
×
196
        } finally {
197
                file.delete(uploadedFile.path);
4✔
198
        }
199
};
200

201
uploadsController.uploadMaskableIcon = async function (req, res, next) {
4✔
202
        const uploadedFile = req.files[0];
×
203
        const allowedTypes = ['image/png'];
×
204

205
        await validateUpload(uploadedFile, allowedTypes);
×
206
        try {
×
207
                const imageObj = await file.saveFileToLocal('maskableicon-orig.png', 'system', uploadedFile.path);
×
208
                res.json([{ name: uploadedFile.name, url: imageObj.url }]);
×
209
        } catch (err) {
210
                next(err);
×
211
        } finally {
212
                file.delete(uploadedFile.path);
×
213
        }
214
};
215

216
uploadsController.uploadScreenshot = async function (req, res, next) {
4✔
217
        const uploadedFile = req.files[0];
×
218
        const allowedTypes = ['image/png', 'image/jpeg'];
×
219

220
        await validateUpload(uploadedFile, allowedTypes);
×
221
        try {
×
222
                const imageObj = await file.saveFileToLocal('screenshot.png', 'system', uploadedFile.path);
×
223
                res.json([{ name: uploadedFile.name, url: imageObj.url }]);
×
224
        } catch (err) {
225
                next(err);
×
226
        } finally {
227
                file.delete(uploadedFile.path);
×
228
        }
229
};
230

231
uploadsController.uploadFile = async function (req, res, next) {
4✔
232
        const uploadedFile = req.files[0];
12✔
233
        let params;
234
        try {
12✔
235
                params = JSON.parse(req.body.params);
12✔
236
        } catch (e) {
237
                file.delete(uploadedFile.path);
×
238
                return next(new Error('[[error:invalid-json]]'));
×
239
        }
240

241
        if (!await file.exists(path.join(nconf.get('upload_path'), params.folder))) {
12✔
242
                return next(new Error('[[error:invalid-path]]'));
8✔
243
        }
244
        try {
4✔
245
                const data = await file.saveFileToLocal(uploadedFile.name, params.folder, uploadedFile.path);
4✔
246
                res.json([{ url: data.url }]);
4✔
247
        } catch (err) {
248
                next(err);
×
249
        } finally {
250
                file.delete(uploadedFile.path);
4✔
251
        }
252
};
253

254
uploadsController.uploadLogo = async function (req, res, next) {
4✔
255
        await upload('site-logo', req, res, next);
4✔
256
};
257

258
uploadsController.uploadDefaultAvatar = async function (req, res, next) {
4✔
259
        await upload('avatar-default', req, res, next);
4✔
260
};
261

262
uploadsController.uploadOgImage = async function (req, res, next) {
4✔
263
        await upload('og:image', req, res, next);
4✔
264
};
265

266
async function upload(name, req, res, next) {
267
        const uploadedFile = req.files[0];
12✔
268

269
        await validateUpload(uploadedFile, allowedImageTypes);
12✔
270
        const filename = name + path.extname(uploadedFile.name);
12✔
271
        await uploadImage(filename, 'system', uploadedFile, req, res, next);
12✔
272
}
273

274
async function validateUpload(uploadedFile, allowedTypes) {
275
        if (!allowedTypes.includes(uploadedFile.type)) {
32✔
276
                file.delete(uploadedFile.path);
4✔
277
                throw new Error(`[[error:invalid-image-type, ${allowedTypes.join('&#44; ')}]]`);
4✔
278
        }
279
}
280

281
async function uploadImage(filename, folder, uploadedFile, req, res, next) {
282
        let imageData;
283
        try {
24✔
284
                if (plugins.hooks.hasListeners('filter:uploadImage')) {
24!
285
                        imageData = await plugins.hooks.fire('filter:uploadImage', {
×
286
                                image: uploadedFile,
287
                                uid: req.uid,
288
                                folder: folder,
289
                        });
290
                } else {
291
                        imageData = await file.saveFileToLocal(filename, folder, uploadedFile.path);
24✔
292
                }
293

294
                if (path.basename(filename, path.extname(filename)) === 'site-logo' && folder === 'system') {
24✔
295
                        const uploadPath = path.join(nconf.get('upload_path'), folder, 'site-logo-x50.png');
4✔
296
                        await image.resizeImage({
4✔
297
                                path: uploadedFile.path,
298
                                target: uploadPath,
299
                                height: 50,
300
                        });
301
                        await meta.configs.set('brand:emailLogo', path.join(nconf.get('upload_url'), 'system/site-logo-x50.png'));
4✔
302
                        const size = await image.size(uploadedFile.path);
4✔
303
                        await meta.configs.setMultiple({
4✔
304
                                'brand:logo:width': size.width,
305
                                'brand:logo:height': size.height,
306
                        });
307
                } else if (path.basename(filename, path.extname(filename)) === 'og:image' && folder === 'system') {
20✔
308
                        const size = await image.size(uploadedFile.path);
4✔
309
                        await meta.configs.setMultiple({
4✔
310
                                'og:image:width': size.width,
311
                                'og:image:height': size.height,
312
                        });
313
                }
314
                res.json([
24✔
315
                        {
316
                                name: uploadedFile.name,
317
                                url: imageData.url.startsWith('http') ?
24!
318
                                        imageData.url :
319
                                        nconf.get('relative_path') + imageData.url,
320
                        },
321
                ]);
322
        } catch (err) {
323
                next(err);
×
324
        } finally {
325
                file.delete(uploadedFile.path);
24✔
326
        }
327
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc