524 lines
17 KiB
JavaScript
524 lines
17 KiB
JavaScript
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const jwt = require('jsonwebtoken');
|
|
const crypto = require('crypto');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const { Pool } = require('pg');
|
|
const multer = require('multer');
|
|
const { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 8084;
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'vignesh_onlyoffice_secret_key_2026';
|
|
const DOCSERVER_PUBLIC_URL = process.env.DOCSERVER_PUBLIC_URL || 'https://documents.tradhox.com';
|
|
const S3_BUCKET = process.env.AWS_S3_BUCKET || 'trypostmedia';
|
|
const S3_PREFIX = process.env.AWS_S3_PREFIX || 'documents/';
|
|
const S3_REGION = process.env.AWS_REGION || 'ap-south-2';
|
|
|
|
// PostgreSQL Pool
|
|
const pool = new Pool({
|
|
host: process.env.PGHOST || 'host.docker.internal',
|
|
port: parseInt(process.env.PGPORT || '5432', 10),
|
|
database: process.env.PGDATABASE || 'onlyoffice',
|
|
user: process.env.PGUSER || 'vignesh',
|
|
password: process.env.PGPASSWORD || 'vignesh_secret_pass_2026',
|
|
max: 10,
|
|
idleTimeoutMillis: 30000,
|
|
});
|
|
|
|
// S3 Client (IAM Role authentication automatically via AWS SDK v3)
|
|
const s3 = new S3Client({ region: S3_REGION });
|
|
|
|
// Password hashing helpers
|
|
function hashPassword(password) {
|
|
const salt = crypto.randomBytes(16).toString('hex');
|
|
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
|
|
return `${salt}:${hash}`;
|
|
}
|
|
|
|
function verifyPassword(password, stored) {
|
|
if (!stored || !stored.includes(':')) return false;
|
|
const [salt, originalHash] = stored.split(':');
|
|
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
|
|
return hash === originalHash;
|
|
}
|
|
|
|
// Audit Logger
|
|
async function auditLog(req, username, action, target, details) {
|
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
try {
|
|
await pool.query(
|
|
'INSERT INTO app_audit_logs (username, action, target, details, ip_address) VALUES ($1, $2, $3, $4, $5)',
|
|
[username || 'anonymous', action, target || '', details || '', ip.toString().split(',')[0].trim()]
|
|
);
|
|
} catch (err) {
|
|
console.error('Failed to write audit log:', err.message);
|
|
}
|
|
}
|
|
|
|
// Middlewares
|
|
app.use(express.json({ limit: '100mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
|
app.use(cookieParser());
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Upload setup (memory storage)
|
|
const upload = multer({ limits: { fileSize: 100 * 1024 * 1024 } });
|
|
|
|
// Auth Middleware
|
|
function authMiddleware(req, res, next) {
|
|
const token = req.cookies.auth_token;
|
|
if (!token) {
|
|
if (req.path.startsWith('/api/')) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
return res.redirect('/login');
|
|
}
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET);
|
|
req.user = decoded;
|
|
next();
|
|
} catch (err) {
|
|
res.clearCookie('auth_token');
|
|
if (req.path.startsWith('/api/')) {
|
|
return res.status(401).json({ error: 'Invalid or expired session' });
|
|
}
|
|
return res.redirect('/login');
|
|
}
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (!req.user || req.user.role !== 'admin') {
|
|
return res.status(403).json({ error: 'Access denied: Admin role required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Page Routes
|
|
app.get('/login', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
|
});
|
|
|
|
app.get('/', authMiddleware, (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
app.get('/editor', authMiddleware, (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'editor.html'));
|
|
});
|
|
|
|
app.get('/admin', authMiddleware, (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
return res.redirect('/');
|
|
}
|
|
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
|
});
|
|
|
|
// Authentication APIs
|
|
app.post('/api/login', async (req, res) => {
|
|
const { username, password } = req.body;
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required' });
|
|
}
|
|
|
|
try {
|
|
const result = await pool.query('SELECT * FROM app_users WHERE username = $1', [username.toLowerCase().trim()]);
|
|
if (result.rows.length === 0) {
|
|
await auditLog(req, username, 'LOGIN_FAILED', username, 'User not found');
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
|
|
const user = result.rows[0];
|
|
if (!verifyPassword(password, user.password_hash)) {
|
|
await auditLog(req, username, 'LOGIN_FAILED', username, 'Incorrect password');
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
|
|
const token = jwt.sign(
|
|
{ id: user.id, username: user.username, displayName: user.display_name, role: user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
res.cookie('auth_token', token, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'lax',
|
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
});
|
|
|
|
await auditLog(req, user.username, 'LOGIN_SUCCESS', user.username, `Successful login (${user.role})`);
|
|
res.json({ success: true, user: { username: user.username, displayName: user.display_name, role: user.role } });
|
|
} catch (err) {
|
|
console.error('Login error:', err);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/logout', authMiddleware, async (req, res) => {
|
|
await auditLog(req, req.user.username, 'LOGOUT', req.user.username, 'User logged out');
|
|
res.clearCookie('auth_token');
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.get('/api/me', authMiddleware, (req, res) => {
|
|
res.json({ user: req.user });
|
|
});
|
|
|
|
// Document APIs
|
|
app.get('/api/documents', authMiddleware, async (req, res) => {
|
|
try {
|
|
const result = await pool.query('SELECT * FROM app_documents ORDER BY updated_at DESC');
|
|
res.json({ documents: result.rows });
|
|
} catch (err) {
|
|
console.error('Fetch docs error:', err);
|
|
res.status(500).json({ error: 'Failed to fetch documents' });
|
|
}
|
|
});
|
|
|
|
// Create new blank document
|
|
app.post('/api/documents/create', authMiddleware, async (req, res) => {
|
|
const { filename, fileType } = req.body;
|
|
if (!filename || !fileType) {
|
|
return res.status(400).json({ error: 'Filename and fileType are required' });
|
|
}
|
|
|
|
const ext = fileType.toLowerCase();
|
|
const validExts = ['docx', 'xlsx', 'pptx'];
|
|
if (!validExts.includes(ext)) {
|
|
return res.status(400).json({ error: 'Invalid file type. Supported: docx, xlsx, pptx' });
|
|
}
|
|
|
|
let finalName = filename.endsWith(`.${ext}`) ? filename : `${filename}.${ext}`;
|
|
finalName = finalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
const s3Key = `${S3_PREFIX}${finalName}`;
|
|
|
|
try {
|
|
// Generate minimal valid OOXML empty zip structure or fetch template
|
|
const templatePath = path.join(__dirname, 'templates', `new.${ext}`);
|
|
let buffer;
|
|
if (fs.existsSync(templatePath)) {
|
|
buffer = fs.readFileSync(templatePath);
|
|
} else {
|
|
buffer = Buffer.from(''); // fallback
|
|
}
|
|
|
|
await s3.send(new PutObjectCommand({
|
|
Bucket: S3_BUCKET,
|
|
Key: s3Key,
|
|
Body: buffer,
|
|
ContentType: getMimeType(ext),
|
|
}));
|
|
|
|
await pool.query(
|
|
`INSERT INTO app_documents (filename, s3_key, file_type, created_by, updated_at)
|
|
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (filename) DO UPDATE SET updated_at = CURRENT_TIMESTAMP`,
|
|
[finalName, s3Key, ext, req.user.username]
|
|
);
|
|
|
|
await auditLog(req, req.user.username, 'DOCUMENT_CREATED', finalName, `Created blank ${ext.toUpperCase()} document`);
|
|
res.json({ success: true, filename: finalName });
|
|
} catch (err) {
|
|
console.error('Create document error:', err);
|
|
res.status(500).json({ error: 'Failed to create document: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// Upload document
|
|
app.post('/api/documents/upload', authMiddleware, upload.single('file'), async (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No file uploaded' });
|
|
}
|
|
|
|
let filename = req.file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
const ext = path.extname(filename).replace('.', '').toLowerCase();
|
|
const s3Key = `${S3_PREFIX}${filename}`;
|
|
|
|
try {
|
|
await s3.send(new PutObjectCommand({
|
|
Bucket: S3_BUCKET,
|
|
Key: s3Key,
|
|
Body: req.file.buffer,
|
|
ContentType: req.file.mimetype || getMimeType(ext),
|
|
}));
|
|
|
|
await pool.query(
|
|
`INSERT INTO app_documents (filename, s3_key, file_type, created_by, updated_at)
|
|
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (filename) DO UPDATE SET updated_at = CURRENT_TIMESTAMP`,
|
|
[filename, s3Key, ext, req.user.username]
|
|
);
|
|
|
|
await auditLog(req, req.user.username, 'DOCUMENT_UPLOADED', filename, `Uploaded ${filename} (${req.file.size} bytes)`);
|
|
res.json({ success: true, filename });
|
|
} catch (err) {
|
|
console.error('Upload error:', err);
|
|
res.status(500).json({ error: 'Failed to upload document' });
|
|
}
|
|
});
|
|
|
|
// Download / Stream document
|
|
app.get('/api/documents/download/:filename', async (req, res) => {
|
|
const { filename } = req.params;
|
|
const s3Key = `${S3_PREFIX}${filename}`;
|
|
|
|
try {
|
|
const s3Res = await s3.send(new GetObjectCommand({
|
|
Bucket: S3_BUCKET,
|
|
Key: s3Key,
|
|
}));
|
|
|
|
const ext = path.extname(filename).replace('.', '').toLowerCase();
|
|
res.setHeader('Content-Type', getMimeType(ext));
|
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
|
s3Res.Body.pipe(res);
|
|
} catch (err) {
|
|
console.error('Download error:', err);
|
|
res.status(404).send('Document not found in S3');
|
|
}
|
|
});
|
|
|
|
// Get ONLYOFFICE editor configuration for logged-in user
|
|
app.get('/api/editor/config/:filename', authMiddleware, async (req, res) => {
|
|
const { filename } = req.params;
|
|
const docResult = await pool.query('SELECT * FROM app_documents WHERE filename = $1', [filename]);
|
|
if (docResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Document not found' });
|
|
}
|
|
|
|
const doc = docResult.rows[0];
|
|
const ext = doc.file_type.toLowerCase();
|
|
const documentType = getDocumentType(ext);
|
|
|
|
// Generate deterministic key for collaboration session based on filename + last updated
|
|
const docKey = crypto.createHash('sha256')
|
|
.update(`${doc.filename}_${doc.updated_at.getTime()}`)
|
|
.digest('hex')
|
|
.substring(0, 20);
|
|
|
|
const docUrl = `${DOCSERVER_PUBLIC_URL}/api/documents/download/${encodeURIComponent(filename)}`;
|
|
const callbackUrl = `${DOCSERVER_PUBLIC_URL}/api/callback?file=${encodeURIComponent(filename)}&user=${encodeURIComponent(req.user.username)}`;
|
|
|
|
const config = {
|
|
document: {
|
|
fileType: ext,
|
|
key: docKey,
|
|
title: filename,
|
|
url: docUrl,
|
|
permissions: {
|
|
comment: true,
|
|
download: true,
|
|
edit: true,
|
|
fillForms: true,
|
|
modifyContentControl: true,
|
|
modifyFilter: true,
|
|
print: true,
|
|
review: true,
|
|
},
|
|
},
|
|
documentType: documentType,
|
|
editorConfig: {
|
|
actionLink: null,
|
|
mode: 'edit',
|
|
lang: 'en',
|
|
coEditing: {
|
|
mode: 'fast',
|
|
change: true,
|
|
},
|
|
user: {
|
|
id: req.user.username,
|
|
name: req.user.displayName,
|
|
},
|
|
customization: {
|
|
autosave: true,
|
|
chat: true,
|
|
comments: true,
|
|
forcesave: true,
|
|
zoom: 100,
|
|
compactHeader: false,
|
|
toolbarNoTabs: false,
|
|
feedback: false,
|
|
},
|
|
callbackUrl: callbackUrl,
|
|
},
|
|
};
|
|
|
|
// Sign ONLYOFFICE config with JWT
|
|
config.token = jwt.sign(config, JWT_SECRET, { expiresIn: '12h' });
|
|
|
|
await auditLog(req, req.user.username, 'DOCUMENT_OPENED', filename, `Opened ${filename} in ONLYOFFICE editor`);
|
|
res.json({ config, docserverUrl: `${DOCSERVER_PUBLIC_URL}/web-apps/apps/api/documents/api.js` });
|
|
});
|
|
|
|
// ONLYOFFICE Document Server Callback Handler
|
|
app.post('/api/callback', async (req, res) => {
|
|
const filename = req.query.file;
|
|
const initiatingUser = req.query.user || 'system';
|
|
const { status, url, users, changesurl } = req.body;
|
|
|
|
console.log(`[ONLYOFFICE Callback] File: ${filename}, Status: ${status}, Users:`, users);
|
|
|
|
// Status 2 = document ready for saving, Status 6 = forcesave document
|
|
if (status === 2 || status === 6) {
|
|
if (url) {
|
|
try {
|
|
const fileBuffer = await downloadFile(url);
|
|
const s3Key = `${S3_PREFIX}${filename}`;
|
|
|
|
await s3.send(new PutObjectCommand({
|
|
Bucket: S3_BUCKET,
|
|
Key: s3Key,
|
|
Body: fileBuffer,
|
|
ContentType: getMimeType(path.extname(filename).replace('.', '')),
|
|
}));
|
|
|
|
await pool.query(
|
|
'UPDATE app_documents SET updated_at = CURRENT_TIMESTAMP WHERE filename = $1',
|
|
[filename]
|
|
);
|
|
|
|
const editingUsers = users && users.length > 0 ? users.join(', ') : initiatingUser;
|
|
await auditLog(
|
|
req,
|
|
editingUsers,
|
|
'DOCUMENT_SAVED',
|
|
filename,
|
|
`Saved edited version to S3 (collaborators: ${editingUsers})`
|
|
);
|
|
} catch (err) {
|
|
console.error('Failed to save document from ONLYOFFICE callback:', err);
|
|
return res.json({ error: 1 });
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({ error: 0 });
|
|
});
|
|
|
|
// Admin User Management APIs
|
|
app.get('/api/admin/users', authMiddleware, requireAdmin, async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
'SELECT id, username, display_name, role, created_at FROM app_users ORDER BY id ASC'
|
|
);
|
|
res.json({ users: result.rows });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to fetch users' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/users', authMiddleware, requireAdmin, async (req, res) => {
|
|
const { username, displayName, password, role } = req.body;
|
|
if (!username || !displayName || !password) {
|
|
return res.status(400).json({ error: 'Username, display name, and password are required' });
|
|
}
|
|
|
|
const cleanUser = username.toLowerCase().trim();
|
|
const userRole = role === 'admin' ? 'admin' : 'user';
|
|
|
|
try {
|
|
const check = await pool.query('SELECT id FROM app_users WHERE username = $1', [cleanUser]);
|
|
if (check.rows.length > 0) {
|
|
return res.status(400).json({ error: 'User already exists' });
|
|
}
|
|
|
|
const hashed = hashPassword(password);
|
|
await pool.query(
|
|
'INSERT INTO app_users (username, display_name, password_hash, role) VALUES ($1, $2, $3, $4)',
|
|
[cleanUser, displayName.trim(), hashed, userRole]
|
|
);
|
|
|
|
await auditLog(req, req.user.username, 'USER_CREATED', cleanUser, `Created new user ${cleanUser} (${userRole})`);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Create user error:', err);
|
|
res.status(500).json({ error: 'Failed to create user' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/admin/users/:username', authMiddleware, requireAdmin, async (req, res) => {
|
|
const targetUser = req.params.username.toLowerCase();
|
|
if (targetUser === 'vignesh' || targetUser === req.user.username) {
|
|
return res.status(400).json({ error: 'Cannot delete primary admin or current logged in user' });
|
|
}
|
|
|
|
try {
|
|
await pool.query('DELETE FROM app_users WHERE username = $1', [targetUser]);
|
|
await auditLog(req, req.user.username, 'USER_DELETED', targetUser, `Deleted user ${targetUser}`);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to delete user' });
|
|
}
|
|
});
|
|
|
|
// Admin Audit Logs API
|
|
app.get('/api/admin/audit-logs', authMiddleware, requireAdmin, async (req, res) => {
|
|
const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
|
|
const search = req.query.search ? `%${req.query.search}%` : null;
|
|
|
|
try {
|
|
let query = 'SELECT * FROM app_audit_logs ';
|
|
const params = [];
|
|
|
|
if (search) {
|
|
query += 'WHERE username ILIKE $1 OR action ILIKE $1 OR target ILIKE $1 OR details ILIKE $1 ';
|
|
params.push(search);
|
|
}
|
|
|
|
query += `ORDER BY created_at DESC LIMIT $${params.length + 1}`;
|
|
params.push(limit);
|
|
|
|
const result = await pool.query(query, params);
|
|
res.json({ logs: result.rows });
|
|
} catch (err) {
|
|
console.error('Audit log fetch error:', err);
|
|
res.status(500).json({ error: 'Failed to fetch audit logs' });
|
|
}
|
|
});
|
|
|
|
// Helper utilities
|
|
function getMimeType(ext) {
|
|
const types = {
|
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
pdf: 'application/pdf',
|
|
txt: 'text/plain',
|
|
};
|
|
return types[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
function getDocumentType(ext) {
|
|
if (['doc', 'docx', 'rtf', 'txt', 'odt'].includes(ext)) return 'word';
|
|
if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return 'cell';
|
|
if (['ppt', 'pptx', 'odp'].includes(ext)) return 'slide';
|
|
return 'word';
|
|
}
|
|
|
|
function downloadFile(fileUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
const protocol = fileUrl.startsWith('https') ? https : http;
|
|
protocol.get(fileUrl, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
return resolve(downloadFile(res.headers.location));
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
return reject(new Error(`Failed to download file, status: ${res.statusCode}`));
|
|
}
|
|
const data = [];
|
|
res.on('data', chunk => data.push(chunk));
|
|
res.on('end', () => resolve(Buffer.concat(data)));
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
// Start Server
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`Document Management Portal running on http://0.0.0.0:${PORT}`);
|
|
});
|