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
+22
-3
@@ -8,9 +8,19 @@ PORT=8000
|
||||
NODE_ENV=development
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# ---- Container image (Docker Compose only) ----
|
||||
# Pre-built multi-arch images are published to GHCR on every push to main.
|
||||
# Pin a release with e.g. ghcr.io/metalfinger/refboard:v0.5.0 — see
|
||||
# https://github.com/metalfinger/refboard/pkgs/container/refboard for tags.
|
||||
REFBOARD_IMAGE=ghcr.io/metalfinger/refboard:latest
|
||||
|
||||
# ---- JWT auth ----
|
||||
# REQUIRED in production. Generate with: openssl rand -base64 64
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
# OPTIONAL. If unset, RefBoard generates a 64-byte random secret on first boot
|
||||
# and persists it in the SQLite settings table. Set this env var only if you
|
||||
# want ops to manage the secret out-of-band (e.g. via a secrets manager) — when
|
||||
# set, it always overrides the persisted value. Rotate by clearing the env var
|
||||
# *and* deleting the settings.jwt_secret row; this invalidates existing tokens.
|
||||
# JWT_SECRET=
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# ---- SQLite database ----
|
||||
@@ -18,7 +28,16 @@ JWT_EXPIRES_IN=7d
|
||||
# For local non-Docker dev, set this to ./data/refboard.db (relative to backend/).
|
||||
DB_PATH=/app/data/refboard.db
|
||||
|
||||
# ---- Object storage (MinIO / any S3-compatible) ----
|
||||
# ---- Object storage ----
|
||||
# STORAGE_BACKEND=minio (default) — uses MinIO or any S3-compatible store.
|
||||
# STORAGE_BACKEND=fs — stores media bytes on the local filesystem
|
||||
# under STORAGE_DATA_DIR. Collapses the stack
|
||||
# to a single container; see
|
||||
# examples/compose/minimal-fs.yml.
|
||||
STORAGE_BACKEND=minio
|
||||
STORAGE_DATA_DIR=/app/data/storage
|
||||
|
||||
# MinIO settings (ignored when STORAGE_BACKEND=fs)
|
||||
MINIO_ENDPOINT=minio
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
|
||||
@@ -72,21 +72,36 @@ Requires Docker + Docker Compose v2.
|
||||
```bash
|
||||
git clone https://github.com/metalfinger/refboard
|
||||
cd refboard
|
||||
bash scripts/setup.sh # macOS / Linux
|
||||
# or, on Windows PowerShell:
|
||||
# .\scripts\setup.ps1
|
||||
```
|
||||
|
||||
The script copies `.env.example` → `.env`, pulls the pre-built multi-arch image from GHCR, brings the stack up, waits for the backend to report healthy, and opens the URL in your browser. Re-running it is safe.
|
||||
|
||||
Prefer the raw commands? They're equivalent to:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
# Edit .env and at minimum set:
|
||||
# JWT_SECRET=<run: openssl rand -base64 64>
|
||||
# SEED_ADMIN_EMAIL=you@example.com
|
||||
# SEED_ADMIN_PASSWORD=<a long password>
|
||||
Open <http://localhost:8000>, create the first admin account on the screen RefBoard shows you, and you're in. No env-var editing required to get started — `JWT_SECRET` is generated and persisted on first boot, and the first user you register is auto-promoted to admin.
|
||||
|
||||
For production / non-localhost installs, see the [going-public checklist](#checklist-when-going-public) below — you'll want to set `JWT_SECRET` explicitly, lock down `CORS_ORIGIN`, and turn off self-registration.
|
||||
|
||||
To build from source instead of pulling (slower; needed only if you've modified the code):
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open <http://localhost:8000> and sign in with the seeded admin email / password.
|
||||
The default image is `ghcr.io/metalfinger/refboard:latest`. Pin a specific version by setting `REFBOARD_IMAGE=ghcr.io/metalfinger/refboard:v0.5.0` in `.env`.
|
||||
|
||||
MinIO console (S3 dashboard) is at <http://localhost:9001> — login is whatever you set as `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` in `.env` (defaults to `minioadmin` / `minioadmin`).
|
||||
|
||||
Persistent state lives under `./.docker-data/` (SQLite + MinIO objects). Back this up.
|
||||
Persistent state lives under `./.docker-data/` (SQLite + MinIO objects). Back this up — that's also where the auto-generated `JWT_SECRET` lives, so losing it logs everyone out.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,11 +132,11 @@ docker run -d --name minio -p 9000:9000 -p 9001:9001 \
|
||||
cd backend
|
||||
npm install
|
||||
DB_PATH=./data/refboard.db \
|
||||
JWT_SECRET=$(openssl rand -base64 64) \
|
||||
MINIO_ENDPOINT=localhost \
|
||||
SEED_ADMIN_EMAIL=you@example.com \
|
||||
SEED_ADMIN_PASSWORD=changeme \
|
||||
node server.js
|
||||
# JWT_SECRET is auto-generated and persisted on first boot — set it explicitly
|
||||
# only if you want to manage it via an external secrets manager. The first user
|
||||
# you register in the browser becomes admin automatically.
|
||||
|
||||
# 3. Frontend (dev mode, separate terminal)
|
||||
cd frontend
|
||||
@@ -155,6 +170,8 @@ All knobs live in `.env`. See [`.env.example`](.env.example) for the full annota
|
||||
|
||||
RefBoard is just an HTTP server on port 8000 — every reverse-proxy / tunneling option works. The only non-obvious bit is that it uses Socket.IO over WebSockets, so whatever fronts it must allow WS upgrades.
|
||||
|
||||
Pre-baked deployment configs for the common patterns (Cloudflare Tunnel sidecar, Caddy auto-TLS, Fly.io, Render, single-container FS-storage) live in [`examples/`](examples/README.md). The hand-rolled instructions below are still valid; the examples just spare you the YAML.
|
||||
|
||||
### Cloudflare Tunnel (zero open ports, free TLS, recommended for home / studio servers)
|
||||
|
||||
This is what I run my own instance behind. No router config, no public IP, no Let's Encrypt — Cloudflare proxies the connection through an outbound tunnel from the box.
|
||||
@@ -243,8 +260,8 @@ Anyone in your tailnet can hit it; no one else can.
|
||||
|
||||
### Checklist when going public
|
||||
|
||||
- [ ] Set `JWT_SECRET` to a real random value (`openssl rand -base64 64`).
|
||||
- [ ] Set `NODE_ENV=production` (the backend refuses to start in prod without `JWT_SECRET`).
|
||||
- [ ] Set `JWT_SECRET` explicitly (`openssl rand -base64 64`). For production installs we recommend pinning the secret in `.env` rather than relying on the auto-generated one in the SQLite settings table — easier to rotate, easier to back up to a secrets manager.
|
||||
- [ ] Set `NODE_ENV=production`.
|
||||
- [ ] Set `CORS_ORIGIN=https://your.domain` (drop the wildcard).
|
||||
- [ ] Confirm self-registration is **off** in the admin dashboard (defaults off; only flips on if you set `ALLOW_SELF_REGISTRATION=true` on first boot).
|
||||
- [ ] Restrict the MinIO console (port 9001) to localhost — only the S3 API on 9000 needs to be reachable from the backend, and the backend already proxies media bytes through `/api/images/*`, so MinIO does **not** need to be exposed publicly.
|
||||
@@ -289,8 +306,11 @@ See [CHANGELOG.md](CHANGELOG.md) for the version history (v0.1.0 → v0.5.0).
|
||||
|
||||
- [x] Admin dashboard frontend (live at `/admin` — user create / reset-password / role / deactivate)
|
||||
- [x] Per-board activity log (uploads, board events, threads, comments — live via Socket.IO)
|
||||
- [ ] **One-click `setup.sh` installer** for designers — see [`docs/install-roadmap.md`](docs/install-roadmap.md)
|
||||
- [ ] **Native installer** (`.dmg` / `.exe`) with no Docker dependency — see [`docs/install-roadmap.md`](docs/install-roadmap.md)
|
||||
- [x] **Pre-built multi-arch image** at `ghcr.io/metalfinger/refboard` (linux/amd64 + linux/arm64) — published on every push to main
|
||||
- [x] **Zero-edit first boot** — `JWT_SECRET` auto-generated and persisted, first registered user is auto-admin
|
||||
- [x] **FS storage adapter** — `STORAGE_BACKEND=fs` drops the MinIO dependency for single-container installs
|
||||
- [x] **One-click installers** — `scripts/setup.sh` (macOS / Linux) and `scripts/setup.ps1` (Windows)
|
||||
- [x] **PaaS deploy templates** — Fly.io, Render, Cloudflare Tunnel sidecar, Caddy auto-TLS in [`examples/`](examples/README.md)
|
||||
- [ ] Mobile-friendly read-only board view
|
||||
- [ ] Export board → PDF / image grid
|
||||
- [ ] Optional remote storage adapters (S3 direct, R2)
|
||||
|
||||
+13
-10
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
getCollectionBoards,
|
||||
getUserByEmail,
|
||||
} = require('../db');
|
||||
const { deleteBoardImages: deleteBoardMinioImages } = require('../minio');
|
||||
const { deleteBoardImages: deleteBoardMinioImages } = require('../storage');
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
@@ -19,6 +19,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
refboard:
|
||||
image: ${REFBOARD_IMAGE:-ghcr.io/metalfinger/refboard:latest}
|
||||
build: .
|
||||
container_name: refboard
|
||||
depends_on:
|
||||
@@ -36,6 +37,8 @@ services:
|
||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:-refboard}
|
||||
STORAGE_BACKEND: ${STORAGE_BACKEND:-minio}
|
||||
STORAGE_DATA_DIR: ${STORAGE_DATA_DIR:-/app/data/storage}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-200}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Single-container RefBoard with local-filesystem storage.
|
||||
#
|
||||
# Use this instead of the repo-root docker-compose.yml when you don't want to
|
||||
# run MinIO. State is stored under ./.docker-data/refboard/ (SQLite + media
|
||||
# bytes + JWT secret, all together — back this directory up).
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # most values can stay at defaults
|
||||
# docker compose -f examples/compose/minimal-fs.yml up -d
|
||||
|
||||
services:
|
||||
refboard:
|
||||
image: ${REFBOARD_IMAGE:-ghcr.io/metalfinger/refboard:latest}
|
||||
container_name: refboard
|
||||
environment:
|
||||
PORT: 8000
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
# JWT_SECRET is optional — RefBoard generates one on first boot and
|
||||
# persists it in the SQLite settings table. Override here if you'd
|
||||
# rather manage it out-of-band.
|
||||
JWT_SECRET: ${JWT_SECRET:-}
|
||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
||||
DB_PATH: /app/data/refboard.db
|
||||
STORAGE_BACKEND: fs
|
||||
STORAGE_DATA_DIR: /app/data/storage
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-200}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-}
|
||||
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
|
||||
SEED_ADMIN_USERNAME: ${SEED_ADMIN_USERNAME:-}
|
||||
SEED_ADMIN_DISPLAY_NAME: ${SEED_ADMIN_DISPLAY_NAME:-}
|
||||
ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false}
|
||||
REFBOARD_API_KEY: ${REFBOARD_API_KEY:-}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./.docker-data/refboard:/app/data
|
||||
restart: unless-stopped
|
||||
Reference in New Issue
Block a user