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:
co-authored by
Claude Opus 4.7
parent
9328713ae5
commit
76a6e462ee
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user