From 75a6dfffdcee5a4467d54c7400615cd2845a5edc Mon Sep 17 00:00:00 2001
From: Julian Lam
Date: Thu, 12 Mar 2026 14:28:21 -0400
Subject: [PATCH] feat: screenshot upload in ACP, send fallback brand icons in
manifest, serve assets for richer PWA install UI
---
.../en-GB/admin/settings/general.json | 4 +-
src/controllers/admin/uploads.js | 16 ++++-
src/controllers/index.js | 68 +++++++++++++++++++
src/privileges/admin.js | 1 +
src/routes/admin.js | 1 +
src/views/admin/settings/general.tpl | 13 ++++
6 files changed, 101 insertions(+), 2 deletions(-)
diff --git a/public/language/en-GB/admin/settings/general.json b/public/language/en-GB/admin/settings/general.json
index d56c819745..0ee921d831 100644
--- a/public/language/en-GB/admin/settings/general.json
+++ b/public/language/en-GB/admin/settings/general.json
@@ -18,7 +18,7 @@
"description": "Site Description",
"keywords": "Site Keywords",
"keywords-placeholder": "Keywords describing your community, comma-separated",
- "logo-and-icons": "Site Logo & Icons",
+ "logo-and-icons": "Media & Branding",
"logo.image": "Image",
"logo.image-placeholder": "Path to a logo to display on forum header",
"logo.upload": "Upload",
@@ -35,6 +35,8 @@
"touch-icon.help": "Recommended size and format: 512x512, PNG format only. If no touch icon is specified, NodeBB will fall back to using the favicon.",
"maskable-icon": "Maskable (Homescreen) Icon",
"maskable-icon.help": "Recommended size and format: 512x512, PNG format only. If no maskable icon is specified, NodeBB will fall back to the Touch Icon.",
+ "screenshot": "Screenshot",
+ "screenshot.help": "Recommended size and format: between 320px and 3480px, JPG and PNG format only. If no screenshot is specified, NodeBB will fall back to a generic screenshot",
"outgoing-links": "Outgoing Links",
"outgoing-links.warning-page": "Use Outgoing Links Warning Page",
"search": "Search",
diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js
index cd9463fce2..21be9c441b 100644
--- a/src/controllers/admin/uploads.js
+++ b/src/controllers/admin/uploads.js
@@ -198,7 +198,6 @@ uploadsController.uploadTouchIcon = async function (req, res, next) {
}
};
-
uploadsController.uploadMaskableIcon = async function (req, res, next) {
const uploadedFile = req.files[0];
const allowedTypes = ['image/png'];
@@ -214,6 +213,21 @@ uploadsController.uploadMaskableIcon = async function (req, res, next) {
}
};
+uploadsController.uploadScreenshot = async function (req, res, next) {
+ const uploadedFile = req.files[0];
+ const allowedTypes = ['image/png', 'image/jpeg'];
+
+ await validateUpload(uploadedFile, allowedTypes);
+ try {
+ const imageObj = await file.saveFileToLocal('screenshot.png', 'system', uploadedFile.path);
+ res.json([{ name: uploadedFile.name, url: imageObj.url }]);
+ } catch (err) {
+ next(err);
+ } finally {
+ file.delete(uploadedFile.path);
+ }
+};
+
uploadsController.uploadFile = async function (req, res, next) {
const uploadedFile = req.files[0];
let params;
diff --git a/src/controllers/index.js b/src/controllers/index.js
index 0336db8d1d..2e55cfe646 100644
--- a/src/controllers/index.js
+++ b/src/controllers/index.js
@@ -1,11 +1,14 @@
'use strict';
+const path = require('path');
const nconf = require('nconf');
const validator = require('validator');
+const mime = require('mime');
const meta = require('../meta');
const user = require('../user');
const plugins = require('../plugins');
+const image = require('../image');
const privilegesHelpers = require('../privileges/helpers');
const helpers = require('./helpers');
@@ -271,6 +274,7 @@ Controllers.manifest = async function (req, res) {
const manifest = {
name: meta.config.title || 'NodeBB',
short_name: meta.config['title:short'] || meta.config.title || 'NodeBB',
+ ...(meta.config.description && { description: meta.config.description }),
start_url: nconf.get('url'),
display: 'standalone',
orientation: 'portrait',
@@ -279,6 +283,33 @@ Controllers.manifest = async function (req, res) {
icons: [],
};
+ if (meta.config['brand:screenshot']) {
+ let sizes;
+ try {
+ const { width, height } = await image.size(path.join(nconf.get('base_dir'), meta.config['brand:screenshot'].replace('assets', 'public')));
+ sizes = `${width}x${height}`;
+ } catch (e) {
+ // noop
+ }
+ manifest.screenshots = [
+ {
+ src: `${nconf.get('relative_path')}${meta.config['brand:screenshot']}`,
+ ...(sizes && { sizes }),
+ type: mime.getType(meta.config['brand:screenshot']),
+ },
+ ];
+ } else {
+ manifest.screenshots = [
+ {
+ src: `${nconf.get('relative_path')}/assets/images/screenshot-default.png`,
+ sizes: '446x778',
+ type: 'image/png',
+ form_factor: 'narrow',
+ label: 'Default home page of a vanilla NodeBB installation.',
+ },
+ ];
+ }
+
if (meta.config['brand:touchIcon']) {
manifest.icons.push({
src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-36.png`,
@@ -316,6 +347,43 @@ Controllers.manifest = async function (req, res) {
type: 'image/png',
density: 10.0,
});
+ } else {
+ manifest.icons.push({
+ src: `${nconf.get('relative_path')}/assets/images/touch/36.png`,
+ sizes: '36x36',
+ type: 'image/png',
+ density: 0.75,
+ }, {
+ src: `${nconf.get('relative_path')}/assets/images/touch/48.png`,
+ sizes: '48x48',
+ type: 'image/png',
+ density: 1.0,
+ }, {
+ src: `${nconf.get('relative_path')}/assets/images/touch/72.png`,
+ sizes: '72x72',
+ type: 'image/png',
+ density: 1.5,
+ }, {
+ src: `${nconf.get('relative_path')}/assets/images/touch/96.png`,
+ sizes: '96x96',
+ type: 'image/png',
+ density: 2.0,
+ }, {
+ src: `${nconf.get('relative_path')}/assets/images/touch/144.png`,
+ sizes: '144x144',
+ type: 'image/png',
+ density: 3.0,
+ }, {
+ src: `${nconf.get('relative_path')}/assets/images/touch/192.png`,
+ sizes: '192x192',
+ type: 'image/png',
+ density: 4.0,
+ }, {
+ src: `${nconf.get('relative_path')}/assets/images/touch/512.png`,
+ sizes: '512x512',
+ type: 'image/png',
+ density: 10.0,
+ });
}
diff --git a/src/privileges/admin.js b/src/privileges/admin.js
index ae26ab069a..8e40029786 100644
--- a/src/privileges/admin.js
+++ b/src/privileges/admin.js
@@ -67,6 +67,7 @@ privsAdmin.routeMap = {
uploadfavicon: 'admin:settings',
uploadTouchIcon: 'admin:settings',
uploadMaskableIcon: 'admin:settings',
+ uploadScreenshot: 'admin:settings',
uploadlogo: 'admin:settings',
uploadOgImage: 'admin:settings',
uploadDefaultAvatar: 'admin:settings',
diff --git a/src/routes/admin.js b/src/routes/admin.js
index 86debb305d..768e457409 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -106,6 +106,7 @@ function apiRoutes(router, name, middleware, controllers) {
router.post(`/api/${name}/uploadfavicon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadFavicon));
router.post(`/api/${name}/uploadTouchIcon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadTouchIcon));
router.post(`/api/${name}/uploadMaskableIcon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadMaskableIcon));
+ router.post(`/api/${name}/uploadScreenshot`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadScreenshot));
router.post(`/api/${name}/uploadlogo`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadLogo));
router.post(`/api/${name}/uploadOgImage`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadOgImage));
router.post(`/api/${name}/upload/file`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadFile));
diff --git a/src/views/admin/settings/general.tpl b/src/views/admin/settings/general.tpl
index 5dccda3f0a..a5c5289104 100644
--- a/src/views/admin/settings/general.tpl
+++ b/src/views/admin/settings/general.tpl
@@ -153,6 +153,19 @@
[[admin/settings/general:maskable-icon.help]]
+
+
+
+
+
+
+
+
+
+
+ [[admin/settings/general:screenshot.help]]
+
+