feat: zero-config first boot and pluggable storage backend

Two changes that drop the friction in self-hosting RefBoard so the install
story becomes "docker compose up, open the URL".

1. JWT_SECRET is now optional. On first boot the backend generates a
   64-byte random secret and persists it in the existing settings table.
   process.env.JWT_SECRET still wins when set, so ops setups that manage
   secrets out-of-band are unaffected. The prod-throws-without-env guard
   is gone (auto-generation is a strictly safer default than the previous
   hardcoded dev fallback).

2. STORAGE_BACKEND=fs|minio picks between MinIO (default, unchanged) and
   a new local-filesystem adapter. The FS adapter exposes a fake minioClient
   that mirrors the methods RefBoard calls (statObject, getObject,
   getPartialObject, listObjectsV2, putObject, removeObject(s), bucketExists,
   makeBucket), so consumers swap require('./minio') for require('./storage')
   and nothing else changes. Sidecar .mime files hold Content-Type so the
   range-aware media proxy still serves the right response headers.

examples/compose/minimal-fs.yml is the single-container variant that uses
the FS adapter. The default docker-compose.yml still spins up MinIO.

README quick-start collapses to one block (cp .env, docker compose pull,
docker compose up -d). The first user you register on the Login screen is
auto-promoted to admin, same as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hiren Kangad
2026-05-21 09:51:20 +05:30
co-authored by Claude Opus 4.7
parent 9328713ae5
commit 76a6e462ee
13 changed files with 382 additions and 35 deletions
+13 -10
View File
@@ -1,27 +1,31 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const JWT_SECRET = process.env.JWT_SECRET || (() => {
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET environment variable is required in production');
}
return 'refboard-dev-secret-do-not-use-in-prod';
})();
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
const BCRYPT_ROUNDS = 12;
const REFBOARD_API_KEY = process.env.REFBOARD_API_KEY || '';
// Memoized — resolved lazily so db.js is required after its module
// initialization side-effects have run. After the first call the secret is
// cached for the life of the process.
let _cachedJwtSecret = null;
function jwtSecret() {
if (_cachedJwtSecret) return _cachedJwtSecret;
const { getOrCreateJwtSecret } = require('./db');
_cachedJwtSecret = getOrCreateJwtSecret();
return _cachedJwtSecret;
}
function generateToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role, username: user.username, display_name: user.display_name },
JWT_SECRET,
jwtSecret(),
{ expiresIn: JWT_EXPIRES_IN }
);
}
function verifyToken(token) {
return jwt.verify(token, JWT_SECRET);
return jwt.verify(token, jwtSecret());
}
async function hashPassword(password) {
@@ -122,5 +126,4 @@ module.exports = {
adminMiddleware,
apiKeyMiddleware,
adminOrApiKeyMiddleware,
JWT_SECRET,
};
+20
View File
@@ -327,6 +327,25 @@ function getBoolSetting(key, defaultValue = false) {
}
})();
// JWT secret resolution.
// 1. process.env.JWT_SECRET wins if set (lets ops manage secrets via env).
// 2. Otherwise read from settings.jwt_secret.
// 3. If neither exists, generate a 64-byte random secret and persist it.
//
// This removes the need to set JWT_SECRET in .env before first boot. The
// first boot of a fresh install lands directly on the Login page in "create
// the first admin" mode; tokens minted there are signed with the auto-
// generated secret.
function getOrCreateJwtSecret() {
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
const existing = getSetting('jwt_secret');
if (existing) return existing;
const generated = require('crypto').randomBytes(64).toString('base64');
setSetting('jwt_secret', generated);
console.log('[db] JWT_SECRET was not set in the environment — generated a fresh one and persisted it in the settings table.');
return generated;
}
// ---------------------
// User helpers
// ---------------------
@@ -823,6 +842,7 @@ module.exports = {
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
// Settings
getSetting, setSetting, getAllSettings, getBoolSetting,
getOrCreateJwtSecret,
// Activity log
logActivity, getBoardActivity,
// Bootstrap
+1 -1
View File
@@ -11,7 +11,7 @@ const {
getCollection,
getCollectionMember,
} = require('../db');
const { deleteBoardImages: deleteBoardMinioImages } = require('../minio');
const { deleteBoardImages: deleteBoardMinioImages } = require('../storage');
const { recordActivity } = require('../activity');
const router = Router();
+1 -1
View File
@@ -14,7 +14,7 @@ const {
getCollectionBoards,
getUserByEmail,
} = require('../db');
const { deleteBoardImages: deleteBoardMinioImages } = require('../minio');
const { deleteBoardImages: deleteBoardMinioImages } = require('../storage');
const router = Router();
+1 -1
View File
@@ -7,7 +7,7 @@ const http = require('http');
const { URL } = require('url');
const { authMiddleware } = require('../auth');
const { getBoard, getCollectionMember, createImage, getImage, createMediaJob, createPdfPage, updateImagePageCount } = require('../db');
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../storage');
const { pdfInfo, bufferToTempFile } = require('../pdf-utils');
const { recordActivity } = require('../activity');
+9 -5
View File
@@ -23,7 +23,7 @@ app.get('/api/images/*', async (req, res) => {
try {
const objectPath = req.params[0]; // everything after /api/images/
if (!objectPath) return res.status(400).json({ error: 'Missing path' });
const { minioClient, MINIO_BUCKET } = require('./minio');
const { minioClient, MINIO_BUCKET } = require('./storage');
const stat = await minioClient.statObject(MINIO_BUCKET, objectPath);
const contentType = stat.metaData?.['content-type'] || 'application/octet-stream';
const totalSize = stat.size;
@@ -162,6 +162,10 @@ async function start() {
const dbModule = require('./db');
console.log('[server] Database initialized');
// Resolve/generate the JWT secret eagerly so the "generated a fresh one"
// log line surfaces at boot, not on the first auth request.
dbModule.getOrCreateJwtSecret();
try {
await dbModule.seedAdminFromEnv();
} catch (err) {
@@ -169,12 +173,12 @@ async function start() {
}
try {
const { initBucket } = require('./minio');
const { initBucket } = require('./storage');
await initBucket();
console.log('[server] MinIO initialized');
console.log('[server] Storage backend initialized');
} catch (err) {
console.error('[server] MinIO initialization failed:', err.message);
console.error('[server] Image uploads will not work until MinIO is available');
console.error('[server] Storage initialization failed:', err.message);
console.error('[server] Image uploads will not work until storage is available');
}
// Start media processing worker
+1 -1
View File
@@ -16,7 +16,7 @@ const {
updatePdfPageHires,
} = require('../db');
const { probeVideo, extractPoster } = require('../video-utils');
const { putBuffer, minioClient, MINIO_BUCKET } = require('../minio');
const { putBuffer, minioClient, MINIO_BUCKET } = require('../storage');
const { pdfRenderPage, bufferToTempFile } = require('../pdf-utils');
const POLL_INTERVAL_MS = 3000;
+221
View File
@@ -0,0 +1,221 @@
// Local-filesystem storage adapter.
//
// Mirrors backend/minio.js exports so it's a drop-in replacement when
// STORAGE_BACKEND=fs. The exported `minioClient` is a fake that supports the
// subset of MinIO Client methods RefBoard actually calls (putObject,
// statObject, getObject, getPartialObject, removeObject, removeObjects,
// listObjectsV2, bucketExists, makeBucket, setBucketPolicy).
//
// Layout on disk:
// {STORAGE_DATA_DIR}/{bucket}/boards/{boardId}/{imageId}.png
// {STORAGE_DATA_DIR}/{bucket}/boards/{boardId}/{imageId}.png.mime
//
// The sibling .mime sidecar holds the Content-Type so the HTTP range-aware
// media proxy can set the correct response header on read.
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { EventEmitter } = require('events');
const MINIO_BUCKET = process.env.MINIO_BUCKET || 'refboard';
const STORAGE_DATA_DIR = process.env.STORAGE_DATA_DIR || '/app/data/storage';
const PUBLIC_URL = process.env.PUBLIC_URL || '';
const MIME_TO_EXT = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/quicktime': '.mov',
'application/pdf': '.pdf',
};
function bucketRoot(bucket) {
return path.join(STORAGE_DATA_DIR, bucket);
}
function objectFile(bucket, name) {
return path.join(bucketRoot(bucket), name);
}
function metaFile(bucket, name) {
return objectFile(bucket, name) + '.mime';
}
function notFound(name) {
const e = new Error(`Object not found: ${name}`);
e.code = 'NoSuchKey';
return e;
}
async function readContentType(bucket, name) {
try {
const ct = await fsp.readFile(metaFile(bucket, name), 'utf8');
return ct.trim() || 'application/octet-stream';
} catch {
return 'application/octet-stream';
}
}
const minioClient = {
async bucketExists(bucket) {
try {
const st = await fsp.stat(bucketRoot(bucket));
return st.isDirectory();
} catch {
return false;
}
},
async makeBucket(bucket /* , region */) {
await fsp.mkdir(bucketRoot(bucket), { recursive: true });
},
async setBucketPolicy(/* bucket, policyJson */) {
// FS adapter has no concept of public-read policies — the bytes are only
// served through the backend's /api/images/* proxy regardless.
},
async putObject(bucket, name, buffer, length, metaDict) {
const target = objectFile(bucket, name);
await fsp.mkdir(path.dirname(target), { recursive: true });
await fsp.writeFile(target, buffer);
const ct = metaDict && (metaDict['Content-Type'] || metaDict['content-type']);
if (ct) await fsp.writeFile(metaFile(bucket, name), ct);
},
async statObject(bucket, name) {
let st;
try {
st = await fsp.stat(objectFile(bucket, name));
} catch (err) {
if (err.code === 'ENOENT') throw notFound(name);
throw err;
}
const contentType = await readContentType(bucket, name);
return {
size: st.size,
lastModified: st.mtime,
metaData: { 'content-type': contentType },
};
},
async getObject(bucket, name) {
const target = objectFile(bucket, name);
if (!fs.existsSync(target)) throw notFound(name);
return fs.createReadStream(target);
},
async getPartialObject(bucket, name, offset, length) {
const target = objectFile(bucket, name);
if (!fs.existsSync(target)) throw notFound(name);
const end = length > 0 ? offset + length - 1 : undefined;
return fs.createReadStream(target, { start: offset, end });
},
async removeObject(bucket, name) {
const target = objectFile(bucket, name);
await fsp.rm(target, { force: true });
await fsp.rm(metaFile(bucket, name), { force: true });
},
async removeObjects(bucket, names) {
await Promise.all(names.map((n) => this.removeObject(bucket, n)));
},
// Returns an EventEmitter that fires 'data' for each object, then 'end'.
// Matches the shape consumers rely on (see backend/minio.js:97-109).
listObjectsV2(bucket, prefix, recursive) {
const emitter = new EventEmitter();
const start = path.join(bucketRoot(bucket), prefix || '');
(async () => {
try {
const stack = [start];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch (err) {
if (err.code === 'ENOENT') continue;
throw err;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (recursive) stack.push(full);
continue;
}
if (entry.name.endsWith('.mime')) continue;
const rel = path.relative(bucketRoot(bucket), full).split(path.sep).join('/');
emitter.emit('data', { name: rel, size: 0 });
}
}
emitter.emit('end');
} catch (err) {
emitter.emit('error', err);
}
})();
return emitter;
},
};
async function initBucket() {
await fsp.mkdir(bucketRoot(MINIO_BUCKET), { recursive: true });
console.log(`[storage-fs] Bucket directory ready at ${bucketRoot(MINIO_BUCKET)}`);
}
async function uploadImage(boardId, imageId, buffer, mimeType) {
const ext = MIME_TO_EXT[mimeType] || '.bin';
const objectName = `boards/${boardId}/${imageId}${ext}`;
await minioClient.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, {
'Content-Type': mimeType,
});
return objectName;
}
async function putBuffer(objectName, buffer, contentType) {
await minioClient.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, {
'Content-Type': contentType,
});
return objectName;
}
async function deleteImage(objectPath) {
await minioClient.removeObject(MINIO_BUCKET, objectPath);
}
async function deleteBoardImages(boardId) {
const prefix = `boards/${boardId}/`;
const dir = path.join(bucketRoot(MINIO_BUCKET), prefix);
if (!fs.existsSync(dir)) return 0;
let count = 0;
await fsp.rm(dir, { recursive: true, force: true });
// Count is approximate (we don't enumerate first); callers only check >0.
count = 1;
return count;
}
function getImageUrl(objectPath) {
if (PUBLIC_URL) return `${PUBLIC_URL.replace(/\/$/, '')}/api/images/${objectPath}`;
return `/api/images/${objectPath}`;
}
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE_MB || '200', 10) * 1024 * 1024;
module.exports = {
minioClient,
initBucket,
uploadImage,
putBuffer,
deleteImage,
deleteBoardImages,
getImageUrl,
MINIO_BUCKET,
MIME_TO_EXT,
MAX_FILE_SIZE,
};
+18
View File
@@ -0,0 +1,18 @@
// Storage backend router.
//
// STORAGE_BACKEND=fs → local filesystem (single-container deploys, native
// installs, designers who don't want to run MinIO)
// STORAGE_BACKEND=minio → MinIO / any S3-compatible store (default)
//
// Both modules export the same shape (minioClient + helper functions), so
// consumers `require('./storage')` and never touch the backend choice
// directly.
const backend = (process.env.STORAGE_BACKEND || 'minio').toLowerCase();
if (backend === 'fs') {
console.log('[storage] Using local filesystem backend (STORAGE_BACKEND=fs)');
module.exports = require('./storage-fs');
} else {
module.exports = require('./minio');
}