Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ STORAGE_CONFIG_SECRET =
# Optional separate key for encrypting TURN, LiveKit SFU, FCM and APNs private keys at rest.
# Falls back to STORAGE_CONFIG_SECRET, then JWT_SECRET. Keep this value stable.
CALL_CONFIG_SECRET =
# Optional separate key for DB-backed chat translation/transcription provider secrets.
# Falls back to CALL_CONFIG_SECRET, STORAGE_CONFIG_SECRET, then JWT_SECRET. Keep stable.
CHAT_AI_CONFIG_SECRET =
APP_ORIGIN = http://localhost:3000,http://127.0.0.1:3000
PUBLIC_ORIGIN = http://localhost:3000
API_BASE_URL = http://127.0.0.1:5599/api
Expand All @@ -36,6 +39,16 @@ SERVE_FRONTEND = false
# FCM/APNs private keys are encrypted in MongoDB using CALL_CONFIG_SECRET.
# Production calling also requires REDIS_URL for shared durable active-call state.

# Chat abuse/flood controls. Redis is used when REDIS_URL is configured;
# otherwise a single-process in-memory fallback is used.
CHAT_RATE_LIMIT_MESSAGES = 30
CHAT_RATE_LIMIT_WINDOW_SEC = 10
CHAT_DUPLICATE_LIMIT = 6
CHAT_DUPLICATE_WINDOW_SEC = 30

# Translation/transcription provider URLs and API keys are configured from the
# DB-backed Admin Chat AI panel. Provider keys are encrypted using the secret chain above.

# Email transport is stored in the database from Admin Settings > App Config.

