chore: prepare standalone public repo

- Remove Mattermost integration (OAuth, channel bridge, file sync watcher,
  frontend import modal). RefBoard now ships as a self-contained app.
- Replace SSO Login screen with email/password form (+ optional register link
  gated by ALLOW_SELF_REGISTRATION).
- Add SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD env-var bootstrap so a fresh
  install ships with an admin account on first boot (idempotent).
- ALLOW_SELF_REGISTRATION flag (default false) gates POST /api/auth/register.
  First user can always register (auto-promoted to admin).
- Drop mattermost_id and mm_file_id columns + board_channel_links table
  from the schema; remove related db helpers and exports.
- Add MIT LICENSE, comprehensive README, .env.example, docker-compose.yml
  (bundles MinIO so one command boots a working stack).
- Expand .gitignore for typical Node + Docker dev artefacts.
This commit is contained in:
Hiren Kangad
2026-04-28 19:58:53 +05:30
parent 24fa9d1252
commit 69b58f73f8
15 changed files with 539 additions and 1327 deletions
+49 -71
View File
@@ -84,18 +84,6 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_boards_created_by ON boards(created_by);
CREATE INDEX IF NOT EXISTS idx_images_board ON images(board_id);
CREATE TABLE IF NOT EXISTS board_channel_links (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
channel_name TEXT,
created_by TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(board_id, channel_id)
);
CREATE INDEX IF NOT EXISTS idx_board_channel_links_board ON board_channel_links(board_id);
CREATE TABLE IF NOT EXISTS media_jobs (
id TEXT PRIMARY KEY,
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
@@ -197,11 +185,6 @@ try {
} catch {
db.exec("ALTER TABLE images ADD COLUMN media_type TEXT DEFAULT 'image'");
}
try {
db.prepare("SELECT mm_file_id FROM images LIMIT 0").get();
} catch {
db.exec("ALTER TABLE images ADD COLUMN mm_file_id TEXT");
}
try {
db.prepare("SELECT poster_asset_key FROM images LIMIT 0").get();
} catch {
@@ -218,12 +201,6 @@ try {
db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER");
db.exec("ALTER TABLE images ADD COLUMN native_height INTEGER");
}
try {
db.prepare("SELECT mattermost_id FROM users LIMIT 0").get();
} catch {
db.exec("ALTER TABLE users ADD COLUMN mattermost_id TEXT");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_mattermost_id ON users(mattermost_id)");
}
try { db.prepare('SELECT page_count FROM images LIMIT 0').get(); }
catch { db.exec('ALTER TABLE images ADD COLUMN page_count INTEGER'); }
@@ -245,14 +222,6 @@ function getUserByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
}
function getUserByMattermostId(mmId) {
return db.prepare('SELECT * FROM users WHERE mattermost_id = ? AND is_active = 1').get(mmId);
}
function updateUserMattermostId(userId, mmId) {
db.prepare("UPDATE users SET mattermost_id = ?, updated_at = datetime('now') WHERE id = ?").run(mmId, userId);
}
function createUser({ id, email, username, passwordHash, displayName, role }) {
db.prepare(`
INSERT INTO users (id, email, username, password_hash, display_name, role)
@@ -461,11 +430,11 @@ function saveBoardCanvas(boardId, canvasState, thumbnail) {
// ---------------------
// Images
// ---------------------
function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy, assetKey, mediaType, mmFileId }) {
function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy, assetKey, mediaType }) {
db.prepare(`
INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type, mm_file_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy, assetKey || null, mediaType || 'image', mmFileId || null);
INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy, assetKey || null, mediaType || 'image');
return db.prepare('SELECT * FROM images WHERE id = ?').get(id);
}
@@ -489,37 +458,6 @@ function deleteBoardImageRecords(boardId) {
return images;
}
// ---------------------
// BoardChannel Links
// ---------------------
function createBoardChannelLink({ id, boardId, channelId, channelName, createdBy }) {
db.prepare(`
INSERT INTO board_channel_links (id, board_id, channel_id, channel_name, created_by)
VALUES (?, ?, ?, ?, ?)
`).run(id, boardId, channelId, channelName || null, createdBy);
return db.prepare('SELECT * FROM board_channel_links WHERE id = ?').get(id);
}
function getBoardChannelLinks(boardId) {
return db.prepare('SELECT * FROM board_channel_links WHERE board_id = ? ORDER BY created_at DESC').all(boardId);
}
function getBoardChannelLink(linkId) {
return db.prepare('SELECT * FROM board_channel_links WHERE id = ?').get(linkId);
}
function deleteBoardChannelLink(linkId) {
db.prepare('DELETE FROM board_channel_links WHERE id = ?').run(linkId);
}
function getAllBoardChannelLinks() {
return db.prepare('SELECT * FROM board_channel_links ORDER BY created_at DESC').all();
}
function getImageByMmFileId(boardId, mmFileId) {
return db.prepare('SELECT * FROM images WHERE board_id = ? AND mm_file_id = ?').get(boardId, mmFileId);
}
// ---------------------
// Media Jobs
// ---------------------
@@ -698,11 +636,52 @@ function deleteComment(commentId) {
db.prepare('DELETE FROM comments WHERE id = ?').run(commentId);
}
// ---------------------
// Seed admin from env (idempotent)
// ---------------------
async function seedAdminFromEnv() {
const email = process.env.SEED_ADMIN_EMAIL;
const password = process.env.SEED_ADMIN_PASSWORD;
if (!email || !password) return;
const existing = getUserByEmail(email);
if (existing) {
if (existing.role !== 'admin') {
db.prepare("UPDATE users SET role = 'admin', updated_at = datetime('now') WHERE id = ?").run(existing.id);
console.log(`[db] Promoted ${email} to admin via SEED_ADMIN_EMAIL.`);
}
return;
}
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const username = (process.env.SEED_ADMIN_USERNAME || email.split('@')[0]).replace(/[^a-zA-Z0-9_-]/g, '');
const displayName = process.env.SEED_ADMIN_DISPLAY_NAME || username;
const passwordHash = await bcrypt.hash(password, 12);
// Avoid username collision
let finalUsername = username;
let suffix = 1;
while (getUserByUsername(finalUsername)) {
finalUsername = `${username}${suffix++}`;
}
createUser({
id: uuidv4(),
email,
username: finalUsername,
passwordHash,
displayName,
role: 'admin',
});
console.log(`[db] Seeded admin user: ${email} (username: ${finalUsername})`);
}
module.exports = {
db,
// Users
getUserByEmail, getUserById, getUserByUsername, getUserByMattermostId,
createUser, updateUserMattermostId,
getUserByEmail, getUserById, getUserByUsername,
createUser,
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
// Collections
getCollections, getCollection, getCollectionByShareToken,
@@ -713,9 +692,6 @@ module.exports = {
getCollectionBoards, getBoard, createBoard, updateBoard, deleteBoard, saveBoardCanvas,
// Images
createImage, getBoardImages, getImage, deleteImage, deleteBoardImageRecords,
// BoardChannel Links
createBoardChannelLink, getBoardChannelLinks, getBoardChannelLink, deleteBoardChannelLink,
getAllBoardChannelLinks, getImageByMmFileId,
// Media Jobs
createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia,
// PDF Pages
@@ -725,4 +701,6 @@ module.exports = {
incrementThreadCommentCount, decrementThreadCommentCount,
// Comments
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
// Bootstrap
seedAdminFromEnv,
};
+11 -3
View File
@@ -22,7 +22,8 @@ const router = Router();
*/
router.post('/register', async (req, res) => {
try {
const { email, username, password, display_name } = req.body;
const { email, username, password, display_name, displayName } = req.body;
const dn = display_name || displayName;
if (!email || !username || !password) {
return res.status(400).json({ error: 'Email, username, and password are required' });
@@ -31,6 +32,14 @@ router.post('/register', async (req, res) => {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
const allowRegistration = (process.env.ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true';
const userCount = getUserCount();
if (!allowRegistration && userCount > 0) {
return res.status(403).json({
error: 'Self-registration is disabled. Ask an admin to create your account.',
});
}
const existing = getUserByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
@@ -42,7 +51,6 @@ router.post('/register', async (req, res) => {
}
const passwordHash = await hashPassword(password);
const userCount = getUserCount();
const role = userCount === 0 ? 'admin' : 'member';
const user = createUser({
@@ -50,7 +58,7 @@ router.post('/register', async (req, res) => {
email,
username,
passwordHash,
displayName: display_name || username,
displayName: dn || username,
role,
});
-319
View File
@@ -1,319 +0,0 @@
const { Router } = require('express');
const { v4: uuidv4 } = require('uuid');
const { authMiddleware } = require('../auth');
const {
getBoard, getCollectionMember,
createBoardChannelLink, getBoardChannelLinks, getBoardChannelLink, deleteBoardChannelLink,
createImage, getImageByMmFileId,
} = require('../db');
const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio');
const sharp = require('sharp');
const router = Router();
const MM_URL = process.env.MM_URL || 'http://mattermost:8065';
const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN || '';
const IMAGE_MIME_TYPES = [
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml',
];
const VIDEO_MIME_TYPES = [
'video/mp4', 'video/webm', 'video/quicktime',
];
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
// All routes require auth
router.use(authMiddleware);
// ---------------------
// Helpers
// ---------------------
/**
* Check board access for editor+ role.
* Returns board or null (sends error response).
*/
function checkEditorAccess(req, res) {
const board = getBoard(req.params.boardId);
if (!board) {
res.status(404).json({ error: 'Board not found' });
return null;
}
const member = getCollectionMember(board.collection_id, req.user.id);
const hierarchy = { owner: 3, editor: 2, viewer: 1 };
if (!member || (hierarchy[member.role] || 0) < 2) {
res.status(403).json({ error: 'Editor or owner access required' });
return null;
}
return board;
}
/**
* Check board access for any authenticated member (viewer+).
*/
function checkViewerAccess(req, res) {
const board = getBoard(req.params.boardId);
if (!board) {
res.status(404).json({ error: 'Board not found' });
return null;
}
const member = getCollectionMember(board.collection_id, req.user.id);
if (!member) {
res.status(403).json({ error: 'Access denied' });
return null;
}
return board;
}
/**
* Make authenticated request to Mattermost API.
*/
async function mmFetch(path, options = {}) {
const url = `${MM_URL}/api/v4${path}`;
const resp = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${MM_BOT_TOKEN}`,
...options.headers,
},
});
if (!resp.ok) {
const body = await resp.text().catch(() => '');
throw new Error(`Mattermost API error ${resp.status}: ${body}`);
}
return resp;
}
/**
* Classify MIME type as image or video.
*/
function classifyMedia(mimeType) {
if (VIDEO_MIME_TYPES.includes(mimeType)) return 'video';
return 'image';
}
/**
* Upload a media file (image or video) to MinIO as a single file.
* GPU handles all scaling natively — no LOD tiers needed.
*/
async function uploadMedia(boardId, imageId, buffer, mimetype) {
const ext = MIME_TO_EXT[mimetype] || '.bin';
const minioPath = `boards/${boardId}/${imageId}${ext}`;
await putBuffer(minioPath, buffer, mimetype);
let width = null, height = null;
if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
try {
const metadata = await sharp(buffer).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch {}
}
return { assetKey: minioPath, minioPath, width, height };
}
// ---------------------
// Channel Link Routes
// ---------------------
/**
* POST /api/boards/:boardId/mm-link
* Link a Mattermost channel to a board.
*/
router.post('/:boardId/mm-link', (req, res) => {
try {
const board = checkEditorAccess(req, res);
if (!board) return;
const { channelId, channelName } = req.body;
if (!channelId) {
return res.status(400).json({ error: 'channelId is required' });
}
const link = createBoardChannelLink({
id: uuidv4(),
boardId: board.id,
channelId,
channelName: channelName || null,
createdBy: req.user.id,
});
return res.status(201).json(link);
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Channel already linked to this board' });
}
console.error('[mm-bridge] link error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/boards/:boardId/mm-link
* List linked channels for a board.
*/
router.get('/:boardId/mm-link', (req, res) => {
try {
const board = checkViewerAccess(req, res);
if (!board) return;
const links = getBoardChannelLinks(board.id);
return res.json({ links });
} catch (err) {
console.error('[mm-bridge] list links error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/**
* DELETE /api/boards/:boardId/mm-link/:linkId
* Unlink a channel from a board.
*/
router.delete('/:boardId/mm-link/:linkId', (req, res) => {
try {
const board = checkEditorAccess(req, res);
if (!board) return;
const link = getBoardChannelLink(req.params.linkId);
if (!link || link.board_id !== board.id) {
return res.status(404).json({ error: 'Link not found' });
}
deleteBoardChannelLink(link.id);
return res.json({ ok: true });
} catch (err) {
console.error('[mm-bridge] unlink error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
// ---------------------
// Media Pull Route
// ---------------------
/**
* POST /api/boards/:boardId/mm-pull
* Manual pull media from a Mattermost channel or thread.
*/
router.post('/:boardId/mm-pull', async (req, res) => {
try {
const board = checkEditorAccess(req, res);
if (!board) return;
if (!MM_BOT_TOKEN) {
return res.status(503).json({ error: 'Mattermost bot token not configured' });
}
const { channelId, threadId } = req.body;
if (!channelId && !threadId) {
return res.status(400).json({ error: 'channelId or threadId is required' });
}
// 1. Fetch posts from Mattermost
let postsData;
if (threadId) {
const resp = await mmFetch(`/posts/${threadId}/thread`);
postsData = await resp.json();
} else {
const resp = await mmFetch(`/channels/${channelId}/posts`);
postsData = await resp.json();
}
// postsData.order is array of post IDs, postsData.posts is { id: post }
const posts = postsData.posts || {};
const order = postsData.order || Object.keys(posts);
// 2. Collect all file_ids from posts
const fileIds = [];
for (const postId of order) {
const post = posts[postId];
if (post && post.file_ids && post.file_ids.length > 0) {
fileIds.push(...post.file_ids);
}
}
if (fileIds.length === 0) {
return res.json({ assets: [], message: 'No files found in posts' });
}
// 3. Process each file
const assets = [];
const errors = [];
for (const fileId of fileIds) {
try {
// Skip if already imported (dedup by mm_file_id)
const existing = getImageByMmFileId(board.id, fileId);
if (existing) {
continue;
}
// Get file info
const infoResp = await mmFetch(`/files/${fileId}/info`);
const fileInfo = await infoResp.json();
const mimeType = fileInfo.mime_type || '';
if (!ALLOWED_MIME_TYPES.includes(mimeType)) {
continue; // skip non-media files
}
// Download file
const fileResp = await mmFetch(`/files/${fileId}`);
const arrayBuf = await fileResp.arrayBuffer();
const buffer = Buffer.from(arrayBuf);
const imageId = uuidv4();
const mediaType = classifyMedia(mimeType);
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType);
const publicUrl = getImageUrl(minioPath);
const image = createImage({
id: imageId,
boardId: board.id,
filename: fileInfo.name || `mm-${fileId}`,
mimeType,
fileSize: buffer.length,
width,
height,
minioPath,
publicUrl,
uploadedBy: req.user.id,
assetKey,
mediaType,
mmFileId: fileId,
});
assets.push({
id: image.id,
url: publicUrl,
public_url: publicUrl,
width: image.width,
height: image.height,
file_size: image.file_size,
mime_type: image.mime_type,
asset_key: image.asset_key,
media_type: image.media_type,
mm_file_id: fileId,
});
} catch (fileErr) {
console.error(`[mm-bridge] failed to process file ${fileId}:`, fileErr.message);
errors.push({ fileId, error: fileErr.message });
}
}
return res.json({
assets,
total_files: fileIds.length,
imported: assets.length,
skipped: fileIds.length - assets.length - errors.length,
errors: errors.length > 0 ? errors : undefined,
});
} catch (err) {
console.error('[mm-bridge] pull error:', err);
return res.status(500).json({ error: 'Failed to pull media from Mattermost' });
}
});
module.exports = router;
-185
View File
@@ -1,185 +0,0 @@
const { Router } = require('express');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const {
getUserByEmail,
getUserByUsername,
createUser,
getUserByMattermostId,
updateUserMattermostId,
} = require('../db');
const { generateToken } = require('../auth');
const router = Router();
// OAuth config from env
const CLIENT_ID = process.env.MATTERMOST_OAUTH_CLIENT_ID || '';
const CLIENT_SECRET = process.env.MATTERMOST_OAUTH_CLIENT_SECRET || '';
const AUTHORIZE_URL = process.env.MATTERMOST_OAUTH_AUTHORIZE_URL || '';
const TOKEN_URL = process.env.MATTERMOST_OAUTH_TOKEN_URL || '';
const USERINFO_URL = process.env.MATTERMOST_OAUTH_USERINFO_URL || '';
const PUBLIC_URL = process.env.PUBLIC_URL || process.env.REFBOARD_PUBLIC_URL || '';
const CALLBACK_PATH = '/api/auth/mattermost/callback';
// CSRF state store: state -> timestamp (expire after 10 min)
const _pendingStates = new Map();
const STATE_TTL = 10 * 60 * 1000;
function _cleanupStates() {
const now = Date.now();
for (const [state, ts] of _pendingStates) {
if (now - ts > STATE_TTL) _pendingStates.delete(state);
}
}
function isConfigured() {
return !!(CLIENT_ID && CLIENT_SECRET && AUTHORIZE_URL && TOKEN_URL && USERINFO_URL);
}
/**
* GET /api/auth/mattermost
* Initiates the OAuth flow — redirects browser to Mattermost authorize page.
*/
router.get('/mattermost', (req, res) => {
if (!isConfigured()) {
return res.status(503).json({ error: 'Mattermost OAuth not configured' });
}
_cleanupStates();
const state = crypto.randomBytes(24).toString('hex');
_pendingStates.set(state, Date.now());
const callbackUrl = `${PUBLIC_URL}${CALLBACK_PATH}`;
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: callbackUrl,
state,
});
res.redirect(`${AUTHORIZE_URL}?${params.toString()}`);
});
/**
* GET /api/auth/mattermost/callback
* Handles the OAuth callback from Mattermost.
*/
router.get('/mattermost/callback', async (req, res) => {
try {
const { code, state, error: oauthError } = req.query;
if (oauthError) {
console.error('[oauth] Mattermost returned error:', oauthError);
return res.redirect(`/login?error=${encodeURIComponent('Login was denied')}`);
}
// Validate CSRF state
if (!state || !_pendingStates.has(state)) {
return res.redirect('/login?error=' + encodeURIComponent('Invalid login session. Please try again.'));
}
_pendingStates.delete(state);
if (!code) {
return res.redirect('/login?error=' + encodeURIComponent('No authorization code received'));
}
// Exchange code for access token
const callbackUrl = `${PUBLIC_URL}${CALLBACK_PATH}`;
const tokenResp = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: callbackUrl,
}).toString(),
});
if (!tokenResp.ok) {
const text = await tokenResp.text();
console.error('[oauth] Token exchange failed:', tokenResp.status, text);
return res.redirect('/login?error=' + encodeURIComponent('Login failed. Please try again.'));
}
const tokenData = await tokenResp.json();
const accessToken = tokenData.access_token;
// Fetch user info from Mattermost
const userResp = await fetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!userResp.ok) {
console.error('[oauth] Userinfo fetch failed:', userResp.status);
return res.redirect('/login?error=' + encodeURIComponent('Failed to get user info'));
}
const mmUser = await userResp.json();
const mmId = mmUser.id;
const mmEmail = mmUser.email;
const mmUsername = mmUser.username;
const mmDisplayName = [mmUser.first_name, mmUser.last_name].filter(Boolean).join(' ')
|| mmUser.nickname || mmUsername;
// Try to find existing RefBoard user
let user = getUserByMattermostId(mmId);
if (!user) {
// Try matching by email
user = getUserByEmail(mmEmail);
if (user) {
// Link existing account
updateUserMattermostId(user.id, mmId);
}
}
if (!user) {
// Auto-create new user
// Handle username collision
let finalUsername = mmUsername;
const existingUsername = getUserByUsername(finalUsername);
if (existingUsername) {
finalUsername = `${mmUsername}_mm`;
}
user = createUser({
id: uuidv4(),
email: mmEmail,
username: finalUsername,
passwordHash: `oauth:mattermost:${crypto.randomBytes(16).toString('hex')}`,
displayName: mmDisplayName,
role: 'member',
});
updateUserMattermostId(user.id, mmId);
console.log(`[oauth] Created RefBoard user for MM user ${mmUsername} (${mmEmail})`);
}
// Generate JWT and redirect to frontend
const jwt = generateToken(user);
const userPayload = encodeURIComponent(JSON.stringify({
id: user.id,
email: user.email,
username: user.username,
display_name: user.display_name,
role: user.role,
}));
res.redirect(`/login?token=${jwt}&user=${userPayload}`);
} catch (err) {
console.error('[oauth] Callback error:', err);
res.redirect('/login?error=' + encodeURIComponent('Something went wrong. Please try again.'));
}
});
/**
* GET /api/auth/mattermost/status
* Check if Mattermost OAuth is configured (for frontend to show/hide button).
*/
router.get('/mattermost/status', (_req, res) => {
res.json({ enabled: isConfigured() });
});
module.exports = router;
+7 -18
View File
@@ -95,22 +95,18 @@ app.get('/api/users/search', (req, res) => {
// ---- API routes ----
const authRoutes = require('./routes/auth');
const oauthRoutes = require('./routes/oauth');
const collectionRoutes = require('./routes/collections');
const boardRoutes = require('./routes/boards');
const uploadRoutes = require('./routes/upload');
const adminRoutes = require('./routes/admin');
const mmBridgeRoutes = require('./routes/mattermost-bridge');
const threadRoutes = require('./routes/threads');
const pdfRoutes = require('./routes/pdf');
app.use('/api/auth', authRoutes);
app.use('/api/auth', oauthRoutes);
app.use('/api/collections', collectionRoutes);
app.use('/api/boards', boardRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/boards', mmBridgeRoutes);
app.use('/api/boards', threadRoutes);
app.use('/api/boards', pdfRoutes);
@@ -161,9 +157,15 @@ app.set('io', io);
// ---- Initialize services and start ----
async function start() {
require('./db');
const dbModule = require('./db');
console.log('[server] Database initialized');
try {
await dbModule.seedAdminFromEnv();
} catch (err) {
console.error('[server] SEED_ADMIN bootstrap failed:', err.message);
}
try {
const { initBucket } = require('./minio');
await initBucket();
@@ -173,14 +175,6 @@ async function start() {
console.error('[server] Image uploads will not work until MinIO is available');
}
// Start Mattermost auto-sync watcher (no-op if env vars missing)
try {
const { startWatcher } = require('./services/mm-watcher');
startWatcher(io);
} catch (err) {
console.error('[server] MM watcher failed to start:', err.message);
}
// Start media processing worker
try {
const { startMediaWorker } = require('./services/media-worker');
@@ -198,11 +192,6 @@ async function start() {
function shutdown(signal) {
console.log(`[server] Received ${signal}, shutting down gracefully...`);
try {
const { stopWatcher } = require('./services/mm-watcher');
stopWatcher();
} catch {}
try {
const { stopMediaWorker } = require('./services/media-worker');
stopMediaWorker();
-323
View File
@@ -1,323 +0,0 @@
/**
* Mattermost Auto-Sync Watcher
*
* Polls linked Mattermost channels for new file attachments and
* automatically imports them into the corresponding RefBoard boards.
*
* Requires MM_URL and MM_BOT_TOKEN env vars. Silently skips if not set.
*/
const https = require('https');
const http = require('http');
const { URL } = require('url');
const { v4: uuidv4 } = require('uuid');
const sharp = require('sharp');
const { getAllBoardChannelLinks, getImageByMmFileId, createImage, getBoard } = require('../db');
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
const MM_URL = process.env.MM_URL;
const MM_BOT_TOKEN = process.env.MM_BOT_TOKEN;
const POLL_INTERVAL_MS = parseInt(process.env.MM_WATCHER_INTERVAL || '30000', 10);
const IMAGE_MIME_TYPES = [
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml',
];
const VIDEO_MIME_TYPES = [
'video/mp4', 'video/webm', 'video/quicktime',
];
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
// Track last-check timestamp per channel link (in-memory)
const lastCheckMap = new Map();
/**
* Make an authenticated GET request to the Mattermost API.
* Returns parsed JSON.
*/
function mmGet(apiPath) {
return new Promise((resolve, reject) => {
const url = new URL(apiPath, MM_URL);
const client = url.protocol === 'https:' ? https : http;
const req = client.get(url.toString(), {
headers: { Authorization: `Bearer ${MM_BOT_TOKEN}` },
timeout: 15000,
}, (res) => {
if (res.statusCode !== 200) {
// Drain and reject
res.resume();
return reject(new Error(`MM API ${apiPath} returned ${res.statusCode}`));
}
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString()));
} catch (e) {
reject(new Error(`MM API ${apiPath}: invalid JSON`));
}
});
res.on('error', reject);
});
req.on('error', reject);
});
}
/**
* Download a file from Mattermost by file ID.
* Returns { buffer, mimeType, filename }.
*/
function mmDownloadFile(fileId) {
return new Promise((resolve, reject) => {
const url = new URL(`/api/v4/files/${fileId}`, MM_URL);
const client = url.protocol === 'https:' ? https : http;
const req = client.get(url.toString(), {
headers: { Authorization: `Bearer ${MM_BOT_TOKEN}` },
timeout: 30000,
}, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
// Follow redirect
return mmDownloadFileUrl(res.headers.location).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`MM file download ${fileId} returned ${res.statusCode}`));
}
const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
const chunks = [];
let totalSize = 0;
const MAX_SIZE = MAX_FILE_SIZE;
res.on('data', (chunk) => {
totalSize += chunk.length;
if (totalSize > MAX_SIZE) {
res.destroy();
return reject(new Error(`File ${fileId} too large`));
}
chunks.push(chunk);
});
res.on('end', () => {
resolve({ buffer: Buffer.concat(chunks), mimeType: contentType });
});
res.on('error', reject);
});
req.on('error', reject);
});
}
/**
* Follow a redirect URL for file download.
*/
function mmDownloadFileUrl(downloadUrl) {
return new Promise((resolve, reject) => {
const parsed = new URL(downloadUrl);
const client = parsed.protocol === 'https:' ? https : http;
const req = client.get(downloadUrl, { timeout: 30000 }, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`File redirect download returned ${res.statusCode}`));
}
const contentType = (res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve({ buffer: Buffer.concat(chunks), mimeType: contentType });
});
res.on('error', reject);
});
req.on('error', reject);
});
}
/**
* Get file metadata from Mattermost.
*/
async function mmGetFileInfo(fileId) {
return mmGet(`/api/v4/files/${fileId}/info`);
}
/**
* Classify MIME type as image or video.
*/
function classifyMedia(mimeType) {
if (VIDEO_MIME_TYPES.includes(mimeType)) return 'video';
return 'image';
}
/**
* Upload a media file (image or video) to MinIO as a single file.
* GPU handles all image scaling natively — no LOD tiers needed.
*/
async function uploadMedia(boardId, imageId, buffer, mimetype) {
const ext = MIME_TO_EXT[mimetype] || '.bin';
const minioPath = `boards/${boardId}/${imageId}${ext}`;
await putBuffer(minioPath, buffer, mimetype);
let width = null, height = null;
if (mimetype !== 'image/svg+xml' && !mimetype.startsWith('video/')) {
try {
const meta = await sharp(buffer).metadata();
width = meta.width || null;
height = meta.height || null;
} catch {}
}
return { assetKey: minioPath, minioPath, width, height };
}
/**
* Process a single channel link: fetch new posts, import new files.
* Returns array of newly imported assets for socket notification.
*/
async function processLink(link) {
const { board_id: boardId, channel_id: channelId, id: linkId } = link;
// Verify board still exists
const board = getBoard(boardId);
if (!board) return [];
const since = lastCheckMap.get(linkId) || Date.now() - POLL_INTERVAL_MS;
lastCheckMap.set(linkId, Date.now());
let postsData;
try {
postsData = await mmGet(`/api/v4/channels/${channelId}/posts?since=${since}`);
} catch (err) {
console.error(`[mm-watcher] Failed to fetch posts for channel ${channelId}:`, err.message);
return [];
}
if (!postsData || !postsData.order || !postsData.posts) return [];
const newAssets = [];
for (const postId of postsData.order) {
const post = postsData.posts[postId];
if (!post || !post.file_ids || post.file_ids.length === 0) continue;
for (const fileId of post.file_ids) {
try {
// Deduplication check
const existing = getImageByMmFileId(boardId, fileId);
if (existing) continue;
// Get file info to check MIME type
const fileInfo = await mmGetFileInfo(fileId);
const mimeType = fileInfo.mime_type || 'application/octet-stream';
if (!ALLOWED_MIME_TYPES.includes(mimeType)) {
continue; // Skip non-image/video files
}
// Download the file
const { buffer } = await mmDownloadFile(fileId);
const imageId = uuidv4();
const mediaType = classifyMedia(mimeType);
const { assetKey, minioPath, width, height } = await uploadMedia(boardId, imageId, buffer, mimeType);
const publicUrl = getImageUrl(minioPath);
// Use the link creator as the uploader
const image = createImage({
id: imageId,
boardId,
filename: fileInfo.name || `mm-${fileId}`,
mimeType,
fileSize: buffer.length,
width,
height,
minioPath,
publicUrl,
uploadedBy: link.created_by,
assetKey,
mediaType,
mmFileId: fileId,
});
newAssets.push({
assetKey: image.asset_key,
name: image.filename,
width: image.width,
height: image.height,
mediaType: image.media_type,
});
console.log(`[mm-watcher] Imported file ${fileInfo.name || fileId} → board ${boardId}`);
} catch (err) {
console.error(`[mm-watcher] Failed to import file ${fileId}:`, err.message);
}
}
}
return newAssets;
}
/**
* Single poll cycle: process all links.
*/
async function pollOnce(io) {
let links;
try {
links = getAllBoardChannelLinks();
} catch (err) {
console.error('[mm-watcher] Failed to query links:', err.message);
return;
}
if (!links || links.length === 0) return;
for (const link of links) {
try {
const newAssets = await processLink(link);
if (newAssets.length > 0 && io) {
io.to(`board:${link.board_id}`).emit('board:media-arrived', {
boardId: link.board_id,
assets: newAssets,
});
}
} catch (err) {
console.error(`[mm-watcher] Error processing link ${link.id}:`, err.message);
}
}
}
let pollTimer = null;
/**
* Start the Mattermost watcher. Requires Socket.IO server instance.
* Silently does nothing if MM_URL or MM_BOT_TOKEN are not set.
*/
function startWatcher(io) {
if (!MM_URL || !MM_BOT_TOKEN) {
console.log('[mm-watcher] MM_URL or MM_BOT_TOKEN not set — watcher disabled');
return;
}
console.log(`[mm-watcher] Starting watcher (interval: ${POLL_INTERVAL_MS}ms)`);
// Run first poll after a short delay to let the server finish starting
setTimeout(() => {
pollOnce(io).catch(err => console.error('[mm-watcher] Poll error:', err.message));
}, 5000);
pollTimer = setInterval(() => {
pollOnce(io).catch(err => console.error('[mm-watcher] Poll error:', err.message));
}, POLL_INTERVAL_MS);
// Don't prevent process exit
if (pollTimer.unref) pollTimer.unref();
}
/**
* Stop the watcher (for graceful shutdown).
*/
function stopWatcher() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
console.log('[mm-watcher] Watcher stopped');
}
}
module.exports = { startWatcher, stopWatcher };