onlyoffice/portal/server.js

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}`);
});