GOOGLE_CLIENT_ID =
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"predev": "node scripts/free-ports.js 8080",
"dev": "nodemon --watch server --watch .env --ignore logs --ignore uploads server",
"build": "node --check server/index.js && node --check server/server.js && node --check server/bootstrap.js && node --check server/config.js && node --check server/db/sequelize.js && node --check server/db/connect.js && node --check server/db/models/inbox.js && node --check server/db/models/profile.js && node --check server/db/models/group.js && node --check server/db/models/channel.js && node --check server/db/models/adminStorageConfig.js && node --check server/db/models/adminCallConfig.js && node --check server/db/models/callHistory.js && node --check server/db/models/profilePhoto.js && node --check server/db/models/nativePushDevice.js && node --check server/helpers/storageConfig.js && node --check server/helpers/callConfig.js && node --check server/helpers/callHistory.js && node --check server/helpers/callState.js && node --check server/helpers/avatarDefaults.js && node --check server/helpers/ensureAvatarDefaults.js && node --check server/helpers/ensureProfile.js && node --check server/helpers/privacy.js && node --check server/helpers/profilePhotos.js && node --check server/helpers/livekit.js && node --check server/helpers/nativePushConfig.js && node --check server/helpers/nativePush.js && node --check server/helpers/storage.js && node --check server/helpers/videoPipeline.js && node --check server/helpers/accountArchive.js && node --check server/helpers/accountExport.js && node --check server/helpers/logger.js && node --check server/controllers/avatar.js && node --check server/controllers/profile.js && node --check server/controllers/chatUpload.js && node --check server/controllers/chatSecurity.js && node --check server/controllers/chatDeletion.js && node --check server/controllers/storageAdmin.js && node --check server/controllers/callingAdmin.js && node --check server/controllers/callingPushAdmin.js && node --check server/controllers/callingConfig.js && node --check server/controllers/nativePush.js && node --check server/controllers/accountStorage.js && node --check server/routes/profile.js && node --check server/routes/inbox.js && node --check server/routes/chat.js && node --check server/routes/storageAdmin.js && node --check server/routes/callingAdmin.js && node --check server/routes/callingPushAdmin.js && node --check server/routes/callingConfig.js && node --check server/routes/setting.js && node --check server/helpers/socketAdapter.js && node --check server/socket/events/calling.js && node --check server/socket/events/backgroundCalling.js && node --check server/socket/events/groupModeration.js && node --check server/socket/events/room.js && node --check server/routes/cron.js && node --check api/index.js",
"build": "node --check server/index.js && node --check server/server.js && node --check server/bootstrap.js && node --check server/config.js && node --check server/db/sequelize.js && node --check server/db/connect.js && node --check server/db/models/inbox.js && node --check server/db/models/chat.js && node --check server/db/models/chatRoomCounter.js && node --check server/db/models/messageReceipt.js && node --check server/db/models/chatDraft.js && node --check server/db/models/messageRequest.js && node --check server/db/models/chatTopic.js && node --check server/db/models/e2eeDeviceKey.js && node --check server/db/models/resumableUpload.js && node --check server/db/models/chatAiConfig.js && node --check server/db/models/profile.js && node --check server/db/models/group.js && node --check server/db/models/channel.js && node --check server/db/models/adminStorageConfig.js && node --check server/db/models/adminCallConfig.js && node --check server/db/models/callHistory.js && node --check server/db/models/profilePhoto.js && node --check server/db/models/nativePushDevice.js && node --check server/helpers/storageConfig.js && node --check server/helpers/callConfig.js && node --check server/helpers/callHistory.js && node --check server/helpers/callState.js && node --check server/helpers/avatarDefaults.js && node --check server/helpers/ensureAvatarDefaults.js && node --check server/helpers/ensureProfile.js && node --check server/helpers/privacy.js && node --check server/helpers/profilePhotos.js && node --check server/helpers/livekit.js && node --check server/helpers/nativePushConfig.js && node --check server/helpers/nativePush.js && node --check server/helpers/storage.js && node --check server/helpers/videoPipeline.js && node --check server/helpers/accountArchive.js && node --check server/helpers/accountExport.js && node --check server/helpers/logger.js && node --check server/helpers/chatAbuse.js && node --check server/helpers/chatMentions.js && node --check server/helpers/messageRequests.js && node --check server/helpers/chatReliability.js && node --check server/helpers/chatAiConfig.js && node --check server/helpers/e2eeKeyDirectory.js && node --check server/helpers/chatMaintenance.js && node --check server/middleware/chatSendIdempotency.js && node --check server/controllers/avatar.js && node --check server/controllers/profile.js && node --check server/controllers/chatUpload.js && node --check server/controllers/chatSecurity.js && node --check server/controllers/chatDeletion.js && node --check server/controllers/chatV2.js && node --check server/controllers/chatAiAdmin.js && node --check server/controllers/chatSuggestions.js && node --check server/controllers/chatResumableUpload.js && node --check server/controllers/storageAdmin.js && node --check server/controllers/callingAdmin.js && node --check server/controllers/callingPushAdmin.js && node --check server/controllers/callingConfig.js && node --check server/controllers/nativePush.js && node --check server/controllers/accountStorage.js && node --check server/routes/profile.js && node --check server/routes/inbox.js && node --check server/routes/chat.js && node --check server/routes/chatV2.js && node --check server/routes/chatAiAdmin.js && node --check server/routes/storageAdmin.js && node --check server/routes/callingAdmin.js && node --check server/routes/callingPushAdmin.js && node --check server/routes/callingConfig.js && node --check server/routes/setting.js && node --check server/helpers/socketAdapter.js && node --check server/socket/auth.js && node --check server/socket/index.js && node --check server/socket/events/chatV2.js && node --check server/socket/events/calling.js && node --check server/socket/events/backgroundCalling.js && node --check server/socket/events/groupModeration.js && node --check server/socket/events/room.js && node --check server/routes/cron.js && node --check api/index.js",
"start": "node server"
},
"dependencies": {
Expand Down
22 changes: 22 additions & 0 deletions backend/server/controllers/chatAiAdmin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const response = require('../helpers/response');
const {
getAdminChatAiConfig,
updateChatAiConfig,
} = require('../helpers/chatAiConfig');

exports.getConfig = async (req, res) => {
try {
response({ res, payload: await getAdminChatAiConfig() });
} catch (error0) {
response({ res, statusCode: 500, success: false, message: error0.message });
}
};

exports.updateConfig = async (req, res) => {
try {
const payload = await updateChatAiConfig(req.body || {});
response({ res, message: 'Chat AI configuration updated', payload });
} catch (error0) {
response({ res, statusCode: 500, success: false, message: error0.message });
}
};
166 changes: 166 additions & 0 deletions backend/server/controllers/chatResumableUpload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
const path = require('path');
const mongoose = require('mongoose');
const ResumableUploadModel = require('../db/models/resumableUpload');
const { toPlain } = require('../db/utils');
const response = require('../helpers/response');
const { saveBufferFile } = require('../helpers/storage');

const safeFileName = (value = 'file.bin') => {
const base = path
.basename(String(value || 'file.bin'))
.replace(/[^a-zA-Z0-9._ -]/g, '_')
.trim();
return base.slice(0, 180) || 'file.bin';
};

const fileTypeFromMime = (mime = '', filename = '') => {
const normalized = String(mime || '').toLowerCase();
if (normalized.startsWith('image/')) return 'image';
if (normalized.startsWith('video/')) return 'video';
if (normalized.startsWith('audio/')) return 'audio';
const ext = path.extname(String(filename || '')).slice(1).toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'].includes(ext)) return 'image';
if (['mp4', 'mov', 'mkv', 'webm', 'm4v'].includes(ext)) return 'video';
if (['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'].includes(ext)) return 'audio';
return 'document';
};

exports.complete = async (req, res) => {
try {
const uploadId = req.params.uploadId;
const row = await ResumableUploadModel.findOne({
where: { uploadId, userId: req.user._id },
});
if (!row) {
response({
res,
statusCode: 404,
success: false,
message: 'Upload session not found',
});
return;
}

if (row.status === 'complete' && row.result?.url) {
response({
res,
message: 'Upload already complete',
payload: row.result,
});
return;
}

if (row.status !== 'uploading') {
response({
res,
statusCode: 409,
success: false,
message: `Upload cannot be completed from ${row.status} state`,
});
return;
}

const collection = mongoose.connection.db.collection(
'resumable_upload_chunks'
);
const chunks = await collection
.find({ uploadId, userId: req.user._id })
.sort({ partNumber: 1 })
.toArray();
const expectedParts = Math.ceil(
Number(row.totalSize) / Number(row.chunkSize)
);

if (
chunks.length !== expectedParts ||
chunks.some((chunk, index) => chunk.partNumber !== index)
) {
response({
res,
statusCode: 409,
success: false,
message: 'Upload is incomplete',
payload: {
expectedParts,
receivedParts: chunks.map((item) => item.partNumber),
},
});
return;
}

const buffer = Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk.data.buffer || chunk.data))
);
if (buffer.length !== Number(row.totalSize)) {
response({
res,
statusCode: 409,
success: false,
message: 'Uploaded byte count does not match expected size',
});
return;
}

