js
js
dotenv.config();
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
// ----------------------------
// Initialize Backblaze B2 & Multer
// ----------------------------
const b2 = new B2({
applicationKeyId: process.env.B2_APPLICATION_KEY_ID,
applicationKey: process.env.B2_APPLICATION_KEY
});
const upload = multer({ storage: multer.memoryStorage() });
// ----------------------------
// Serve Static Files & Fallback Route
// ----------------------------
app.use(express.static(path.join(__dirname, 'public')));
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// ----------------------------
// File Upload Endpoint (Backblaze B2)
// ----------------------------
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded.' });
}
try {
await b2.authorize();
const { data: { uploadUrl, authorizationToken } } = await b2.getUploadUrl({
bucketId: process.env.B2_BUCKET_ID
});
const fileBuffer = req.file.buffer;
const fileName = req.file.originalname;
await b2.uploadFile({
uploadUrl,
uploadAuthToken: authorizationToken,
fileName,
data: fileBuffer,
contentType: req.file.mimetype
});
const publicUrl = `${process.env.B2_BUCKET_URL}/${fileName}`;
res.json({ url: publicUrl });
} catch (err) {
console.error('Error uploading file:', err);
res.status(500).json({ error: 'Error uploading the file.' });
}
});
// ----------------------------
// Transcription Endpoint (Groq API)
// ----------------------------
app.post("/transcribe", upload.single('audio'), async (req, res) => {
try {
let audioBuffer;
let filename;
if (req.file) {
audioBuffer = req.file.buffer;
filename = req.file.originalname;
const MAX_FILE_SIZE = 25 * 1024 * 1024;
if (req.file.size > MAX_FILE_SIZE) {
return res.status(400).json({ error: "File size exceeds 25MB limit." });
}
const supportedTypes = [
'audio/flac', 'audio/mp3', 'audio/mp4', 'audio/mpeg',
'audio/mpga', 'audio/m4a', 'audio/ogg', 'audio/wav', 'audio/webm'
];
if (!supportedTypes.includes(req.file.mimetype)) {
return res.status(400).json({
error: "Unsupported file type. Supported types: flac, mp3, mp4, mpeg,
mpga, m4a, ogg, wav, webm"
});
}
} else if (req.body && req.body.url) {
const audioUrl = req.body.url;
console.log("Fetching audio from URL:", audioUrl);
const response = await fetch(audioUrl);
if (!response.ok) {
return res.status(400).json({ error: `Failed to fetch audio from URL.
Status: ${response.status}` });
}
const contentType = response.headers.get('content-type');
if (contentType) console.log("Fetched content-type:", contentType);
audioBuffer = Buffer.from(await response.arrayBuffer());
filename = path.basename(audioUrl) || `audio_${Date.now()}.mp3`;
const MAX_FILE_SIZE = 25 * 1024 * 1024;
if (audioBuffer.length > MAX_FILE_SIZE) {
return res.status(400).json({ error: "File size exceeds 25MB limit." });
}
} else {
return res.status(400).json({ error: "No audio provided. Please upload a
file or provide a URL." });
}
if (!audioBuffer) {
return res.status(400).json({ error: "Failed to process audio." });
}
fs.unlinkSync(tempPath);
console.log(`Temporary file deleted: ${tempPath}`);
// ----------------------------
// Session Middleware
// ----------------------------
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: true,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000
}
}));
// ----------------------------
// Initialize PostgreSQL Personal Database Pool
// ----------------------------
const personalPool = new Pool({
connectionString: process.env.DATABASE_PUBLIC_URL || process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } :
false,
});
// ----------------------------
// Initialize MongoDB (General Database) with Mongoose
// ----------------------------
const dbName = "openchat";
const generalDbURI = process.env.GENERAL_MONGO_URI ||
`mongodb+srv://londonjeremie:[email protected]/${dbName}?ret
ryWrites=true&w=majority`;
mongoose.connect(generalDbURI)
.then(() => {
console.log(`✅ Connected to MongoDB general database: "${dbName}" created
successfully!`);
})
.catch((error) => console.error('❌ Connection error to MongoDB general
database:', error));
// ----------------------------
// Create or Update Tables in Personal Database
// ----------------------------
personalPool.query(`
DO $$
BEGIN
-- Create users table if it doesn't exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema='public' AND table_name='users'
) THEN
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password TEXT,
online BOOLEAN DEFAULT FALSE,
push_subscription TEXT,
public_key TEXT,
private_key TEXT,
symmetric_key TEXT
);
ELSE
-- Add symmetric_key column if it doesn't exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='users' AND
column_name='symmetric_key'
) THEN
ALTER TABLE users ADD COLUMN symmetric_key TEXT;
END IF;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='users' AND column_name='private_key'
) THEN
ALTER TABLE users ADD COLUMN private_key TEXT;
END IF;
END $$;
`, (err) => {
if (err) {
console.error('Error setting up database schema:', err);
} else {
console.log('Database schema setup successfully.');
}
});
// ----------------------------
// Helper Functions for Authenticator and Registration
// ----------------------------
function generateAuthenticator() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
try {
// Double-check if username already exists
const existingUser = await GeneralUser.findOne({ username }).exec();
if (existingUser) {
throw new Error(`Username '${username}' already exists in general
database`);
}
await newGeneralUser.save();
return authentificator;
} catch (err) {
console.error(`Error registering user '${username}' in general database:`,
err);
return crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING
},
Buffer.from(encryptedMessage, 'base64')
).toString();
} catch (err) {
console.error('RSA decryption error:', err);
return encryptedMessage;
}
}
// ----------------------------
// New Endpoint to Link External Databases (Bidirectional Insertion)
// ----------------------------
// When a user (e.g. Alice) submits another user's authenticator (e.g. Bob's),
// - Step 1: Insert a record into the central external_databases for the
current user (Alice)
// using her own username as owner and storing Bob's authenticator
and Bob's database URL.
// - Step 2: Retrieve Alice's general record.
// - Step 3: Connect to Bob's external database (using Bob's database URL) and
insert a record
// so that Bob's external database now has a reciprocal record for
Alice.
// ----------------------------
// New Endpoint to Link External Databases (Bidirectional Insertion)
// ----------------------------
app.post('/link-database', async (req, res) => {
// The linking user (e.g. Alice) sends in her own username and the
authenticator of the target user (e.g. Bob)
const { externalAuthenticator, username } = req.body;
// currentUser is the linking user (Alice)
const currentUser = req.session.username || username;
if (!externalAuthenticator || !currentUser) {
return res.status(400).json({ error: 'Authenticator and username are
required.' });
}
try {
// 0. First, check if the authenticator belongs to the current user to
prevent self-linking
const currentUserRecord = await GeneralUser.findOne({ username: currentUser
}).exec();
if (!currentUserRecord) {
return res.status(404).json({ error: 'Current user not found in general
database.' });
}
if (existingLink.rows.length > 0) {
return res.status(400).json({ error: 'Databases are already linked.' });
}
if (currentUserKeyQuery.rows.length === 0 ||
!currentUserKeyQuery.rows[0].public_key) {
return res.status(400).json({ error: 'Current user public key not found.'
});
}
try {
targetExtPool = new Pool({
connectionString: targetUser.database_url,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false
} : false,
});
// 4. Insert into Alice's external_databases table a record for Bob with his
public key
try {
await personalPool.query(
'INSERT INTO external_databases (username, authentificator,
database_url, public_key) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING',
[targetUser.username, externalAuthenticator, targetUser.database_url,
targetUserPublicKey]
);
} catch (err) {
console.error('Error inserting external database record:', err);
return res.status(500).json({ error: 'Error inserting external database
record.' });
}
res.json({
message: 'External database linked successfully.',
linkedUser: targetUser.username
});
} catch (err) {
console.error('Error connecting to or inserting into target database:',
err);
return res.status(500).json({
error: 'Error connecting to target database. Please verify the
authenticator is correct.'
});
} finally {
if (targetExtPool) {
targetExtPool.end();
}
}
} catch (err) {
console.error('Error linking database:', err);
res.status(500).json({ error: 'Internal server error: ' + err.message });
}
});
// ----------------------------
// Helper Function: Save Message to an External Database
// ----------------------------
async function saveMessageExternal(database_url, sender, receiver, msg,
fileData) {
const extPool = new Pool({
connectionString: database_url,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } :
false,
});
try {
if (fileData) {
const query = `
INSERT INTO messages (sender, receiver, message, file_url, file_name,
file_type, file_size)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`;
const placeholderMessage = 'File attachment';
await extPool.query(query, [sender, receiver, placeholderMessage,
fileData.fileUrl, fileData.name, fileData.type, fileData.size]);
} else {
await extPool.query('INSERT INTO messages (sender, receiver, message)
VALUES ($1, $2, $3)', [sender, receiver, msg]);
}
} catch (err) {
console.error('Error inserting message into external DB:', err);
} finally {
extPool.end();
}
}
// ----------------------------
// Track Users and Their Socket Connections
// ----------------------------
const users = {}; // { username: { socketId, online, pushSubscription } }
// ----------------------------
// Web Push Configuration
// ----------------------------
webpush.setVapidDetails(
'mailto:[email protected]',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// ----------------------------
// Create HTTP Server and Attach Socket.IO
// ----------------------------
const server = createServer(app);
const io = new Server(server);
// ----------------------------
// Load Combined Users for a Socket (Local + External)
// ----------------------------
async function loadCombinedUsers(socket) {
const currentUser = socket.username;
try {
const localResult = await personalPool.query(
'SELECT username, online FROM users WHERE username <> $1',
[currentUser]
);
let allUsers = localResult.rows;
const externalLinksResult = await personalPool.query(
'SELECT * FROM external_databases WHERE username = $1',
[currentUser]
);
for (const link of externalLinksResult.rows) {
const externalPool = new Pool({
connectionString: link.database_url,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false
} : false,
});
const externalUsersResult = await externalPool.query(
'SELECT username, online FROM users WHERE username <> $1',
[currentUser]
);
allUsers = allUsers.concat(externalUsersResult.rows);
externalPool.end();
}
const uniqueUsers = {};
allUsers.forEach(u => { uniqueUsers[u.username] = u; });
const userList = Object.values(uniqueUsers);
socket.emit('users', userList);
console.log(`Users list for ${currentUser} updated:`, userList);
} catch (err) {
console.error(`Error fetching users list for ${currentUser}:`, err);
}
}
// ----------------------------
// Socket.IO Events
// ----------------------------
io.on('connection', (socket) => {
console.log('A user connected');
if (existingGeneralUser) {
console.log(`Username '${username}' already exists in general
database.`);
return socket.emit('signup failed', 'Username already exists in our
system.');
}
if (userQuery.rows.length > 0) {
console.log(`Username '${username}' already exists in personal
database.`);
return socket.emit('signup failed', 'Username already exists in local
database.');
}
} catch (err) {
console.error('Error during signup:', err);
socket.emit('signup failed', 'Registration failed. Please try again
later.');
}
});
try {
// Get recipient's public key for E2E encryption
let recipientPublicKey = null;
const userQuery = await personalPool.query(
'SELECT public_key FROM users WHERE username = $1',
[to]
);
// Cross-database messaging
const recipientExternalResult = await personalPool.query(
'SELECT * FROM external_databases WHERE username = $1',
[to]
);
if (recipientExternalResult.rows.length > 0) {
const recipientDB = recipientExternalResult.rows[0];
saveMessageToExternalDB(
recipientDB.database_url,
socket.username,
to,
recipientEncryptedMsg,
null,
true // E2E encrypted
);
}
} catch (err) {
console.error('Error in chat message:', err);
}
});
try {
// Get recipient's public key for encryption
let recipientPublicKey = null;
const userQuery = await personalPool.query(
'SELECT public_key FROM users WHERE username = $1',
[to]
);
if (recipientExternalResult.rows.length > 0) {
const recipientDB = recipientExternalResult.rows[0];
saveMessageToExternalDB(
recipientDB.database_url,
socket.username,
to,
null,
{
fileUrl: recipientEncryptedUrl,
name: recipientEncryptedName,
type: recipientEncryptedType,
size
},
true
);
}
} catch (err) {
console.error('Error in file message:', err);
}
});
socket.on('load messages', ({ user }) => {
if (socket.username && user) {
loadPrivateMessageHistory(socket.username, user, (messages) => {
socket.emit('chat history', messages);
});
} else {
socket.emit('chat history', []);
}
});
socket.on('disconnect', () => {
if (socket.username) {
personalPool.query('UPDATE users SET online = FALSE WHERE username = $1',
[socket.username], (err) => {
if (err) console.error('Error marking user offline:', err);
if (users[socket.username]) {
users[socket.username].online = false;
}
for (const [id, sock] of io.of("/").sockets) {
if (sock.username) {
loadCombinedUsers(sock);
}
}
});
}
console.log('A user disconnected');
});
// ----------------------------
// Helper Functions (Outside Socket.IO)
// ----------------------------
async function loginUser(socket, username) {
await personalPool.query('UPDATE users SET online = TRUE WHERE username = $1',
[username]);
users[username] = { socketId: socket.id, online: true };
socket.username = username;
if (userQuery.rows.length > 0) {
const user = userQuery.rows[0];
if (!user.public_key || !user.private_key) {
console.log(`User ${username} is missing RSA keys. Generating...`);
const { publicKey, privateKey } = generateKeyPair();
await personalPool.query(
'UPDATE users SET public_key = $1, private_key = $2 WHERE username =
$3',
[publicKey, privateKey, username]
);
}
if (!user.symmetric_key) {
console.log(`User ${username} is missing symmetric key. Generating...`);
const symmetricKey = generateSymmetricKey();
await personalPool.query(
'UPDATE users SET symmetric_key = $1 WHERE username = $2',
[symmetricKey, username]
);
}
}
} catch (error) {
console.error('Error checking/generating keys for', username, error);
}
let authentificator = 'Not set';
try {
const generalUser = await GeneralUser.findOne({ username }).exec();
if (generalUser && generalUser.authentificator) {
authentificator = generalUser.authentificator;
}
} catch (error) {
console.error('Error retrieving authentificator for', username, error);
}
loadCombinedUsers(socket);
if (shouldDecrypt) {
try {
if (isSentByMe && symmetricKey) {
// Decrypt self-messages with symmetric key
if (!isFileMessage && row.message) {
finalMessage = decryptWithSymmetricKey(symmetricKey,
row.message);
}
if (isFileMessage) {
if (row.file_url) finalFileUrl =
decryptWithSymmetricKey(symmetricKey, row.file_url);
if (row.file_name) finalFileName =
decryptWithSymmetricKey(symmetricKey, row.file_name);
if (row.file_type) finalFileType =
decryptWithSymmetricKey(symmetricKey, row.file_type);
}
} else if (!isSentByMe && privateKey) {
// Decrypt messages from others with private key (E2E)
if (!isFileMessage && row.message) {
finalMessage = decryptWithPrivateKey(privateKey,
row.message);
}
if (isFileMessage) {
if (row.file_url) finalFileUrl =
decryptWithPrivateKey(privateKey, row.file_url);
if (row.file_name) finalFileName =
decryptWithPrivateKey(privateKey, row.file_name);
if (row.file_type) finalFileType =
decryptWithPrivateKey(privateKey, row.file_type);
}
}
} catch (decryptError) {
console.error('Error decrypting message content:',
decryptError);
// Keep the original values if decryption fails
}
}
callback(messages);
});
});
}
);
callback(messages);
});
}
}
function formatDate(date) {
const options = { year: '2-digit', month: '2-digit', day: '2-digit' };
return new Date(date).toLocaleDateString('en-GB', options);
}
function formatTime(date) {
const d = new Date(date);
return `${d.getHours().toString().padStart(2,
'0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
function formatDayLabel(date) {
const today = new Date();
const messageDate = new Date(date);
const todayString = today.toDateString();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const yesterdayString = yesterday.toDateString();
if (todayString === messageDate.toDateString()) {
return "Today";
} else if (yesterdayString === messageDate.toDateString()) {
return "Yesterday";
} else {
return formatDate(messageDate);
}
}
function generateMessageId() {
return `${Date.now()}${Math.random().toString(36).substring(2, 9)}`;
}
try {
// First check if schema is compatible with our current code
const schemaCheck = await extPool.query(`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'messages'
AND column_name = 'is_encrypted'
) as has_is_encrypted
`);
if (fileData) {
// Construct the query based on schema compatibility
const query = hasIsEncrypted ?
`INSERT INTO messages (sender, receiver, message, file_url, file_name,
file_type, file_size, is_encrypted)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` :
`INSERT INTO messages (sender, receiver, message, file_url, file_name,
file_type, file_size)
VALUES ($1, $2, $3, $4, $5, $6, $7)`;
// ----------------------------
// Start the Server
// ----------------------------
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});