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 ADDED
@@ -0,0 +1,5 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy...
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
+ }