const stored = await saveBufferFile({
buffer,
folder: `chat/${req.user._id}`,
filename: `${Date.now()}-${safeFileName(row.filename)}`,
});
const ext = path.extname(row.filename).slice(1).toLowerCase() || 'bin';
const payload = {
url: stored.url,
originalname: row.filename,
type: fileTypeFromMime(row.mime, row.filename),
format: ext.slice(0, 24),
size: Number(row.totalSize),
duration: 0,
thumbnailUrl: '',
streamUrl: '',
streamHdUrl: '',
width: 0,
height: 0,
};

// The final chat send endpoint creates the canonical FileModel row. Keeping
// the resumable finalizer storage-only prevents an orphan duplicate DB row.
await row.update({
status: 'complete',
uploadedBytes: row.totalSize,
result: payload,
});
await collection.deleteMany({ uploadId, userId: req.user._id });

response({
res,
message: 'Upload complete',
payload,
});
} catch (error0) {
response({
res,
statusCode: error0.statusCode || 500,
success: false,
message: error0.message,
});
}
};

exports.status = async (req, res) => {
try {
const row = await ResumableUploadModel.findOne({
where: { uploadId: req.params.uploadId, userId: req.user._id },
});
if (!row) {
response({
res,
statusCode: 404,
success: false,
message: 'Upload session not found',
});
return;
}
response({ res, payload: toPlain(row) });
} catch (error0) {
response({ res, statusCode: 500, success: false, message: error0.message });
}
};
62 changes: 62 additions & 0 deletions backend/server/controllers/chatSuggestions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const { Op } = require('sequelize');
const InboxModel = require('../db/models/inbox');
const ProfileModel = require('../db/models/profile');
const { asArray, toPlainMany } = require('../db/utils');
const response = require('../helpers/response');

exports.mentionSuggestions = async (req, res) => {
try {
const roomId = String(req.params.roomId || '').trim();
const userId = req.user?._id;
const q = String(req.query.q || '').trim().toLowerCase().slice(0, 32);

const inbox = await InboxModel.findOne({ where: { roomId } });
if (!inbox || !asArray(inbox.ownersId).includes(userId)) {
response({
res,
statusCode: 403,
success: false,
message: 'Forbidden',
});
return;
}

const participantIds = asArray(inbox.ownersId).filter(Boolean).slice(0, 5000);
if (!participantIds.length) {
response({ res, payload: [] });
return;
}

const rows = await ProfileModel.findAll({
where: { userId: { [Op.in]: participantIds } },
attributes: ['userId', 'username', 'fullname', 'avatar'],
limit: 5000,
});

const payload = toPlainMany(rows)
.filter((profile) => profile.userId !== userId)
.filter((profile) => {
if (!q) return true;
return (
String(profile.username || '').toLowerCase().includes(q) ||
String(profile.fullname || '').toLowerCase().includes(q)
);
})
.filter((profile) => !!String(profile.username || '').trim())
.sort((left, right) =>
String(left.fullname || left.username || '').localeCompare(
String(right.fullname || right.username || '')
)
)
.slice(0, 12);

response({ res, payload });
} catch (error0) {
response({
res,
statusCode: error0.statusCode || 500,
success: false,
message: error0.message,
});
}
};
Loading
Loading