nodebb-plugin-pdf-secure2 1.2.38 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -1,246 +1,257 @@
1
- 'use strict';
2
-
3
- const path = require('path');
4
- const fs = require('fs');
5
- const meta = require.main.require('./src/meta');
6
- const groups = require.main.require('./src/groups');
7
- const routeHelpers = require.main.require('./src/routes/helpers');
8
-
9
- const controllers = require('./lib/controllers');
10
- const nonceStore = require('./lib/nonce-store');
11
- const pdfHandler = require('./lib/pdf-handler');
12
-
13
- const plugin = {};
14
-
15
- // Memory cache for viewer.html
16
- let viewerHtmlCache = null;
17
-
18
- plugin.init = async (params) => {
19
- const { router, middleware } = params;
20
-
21
- // Pre-load viewer.html into memory cache
22
- const viewerPath = path.join(__dirname, 'static', 'viewer.html');
23
- try {
24
- viewerHtmlCache = fs.readFileSync(viewerPath, 'utf8');
25
- } catch (err) {
26
- }
27
-
28
- // Double slash bypass protection - catches /uploads//files/ attempts
29
- router.use((req, res, next) => {
30
- if (req.path.includes('//') && req.path.toLowerCase().includes('.pdf')) {
31
- return res.status(403).json({ error: 'Invalid path' });
32
- }
33
- next();
34
- });
35
-
36
- // PDF direct access blocker middleware
37
- // Intercepts requests to uploaded PDF files and returns 403
38
- // Admin and Global Moderators can bypass this restriction
39
- router.get('/assets/uploads/files/:filename', async (req, res, next) => {
40
- if (req.params.filename && req.params.filename.toLowerCase().endsWith('.pdf')) {
41
- // Admin ve Global Mod'lar direkt erişebilsin
42
- if (req.uid) {
43
- const [isAdmin, isGlobalMod, isVip] = await Promise.all([
44
- groups.isMember(req.uid, 'administrators'),
45
- groups.isMember(req.uid, 'Global Moderators'),
46
- groups.isMember(req.uid, 'VIP'),
47
- ]);
48
- if (isAdmin || isGlobalMod || isVip) {
49
- return next();
50
- }
51
- }
52
- return res.status(403).json({ error: 'Direct PDF access is not allowed. Use the secure viewer.' });
53
- }
54
- next();
55
- });
56
-
57
- // PDF binary endpoint (nonce-validated, authentication required)
58
- router.get('/api/v3/plugins/pdf-secure/pdf-data', controllers.servePdfBinary);
59
-
60
- // Admin page route
61
- routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
62
-
63
- // Viewer page route (fullscreen Mozilla PDF.js viewer, authentication required)
64
- router.get('/plugins/pdf-secure/viewer', async (req, res) => {
65
- // Authentication gate - require logged-in user
66
- if (!req.uid) {
67
- return res.status(401).json({ error: 'Authentication required' });
68
- }
69
-
70
- const { file } = req.query;
71
- if (!file) {
72
- return res.status(400).send('Missing file parameter');
73
- }
74
-
75
- // Sanitize filename
76
- const safeName = path.basename(file);
77
- if (!safeName || !safeName.toLowerCase().endsWith('.pdf')) {
78
- return res.status(400).send('Invalid file');
79
- }
80
-
81
- // Check cache
82
- if (!viewerHtmlCache) {
83
- return res.status(500).send('Viewer not available');
84
- }
85
-
86
- // Check if user is Premium or Lite (admins/global mods always premium)
87
- let isPremium = false;
88
- let isLite = false;
89
- if (req.uid) {
90
- const [isAdmin, isGlobalMod, isPremiumMember, isVipMember, isLiteMember] = await Promise.all([
91
- groups.isMember(req.uid, 'administrators'),
92
- groups.isMember(req.uid, 'Global Moderators'),
93
- groups.isMember(req.uid, 'Premium'),
94
- groups.isMember(req.uid, 'VIP'),
95
- groups.isMember(req.uid, 'Lite'),
96
- ]);
97
- isPremium = isAdmin || isGlobalMod || isPremiumMember || isVipMember;
98
- // Lite: full PDF access but restricted UI (no annotations, sidebar, etc.)
99
- isLite = !isPremium && isLiteMember;
100
- }
101
-
102
- // Lite users get full PDF like premium (for nonce/server-side PDF data)
103
- const hasFullAccess = isPremium || isLite;
104
- const nonceData = nonceStore.generate(req.uid, safeName, hasFullAccess);
105
-
106
- // For non-premium users, get total page count so client can show lock overlay
107
- let totalPages = 1;
108
- if (!hasFullAccess) {
109
- try {
110
- totalPages = await pdfHandler.getTotalPages(safeName);
111
- } catch (err) {
112
- }
113
- }
114
-
115
- // Serve the viewer template with comprehensive security headers
116
- res.set({
117
- 'X-Frame-Options': 'SAMEORIGIN',
118
- 'X-Content-Type-Options': 'nosniff',
119
- 'X-XSS-Protection': '1; mode=block',
120
- 'Cache-Control': 'no-store, no-cache, must-revalidate, private, max-age=0',
121
- 'Pragma': 'no-cache',
122
- 'Expires': '0',
123
- 'Referrer-Policy': 'no-referrer',
124
- 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
125
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob: https://i.ibb.co; connect-src 'self'; frame-ancestors 'self'",
126
- });
127
-
128
- // Inject the filename, nonce, and key into the cached viewer
129
- // Key is embedded in HTML - NOT visible in any network API response!
130
- const injectedHtml = viewerHtmlCache
131
- .replace('</head>', `
132
- <style>
133
- /* Hide upload overlay since PDF will auto-load */
134
- #uploadOverlay { display: none !important; }
135
- </style>
136
- <script>
137
- window.PDF_SECURE_CONFIG = {
138
- filename: ${JSON.stringify(safeName)},
139
- relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
140
- csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
141
- nonce: ${JSON.stringify(nonceData.nonce)},
142
- dk: ${JSON.stringify(nonceData.dk)},
143
- iv: ${JSON.stringify(nonceData.iv)},
144
- isPremium: ${JSON.stringify(isPremium)},
145
- isLite: ${JSON.stringify(isLite)},
146
- uid: ${JSON.stringify(req.uid)},
147
- totalPages: ${JSON.stringify(totalPages)}
148
- };
149
- </script>
150
- </head>`);
151
-
152
- res.type('html').send(injectedHtml);
153
- });
154
- };
155
-
156
- plugin.addRoutes = async ({ router, middleware, helpers }) => {
157
- // Nonce endpoint removed - nonce is now generated in viewer route
158
- // This improves security by not exposing any key-related data in API responses
159
- };
160
-
161
- plugin.addAdminNavigation = (header) => {
162
- header.plugins.push({
163
- route: '/plugins/pdf-secure',
164
- icon: 'fa-file-pdf-o',
165
- name: 'PDF Secure Viewer',
166
- });
167
-
168
- return header;
169
- };
170
-
171
- // Filter meta tags to hide PDF URLs and filenames
172
- plugin.filterMetaTags = async (hookData) => {
173
- if (!hookData || !hookData.tags) {
174
- return hookData;
175
- }
176
-
177
- // Admin/Global Moderator bypass - no filtering for privileged users
178
- if (hookData.req && hookData.req.uid) {
179
- const [isAdmin, isGlobalMod] = await Promise.all([
180
- groups.isMember(hookData.req.uid, 'administrators'),
181
- groups.isMember(hookData.req.uid, 'Global Moderators'),
182
- ]);
183
- if (isAdmin || isGlobalMod) {
184
- return hookData;
185
- }
186
- }
187
-
188
- // Filter out PDF-related meta tags
189
- hookData.tags = hookData.tags.filter(tag => {
190
- // Remove og:image and og:image:url if it contains .pdf
191
- if ((tag.property === 'og:image' || tag.property === 'og:image:url') && tag.content && tag.content.toLowerCase().includes('.pdf')) {
192
- return false;
193
- }
194
- // Remove twitter:image if it contains .pdf
195
- if (tag.name === 'twitter:image' && tag.content && tag.content.toLowerCase().includes('.pdf')) {
196
- return false;
197
- }
198
- return true;
199
- });
200
-
201
- // Sanitize description to hide .pdf extensions
202
- hookData.tags = hookData.tags.map(tag => {
203
- if ((tag.name === 'description' || tag.property === 'og:description') && tag.content) {
204
- // Replace .pdf extension with empty string in description
205
- tag.content = tag.content.replace(/\.pdf/gi, '');
206
- }
207
- return tag;
208
- });
209
-
210
- return hookData;
211
- };
212
-
213
- // Transform PDF links to secure placeholders (server-side)
214
- // This hides PDF URLs from: page source, API, RSS, ActivityPub
215
- plugin.transformPdfLinks = async (data) => {
216
- if (!data || !data.postData || !data.postData.content) {
217
- return data;
218
- }
219
-
220
- // Regex to match PDF links: <a href="...xxx.pdf">text</a>
221
- // Captures: full URL path, filename, link text
222
- const pdfLinkRegex = /<a\s+[^>]*href=["']([^"']*\/([^"'\/]+\.pdf))["'][^>]*>([^<]*)<\/a>/gi;
223
-
224
- data.postData.content = data.postData.content.replace(pdfLinkRegex, (match, fullPath, filename, linkText) => {
225
- // Decode filename to prevent double encoding (URL may already be encoded)
226
- let decodedFilename;
227
- try { decodedFilename = decodeURIComponent(filename); }
228
- catch (e) { decodedFilename = filename; }
229
-
230
- // Sanitize for HTML attribute
231
- const safeFilename = decodedFilename.replace(/[<>"'&]/g, '');
232
- const displayName = linkText.trim() || safeFilename;
233
-
234
- // Return secure placeholder div instead of actual link
235
- return `<div class="pdf-secure-placeholder" data-filename="${safeFilename}">
236
- <svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:#e81224;vertical-align:middle;margin-right:8px;">
237
- <path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
238
- </svg>
239
- <span>${displayName}</span>
240
- </div>`;
241
- });
242
-
243
- return data;
244
- };
245
-
246
- module.exports = plugin;
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const meta = require.main.require('./src/meta');
6
+ const groups = require.main.require('./src/groups');
7
+ const routeHelpers = require.main.require('./src/routes/helpers');
8
+
9
+ const controllers = require('./lib/controllers');
10
+ const nonceStore = require('./lib/nonce-store');
11
+ const pdfHandler = require('./lib/pdf-handler');
12
+ const geminiChat = require('./lib/gemini-chat');
13
+
14
+ const plugin = {};
15
+
16
+ // Memory cache for viewer.html
17
+ let viewerHtmlCache = null;
18
+
19
+ plugin.init = async (params) => {
20
+ const { router, middleware } = params;
21
+
22
+ // Pre-load viewer.html into memory cache
23
+ const viewerPath = path.join(__dirname, 'static', 'viewer.html');
24
+ try {
25
+ viewerHtmlCache = fs.readFileSync(viewerPath, 'utf8');
26
+ } catch (err) {
27
+ }
28
+
29
+ // Double slash bypass protection - catches /uploads//files/ attempts
30
+ router.use((req, res, next) => {
31
+ if (req.path.includes('//') && req.path.toLowerCase().includes('.pdf')) {
32
+ return res.status(403).json({ error: 'Invalid path' });
33
+ }
34
+ next();
35
+ });
36
+
37
+ // PDF direct access blocker middleware
38
+ // Intercepts requests to uploaded PDF files and returns 403
39
+ // Admin and Global Moderators can bypass this restriction
40
+ router.get('/assets/uploads/files/:filename', async (req, res, next) => {
41
+ if (req.params.filename && req.params.filename.toLowerCase().endsWith('.pdf')) {
42
+ // Admin ve Global Mod'lar direkt erişebilsin
43
+ if (req.uid) {
44
+ const [isAdmin, isGlobalMod, isVip] = await Promise.all([
45
+ groups.isMember(req.uid, 'administrators'),
46
+ groups.isMember(req.uid, 'Global Moderators'),
47
+ groups.isMember(req.uid, 'VIP'),
48
+ ]);
49
+ if (isAdmin || isGlobalMod || isVip) {
50
+ return next();
51
+ }
52
+ }
53
+ return res.status(403).json({ error: 'Direct PDF access is not allowed. Use the secure viewer.' });
54
+ }
55
+ next();
56
+ });
57
+
58
+ // PDF binary endpoint (nonce-validated, authentication required)
59
+ router.get('/api/v3/plugins/pdf-secure/pdf-data', controllers.servePdfBinary);
60
+
61
+ // Chat endpoint (Premium/VIP only)
62
+ router.post('/api/v3/plugins/pdf-secure/chat', controllers.handleChat);
63
+
64
+ // Initialize Gemini AI chat (if API key is configured)
65
+ const settings = await meta.settings.get('pdf-secure');
66
+ if (settings && settings.geminiApiKey) {
67
+ geminiChat.init(settings.geminiApiKey);
68
+ }
69
+
70
+ // Admin page route
71
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
72
+
73
+ // Viewer page route (fullscreen Mozilla PDF.js viewer, authentication required)
74
+ router.get('/plugins/pdf-secure/viewer', async (req, res) => {
75
+ // Authentication gate - require logged-in user
76
+ if (!req.uid) {
77
+ return res.status(401).json({ error: 'Authentication required' });
78
+ }
79
+
80
+ const { file } = req.query;
81
+ if (!file) {
82
+ return res.status(400).send('Missing file parameter');
83
+ }
84
+
85
+ // Sanitize filename
86
+ const safeName = path.basename(file);
87
+ if (!safeName || !safeName.toLowerCase().endsWith('.pdf')) {
88
+ return res.status(400).send('Invalid file');
89
+ }
90
+
91
+ // Check cache
92
+ if (!viewerHtmlCache) {
93
+ return res.status(500).send('Viewer not available');
94
+ }
95
+
96
+ // Check if user is Premium or Lite (admins/global mods always premium)
97
+ let isPremium = false;
98
+ let isLite = false;
99
+ if (req.uid) {
100
+ const [isAdmin, isGlobalMod, isPremiumMember, isVipMember, isLiteMember] = await Promise.all([
101
+ groups.isMember(req.uid, 'administrators'),
102
+ groups.isMember(req.uid, 'Global Moderators'),
103
+ groups.isMember(req.uid, 'Premium'),
104
+ groups.isMember(req.uid, 'VIP'),
105
+ groups.isMember(req.uid, 'Lite'),
106
+ ]);
107
+ isPremium = isAdmin || isGlobalMod || isPremiumMember || isVipMember;
108
+ // Lite: full PDF access but restricted UI (no annotations, sidebar, etc.)
109
+ isLite = !isPremium && isLiteMember;
110
+ }
111
+
112
+ // Lite users get full PDF like premium (for nonce/server-side PDF data)
113
+ const hasFullAccess = isPremium || isLite;
114
+ const nonceData = nonceStore.generate(req.uid, safeName, hasFullAccess);
115
+
116
+ // For non-premium users, get total page count so client can show lock overlay
117
+ let totalPages = 1;
118
+ if (!hasFullAccess) {
119
+ try {
120
+ totalPages = await pdfHandler.getTotalPages(safeName);
121
+ } catch (err) {
122
+ }
123
+ }
124
+
125
+ // Serve the viewer template with comprehensive security headers
126
+ res.set({
127
+ 'X-Frame-Options': 'SAMEORIGIN',
128
+ 'X-Content-Type-Options': 'nosniff',
129
+ 'X-XSS-Protection': '1; mode=block',
130
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private, max-age=0',
131
+ 'Pragma': 'no-cache',
132
+ 'Expires': '0',
133
+ 'Referrer-Policy': 'no-referrer',
134
+ 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
135
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob: https://cdnjs.cloudflare.com https://i.ibb.co; connect-src 'self'; frame-ancestors 'self'",
136
+ });
137
+
138
+ // Inject the filename, nonce, and key into the cached viewer
139
+ // Key is embedded in HTML - NOT visible in any network API response!
140
+ const injectedHtml = viewerHtmlCache
141
+ .replace('</head>', `
142
+ <style>
143
+ /* Hide upload overlay since PDF will auto-load */
144
+ #uploadOverlay { display: none !important; }
145
+ </style>
146
+ <script>
147
+ window.PDF_SECURE_CONFIG = {
148
+ filename: ${JSON.stringify(safeName)},
149
+ relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
150
+ csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
151
+ nonce: ${JSON.stringify(nonceData.nonce)},
152
+ dk: ${JSON.stringify(nonceData.dk)},
153
+ iv: ${JSON.stringify(nonceData.iv)},
154
+ isPremium: ${JSON.stringify(isPremium)},
155
+ isLite: ${JSON.stringify(isLite)},
156
+ uid: ${JSON.stringify(req.uid)},
157
+ totalPages: ${JSON.stringify(totalPages)},
158
+ chatEnabled: ${JSON.stringify(geminiChat.isAvailable())}
159
+ };
160
+ </script>
161
+ </head>`);
162
+
163
+ res.type('html').send(injectedHtml);
164
+ });
165
+ };
166
+
167
+ plugin.addRoutes = async ({ router, middleware, helpers }) => {
168
+ // Nonce endpoint removed - nonce is now generated in viewer route
169
+ // This improves security by not exposing any key-related data in API responses
170
+ };
171
+
172
+ plugin.addAdminNavigation = (header) => {
173
+ header.plugins.push({
174
+ route: '/plugins/pdf-secure',
175
+ icon: 'fa-file-pdf-o',
176
+ name: 'PDF Secure Viewer',
177
+ });
178
+
179
+ return header;
180
+ };
181
+
182
+ // Filter meta tags to hide PDF URLs and filenames
183
+ plugin.filterMetaTags = async (hookData) => {
184
+ if (!hookData || !hookData.tags) {
185
+ return hookData;
186
+ }
187
+
188
+ // Admin/Global Moderator bypass - no filtering for privileged users
189
+ if (hookData.req && hookData.req.uid) {
190
+ const [isAdmin, isGlobalMod] = await Promise.all([
191
+ groups.isMember(hookData.req.uid, 'administrators'),
192
+ groups.isMember(hookData.req.uid, 'Global Moderators'),
193
+ ]);
194
+ if (isAdmin || isGlobalMod) {
195
+ return hookData;
196
+ }
197
+ }
198
+
199
+ // Filter out PDF-related meta tags
200
+ hookData.tags = hookData.tags.filter(tag => {
201
+ // Remove og:image and og:image:url if it contains .pdf
202
+ if ((tag.property === 'og:image' || tag.property === 'og:image:url') && tag.content && tag.content.toLowerCase().includes('.pdf')) {
203
+ return false;
204
+ }
205
+ // Remove twitter:image if it contains .pdf
206
+ if (tag.name === 'twitter:image' && tag.content && tag.content.toLowerCase().includes('.pdf')) {
207
+ return false;
208
+ }
209
+ return true;
210
+ });
211
+
212
+ // Sanitize description to hide .pdf extensions
213
+ hookData.tags = hookData.tags.map(tag => {
214
+ if ((tag.name === 'description' || tag.property === 'og:description') && tag.content) {
215
+ // Replace .pdf extension with empty string in description
216
+ tag.content = tag.content.replace(/\.pdf/gi, '');
217
+ }
218
+ return tag;
219
+ });
220
+
221
+ return hookData;
222
+ };
223
+
224
+ // Transform PDF links to secure placeholders (server-side)
225
+ // This hides PDF URLs from: page source, API, RSS, ActivityPub
226
+ plugin.transformPdfLinks = async (data) => {
227
+ if (!data || !data.postData || !data.postData.content) {
228
+ return data;
229
+ }
230
+
231
+ // Regex to match PDF links: <a href="...xxx.pdf">text</a>
232
+ // Captures: full URL path, filename, link text
233
+ const pdfLinkRegex = /<a\s+[^>]*href=["']([^"']*\/([^"'\/]+\.pdf))["'][^>]*>([^<]*)<\/a>/gi;
234
+
235
+ data.postData.content = data.postData.content.replace(pdfLinkRegex, (match, fullPath, filename, linkText) => {
236
+ // Decode filename to prevent double encoding (URL may already be encoded)
237
+ let decodedFilename;
238
+ try { decodedFilename = decodeURIComponent(filename); }
239
+ catch (e) { decodedFilename = filename; }
240
+
241
+ // Sanitize for HTML attribute
242
+ const safeFilename = decodedFilename.replace(/[<>"'&]/g, '');
243
+ const displayName = linkText.trim() || safeFilename;
244
+
245
+ // Return secure placeholder div instead of actual link
246
+ return `<div class="pdf-secure-placeholder" data-filename="${safeFilename}">
247
+ <svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:#e81224;vertical-align:middle;margin-right:8px;">
248
+ <path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
249
+ </svg>
250
+ <span>${displayName}</span>
251
+ </div>`;
252
+ });
253
+
254
+ return data;
255
+ };
256
+
257
+ module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure2",
3
- "version": "1.2.38",
3
+ "version": "1.3.1",
4
4
  "description": "Secure PDF viewer plugin for NodeBB - prevents downloading, enables canvas-only rendering with Premium group support",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -38,6 +38,7 @@
38
38
  "compatibility": "^3.2.0"
39
39
  },
40
40
  "dependencies": {
41
+ "@google/genai": "^1.0.0",
41
42
  "pdf-lib": "^1.17.1",
42
43
  "uuid": "^11.1.0"
43
44
  },
package/plugin.json CHANGED
@@ -33,5 +33,8 @@
33
33
  "scripts": [
34
34
  "static/lib/main.js"
35
35
  ],
36
- "templates": "./static/templates"
36
+ "templates": "./static/templates",
37
+ "modules": {
38
+ "../admin/plugins/pdf-secure.js": "static/lib/admin.js"
39
+ }
37
40
  }
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ define('admin/plugins/pdf-secure', ['settings', 'alerts'], function (Settings, alerts) {
4
+ var ACP = {};
5
+
6
+ ACP.init = function () {
7
+ Settings.load('pdf-secure', $('#pdf-secure-settings'), function () {
8
+ console.log('[PDF-Secure] Admin settings loaded');
9
+ });
10
+
11
+ $('#save').on('click', function () {
12
+ Settings.save('pdf-secure', $('#pdf-secure-settings'), function () {
13
+ alerts.alert({
14
+ type: 'success',
15
+ alert_id: 'pdf-secure-saved',
16
+ title: 'Settings Saved',
17
+ message: 'Settings have been saved. Restart NodeBB to apply.',
18
+ timeout: 3000,
19
+ });
20
+ });
21
+ });
22
+ };
23
+
24
+ return ACP;
25
+ });