Files
refboard-ayon/backend/routes/mattermost-bridge.js
T
Hiren Kangad 7f6c9cd409 - Add board_channel_links table to db.js with CRUD helpers
- Add mm_file_id column to images table for dedup tracking
- mm-pull fetches posts from MM API, downloads media files, uploads with LOD to MinIO
- Register bridge routes in server.js under /api/boards
2026-03-09 23:34:42 +05:30

348 lines
10 KiB
JavaScript

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 { generateLOD } = require('../services/lod-generator');
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 image buffer with LOD tiers to MinIO.
*/
async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) {
const assetKey = `boards/${boardId}/${imageId}`;
const originalExt = MIME_TO_EXT[mimetype] || '.bin';
if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') {
const fullPath = `${assetKey}/full${originalExt}`;
await putBuffer(fullPath, buffer, mimetype);
let width = null, height = null;
try {
const metadata = await sharp(buffer).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch {}
return { assetKey, minioPath: fullPath, width, height };
}
const lod = await generateLOD(buffer, originalExt);
await Promise.all([
putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype),
putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype),
putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype),
]);
const minioPath = `${assetKey}/full${lod.full.ext}`;
return { assetKey, minioPath, width: lod.full.width, height: lod.full.height };
}
/**
* Upload video buffer to MinIO (no LOD).
*/
async function uploadVideo(boardId, imageId, buffer, mimetype) {
const assetKey = `boards/${boardId}/${imageId}`;
const ext = MIME_TO_EXT[mimetype] || '.bin';
const fullPath = `${assetKey}/full${ext}`;
await putBuffer(fullPath, buffer, mimetype);
return { assetKey, minioPath: fullPath, width: null, height: null };
}
// ---------------------
// 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);
let assetKey, minioPath, width, height;
if (mediaType === 'video') {
({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimeType));
} else {
({ assetKey, minioPath, width, height } = await uploadImageWithLOD(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;