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:
+11
-3
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user