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/.claude/settings.local.json +11 -0
- package/lib/controllers.js +105 -0
- package/lib/gemini-chat.js +125 -0
- package/library.js +257 -246
- package/package.json +2 -1
- package/plugin.json +4 -1
- package/static/lib/admin.js +25 -0
- package/static/lib/main.js +468 -468
- package/static/lib/pdf.min.mjs +20 -20
- package/static/lib/pdf.worker.min.mjs +20 -20
- package/static/templates/admin/plugins/pdf-secure.tpl +14 -5
- package/static/viewer-app.js +2 -2
- package/static/viewer.html +6353 -5893
- package/static/lib/pdf-secure (1).pdf +0 -0
- package/static/lib/viewer.js +0 -161
- package/static/viewer-yedek.html +0 -4548
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
groups.isMember(req.uid, '
|
|
46
|
-
groups.isMember(req.uid, '
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
plugin.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
hookData.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
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
|
@@ -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
|
+
});
|