nodebb-plugin-cloudflare-r2 1.0.0
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/LICENSE +5 -0
- package/README.md +53 -0
- package/library.js +232 -0
- package/package.json +24 -0
- package/plugin.json +21 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# nodebb-plugin-cloudflare-r2 (env)
|
|
2
|
+
|
|
3
|
+
Upload provider for **NodeBB v4.x** that stores uploads in **Cloudflare R2** (S3-compatible).
|
|
4
|
+
|
|
5
|
+
This build is **env-first** (no ACP settings page). Configure using environment variables.
|
|
6
|
+
|
|
7
|
+
## Environment variables
|
|
8
|
+
|
|
9
|
+
Required:
|
|
10
|
+
- `NODEBB_R2_ACCESS_KEY_ID`
|
|
11
|
+
- `NODEBB_R2_SECRET_ACCESS_KEY`
|
|
12
|
+
- `NODEBB_R2_BUCKET`
|
|
13
|
+
- `NODEBB_R2_ENDPOINT` (example: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`)
|
|
14
|
+
|
|
15
|
+
Optional:
|
|
16
|
+
- `NODEBB_R2_REGION` (default: `auto`)
|
|
17
|
+
- `NODEBB_R2_UPLOAD_PATH` (default: empty)
|
|
18
|
+
- `NODEBB_R2_HOST` (public base URL returned to NodeBB; default: `https://<bucket>`)
|
|
19
|
+
- `NODEBB_R2_FORCE_PATH_STYLE` (`true|false`, default: `false`)
|
|
20
|
+
|
|
21
|
+
Notes:
|
|
22
|
+
- `endpoint` is the **S3 API endpoint** (cloudflarestorage.com)
|
|
23
|
+
- `host` is the **public URL base** (custom domain / CDN / public bucket URL)
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd /path/to/nodebb
|
|
29
|
+
npm i /path/to/nodebb-plugin-cloudflare-r2
|
|
30
|
+
./nodebb build
|
|
31
|
+
./nodebb restart
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Enable plugin in ACP → Extend → Plugins.
|
|
35
|
+
|
|
36
|
+
## Docker Compose example
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
environment:
|
|
40
|
+
NODEBB_R2_ACCESS_KEY_ID: "xxx"
|
|
41
|
+
NODEBB_R2_SECRET_ACCESS_KEY: "yyy"
|
|
42
|
+
NODEBB_R2_BUCKET: "my-bucket"
|
|
43
|
+
NODEBB_R2_ENDPOINT: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
|
|
44
|
+
NODEBB_R2_REGION: "auto"
|
|
45
|
+
NODEBB_R2_UPLOAD_PATH: "uploads"
|
|
46
|
+
NODEBB_R2_HOST: "https://cdn.example.com"
|
|
47
|
+
NODEBB_R2_FORCE_PATH_STYLE: "false"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## What it does
|
|
51
|
+
- Hooks: `filter:uploadImage`, `filter:uploadFile`
|
|
52
|
+
- Validates extensions using the **original filename** (fixes drag&drop tmp-path issues)
|
|
53
|
+
- Streams uploads to R2 (does not read entire file into RAM)
|
package/library.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
\
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* nodebb-plugin-cloudflare-r2 (env)
|
|
6
|
+
* - NodeBB v4.x
|
|
7
|
+
* - Cloudflare R2 via S3 API
|
|
8
|
+
* - Configuration via environment variables
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { S3Client } = require('@aws-sdk/client-s3');
|
|
12
|
+
const { Upload } = require('@aws-sdk/lib-storage');
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { v4: uuidv4 } = require('uuid');
|
|
17
|
+
const mime = require('mime-types');
|
|
18
|
+
|
|
19
|
+
const winston = require.main.require('winston');
|
|
20
|
+
const meta = require.main.require('./src/meta');
|
|
21
|
+
const fileModule = require.main.require('./src/file');
|
|
22
|
+
|
|
23
|
+
const plugin = {};
|
|
24
|
+
plugin._s3 = null;
|
|
25
|
+
|
|
26
|
+
function env(name, def = '') {
|
|
27
|
+
const v = process.env[name];
|
|
28
|
+
return (v !== undefined && v !== null && String(v).length) ? String(v) : def;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function envBool(name, def = false) {
|
|
32
|
+
const v = process.env[name];
|
|
33
|
+
if (v === undefined || v === null || String(v).trim() === '') return def;
|
|
34
|
+
const s = String(v).toLowerCase().trim();
|
|
35
|
+
return s === 'true' || s === '1' || s === 'on' || s === 'yes';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizePrefix(prefix) {
|
|
39
|
+
let p = (prefix || '').trim();
|
|
40
|
+
if (!p) return '';
|
|
41
|
+
p = p.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
42
|
+
return p ? `${p}/` : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeFolder(folder) {
|
|
46
|
+
let f = (folder || '').trim();
|
|
47
|
+
if (!f) return '';
|
|
48
|
+
f = f.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
49
|
+
return f ? `${f}/` : '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getSettings() {
|
|
53
|
+
// Env-only by design
|
|
54
|
+
return {
|
|
55
|
+
accessKeyId: env('NODEBB_R2_ACCESS_KEY_ID'),
|
|
56
|
+
secretAccessKey: env('NODEBB_R2_SECRET_ACCESS_KEY'),
|
|
57
|
+
bucket: env('NODEBB_R2_BUCKET'),
|
|
58
|
+
endpoint: env('NODEBB_R2_ENDPOINT'),
|
|
59
|
+
region: env('NODEBB_R2_REGION', 'auto'),
|
|
60
|
+
uploadPath: env('NODEBB_R2_UPLOAD_PATH', ''),
|
|
61
|
+
host: env('NODEBB_R2_HOST', ''),
|
|
62
|
+
forcePathStyle: envBool('NODEBB_R2_FORCE_PATH_STYLE', false),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertConfigured(s) {
|
|
67
|
+
const missing = [];
|
|
68
|
+
if (!s.accessKeyId) missing.push('NODEBB_R2_ACCESS_KEY_ID');
|
|
69
|
+
if (!s.secretAccessKey) missing.push('NODEBB_R2_SECRET_ACCESS_KEY');
|
|
70
|
+
if (!s.bucket) missing.push('NODEBB_R2_BUCKET');
|
|
71
|
+
if (!s.endpoint) missing.push('NODEBB_R2_ENDPOINT');
|
|
72
|
+
|
|
73
|
+
if (missing.length) {
|
|
74
|
+
throw new Error(`nodebb-plugin-cloudflare-r2 :: Missing env vars: ${missing.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getS3Client(s) {
|
|
79
|
+
// Recreate client if settings changed (endpoint/key rotation)
|
|
80
|
+
const fingerprint = `${s.endpoint}|${s.region}|${s.accessKeyId}|${s.bucket}|${s.forcePathStyle}`;
|
|
81
|
+
if (plugin._s3 && plugin._s3Fingerprint === fingerprint) return plugin._s3;
|
|
82
|
+
|
|
83
|
+
plugin._s3 = new S3Client({
|
|
84
|
+
region: s.region || 'auto',
|
|
85
|
+
endpoint: s.endpoint,
|
|
86
|
+
forcePathStyle: !!s.forcePathStyle,
|
|
87
|
+
credentials: {
|
|
88
|
+
accessKeyId: s.accessKeyId,
|
|
89
|
+
secretAccessKey: s.secretAccessKey,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
plugin._s3Fingerprint = fingerprint;
|
|
93
|
+
return plugin._s3;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isExtensionAllowed(originalName, allowed) {
|
|
97
|
+
const extension = path.extname(originalName || '').toLowerCase();
|
|
98
|
+
return !(
|
|
99
|
+
allowed.length > 0 &&
|
|
100
|
+
(!extension || extension === '.' || !allowed.includes(extension))
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function publicUrlForKey(s, key) {
|
|
105
|
+
let host = (s.host || '').trim();
|
|
106
|
+
if (!host) {
|
|
107
|
+
// Fallback; you should set NODEBB_R2_HOST to a real public URL.
|
|
108
|
+
host = `https://${s.bucket}`;
|
|
109
|
+
}
|
|
110
|
+
if (!/^https?:\/\//i.test(host)) host = `https://${host}`;
|
|
111
|
+
host = host.replace(/\/+$/, '');
|
|
112
|
+
return `${host}/${key}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function uploadFromPath({ s, filePath, originalName, folder }) {
|
|
116
|
+
const allowed = fileModule.allowedExtensions();
|
|
117
|
+
|
|
118
|
+
// IMPORTANT: validate against original filename, not tmp path
|
|
119
|
+
if (!isExtensionAllowed(originalName, allowed)) {
|
|
120
|
+
throw new Error(`[[error:invalid-file-type, ${allowed.join(', ')}]]`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check NodeBB's max upload size (KiB)
|
|
124
|
+
const st = await fs.promises.stat(filePath);
|
|
125
|
+
const maxBytes = parseInt(meta.config.maximumFileSize, 10) * 1024;
|
|
126
|
+
if (Number.isFinite(maxBytes) && st.size > maxBytes) {
|
|
127
|
+
throw new Error(`[[error:file-too-big, ${meta.config.maximumFileSize}]]`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const prefix = normalizePrefix(s.uploadPath);
|
|
131
|
+
const folderPrefix = normalizeFolder(folder);
|
|
132
|
+
const ext = path.extname(originalName);
|
|
133
|
+
const key = `${prefix}${folderPrefix}${uuidv4()}${ext}`;
|
|
134
|
+
|
|
135
|
+
const contentType = mime.lookup(originalName) || 'application/octet-stream';
|
|
136
|
+
|
|
137
|
+
const client = getS3Client(s);
|
|
138
|
+
const stream = fs.createReadStream(filePath);
|
|
139
|
+
|
|
140
|
+
const uploader = new Upload({
|
|
141
|
+
client,
|
|
142
|
+
params: {
|
|
143
|
+
Bucket: s.bucket,
|
|
144
|
+
Key: key,
|
|
145
|
+
Body: stream,
|
|
146
|
+
ContentType: contentType,
|
|
147
|
+
ContentLength: st.size,
|
|
148
|
+
},
|
|
149
|
+
queueSize: 4,
|
|
150
|
+
partSize: 8 * 1024 * 1024,
|
|
151
|
+
leavePartsOnError: false,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await uploader.done();
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
name: originalName,
|
|
158
|
+
url: publicUrlForKey(s, key),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
plugin.init = async () => {
|
|
163
|
+
// Validate early and log helpful info (without secrets)
|
|
164
|
+
try {
|
|
165
|
+
const s = getSettings();
|
|
166
|
+
assertConfigured(s);
|
|
167
|
+
winston.info(`[cloudflare-r2] enabled (bucket=${s.bucket}, endpoint=${s.endpoint}, region=${s.region}, host=${s.host || '(default)'}, forcePathStyle=${s.forcePathStyle})`);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
winston.warn(`[cloudflare-r2] not configured: ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
plugin.uploadImage = async (data) => {
|
|
174
|
+
const { image, folder } = data || {};
|
|
175
|
+
if (!image) throw new Error('invalid image');
|
|
176
|
+
|
|
177
|
+
const s = getSettings();
|
|
178
|
+
assertConfigured(s);
|
|
179
|
+
|
|
180
|
+
if (image.url) {
|
|
181
|
+
// Keeping things secure/simple: do not fetch remote URLs.
|
|
182
|
+
throw new Error('Remote image URLs are not supported. Please upload the file.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const originalName = image.name || image.originalname || image.filename || 'image';
|
|
186
|
+
if (!image.path) throw new Error('invalid image path');
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
return await uploadFromPath({
|
|
190
|
+
s,
|
|
191
|
+
filePath: image.path,
|
|
192
|
+
originalName,
|
|
193
|
+
folder,
|
|
194
|
+
});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
throw createError(err);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
plugin.uploadFile = async (data) => {
|
|
201
|
+
const { file, folder } = data || {};
|
|
202
|
+
if (!file) throw new Error('invalid file');
|
|
203
|
+
if (!file.path) throw new Error('invalid file path');
|
|
204
|
+
|
|
205
|
+
const s = getSettings();
|
|
206
|
+
assertConfigured(s);
|
|
207
|
+
|
|
208
|
+
const originalName = file.name || file.originalname || file.filename || 'file';
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
return await uploadFromPath({
|
|
212
|
+
s,
|
|
213
|
+
filePath: file.path,
|
|
214
|
+
originalName,
|
|
215
|
+
folder,
|
|
216
|
+
});
|
|
217
|
+
} catch (err) {
|
|
218
|
+
throw createError(err);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function createError(err) {
|
|
223
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
224
|
+
// Preserve i18n style messages (e.g., [[error:...]])
|
|
225
|
+
if (!/\[\[.*\]\]/.test(e.message)) {
|
|
226
|
+
e.message = `nodebb-plugin-cloudflare-r2 :: ${e.message}`;
|
|
227
|
+
}
|
|
228
|
+
winston.error(e.message);
|
|
229
|
+
return e;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nodebb-plugin-cloudflare-r2",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "NodeBB v4 upload provider for Cloudflare R2 (S3-compatible) using environment variables",
|
|
5
|
+
"main": "library.js",
|
|
6
|
+
"author": "You",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"nodebb",
|
|
10
|
+
"cloudflare",
|
|
11
|
+
"r2",
|
|
12
|
+
"s3",
|
|
13
|
+
"uploads"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@aws-sdk/client-s3": "^3.540.0",
|
|
17
|
+
"@aws-sdk/lib-storage": "^3.540.0",
|
|
18
|
+
"mime-types": "^2.1.35",
|
|
19
|
+
"uuid": "^9.0.1"
|
|
20
|
+
},
|
|
21
|
+
"nbbpm": {
|
|
22
|
+
"compatibility": "^4.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nodebb-plugin-cloudflare-r2",
|
|
3
|
+
"name": "Cloudflare R2 Uploads (Env)",
|
|
4
|
+
"description": "Stores NodeBB uploads in Cloudflare R2 via the S3 API. Configured via environment variables.",
|
|
5
|
+
"url": "https://example.invalid/nodebb-plugin-r2-uploads",
|
|
6
|
+
"library": "library.js",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"hook": "static:app.load",
|
|
10
|
+
"method": "init"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"hook": "filter:uploadImage",
|
|
14
|
+
"method": "uploadImage"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"hook": "filter:uploadFile",
|
|
18
|
+
"method": "uploadFile"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|