nodebb-plugin-pdf-secure 1.0.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 +8 -0
- package/.gitattributes +22 -0
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/commitlint.config.js +26 -0
- package/eslint.config.mjs +10 -0
- package/languages/de/pdf-secure.json +7 -0
- package/languages/en-GB/pdf-secure.json +7 -0
- package/languages/en-US/pdf-secure.json +7 -0
- package/lib/controllers.js +51 -0
- package/lib/nonce-store.js +52 -0
- package/lib/pdf-handler.js +89 -0
- package/library.js +72 -0
- package/package.json +53 -0
- package/plugin.json +20 -0
- package/renovate.json +5 -0
- package/static/lib/main.js +108 -0
- package/static/lib/pdf.min.mjs +21 -0
- package/static/lib/pdf.worker.min.mjs +21 -0
- package/static/lib/viewer.css +158 -0
- package/static/lib/viewer.html +29 -0
- package/static/lib/viewer.js +181 -0
- package/static/style.less +42 -0
- package/static/templates/admin/plugins/pdf-secure.tpl +31 -0
- package/test/.eslintrc +9 -0
- package/test/index.js +41 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/* PDF Secure Viewer Styles */
|
|
2
|
+
* {
|
|
3
|
+
margin: 0;
|
|
4
|
+
padding: 0;
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
html, body {
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: 100%;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
background: #2b2b2b;
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
14
|
+
user-select: none;
|
|
15
|
+
-webkit-user-select: none;
|
|
16
|
+
-moz-user-select: none;
|
|
17
|
+
-ms-user-select: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#viewer-container {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
height: 100%;
|
|
24
|
+
width: 100%;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#toolbar {
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
gap: 12px;
|
|
32
|
+
padding: 8px 16px;
|
|
33
|
+
background: #333;
|
|
34
|
+
color: #fff;
|
|
35
|
+
flex-shrink: 0;
|
|
36
|
+
z-index: 10;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#toolbar button {
|
|
40
|
+
background: #555;
|
|
41
|
+
color: #fff;
|
|
42
|
+
border: none;
|
|
43
|
+
padding: 6px 14px;
|
|
44
|
+
border-radius: 4px;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#toolbar button:hover {
|
|
50
|
+
background: #666;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#toolbar button:disabled {
|
|
54
|
+
opacity: 0.4;
|
|
55
|
+
cursor: not-allowed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#page-info {
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
min-width: 100px;
|
|
61
|
+
text-align: center;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#close-btn {
|
|
65
|
+
position: absolute;
|
|
66
|
+
right: 16px;
|
|
67
|
+
top: 8px;
|
|
68
|
+
background: #c0392b !important;
|
|
69
|
+
font-size: 16px !important;
|
|
70
|
+
padding: 6px 12px !important;
|
|
71
|
+
z-index: 20;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#close-btn:hover {
|
|
75
|
+
background: #e74c3c !important;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#canvas-wrapper {
|
|
79
|
+
flex: 1;
|
|
80
|
+
overflow: auto;
|
|
81
|
+
display: flex;
|
|
82
|
+
justify-content: center;
|
|
83
|
+
align-items: flex-start;
|
|
84
|
+
padding: 20px;
|
|
85
|
+
background: #2b2b2b;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#pdf-canvas {
|
|
89
|
+
pointer-events: none;
|
|
90
|
+
display: block;
|
|
91
|
+
max-width: 100%;
|
|
92
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#loading {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
justify-content: center;
|
|
99
|
+
height: 100%;
|
|
100
|
+
color: #aaa;
|
|
101
|
+
font-size: 18px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#error-msg {
|
|
105
|
+
display: none;
|
|
106
|
+
align-items: center;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
height: 100%;
|
|
109
|
+
color: #e74c3c;
|
|
110
|
+
font-size: 18px;
|
|
111
|
+
text-align: center;
|
|
112
|
+
padding: 20px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#premium-banner {
|
|
116
|
+
display: none;
|
|
117
|
+
background: linear-gradient(135deg, #f39c12, #e67e22);
|
|
118
|
+
color: #fff;
|
|
119
|
+
text-align: center;
|
|
120
|
+
padding: 10px 16px;
|
|
121
|
+
font-size: 14px;
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
flex-shrink: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Anti-print */
|
|
127
|
+
@media print {
|
|
128
|
+
html, body {
|
|
129
|
+
visibility: hidden !important;
|
|
130
|
+
}
|
|
131
|
+
body::after {
|
|
132
|
+
content: 'Printing is not allowed.';
|
|
133
|
+
visibility: visible;
|
|
134
|
+
display: block;
|
|
135
|
+
text-align: center;
|
|
136
|
+
padding: 50px;
|
|
137
|
+
font-size: 24px;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Responsive */
|
|
142
|
+
@media (max-width: 600px) {
|
|
143
|
+
#toolbar {
|
|
144
|
+
gap: 6px;
|
|
145
|
+
padding: 6px 8px;
|
|
146
|
+
}
|
|
147
|
+
#toolbar button {
|
|
148
|
+
padding: 4px 8px;
|
|
149
|
+
font-size: 12px;
|
|
150
|
+
}
|
|
151
|
+
#page-info {
|
|
152
|
+
font-size: 12px;
|
|
153
|
+
min-width: 70px;
|
|
154
|
+
}
|
|
155
|
+
#canvas-wrapper {
|
|
156
|
+
padding: 10px;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>PDF Viewer</title>
|
|
7
|
+
<link rel="stylesheet" href="viewer.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="viewer-container">
|
|
11
|
+
<div id="toolbar">
|
|
12
|
+
<button id="prev-btn" disabled>« Prev</button>
|
|
13
|
+
<span id="page-info">Loading...</span>
|
|
14
|
+
<button id="next-btn" disabled>Next »</button>
|
|
15
|
+
<button id="close-btn">× Close</button>
|
|
16
|
+
</div>
|
|
17
|
+
<div id="premium-banner">
|
|
18
|
+
This is a preview (page 1 only). Upgrade to Premium to view the full document.
|
|
19
|
+
</div>
|
|
20
|
+
<div id="canvas-wrapper">
|
|
21
|
+
<div id="loading">Loading PDF...</div>
|
|
22
|
+
<div id="error-msg"></div>
|
|
23
|
+
<canvas id="pdf-canvas"></canvas>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<script type="module" src="viewer.js"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { getDocument, GlobalWorkerOptions } from './pdf.min.mjs';
|
|
2
|
+
|
|
3
|
+
// Set worker path
|
|
4
|
+
GlobalWorkerOptions.workerSrc = './pdf.worker.min.mjs';
|
|
5
|
+
|
|
6
|
+
// --- Security event listeners ---
|
|
7
|
+
|
|
8
|
+
// Block right-click context menu
|
|
9
|
+
document.addEventListener('contextmenu', (e) => {
|
|
10
|
+
e.preventDefault();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Block keyboard shortcuts (Ctrl+S, Ctrl+P, Ctrl+U, F12)
|
|
14
|
+
document.addEventListener('keydown', (e) => {
|
|
15
|
+
if (e.key === 'F12') {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if ((e.ctrlKey || e.metaKey) && ['s', 'p', 'u', 'a'].includes(e.key.toLowerCase())) {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Block drag
|
|
26
|
+
document.addEventListener('dragstart', (e) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Block text selection via JS as well
|
|
31
|
+
document.addEventListener('selectstart', (e) => {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// --- PDF Viewer Logic ---
|
|
36
|
+
|
|
37
|
+
const canvas = document.getElementById('pdf-canvas');
|
|
38
|
+
const ctx = canvas.getContext('2d');
|
|
39
|
+
const prevBtn = document.getElementById('prev-btn');
|
|
40
|
+
const nextBtn = document.getElementById('next-btn');
|
|
41
|
+
const pageInfo = document.getElementById('page-info');
|
|
42
|
+
const loadingEl = document.getElementById('loading');
|
|
43
|
+
const errorEl = document.getElementById('error-msg');
|
|
44
|
+
const premiumBanner = document.getElementById('premium-banner');
|
|
45
|
+
const closeBtn = document.getElementById('close-btn');
|
|
46
|
+
|
|
47
|
+
let pdfDoc = null;
|
|
48
|
+
let currentPage = 1;
|
|
49
|
+
let totalPages = 0;
|
|
50
|
+
let rendering = false;
|
|
51
|
+
|
|
52
|
+
// Parse URL parameters
|
|
53
|
+
const params = new URLSearchParams(window.location.search);
|
|
54
|
+
const nonce = params.get('nonce');
|
|
55
|
+
const isPremium = params.get('premium') === 'true';
|
|
56
|
+
const apiBase = params.get('apiBase') || '';
|
|
57
|
+
|
|
58
|
+
// Show premium banner for non-premium users
|
|
59
|
+
if (!isPremium) {
|
|
60
|
+
premiumBanner.style.display = 'block';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Close button handler
|
|
64
|
+
closeBtn.addEventListener('click', () => {
|
|
65
|
+
if (window.parent && window.parent !== window) {
|
|
66
|
+
window.parent.postMessage({ type: 'pdf-viewer-close' }, '*');
|
|
67
|
+
} else {
|
|
68
|
+
window.close();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function renderPage(pageNum) {
|
|
73
|
+
if (rendering) return;
|
|
74
|
+
rendering = true;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const page = await pdfDoc.getPage(pageNum);
|
|
78
|
+
|
|
79
|
+
// Calculate scale to fit container width
|
|
80
|
+
const wrapper = document.getElementById('canvas-wrapper');
|
|
81
|
+
const maxWidth = wrapper.clientWidth - 40; // padding
|
|
82
|
+
const viewport = page.getViewport({ scale: 1 });
|
|
83
|
+
const scale = Math.min(maxWidth / viewport.width, 2.0); // max 2x
|
|
84
|
+
const scaledViewport = page.getViewport({ scale });
|
|
85
|
+
|
|
86
|
+
canvas.width = scaledViewport.width;
|
|
87
|
+
canvas.height = scaledViewport.height;
|
|
88
|
+
|
|
89
|
+
await page.render({
|
|
90
|
+
canvasContext: ctx,
|
|
91
|
+
viewport: scaledViewport,
|
|
92
|
+
}).promise;
|
|
93
|
+
|
|
94
|
+
currentPage = pageNum;
|
|
95
|
+
pageInfo.textContent = `Page ${currentPage} / ${totalPages}`;
|
|
96
|
+
prevBtn.disabled = currentPage <= 1;
|
|
97
|
+
nextBtn.disabled = currentPage >= totalPages;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
showError('Error rendering page.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
rendering = false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function showError(msg) {
|
|
106
|
+
loadingEl.style.display = 'none';
|
|
107
|
+
canvas.style.display = 'none';
|
|
108
|
+
errorEl.style.display = 'flex';
|
|
109
|
+
errorEl.textContent = msg;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Navigation
|
|
113
|
+
prevBtn.addEventListener('click', () => {
|
|
114
|
+
if (currentPage > 1) renderPage(currentPage - 1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
nextBtn.addEventListener('click', () => {
|
|
118
|
+
if (currentPage < totalPages) renderPage(currentPage + 1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Keyboard navigation
|
|
122
|
+
document.addEventListener('keydown', (e) => {
|
|
123
|
+
if (e.key === 'ArrowLeft' && currentPage > 1) {
|
|
124
|
+
renderPage(currentPage - 1);
|
|
125
|
+
} else if (e.key === 'ArrowRight' && currentPage < totalPages) {
|
|
126
|
+
renderPage(currentPage + 1);
|
|
127
|
+
} else if (e.key === 'Escape') {
|
|
128
|
+
closeBtn.click();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Load PDF
|
|
133
|
+
async function init() {
|
|
134
|
+
if (!nonce) {
|
|
135
|
+
showError('Missing access token.');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch(`${apiBase}/api/v3/plugins/pdf-secure/pdf-data?nonce=${encodeURIComponent(nonce)}`, {
|
|
141
|
+
credentials: 'same-origin',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
if (response.status === 401) {
|
|
146
|
+
showError('You must be logged in to view this PDF.');
|
|
147
|
+
} else if (response.status === 403) {
|
|
148
|
+
showError('Access denied. The link may have expired.');
|
|
149
|
+
} else if (response.status === 404) {
|
|
150
|
+
showError('PDF file not found.');
|
|
151
|
+
} else {
|
|
152
|
+
showError('Failed to load PDF.');
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
158
|
+
const data = new Uint8Array(arrayBuffer);
|
|
159
|
+
|
|
160
|
+
pdfDoc = await getDocument({ data }).promise;
|
|
161
|
+
totalPages = pdfDoc.numPages;
|
|
162
|
+
|
|
163
|
+
loadingEl.style.display = 'none';
|
|
164
|
+
canvas.style.display = 'block';
|
|
165
|
+
|
|
166
|
+
await renderPage(1);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
showError('Failed to load PDF. Please try again.');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Handle window resize
|
|
173
|
+
let resizeTimeout;
|
|
174
|
+
window.addEventListener('resize', () => {
|
|
175
|
+
clearTimeout(resizeTimeout);
|
|
176
|
+
resizeTimeout = setTimeout(() => {
|
|
177
|
+
if (pdfDoc) renderPage(currentPage);
|
|
178
|
+
}, 250);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
init();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* PDF Secure Viewer Plugin Styles */
|
|
2
|
+
|
|
3
|
+
.pdf-secure-btn {
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: 6px;
|
|
7
|
+
cursor: pointer;
|
|
8
|
+
margin: 2px 0;
|
|
9
|
+
|
|
10
|
+
i {
|
|
11
|
+
color: #c0392b;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.pdf-secure-overlay {
|
|
16
|
+
position: fixed;
|
|
17
|
+
top: 0;
|
|
18
|
+
left: 0;
|
|
19
|
+
width: 100%;
|
|
20
|
+
height: 100%;
|
|
21
|
+
z-index: 100000;
|
|
22
|
+
background: rgba(0, 0, 0, 0.85);
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: center;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.pdf-secure-iframe {
|
|
29
|
+
width: 95%;
|
|
30
|
+
height: 95%;
|
|
31
|
+
border: none;
|
|
32
|
+
border-radius: 8px;
|
|
33
|
+
background: #2b2b2b;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@media (max-width: 768px) {
|
|
37
|
+
.pdf-secure-iframe {
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: 100%;
|
|
40
|
+
border-radius: 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<div class="acp-page-container">
|
|
2
|
+
<!-- IMPORT admin/partials/settings/header.tpl -->
|
|
3
|
+
|
|
4
|
+
<div class="row m-0">
|
|
5
|
+
<div id="spy-container" class="col-12 col-md-8 px-0 mb-4" tabindex="0">
|
|
6
|
+
<form role="form" class="pdf-secure-settings">
|
|
7
|
+
<div class="mb-4">
|
|
8
|
+
<h5 class="fw-bold tracking-tight settings-header">PDF Secure Viewer Settings</h5>
|
|
9
|
+
|
|
10
|
+
<p class="lead">
|
|
11
|
+
Configure the secure PDF viewer plugin settings below.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<div class="mb-3">
|
|
15
|
+
<label class="form-label" for="premiumGroup">Premium Group Name</label>
|
|
16
|
+
<input type="text" id="premiumGroup" name="premiumGroup" title="Premium Group Name" class="form-control" placeholder="Premium" value="Premium">
|
|
17
|
+
<div class="form-text">Users in this group can view full PDFs. Others can only see the first page.</div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="form-check form-switch mb-3">
|
|
21
|
+
<input type="checkbox" class="form-check-input" id="watermarkEnabled" name="watermarkEnabled">
|
|
22
|
+
<label for="watermarkEnabled" class="form-check-label">Enable Watermark</label>
|
|
23
|
+
<div class="form-text">Show a watermark overlay on PDF pages.</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</form>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- IMPORT admin/partials/settings/toc.tpl -->
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
package/test/.eslintrc
ADDED
package/test/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* You can run these tests by executing `npx mocha test/plugins-installed.js`
|
|
3
|
+
* from the NodeBB root folder. The regular test runner will also run these
|
|
4
|
+
* tests.
|
|
5
|
+
*
|
|
6
|
+
* Keep in mind tests do not activate all plugins, so if you are testing
|
|
7
|
+
* hook listeners, socket.io, or mounted routes, you will need to add your
|
|
8
|
+
* plugin to `config.json`, e.g.
|
|
9
|
+
*
|
|
10
|
+
* {
|
|
11
|
+
* "test_plugins": [
|
|
12
|
+
* "nodebb-plugin-pdf-secure"
|
|
13
|
+
* ]
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
/* globals describe, it, before */
|
|
20
|
+
|
|
21
|
+
const assert = require('assert');
|
|
22
|
+
|
|
23
|
+
const db = require.main.require('./test/mocks/databasemock');
|
|
24
|
+
|
|
25
|
+
describe('nodebb-plugin-pdf-secure', () => {
|
|
26
|
+
before(() => {
|
|
27
|
+
// Prepare for tests here
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should pass', (done) => {
|
|
31
|
+
const actual = 'value';
|
|
32
|
+
const expected = 'value';
|
|
33
|
+
assert.strictEqual(actual, expected);
|
|
34
|
+
done();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should load config object', async () => {
|
|
38
|
+
const config = await db.getObject('config');
|
|
39
|
+
assert(config);
|
|
40
|
+
});
|
|
41
|
+
});
|